From 00d879b45fd479ef322a268db1c349e444e798ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 10:37:17 -0700 Subject: [PATCH 01/21] Stream git status updates over WebSocket - Add server-side status broadcaster and cache invalidation - Refresh git status after git actions and checkout - Co-authored-by: codex --- apps/server/src/git/Layers/GitCore.test.ts | 3 +- apps/server/src/git/Layers/GitCore.ts | 7 + apps/server/src/git/Layers/GitManager.ts | 26 +++ .../git/Layers/GitStatusBroadcaster.test.ts | 94 +++++++++++ .../src/git/Layers/GitStatusBroadcaster.ts | 148 ++++++++++++++++++ apps/server/src/git/Services/GitCore.ts | 3 +- apps/server/src/git/Services/GitManager.ts | 5 + .../src/git/Services/GitStatusBroadcaster.ts | 18 +++ apps/server/src/server.test.ts | 20 ++- apps/server/src/server.ts | 18 ++- apps/server/src/ws.ts | 68 +++++--- .../BranchToolbarBranchSelector.tsx | 28 ++-- apps/web/src/components/ChatView.browser.tsx | 18 --- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/DiffPanel.tsx | 4 +- .../components/GitActionsControl.browser.tsx | 39 +++-- apps/web/src/components/GitActionsControl.tsx | 9 +- .../components/KeybindingsToast.browser.tsx | 14 -- apps/web/src/components/Sidebar.tsx | 25 +-- apps/web/src/lib/gitReactQuery.test.ts | 29 ---- apps/web/src/lib/gitReactQuery.ts | 111 ++++++------- apps/web/src/lib/gitStatusState.test.ts | 61 ++++++++ apps/web/src/lib/gitStatusState.ts | 125 +++++++++++++++ apps/web/src/wsNativeApi.test.ts | 32 +++- apps/web/src/wsNativeApi.ts | 2 +- apps/web/src/wsRpcClient.ts | 14 +- apps/web/test/wsRpcHarness.ts | 1 + packages/contracts/src/git.ts | 14 ++ packages/contracts/src/ipc.ts | 11 +- packages/contracts/src/rpc.ts | 9 +- packages/shared/src/git.ts | 67 +++++++- 31 files changed, 782 insertions(+), 245 deletions(-) create mode 100644 apps/server/src/git/Layers/GitStatusBroadcaster.test.ts create mode 100644 apps/server/src/git/Layers/GitStatusBroadcaster.ts create mode 100644 apps/server/src/git/Services/GitStatusBroadcaster.ts create mode 100644 apps/web/src/lib/gitStatusState.test.ts create mode 100644 apps/web/src/lib/gitStatusState.ts diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5e4416d8b9..5ff2714b61 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -949,11 +949,12 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ + const checkoutResult = yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: `${remoteName}/${featureBranch}`, }); + expect(checkoutResult.branch).toBe("upstream/feature"); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); const realGitCore = yield* GitCore; let fetchArgs: readonly string[] | null = null; diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 1178a4b67e..529387f978 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -2078,6 +2078,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); + + const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + + return { branch }; }, ); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 7fedb15714..8e38a70fba 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -11,6 +11,7 @@ import { ModelSelection, } from "@t3tools/contracts"; import { + detectGitHostingProviderFromRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -723,9 +724,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { Effect.catch(() => Effect.succeed(null)), ) : null; + const hostingProvider = details.isRepo + ? yield* resolveHostingProvider(cwd, details.branch) + : null; return { isRepo: details.isRepo, + ...(hostingProvider ? { hostingProvider } : {}), hasOriginRemote: details.hasOriginRemote, isDefaultBranch: details.isDefaultBranch, branch: details.branch, @@ -748,6 +753,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( + cwd: string, + branch: string | null, + ) { + const preferredRemoteName = + branch === null + ? "origin" + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + const remoteUrl = + (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? + (yield* readConfigValueNullable(cwd, "remote.origin.url")); + + return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; + }); + const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( cwd: string, remoteName: string | null, @@ -1314,6 +1334,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); }); + const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + function* (cwd) { + yield* invalidateStatusResultCache(cwd); + }, + ); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { @@ -1708,6 +1733,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return { status, + invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts new file mode 100644 index 0000000000..1ab64a369b --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import type { GitStatusResult } from "@t3tools/contracts"; +import { describe } from "vitest"; + +import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; +import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; +import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; + +const baseStatus: GitStatusResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/status-broadcast", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +function makeTestLayer(state: { + currentStatus: GitStatusResult; + statusCalls: number; + invalidationCalls: number; +}) { + const gitManager: GitManagerShape = { + status: () => + Effect.sync(() => { + state.statusCalls += 1; + return state.currentStatus; + }), + invalidateStatus: () => + Effect.sync(() => { + state.invalidationCalls += 1; + }), + resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + }; + + return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); +} + +describe("GitStatusBroadcasterLive", () => { + it.effect("reuses the cached git status across repeated reads", () => { + const state = { + currentStatus: baseStatus, + statusCalls: 0, + invalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(first, baseStatus); + assert.deepStrictEqual(second, baseStatus); + assert.equal(state.statusCalls, 1); + assert.equal(state.invalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes the cached snapshot after explicit invalidation", () => { + const state = { + currentStatus: baseStatus, + statusCalls: 0, + invalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentStatus = { + ...baseStatus, + branch: "feature/updated-status", + aheadCount: 2, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshed, state.currentStatus); + assert.deepStrictEqual(cached, state.currentStatus); + assert.equal(state.statusCalls, 2); + assert.equal(state.invalidationCalls, 2); + }).pipe(Effect.provide(makeTestLayer(state))); + }); +}); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..c75326dfbe --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -0,0 +1,148 @@ +import { realpathSync } from "node:fs"; + +import { Duration, Effect, Exit, Layer, PubSub, Ref, Scope, Stream } from "effect"; +import type { GitStatusInput, GitStatusResult } from "@t3tools/contracts"; + +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "../Services/GitStatusBroadcaster.ts"; +import { GitManager } from "../Services/GitManager.ts"; + +const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); + +interface GitStatusChange { + readonly cwd: string; + readonly status: GitStatusResult; +} + +interface CachedGitStatus { + readonly fingerprint: string; + readonly status: GitStatusResult; +} + +function normalizeCwd(cwd: string): string { + try { + return realpathSync.native(cwd); + } catch { + return cwd; + } +} + +function fingerprintStatus(status: GitStatusResult): string { + return JSON.stringify(status); +} + +export const GitStatusBroadcasterLive = Layer.effect( + GitStatusBroadcaster, + Effect.gen(function* () { + const gitManager = yield* GitManager; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* Ref.make(new Set()); + + const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( + function* (cwd) { + const normalizedCwd = normalizeCwd(cwd); + yield* gitManager.invalidateStatus(normalizedCwd); + const nextStatus = yield* gitManager.status({ cwd: normalizedCwd }); + const nextFingerprint = fingerprintStatus(nextStatus); + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(normalizedCwd); + const nextCache = new Map(cache); + nextCache.set(normalizedCwd, { + fingerprint: nextFingerprint, + status: nextStatus, + }); + return [previous?.fingerprint !== nextFingerprint, nextCache] as const; + }); + + if (shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd: normalizedCwd, + status: nextStatus, + }); + } + + return nextStatus; + }, + ); + + const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( + input: GitStatusInput, + ) { + const normalizedCwd = normalizeCwd(input.cwd); + const cached = yield* Ref.get(cacheRef).pipe( + Effect.map((cache) => cache.get(normalizedCwd)?.status ?? null), + ); + if (cached) { + return cached; + } + + return yield* refreshStatus(normalizedCwd); + }); + + const ensurePoller = Effect.fn("ensurePoller")(function* (cwd: string) { + const normalizedCwd = normalizeCwd(cwd); + const shouldStart = yield* Ref.modify(pollersRef, (activePollers) => { + if (activePollers.has(normalizedCwd)) { + return [false, activePollers] as const; + } + + const nextPollers = new Set(activePollers); + nextPollers.add(normalizedCwd); + return [true, nextPollers] as const; + }); + + if (!shouldStart) { + return; + } + + const refreshLoop = Effect.forever( + Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( + Effect.andThen( + refreshStatus(normalizedCwd).pipe( + Effect.catch((error) => + Effect.logWarning("git status refresh failed", { + cwd: normalizedCwd, + detail: error.message, + }), + ), + ), + ), + ), + ); + + yield* Effect.forkIn(refreshLoop, broadcasterScope); + }); + + const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const normalizedCwd = normalizeCwd(input.cwd); + yield* ensurePoller(normalizedCwd); + const initialStatus = yield* getStatus({ cwd: normalizedCwd }); + + return Stream.concat( + Stream.make(initialStatus), + Stream.fromPubSub(changesPubSub).pipe( + Stream.filter((event) => event.cwd === normalizedCwd), + Stream.map((event) => event.status), + ), + ); + }), + ); + + return { + getStatus, + refreshStatus, + streamStatus, + } satisfies GitStatusBroadcasterShape; + }), +); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d7a28d1763..4e43df8587 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -10,6 +10,7 @@ import { ServiceMap } from "effect"; import type { Effect, Scope } from "effect"; import type { GitCheckoutInput, + GitCheckoutResult, GitCreateBranchInput, GitCreateWorktreeInput, GitCreateWorktreeResult, @@ -285,7 +286,7 @@ export interface GitCoreShape { */ readonly checkoutBranch: ( input: GitCheckoutInput, - ) => Effect.Effect; + ) => Effect.Effect; /** * Initialize a repository in the provided directory. diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 86842257b4..2c13b3d82d 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -41,6 +41,11 @@ export interface GitManagerShape { input: GitStatusInput, ) => Effect.Effect; + /** + * Clear any cached status snapshot for a repository so the next read is fresh. + */ + readonly invalidateStatus: (cwd: string) => Effect.Effect; + /** * Resolve a pull request by URL/number against the current repository. */ diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..c7562d850b --- /dev/null +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -0,0 +1,18 @@ +import { ServiceMap } from "effect"; +import type { Effect, Stream } from "effect"; +import type { GitManagerServiceError, GitStatusInput, GitStatusResult } from "@t3tools/contracts"; + +export interface GitStatusBroadcasterShape { + readonly getStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: GitStatusInput, + ) => Stream.Stream; +} + +export class GitStatusBroadcaster extends ServiceMap.Service< + GitStatusBroadcaster, + GitStatusBroadcasterShape +>()("t3/git/Services/GitStatusBroadcaster") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7a23058fc7..060dddd5b9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -44,6 +44,7 @@ import { } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -294,6 +295,10 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const gitManagerLayer = Layer.mock(GitManager)({ + ...options?.layers?.gitManager, + }); + const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); const appLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -333,11 +338,8 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitCore, }), ), - Layer.provide( - Layer.mock(GitManager)({ - ...options?.layers?.gitManager, - }), - ), + Layer.provide(gitManagerLayer), + Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -1260,6 +1262,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitManager: { + invalidateStatus: () => Effect.void, status: () => Effect.succeed({ isRepo: true, @@ -1374,7 +1377,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), removeWorktree: () => Effect.void, createBranch: () => Effect.void, - checkoutBranch: () => Effect.void, + checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), initRepo: () => Effect.void, }, }, @@ -1382,11 +1385,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); - const status = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })), - ); - assert.equal(status.branch, "main"); - const pull = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..1d6f6ac66e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { GitManagerLive } from "./git/Layers/GitManager"; @@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const GitManagerLayerLive = GitManagerLive.pipe( + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(RoutingTextGenerationLive), +); + const GitLayerLive = Layer.empty.pipe( - Layer.provideMerge( - GitManagerLive.pipe( - Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(RoutingTextGenerationLive), - ), - ), + Layer.provideMerge(GitManagerLayerLive), + Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), Layer.provideMerge(GitCoreLive), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 33a0518611..8514469495 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore"; import { GitManager } from "./git/Services/GitManager"; +import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { normalizeDispatchCommand } from "./orchestration/Normalizer"; @@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( const open = yield* Open; const gitManager = yield* GitManager; const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -348,6 +350,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( }; }); + const refreshGitStatus = (cwd: string) => + gitStatusBroadcaster + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.asVoid); + return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => observeRpcEffect( @@ -559,14 +566,16 @@ const WsRpcLayer = WsRpcGroup.toLayer( observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", }), - [WS_METHODS.gitStatus]: (input) => - observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), { + [WS_METHODS.subscribeGitStatus]: (input) => + observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { "rpc.aggregate": "git", }), [WS_METHODS.gitPull]: (input) => - observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitPull, + git.pullCurrentBranch(input.cwd).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, @@ -581,7 +590,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( .pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), }), ), ), @@ -594,7 +606,9 @@ const WsRpcLayer = WsRpcGroup.toLayer( [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitManager.preparePullRequestThread(input), + gitManager + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitListBranches]: (input) => @@ -602,23 +616,37 @@ const WsRpcLayer = WsRpcGroup.toLayer( "rpc.aggregate": "git", }), [WS_METHODS.gitCreateWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCreateWorktree, + git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRemoveWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitRemoveWorktree, + git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitCreateBranch]: (input) => - observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCreateBranch, + git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitCheckout]: (input) => - observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCheckout, + Effect.scoped(git.checkoutBranch(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitInit]: (input) => - observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }), + observeRpcEffect( + WS_METHODS.gitInit, + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e1dbb8756c..dfc3b34805 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ import type { GitBranch } from "@t3tools/contracts"; -import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -17,9 +17,9 @@ import { import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys, - gitStatusQueryOptions, invalidateGitQueries, } from "../lib/gitReactQuery"; +import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { @@ -89,7 +89,7 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const branchStatusQuery = useGitStatus(branchCwd); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); @@ -228,27 +228,23 @@ export function BranchToolbarBranchSelector({ runBranchAction(async () => { setOptimisticBranch(selectedBranchName); try { - await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); + const checkoutResult = await api.git.checkout({ + cwd: selectionTarget.checkoutCwd, + branch: branch.name, + }); await invalidateGitQueries(queryClient); + const nextBranchName = branch.isRemote + ? (checkoutResult.branch ?? selectedBranchName) + : selectedBranchName; + setOptimisticBranch(nextBranchName); + onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { toastManager.add({ type: "error", title: "Failed to checkout branch.", description: toBranchActionErrorMessage(error), }); - return; - } - - let nextBranchName = selectedBranchName; - if (branch.isRemote) { - const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); - if (status?.branch) { - nextBranchName = status.branch; - } } - - setOptimisticBranch(nextBranchName); - onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); }); }; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 84302386dc..e533a104d6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -651,24 +651,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { ], }; } - if (tag === WS_METHODS.gitStatus) { - return { - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f995bb4ce7..f8ae73cbce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); + const gitStatusQuery = useGitStatus(gitCwd); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dc376a5b3d..ff216baed7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -19,7 +19,7 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); + const gitStatusQuery = useGitStatus(activeCwd ?? null); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index a975a65bbe..b651fb02cb 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope"; const { invalidateGitQueriesSpy, - invalidateGitStatusQuerySpy, + refreshGitStatusSpy, runStackedActionMutateAsyncSpy, setThreadBranchSpy, toastAddSpy, @@ -19,7 +19,7 @@ const { toastUpdateSpy, } = vi.hoisted(() => ({ invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), - invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()), + refreshGitStatusSpy: vi.fn(), runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), @@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => { }; }), useQuery: vi.fn((options: { queryKey?: string[] }) => { - if (options.queryKey?.[0] === "git-status") { - return { - data: { - branch: BRANCH_NAME, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 1, - behindCount: 0, - pr: null, - }, - error: null, - }; - } - if (options.queryKey?.[0] === "git-branches") { return { data: { @@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({ })); vi.mock("~/lib/gitReactQuery", () => ({ - gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })), gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), gitMutationKeys: { pull: vi.fn(() => ["pull"]), @@ -118,9 +102,24 @@ vi.mock("~/lib/gitReactQuery", () => ({ }, gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), - gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })), invalidateGitQueries: invalidateGitQueriesSpy, - invalidateGitStatusQuery: invalidateGitStatusQuerySpy, +})); + +vi.mock("~/lib/gitStatusState", () => ({ + refreshGitStatus: refreshGitStatusSpy, + useGitStatus: vi.fn(() => ({ + data: { + branch: BRANCH_NAME, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 1, + behindCount: 0, + pr: null, + }, + error: null, + isPending: false, + })), })); vi.mock("~/lib/utils", async () => { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..1a30f9b6b0 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -5,7 +5,7 @@ import type { GitStatusResult, ThreadId, } from "@t3tools/contracts"; -import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; @@ -45,9 +45,8 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, - gitStatusQueryOptions, - invalidateGitStatusQuery, } from "~/lib/gitReactQuery"; +import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; @@ -275,7 +274,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); + const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; @@ -801,7 +800,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions { - if (open) void invalidateGitStatusQuery(queryClient, gitCwd); + if (open) refreshGitStatus(gitCwd); }} > ({ - ...gitStatusQueryOptions(cwd), - staleTime: 30_000, - refetchInterval: 60_000, - })), - }); + const threadGitStatuses = useGitStatuses(threadGitStatusCwds); const prByThreadId = useMemo(() => { - const statusByCwd = new Map(); - for (let index = 0; index < threadGitStatusCwds.length; index += 1) { - const cwd = threadGitStatusCwds[index]; - if (!cwd) continue; - const status = threadGitStatusQueries[index]?.data; - if (status) { - statusByCwd.set(cwd, status); - } - } - const map = new Map(); for (const target of threadGitTargets) { - const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; + const status = target.cwd ? threadGitStatuses.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); } return map; - }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); + }, [threadGitStatuses, threadGitTargets]); const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { event.preventDefault(); diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index d260c2aee8..254b93eb6d 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -15,12 +15,9 @@ import type { GitListBranchesResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, gitMutationKeys, - gitQueryKeys, gitPreparePullRequestThreadMutationOptions, gitPullMutationOptions, gitRunStackedActionMutationOptions, - invalidateGitStatusQuery, - gitStatusQueryOptions, invalidateGitQueries, } from "./gitReactQuery"; @@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => { it("can invalidate a single cwd without blasting other git query scopes", async () => { const queryClient = new QueryClient(); - queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ cwd: "/repo/a", @@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => { }).queryKey, BRANCH_SEARCH_RESULT, ); - queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ cwd: "/repo/b", @@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => { await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(true); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ @@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => { }).queryKey, )?.isInvalidated, ).toBe(true); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, - ).toBe(false); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ @@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => { ).toBe(false); }); }); - -describe("invalidateGitStatusQuery", () => { - it("invalidates only status for the selected cwd", async () => { - const queryClient = new QueryClient(); - - queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); - queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); - - await invalidateGitStatusQuery(queryClient, "/repo/a"); - - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, - ).toBe(false); - }); -}); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index bfac623db9..2dc25c1896 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -12,15 +12,12 @@ import { import { ensureNativeApi } from "../nativeApi"; import { getWsRpcClient } from "../wsRpcClient"; -const GIT_STATUS_STALE_TIME_MS = 5_000; -const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, - status: (cwd: string | null) => ["git", "status", cwd] as const, branches: (cwd: string | null) => ["git", "branches", cwd] as const, branchSearch: (cwd: string | null, query: string) => ["git", "branches", cwd, "search", query] as const, @@ -38,37 +35,18 @@ export const gitMutationKeys = { export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { const cwd = input?.cwd ?? null; if (cwd !== null) { - return Promise.all([ - queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }), - queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }), - ]); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); } return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); } -export function invalidateGitStatusQuery(queryClient: QueryClient, cwd: string | null) { +function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) { if (cwd === null) { return Promise.resolve(); } - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }); -} - -export function gitStatusQueryOptions(cwd: string | null) { - return queryOptions({ - queryKey: gitQueryKeys.status(cwd), - queryFn: async () => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git status is unavailable."); - return api.git.status({ cwd }); - }, - enabled: cwd !== null, - staleTime: GIT_STATUS_STALE_TIME_MS, - refetchOnWindowFocus: "always", - refetchOnReconnect: "always", - refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, - }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); } export function gitBranchSearchInfiniteQueryOptions(input: { @@ -129,7 +107,7 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient: return api.git.init({ cwd: input.cwd }); }, onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -146,7 +124,7 @@ export function gitCheckoutMutationOptions(input: { return api.git.checkout({ cwd: input.cwd, branch }); }, onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: { if (!input.cwd) throw new Error("Git action is unavailable."); return getWsRpcClient().git.runStackedAction( { + action, actionId, cwd: input.cwd, - action, ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch } : {}), - ...(filePaths ? { filePaths } : {}), + ...(featureBranch ? { featureBranch: true } : {}), + ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), }, ...(onProgress ? [{ onProgress }] : []), ); }, - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient: if (!input.cwd) throw new Error("Git pull is unavailable."); return api.git.pull({ cwd: input.cwd }); }, - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { return mutationOptions({ - mutationFn: async ({ - cwd, - branch, - newBranch, - path, - }: { - cwd: string; - branch: string; - newBranch: string; - path?: string | null; - }) => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git worktree creation is unavailable."); - return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); - }, mutationKey: ["git", "mutation", "create-worktree"] as const, - onSettled: async () => { + mutationFn: ( + args: Parameters["git"]["createWorktree"]>[0], + ) => ensureNativeApi().git.createWorktree(args), + onSuccess: async () => { await invalidateGitQueries(input.queryClient); }, }); @@ -231,28 +197,40 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { return mutationOptions({ - mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git worktree removal is unavailable."); - return api.git.removeWorktree({ cwd, path, force }); - }, mutationKey: ["git", "mutation", "remove-worktree"] as const, - onSettled: async () => { + mutationFn: ( + args: Parameters["git"]["removeWorktree"]>[0], + ) => ensureNativeApi().git.removeWorktree(args), + onSuccess: async () => { await invalidateGitQueries(input.queryClient); }, }); } +export function gitCreateBranchMutationOptions(input: { + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationKey: ["git", "mutation", "create-branch", input.cwd] as const, + mutationFn: async (branch: string) => { + const api = ensureNativeApi(); + if (!input.cwd) throw new Error("Git branch creation is unavailable."); + return api.git.createBranch({ cwd: input.cwd, branch }); + }, + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); + }, + }); +} + export function gitPreparePullRequestThreadMutationOptions(input: { cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ - reference, - mode, - threadId, - }: { + mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), + mutationFn: async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId; @@ -261,14 +239,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: { if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); return api.git.preparePullRequestThread({ cwd: input.cwd, - reference, - mode, - ...(threadId ? { threadId } : {}), + reference: args.reference, + mode: args.mode, + ...(args.threadId ? { threadId: args.threadId } : {}), }); }, - mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts new file mode 100644 index 0000000000..85dea51f0b --- /dev/null +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -0,0 +1,61 @@ +import { GitManagerError, type GitStatusResult } from "@t3tools/contracts"; +import { Cause, Option } from "effect"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vitest"; + +import { deriveGitStatusState } from "./gitStatusState"; + +const BASE_STATUS: GitStatusResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/push-status", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +describe("deriveGitStatusState", () => { + it("uses the latest streamed snapshot as the current git status", () => { + const streamedStatuses: [GitStatusResult, ...GitStatusResult[]] = [ + BASE_STATUS, + { ...BASE_STATUS, branch: "feature/updated-status" }, + ]; + const state = deriveGitStatusState( + AsyncResult.success({ + done: false, + items: streamedStatuses, + }), + ); + + expect(state).toEqual({ + data: { ...BASE_STATUS, branch: "feature/updated-status" }, + error: null, + cause: null, + isPending: false, + }); + }); + + it("preserves the previous snapshot when the stream fails after succeeding", () => { + const previousSuccess = AsyncResult.success({ + done: false, + items: [BASE_STATUS] as [GitStatusResult], + }); + const error = new GitManagerError({ + operation: "subscribeGitStatus", + detail: "stream disconnected", + }); + const state = deriveGitStatusState( + AsyncResult.failure(Cause.fail(error), { + previousSuccess: Option.some(previousSuccess), + }), + ); + + expect(state.data).toEqual(BASE_STATUS); + expect(state.error).toBe(error); + expect(state.isPending).toBe(false); + }); +}); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts new file mode 100644 index 0000000000..44b5edbe7b --- /dev/null +++ b/apps/web/src/lib/gitStatusState.ts @@ -0,0 +1,125 @@ +import { useAtomValue } from "@effect/atom-react"; +import { type GitManagerServiceError, type GitStatusResult, WS_METHODS } from "@t3tools/contracts"; +import { Cause, Option } from "effect"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useEffect, useState } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { WsRpcAtomClient } from "../rpc/client"; + +export type GitStatusStreamError = + | GitManagerServiceError + | RpcClientError + | Cause.NoSuchElementError; + +export interface GitStatusState { + readonly data: GitStatusResult | null; + readonly error: GitStatusStreamError | null; + readonly cause: Cause.Cause | null; + readonly isPending: boolean; +} + +const EMPTY_GIT_STATUS_STATE = Object.freeze({ + data: null, + error: null, + cause: null, + isPending: false, +}); + +const emptyGitStatusStateAtom = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("git-status-empty"), +); + +const gitStatusStreamAtom = Atom.family((cwd: string) => + WsRpcAtomClient.query(WS_METHODS.subscribeGitStatus, { cwd }).pipe( + Atom.withLabel(`git-status-stream:${cwd}`), + ), +); + +const gitStatusStateAtom = Atom.family((cwd: string) => + Atom.make((get) => deriveGitStatusState(get(gitStatusStreamAtom(cwd)))).pipe( + Atom.withLabel(`git-status-state:${cwd}`), + ), +); + +export function deriveGitStatusState( + result: Atom.PullResult, +): GitStatusState { + if (AsyncResult.isSuccess(result)) { + return { + data: getLatestGitStatusResult(result.value), + error: null, + cause: null, + isPending: result.waiting, + }; + } + + if (AsyncResult.isFailure(result)) { + const previousSuccess = Option.getOrNull(result.previousSuccess); + return { + data: previousSuccess ? getLatestGitStatusResult(previousSuccess.value) : null, + error: Option.getOrNull(Cause.findErrorOption(result.cause)), + cause: result.cause, + isPending: result.waiting, + }; + } + + return { + ...EMPTY_GIT_STATUS_STATE, + isPending: true, + }; +} + +export function refreshGitStatus(cwd: string | null): void { + if (cwd === null) { + return; + } + + appAtomRegistry.refresh(gitStatusStreamAtom(cwd)); +} + +export function useGitStatus(cwd: string | null): GitStatusState { + return useAtomValue(cwd === null ? emptyGitStatusStateAtom : gitStatusStateAtom(cwd)); +} + +export function useGitStatuses(cwds: ReadonlyArray): ReadonlyMap { + const [statusByCwd, setStatusByCwd] = useState>( + () => new Map(), + ); + + useEffect(() => { + const cleanups = cwds.map((cwd) => + appAtomRegistry.subscribe( + gitStatusStateAtom(cwd), + (state) => { + setStatusByCwd((current) => { + const next = new Map(current); + if (state.data) { + next.set(cwd, state.data); + } else { + next.delete(cwd); + } + return next; + }); + }, + { immediate: true }, + ), + ); + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [cwds]); + + return statusByCwd; +} + +function getLatestGitStatusResult(value: { + readonly items: ReadonlyArray; +}): GitStatusResult | null { + return value.items.at(-1) ?? null; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index cfa6ca6942..7b7c83cb8f 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EventId, + type GitStatusResult, ProjectId, type OrchestrationEvent, type ServerConfig, @@ -31,6 +32,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); +const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); const rpcClientMock = { dispose: vi.fn(), @@ -54,7 +56,9 @@ const rpcClientMock = { }, git: { pull: vi.fn(), - status: vi.fn(), + onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(gitStatusListeners, listener), + ), runStackedAction: vi.fn(), listBranches: vi.fn(), createWorktree: vi.fn(), @@ -243,6 +247,32 @@ describe("wsNativeApi", () => { expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); }); + it("forwards git status stream events", async () => { + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + const onStatus = vi.fn(); + + api.git.onStatus({ cwd: "/repo" }, onStatus); + + const gitStatus = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/streamed", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + } satisfies GitStatusResult; + emitEvent(gitStatusListeners, gitStatus); + + expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); + expect(onStatus).toHaveBeenCalledWith(gitStatus); + }); + it("forwards orchestration stream subscription options to the RPC client", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 99045dbf07..a19d661844 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -65,7 +65,7 @@ export function createWsNativeApi(): NativeApi { }, git: { pull: rpcClient.git.pull, - status: rpcClient.git.status, + onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), listBranches: rpcClient.git.listBranches, createWorktree: rpcClient.git.createWorktree, removeWorktree: rpcClient.git.removeWorktree, diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 1d411aa1b9..3e099507b5 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -2,6 +2,7 @@ import { type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, + type GitStatusResult, type NativeApi, ORCHESTRATION_WS_METHODS, type ServerSettingsPatch, @@ -64,7 +65,11 @@ export interface WsRpcClient { }; readonly git: { readonly pull: RpcUnaryMethod; - readonly status: RpcUnaryMethod; + readonly onStatus: ( + input: RpcInput, + listener: (status: GitStatusResult) => void, + options?: StreamSubscriptionOptions, + ) => () => void; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, @@ -149,7 +154,12 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { }, git: { pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), - status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), + onStatus: (input, listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeGitStatus](input), + listener, + options, + ), runStackedAction: async (input, options) => { let result: GitRunStackedActionResult | null = null; diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index dcb6dc7252..8b5fe24294 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions { const STREAM_METHODS = new Set([ WS_METHODS.gitRunStackedAction, + WS_METHODS.subscribeGitStatus, WS_METHODS.subscribeOrchestrationDomainEvents, WS_METHODS.subscribeTerminalEvents, WS_METHODS.subscribeServerConfig, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index f28a74f6de..b342159666 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]); +export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; +export const GitHostingProvider = Schema.Struct({ + kind: GitHostingProviderKind, + name: TrimmedNonEmptyStringSchema, + baseUrl: Schema.String, +}); +export type GitHostingProvider = typeof GitHostingProvider.Type; export const GitRunStackedActionToastRunAction = Schema.Struct({ kind: GitStackedAction, }); @@ -188,6 +196,7 @@ const GitStatusPr = Schema.Struct({ export const GitStatusResult = Schema.Struct({ isRepo: Schema.Boolean, + hostingProvider: Schema.optional(GitHostingProvider), hasOriginRemote: Schema.Boolean, isDefaultBranch: Schema.Boolean, branch: Schema.NullOr(TrimmedNonEmptyStringSchema), @@ -236,6 +245,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ }); export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; +export const GitCheckoutResult = Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), +}); +export type GitCheckoutResult = typeof GitCheckoutResult.Type; + export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, branch: Schema.Struct({ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 57a1c4c3dc..01b6d53f21 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,5 +1,6 @@ import type { GitCheckoutInput, + GitCheckoutResult, GitCreateBranchInput, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, @@ -149,7 +150,7 @@ export interface NativeApi { createWorktree: (input: GitCreateWorktreeInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; createBranch: (input: GitCreateBranchInput) => Promise; - checkout: (input: GitCheckoutInput) => Promise; + checkout: (input: GitCheckoutInput) => Promise; init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; preparePullRequestThread: ( @@ -157,7 +158,13 @@ export interface NativeApi { ) => Promise; // Stacked action API pull: (input: GitPullInput) => Promise; - status: (input: GitStatusInput) => Promise; + onStatus: ( + input: GitStatusInput, + callback: (status: GitStatusResult) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; }; contextMenu: { show: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 34968e66ec..f86a4c0457 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -6,6 +6,7 @@ import { OpenError, OpenInEditorInput } from "./editor"; import { GitActionProgressEvent, GitCheckoutInput, + GitCheckoutResult, GitCommandError, GitCreateBranchInput, GitCreateWorktreeInput, @@ -83,7 +84,6 @@ export const WS_METHODS = { // Git methods gitPull: "git.pull", - gitStatus: "git.status", gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", @@ -110,6 +110,7 @@ export const WS_METHODS = { serverUpdateSettings: "server.updateSettings", // Streaming subscriptions + subscribeGitStatus: "subscribeGitStatus", subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", @@ -162,10 +163,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: OpenError, }); -export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, { +export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, success: GitStatusResult, error: GitManagerServiceError, + stream: true, }); export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { @@ -217,6 +219,7 @@ export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { payload: GitCheckoutInput, + success: GitCheckoutResult, error: GitCommandError, }); @@ -330,7 +333,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, - WsGitStatusRpc, + WsSubscribeGitStatusRpc, WsGitPullRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 90bc655a76..a7530274ec 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,4 +1,4 @@ -import type { GitBranch } from "@t3tools/contracts"; +import type { GitBranch, GitHostingProvider } from "@t3tools/contracts"; /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. @@ -119,3 +119,68 @@ export function dedupeRemoteBranchesWithLocalMatches( return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } + +function parseGitRemoteHost(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) { + return null; + } + + if (trimmed.startsWith("git@")) { + const hostWithPath = trimmed.slice("git@".length); + const separatorIndex = hostWithPath.search(/[:/]/); + if (separatorIndex <= 0) { + return null; + } + return hostWithPath.slice(0, separatorIndex).toLowerCase(); + } + + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch { + return null; + } +} + +function toBaseUrl(host: string): string { + return `https://${host}`; +} + +function isGitHubHost(host: string): boolean { + return host === "github.com" || host.includes("github"); +} + +function isGitLabHost(host: string): boolean { + return host === "gitlab.com" || host.includes("gitlab"); +} + +export function detectGitHostingProviderFromRemoteUrl( + remoteUrl: string, +): GitHostingProvider | null { + const host = parseGitRemoteHost(remoteUrl); + if (!host) { + return null; + } + + if (isGitHubHost(host)) { + return { + kind: "github", + name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitLabHost(host)) { + return { + kind: "gitlab", + name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + return { + kind: "unknown", + name: host, + baseUrl: toBaseUrl(host), + }; +} From 0cd98e3856eef8e52c1ac4a129931082ba4b31a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 11:20:02 -0700 Subject: [PATCH 02/21] Stabilize git status state and browser bootstrap - prune stale cwd status entries - make native API resets and test bootstrap async-safe --- apps/web/src/components/ChatView.browser.tsx | 52 ++++++++++++---- .../components/KeybindingsToast.browser.tsx | 42 ++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../settings/SettingsPanels.browser.tsx | 8 +-- .../web/src/components/timelineHeight.test.ts | 8 +-- apps/web/src/components/timelineHeight.ts | 5 +- apps/web/src/lib/gitReactQuery.ts | 17 ------ apps/web/src/lib/gitStatusState.test.ts | 13 +++- apps/web/src/lib/gitStatusState.ts | 59 ++++++++++++++++--- apps/web/src/nativeApi.ts | 4 +- apps/web/src/wsNativeApi.test.ts | 1 + apps/web/src/wsNativeApi.ts | 4 +- apps/web/test/wsRpcHarness.ts | 2 +- 13 files changed, 165 insertions(+), 52 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e533a104d6..26a98f1aad 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -31,6 +31,8 @@ import { } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetNativeApiForTests } from "../nativeApi"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -38,12 +40,18 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +vi.mock("../lib/gitStatusState", () => ({ + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + refreshGitStatus: () => undefined, +})); + const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); -const ATTACHMENT_SVG = ""; +const ATTACHMENT_SVG = ""; interface TestFixture { snapshot: OrchestrationReadModel; @@ -93,9 +101,9 @@ const TEXT_VIEWPORT_MATRIX = [ { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, ] as const satisfies readonly ViewportSpec[]; interface UserRowMeasurement { @@ -224,6 +232,7 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, + previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -397,6 +406,22 @@ async function waitForWsClient(): Promise { (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, ), ).toBe(true); + expect( + wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( + true, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForAppBootstrap(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + expect(useStore.getState().bootstrapComplete).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -1026,10 +1051,17 @@ async function mountChatView(options: { }), ); - const screen = await render(, { - container: host, - }); + const screen = await render( + + + , + { + container: host, + }, + ); + await waitForWsClient(); + await waitForAppBootstrap(); await waitForLayout(); const cleanup = async () => { @@ -1120,7 +1152,7 @@ describe("ChatView timeline estimator parity (full app)", () => { return []; }, }); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; @@ -1281,7 +1313,7 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, - targetAttachmentCount: 3, + targetAttachmentCount: 2, }), }); @@ -1295,7 +1327,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { role: "user", text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], }, { timelineWidthPx: timelineWidthMeasuredPx }, ); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 0a632e0749..26e3cfaded 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -19,10 +19,18 @@ import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; import { __resetNativeApiForTests } from "../nativeApi"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { useStore } from "../store"; import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; +vi.mock("../lib/gitStatusState", () => ({ + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + refreshGitStatus: () => undefined, +})); + const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; @@ -244,6 +252,29 @@ async function waitForNoToast(title: string): Promise { ); } +async function waitForInitialWsSubscriptions(): Promise { + await vi.waitFor( + () => { + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForServerConfigSnapshot(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); +} + async function mountApp(): Promise<{ cleanup: () => Promise }> { const host = document.createElement("div"); host.style.position = "fixed"; @@ -256,8 +287,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); - const screen = await render(, { container: host }); + const screen = await render( + + + , + { container: host }, + ); await waitForComposerEditor(); + await waitForInitialWsSubscriptions(); + await waitForServerConfigSnapshot(); return { cleanup: async () => { @@ -308,7 +346,7 @@ describe("Keybindings update toast", () => { return []; }, }); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8cb8b89684..e823569c13 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {image.name} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 090f6f12ad..f0ea32d4be 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig { } describe("GeneralSettingsPanel observability", () => { - beforeEach(() => { + beforeEach(async () => { resetServerStateForTests(); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; }); - afterEach(() => { + afterEach(async () => { resetServerStateForTests(); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); document.body.innerHTML = ""; }); diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index df2a21dab1..35c90d0120 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }], }), - ).toBe(346); + ).toBe(234); expect( estimateTimelineMessageHeight({ @@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }], }), - ).toBe(346); + ).toBe(234); }); it("adds a second attachment row for three or four user attachments", () => { @@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], }), - ).toBe(574); + ).toBe(350); expect( estimateTimelineMessageHeight({ @@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], }), - ).toBe(574); + ).toBe(350); }); it("does not cap long user message estimates", () => { diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 57a15f26ed..776fe9ad88 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -11,8 +11,9 @@ const ASSISTANT_LINE_HEIGHT_PX = 22.75; const ASSISTANT_BASE_HEIGHT_PX = 41; const USER_BASE_HEIGHT_PX = 96; const ATTACHMENTS_PER_ROW = 2; -// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. -const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; +// Full-app browser measurements land closer to a ~116px attachment row once +// the bubble shrinks to content width, so calibrate the estimate to that DOM. +const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 2dc25c1896..ff2d7b2591 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -207,23 +207,6 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie }); } -export function gitCreateBranchMutationOptions(input: { - cwd: string | null; - queryClient: QueryClient; -}) { - return mutationOptions({ - mutationKey: ["git", "mutation", "create-branch", input.cwd] as const, - mutationFn: async (branch: string) => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git branch creation is unavailable."); - return api.git.createBranch({ cwd: input.cwd, branch }); - }, - onSuccess: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); - }, - }); -} - export function gitPreparePullRequestThreadMutationOptions(input: { cwd: string | null; queryClient: QueryClient; diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 85dea51f0b..5e46273cfb 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -3,7 +3,7 @@ import { Cause, Option } from "effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { describe, expect, it } from "vitest"; -import { deriveGitStatusState } from "./gitStatusState"; +import { deriveGitStatusState, pruneStatusByCwd } from "./gitStatusState"; const BASE_STATUS: GitStatusResult = { isRepo: true, @@ -58,4 +58,15 @@ describe("deriveGitStatusState", () => { expect(state.error).toBe(error); expect(state.isPending).toBe(false); }); + + it("prunes stale cwd entries when the tracked cwd list shrinks", () => { + const current = new Map([ + ["/repo/a", BASE_STATUS], + ["/repo/b", { ...BASE_STATUS, branch: "feature/other" }], + ]); + + expect(pruneStatusByCwd(current, ["/repo/b"])).toEqual( + new Map([["/repo/b", { ...BASE_STATUS, branch: "feature/other" }]]), + ); + }); }); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 44b5edbe7b..1c6e22e71f 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -1,4 +1,3 @@ -import { useAtomValue } from "@effect/atom-react"; import { type GitManagerServiceError, type GitStatusResult, WS_METHODS } from "@t3tools/contracts"; import { Cause, Option } from "effect"; import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; @@ -27,11 +26,6 @@ const EMPTY_GIT_STATUS_STATE = Object.freeze({ isPending: false, }); -const emptyGitStatusStateAtom = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("git-status-empty"), -); - const gitStatusStreamAtom = Atom.family((cwd: string) => WsRpcAtomClient.query(WS_METHODS.subscribeGitStatus, { cwd }).pipe( Atom.withLabel(`git-status-stream:${cwd}`), @@ -81,7 +75,30 @@ export function refreshGitStatus(cwd: string | null): void { } export function useGitStatus(cwd: string | null): GitStatusState { - return useAtomValue(cwd === null ? emptyGitStatusStateAtom : gitStatusStateAtom(cwd)); + const [snapshot, setSnapshot] = useState<{ + readonly cwd: string | null; + readonly state: GitStatusState; + }>({ + cwd: null, + state: EMPTY_GIT_STATUS_STATE, + }); + + useEffect(() => { + if (cwd === null) { + setSnapshot({ cwd: null, state: EMPTY_GIT_STATUS_STATE }); + return; + } + + return appAtomRegistry.subscribe( + gitStatusStateAtom(cwd), + (state) => { + setSnapshot({ cwd, state }); + }, + { immediate: true }, + ); + }, [cwd]); + + return snapshot.cwd === cwd ? snapshot.state : EMPTY_GIT_STATUS_STATE; } export function useGitStatuses(cwds: ReadonlyArray): ReadonlyMap { @@ -90,6 +107,8 @@ export function useGitStatuses(cwds: ReadonlyArray): ReadonlyMap { + setStatusByCwd((current) => pruneStatusByCwd(current, cwds)); + const cleanups = cwds.map((cwd) => appAtomRegistry.subscribe( gitStatusStateAtom(cwd), @@ -118,6 +137,32 @@ export function useGitStatuses(cwds: ReadonlyArray): ReadonlyMap, + cwds: ReadonlyArray, +): ReadonlyMap { + const cwdSet = new Set(cwds); + let shouldPrune = false; + for (const key of current.keys()) { + if (!cwdSet.has(key)) { + shouldPrune = true; + break; + } + } + + if (!shouldPrune) { + return current; + } + + const next = new Map(); + for (const [key, value] of current) { + if (cwdSet.has(key)) { + next.set(key, value); + } + } + return next; +} + function getLatestGitStatusResult(value: { readonly items: ReadonlyArray; }): GitStatusResult | null { diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts index 9f528b6342..f9b0607347 100644 --- a/apps/web/src/nativeApi.ts +++ b/apps/web/src/nativeApi.ts @@ -25,7 +25,7 @@ export function ensureNativeApi(): NativeApi { return api; } -export function __resetNativeApiForTests() { +export async function __resetNativeApiForTests() { cachedApi = undefined; - __resetWsNativeApiForTests(); + await __resetWsNativeApiForTests(); } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 7b7c83cb8f..38712e8eed 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -178,6 +178,7 @@ beforeEach(() => { showContextMenuFallbackMock.mockReset(); terminalEventListeners.clear(); orchestrationEventListeners.clear(); + gitStatusListeners.clear(); Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index a19d661844..61e390c362 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,6 +1,7 @@ import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; import { showContextMenuFallback } from "./contextMenuFallback"; +import { __resetWsRpcAtomClientForTests } from "./rpc/client"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; import { resetServerStateForTests } from "./rpc/serverState"; import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; @@ -8,8 +9,9 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; let instance: { api: NativeApi } | null = null; -export function __resetWsNativeApiForTests() { +export async function __resetWsNativeApiForTests() { instance = null; + await __resetWsRpcAtomClientForTests(); __resetWsRpcClientForTests(); resetRequestLatencyStateForTests(); resetServerStateForTests(); diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index 8b5fe24294..aeae92d101 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -109,7 +109,7 @@ export class BrowserWsRpcHarness { async onMessage(rawData: string): Promise { const server = await this.serverReady; if (!server) { - throw new Error("RPC test server is not connected"); + return; } const messages = this.parser.decode(rawData); for (const message of messages) { From e8503a9f05f1d6fb350cc3649a66029c17ac4624 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 11:55:03 -0700 Subject: [PATCH 03/21] Share git status subscriptions across consumers - Cache one live git-status stream per cwd - Reset status state for tests and native API teardown - Keep tracked cwd subscriptions stable across re-renders --- apps/web/src/lib/gitStatusState.test.ts | 113 +++++++---- apps/web/src/lib/gitStatusState.ts | 239 ++++++++++++++++-------- apps/web/src/wsNativeApi.ts | 4 +- 3 files changed, 244 insertions(+), 112 deletions(-) diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 5e46273cfb..39a9f9574e 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -1,9 +1,23 @@ -import { GitManagerError, type GitStatusResult } from "@t3tools/contracts"; -import { Cause, Option } from "effect"; -import { AsyncResult } from "effect/unstable/reactivity"; -import { describe, expect, it } from "vitest"; +import type { GitStatusResult } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { deriveGitStatusState, pruneStatusByCwd } from "./gitStatusState"; +import { + getGitStatusSnapshot, + getGitStatusSubscriptionKey, + pruneStatusByCwd, + refreshGitStatus, + resetGitStatusStateForTests, + retainGitStatusSync, +} from "./gitStatusState"; + +function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); const BASE_STATUS: GitStatusResult = { isRepo: true, @@ -18,45 +32,68 @@ const BASE_STATUS: GitStatusResult = { pr: null, }; -describe("deriveGitStatusState", () => { - it("uses the latest streamed snapshot as the current git status", () => { - const streamedStatuses: [GitStatusResult, ...GitStatusResult[]] = [ - BASE_STATUS, - { ...BASE_STATUS, branch: "feature/updated-status" }, - ]; - const state = deriveGitStatusState( - AsyncResult.success({ - done: false, - items: streamedStatuses, - }), - ); +const gitClient = { + onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(gitStatusListeners, listener), + ), +}; + +function emitGitStatus(event: GitStatusResult) { + for (const listener of gitStatusListeners) { + listener(event); + } +} + +afterEach(() => { + gitStatusListeners.clear(); + gitClient.onStatus.mockClear(); + resetGitStatusStateForTests(); +}); + +describe("gitStatusState", () => { + it("shares one live status subscription per cwd and updates the cached snapshot", () => { + const releaseA = retainGitStatusSync("/repo", gitClient); + const releaseB = retainGitStatusSync("/repo", gitClient); + + expect(gitClient.onStatus).toHaveBeenCalledOnce(); + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: null, + error: null, + cause: null, + isPending: true, + }); - expect(state).toEqual({ - data: { ...BASE_STATUS, branch: "feature/updated-status" }, + emitGitStatus(BASE_STATUS); + + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: BASE_STATUS, error: null, cause: null, isPending: false, }); + + releaseA(); + expect(gitStatusListeners.size).toBe(1); + + releaseB(); + expect(gitStatusListeners.size).toBe(0); }); - it("preserves the previous snapshot when the stream fails after succeeding", () => { - const previousSuccess = AsyncResult.success({ - done: false, - items: [BASE_STATUS] as [GitStatusResult], - }); - const error = new GitManagerError({ - operation: "subscribeGitStatus", - detail: "stream disconnected", + it("restarts the live stream when explicitly refreshed", () => { + const release = retainGitStatusSync("/repo", gitClient); + + emitGitStatus(BASE_STATUS); + refreshGitStatus("/repo"); + + expect(gitClient.onStatus).toHaveBeenCalledTimes(2); + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: BASE_STATUS, + error: null, + cause: null, + isPending: true, }); - const state = deriveGitStatusState( - AsyncResult.failure(Cause.fail(error), { - previousSuccess: Option.some(previousSuccess), - }), - ); - expect(state.data).toEqual(BASE_STATUS); - expect(state.error).toBe(error); - expect(state.isPending).toBe(false); + release(); }); it("prunes stale cwd entries when the tracked cwd list shrinks", () => { @@ -69,4 +106,10 @@ describe("deriveGitStatusState", () => { new Map([["/repo/b", { ...BASE_STATUS, branch: "feature/other" }]]), ); }); + + it("keeps the same subscription key when the tracked cwd set is unchanged", () => { + expect(getGitStatusSubscriptionKey(["/repo/b", "/repo/a", "/repo/a"])).toBe( + getGitStatusSubscriptionKey(["/repo/a", "/repo/b"]), + ); + }); }); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 1c6e22e71f..7ef013f538 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -1,16 +1,13 @@ -import { type GitManagerServiceError, type GitStatusResult, WS_METHODS } from "@t3tools/contracts"; -import { Cause, Option } from "effect"; -import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useEffect, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts"; +import { Cause } from "effect"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { WsRpcAtomClient } from "../rpc/client"; +import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; -export type GitStatusStreamError = - | GitManagerServiceError - | RpcClientError - | Cause.NoSuchElementError; +export type GitStatusStreamError = GitManagerServiceError; export interface GitStatusState { readonly data: GitStatusResult | null; @@ -19,6 +16,15 @@ export interface GitStatusState { readonly isPending: boolean; } +type GitStatusClient = Pick; + +interface GitStatusEntry { + readonly atom: typeof EMPTY_GIT_STATUS_STATE_ATOM; + client: GitStatusClient | null; + retainCount: number; + unsubscribe: () => void; +} + const EMPTY_GIT_STATUS_STATE = Object.freeze({ data: null, error: null, @@ -26,43 +32,116 @@ const EMPTY_GIT_STATUS_STATE = Object.freeze({ isPending: false, }); -const gitStatusStreamAtom = Atom.family((cwd: string) => - WsRpcAtomClient.query(WS_METHODS.subscribeGitStatus, { cwd }).pipe( - Atom.withLabel(`git-status-stream:${cwd}`), - ), -); - -const gitStatusStateAtom = Atom.family((cwd: string) => - Atom.make((get) => deriveGitStatusState(get(gitStatusStreamAtom(cwd)))).pipe( - Atom.withLabel(`git-status-state:${cwd}`), - ), -); - -export function deriveGitStatusState( - result: Atom.PullResult, -): GitStatusState { - if (AsyncResult.isSuccess(result)) { - return { - data: getLatestGitStatusResult(result.value), - error: null, - cause: null, - isPending: result.waiting, - }; +const EMPTY_GIT_STATUS_STATE_ATOM = makeStateAtom("git-status-empty", EMPTY_GIT_STATUS_STATE); +const NOOP: () => void = () => undefined; +const gitStatusEntries = new Map(); + +function makeStateAtom(label: string, initialValue: A) { + return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); +} + +function getGitStatusEntry(cwd: string): GitStatusEntry { + const existing = gitStatusEntries.get(cwd); + if (existing) { + return existing; } - if (AsyncResult.isFailure(result)) { - const previousSuccess = Option.getOrNull(result.previousSuccess); - return { - data: previousSuccess ? getLatestGitStatusResult(previousSuccess.value) : null, - error: Option.getOrNull(Cause.findErrorOption(result.cause)), - cause: result.cause, - isPending: result.waiting, - }; + const entry: GitStatusEntry = { + atom: makeStateAtom(`git-status:${cwd}`, EMPTY_GIT_STATUS_STATE), + client: null, + retainCount: 0, + unsubscribe: NOOP, + }; + gitStatusEntries.set(cwd, entry); + return entry; +} + +function connectGitStatusEntry(cwd: string, entry: GitStatusEntry): void { + if (!entry.client) { + return; + } + + markGitStatusPending(entry.atom); + entry.unsubscribe = entry.client.onStatus( + { cwd }, + (status) => { + appAtomRegistry.set(entry.atom, { + data: status, + error: null, + cause: null, + isPending: false, + }); + }, + { + onResubscribe: () => { + markGitStatusPending(entry.atom); + }, + }, + ); +} + +function disconnectGitStatusEntry(entry: GitStatusEntry): void { + entry.unsubscribe(); + entry.unsubscribe = NOOP; +} + +function markGitStatusPending(atom: typeof EMPTY_GIT_STATUS_STATE_ATOM): void { + const current = appAtomRegistry.get(atom); + const nextState = + current.data === null + ? { ...EMPTY_GIT_STATUS_STATE, isPending: true } + : { + ...current, + error: null, + cause: null, + isPending: true, + }; + + if ( + current.data === nextState.data && + current.error === nextState.error && + current.cause === nextState.cause && + current.isPending === nextState.isPending + ) { + return; + } + + appAtomRegistry.set(atom, nextState); +} + +export function getGitStatusSnapshot(cwd: string | null): GitStatusState { + if (cwd === null) { + return EMPTY_GIT_STATUS_STATE; + } + + return appAtomRegistry.get(getGitStatusEntry(cwd).atom); +} + +export function retainGitStatusSync( + cwd: string | null, + client: GitStatusClient = getWsRpcClient().git, +): () => void { + if (cwd === null) { + return NOOP; + } + + const entry = getGitStatusEntry(cwd); + entry.client = client; + entry.retainCount += 1; + + if (entry.retainCount === 1) { + connectGitStatusEntry(cwd, entry); } - return { - ...EMPTY_GIT_STATUS_STATE, - isPending: true, + return () => { + if (entry.retainCount === 0) { + return; + } + + entry.retainCount -= 1; + if (entry.retainCount === 0) { + disconnectGitStatusEntry(entry); + } }; } @@ -71,47 +150,50 @@ export function refreshGitStatus(cwd: string | null): void { return; } - appAtomRegistry.refresh(gitStatusStreamAtom(cwd)); + const entry = gitStatusEntries.get(cwd); + if (!entry || entry.retainCount === 0) { + return; + } + + disconnectGitStatusEntry(entry); + connectGitStatusEntry(cwd, entry); } -export function useGitStatus(cwd: string | null): GitStatusState { - const [snapshot, setSnapshot] = useState<{ - readonly cwd: string | null; - readonly state: GitStatusState; - }>({ - cwd: null, - state: EMPTY_GIT_STATUS_STATE, - }); +export function resetGitStatusStateForTests(): void { + for (const entry of gitStatusEntries.values()) { + disconnectGitStatusEntry(entry); + } + gitStatusEntries.clear(); +} - useEffect(() => { - if (cwd === null) { - setSnapshot({ cwd: null, state: EMPTY_GIT_STATUS_STATE }); - return; - } +export function useGitStatus(cwd: string | null): GitStatusState { + useEffect(() => retainGitStatusSync(cwd), [cwd]); - return appAtomRegistry.subscribe( - gitStatusStateAtom(cwd), - (state) => { - setSnapshot({ cwd, state }); - }, - { immediate: true }, - ); - }, [cwd]); + const atom = useMemo( + () => (cwd === null ? EMPTY_GIT_STATUS_STATE_ATOM : getGitStatusEntry(cwd).atom), + [cwd], + ); - return snapshot.cwd === cwd ? snapshot.state : EMPTY_GIT_STATUS_STATE; + return useAtomValue(atom); } export function useGitStatuses(cwds: ReadonlyArray): ReadonlyMap { const [statusByCwd, setStatusByCwd] = useState>( () => new Map(), ); + const subscriptionKey = getGitStatusSubscriptionKey(cwds); + const trackedCwds = useMemo>( + () => (subscriptionKey.length === 0 ? [] : subscriptionKey.split("\u0000")), + [subscriptionKey], + ); useEffect(() => { - setStatusByCwd((current) => pruneStatusByCwd(current, cwds)); + const releaseSubscriptions = trackedCwds.map((cwd) => retainGitStatusSync(cwd)); + setStatusByCwd((current) => pruneStatusByCwd(current, trackedCwds)); - const cleanups = cwds.map((cwd) => + const cleanups = trackedCwds.map((cwd) => appAtomRegistry.subscribe( - gitStatusStateAtom(cwd), + getGitStatusEntry(cwd).atom, (state) => { setStatusByCwd((current) => { const next = new Map(current); @@ -131,12 +213,23 @@ export function useGitStatuses(cwds: ReadonlyArray): ReadonlyMap): string { + return normalizeTrackedGitStatusCwds(cwds).join("\u0000"); +} + +function normalizeTrackedGitStatusCwds(cwds: ReadonlyArray): ReadonlyArray { + return [...new Set(cwds)].toSorted(); +} + export function pruneStatusByCwd( current: ReadonlyMap, cwds: ReadonlyArray, @@ -162,9 +255,3 @@ export function pruneStatusByCwd( } return next; } - -function getLatestGitStatusResult(value: { - readonly items: ReadonlyArray; -}): GitStatusResult | null { - return value.items.at(-1) ?? null; -} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 61e390c362..045f6bc9f0 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,5 +1,6 @@ import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; +import { resetGitStatusStateForTests } from "./lib/gitStatusState"; import { showContextMenuFallback } from "./contextMenuFallback"; import { __resetWsRpcAtomClientForTests } from "./rpc/client"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; @@ -12,7 +13,8 @@ let instance: { api: NativeApi } | null = null; export async function __resetWsNativeApiForTests() { instance = null; await __resetWsRpcAtomClientForTests(); - __resetWsRpcClientForTests(); + await __resetWsRpcClientForTests(); + resetGitStatusStateForTests(); resetRequestLatencyStateForTests(); resetServerStateForTests(); resetWsConnectionStateForTests(); From 366182e825034b3f7c0b07c4605892bef71cd009 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 12:25:52 -0700 Subject: [PATCH 04/21] Use per-thread git status subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve PR status from each thread’s cwd in the sidebar - Refactor git status state to shared per-cwd watches - Update git status state tests - Co-authored-by: codex --- apps/web/src/components/Sidebar.tsx | 54 ++--- apps/web/src/lib/gitStatusState.test.ts | 29 +-- apps/web/src/lib/gitStatusState.ts | 283 +++++++++--------------- 3 files changed, 133 insertions(+), 233 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c497415a0c..65db68e7f8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -69,7 +69,7 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { useGitStatuses } from "../lib/gitStatusState"; +import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -244,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { return null; } +function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + interface SidebarThreadRowProps { threadId: ThreadId; + projectCwd: string | null; orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet; @@ -276,7 +288,6 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadId: ThreadId) => Promise; openPrLink: (event: MouseEvent, prUrl: string) => void; - pr: ThreadPr | null; } function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -291,6 +302,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { return null; } + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus(thread.branch !== null ? gitCwd : null); const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; @@ -302,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { lastVisitedAt, }, }); - const prStatus = prStatusIndicator(props.pr); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -761,38 +775,6 @@ export default function Sidebar() { }), [platform, routeTerminalOpen], ); - const threadGitTargets = useMemo( - () => - sidebarThreads.map((thread) => ({ - threadId: thread.id, - branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, - })), - [projectCwdById, sidebarThreads], - ); - const threadGitStatusCwds = useMemo( - () => [ - ...new Set( - threadGitTargets - .filter((target) => target.branch !== null) - .map((target) => target.cwd) - .filter((cwd): cwd is string => cwd !== null), - ), - ], - [threadGitTargets], - ); - const threadGitStatuses = useGitStatuses(threadGitStatusCwds); - const prByThreadId = useMemo(() => { - const map = new Map(); - for (const target of threadGitTargets) { - const status = target.cwd ? threadGitStatuses.get(target.cwd) : undefined; - const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); - } - return map; - }, [threadGitStatuses, threadGitTargets]); - const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -1701,6 +1683,7 @@ export default function Sidebar() { ))} diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 39a9f9574e..eaab931c30 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -3,11 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getGitStatusSnapshot, - getGitStatusSubscriptionKey, - pruneStatusByCwd, refreshGitStatus, resetGitStatusStateForTests, - retainGitStatusSync, + watchGitStatus, } from "./gitStatusState"; function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { @@ -51,9 +49,9 @@ afterEach(() => { }); describe("gitStatusState", () => { - it("shares one live status subscription per cwd and updates the cached snapshot", () => { - const releaseA = retainGitStatusSync("/repo", gitClient); - const releaseB = retainGitStatusSync("/repo", gitClient); + it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { + const releaseA = watchGitStatus("/repo", gitClient); + const releaseB = watchGitStatus("/repo", gitClient); expect(gitClient.onStatus).toHaveBeenCalledOnce(); expect(getGitStatusSnapshot("/repo")).toEqual({ @@ -80,7 +78,7 @@ describe("gitStatusState", () => { }); it("restarts the live stream when explicitly refreshed", () => { - const release = retainGitStatusSync("/repo", gitClient); + const release = watchGitStatus("/repo", gitClient); emitGitStatus(BASE_STATUS); refreshGitStatus("/repo"); @@ -95,21 +93,4 @@ describe("gitStatusState", () => { release(); }); - - it("prunes stale cwd entries when the tracked cwd list shrinks", () => { - const current = new Map([ - ["/repo/a", BASE_STATUS], - ["/repo/b", { ...BASE_STATUS, branch: "feature/other" }], - ]); - - expect(pruneStatusByCwd(current, ["/repo/b"])).toEqual( - new Map([["/repo/b", { ...BASE_STATUS, branch: "feature/other" }]]), - ); - }); - - it("keeps the same subscription key when the tracked cwd set is unchanged", () => { - expect(getGitStatusSubscriptionKey(["/repo/b", "/repo/a", "/repo/a"])).toBe( - getGitStatusSubscriptionKey(["/repo/a", "/repo/b"]), - ); - }); }); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 7ef013f538..b468686f00 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts"; import { Cause } from "effect"; import { Atom } from "effect/unstable/reactivity"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; @@ -18,10 +18,8 @@ export interface GitStatusState { type GitStatusClient = Pick; -interface GitStatusEntry { - readonly atom: typeof EMPTY_GIT_STATUS_STATE_ATOM; - client: GitStatusClient | null; - retainCount: number; +interface WatchedGitStatus { + refCount: number; unsubscribe: () => void; } @@ -32,92 +30,29 @@ const EMPTY_GIT_STATUS_STATE = Object.freeze({ isPending: false, }); -const EMPTY_GIT_STATUS_STATE_ATOM = makeStateAtom("git-status-empty", EMPTY_GIT_STATUS_STATE); const NOOP: () => void = () => undefined; -const gitStatusEntries = new Map(); +const watchedGitStatuses = new Map(); +const knownGitStatusCwds = new Set(); -function makeStateAtom(label: string, initialValue: A) { - return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); -} - -function getGitStatusEntry(cwd: string): GitStatusEntry { - const existing = gitStatusEntries.get(cwd); - if (existing) { - return existing; - } +let sharedGitStatusClient: GitStatusClient | null = null; - const entry: GitStatusEntry = { - atom: makeStateAtom(`git-status:${cwd}`, EMPTY_GIT_STATUS_STATE), - client: null, - retainCount: 0, - unsubscribe: NOOP, - }; - gitStatusEntries.set(cwd, entry); - return entry; -} - -function connectGitStatusEntry(cwd: string, entry: GitStatusEntry): void { - if (!entry.client) { - return; - } - - markGitStatusPending(entry.atom); - entry.unsubscribe = entry.client.onStatus( - { cwd }, - (status) => { - appAtomRegistry.set(entry.atom, { - data: status, - error: null, - cause: null, - isPending: false, - }); - }, - { - onResubscribe: () => { - markGitStatusPending(entry.atom); - }, - }, +const gitStatusStateAtom = Atom.family((cwd: string) => { + knownGitStatusCwds.add(cwd); + return Atom.make(EMPTY_GIT_STATUS_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`git-status:${cwd}`), ); -} - -function disconnectGitStatusEntry(entry: GitStatusEntry): void { - entry.unsubscribe(); - entry.unsubscribe = NOOP; -} - -function markGitStatusPending(atom: typeof EMPTY_GIT_STATUS_STATE_ATOM): void { - const current = appAtomRegistry.get(atom); - const nextState = - current.data === null - ? { ...EMPTY_GIT_STATUS_STATE, isPending: true } - : { - ...current, - error: null, - cause: null, - isPending: true, - }; - - if ( - current.data === nextState.data && - current.error === nextState.error && - current.cause === nextState.cause && - current.isPending === nextState.isPending - ) { - return; - } - - appAtomRegistry.set(atom, nextState); -} +}); export function getGitStatusSnapshot(cwd: string | null): GitStatusState { if (cwd === null) { return EMPTY_GIT_STATUS_STATE; } - return appAtomRegistry.get(getGitStatusEntry(cwd).atom); + return appAtomRegistry.get(gitStatusStateAtom(cwd)); } -export function retainGitStatusSync( +export function watchGitStatus( cwd: string | null, client: GitStatusClient = getWsRpcClient().git, ): () => void { @@ -125,24 +60,20 @@ export function retainGitStatusSync( return NOOP; } - const entry = getGitStatusEntry(cwd); - entry.client = client; - entry.retainCount += 1; + ensureGitStatusClient(client); - if (entry.retainCount === 1) { - connectGitStatusEntry(cwd, entry); + const watched = watchedGitStatuses.get(cwd); + if (watched) { + watched.refCount += 1; + return () => unwatchGitStatus(cwd); } - return () => { - if (entry.retainCount === 0) { - return; - } + watchedGitStatuses.set(cwd, { + refCount: 1, + unsubscribe: subscribeToGitStatus(cwd), + }); - entry.retainCount -= 1; - if (entry.retainCount === 0) { - disconnectGitStatusEntry(entry); - } - }; + return () => unwatchGitStatus(cwd); } export function refreshGitStatus(cwd: string | null): void { @@ -150,108 +81,114 @@ export function refreshGitStatus(cwd: string | null): void { return; } - const entry = gitStatusEntries.get(cwd); - if (!entry || entry.retainCount === 0) { + const watched = watchedGitStatuses.get(cwd); + if (!watched) { return; } - disconnectGitStatusEntry(entry); - connectGitStatusEntry(cwd, entry); + watched.unsubscribe(); + watched.unsubscribe = subscribeToGitStatus(cwd); } export function resetGitStatusStateForTests(): void { - for (const entry of gitStatusEntries.values()) { - disconnectGitStatusEntry(entry); + for (const watched of watchedGitStatuses.values()) { + watched.unsubscribe(); } - gitStatusEntries.clear(); + watchedGitStatuses.clear(); + sharedGitStatusClient = null; + + for (const cwd of knownGitStatusCwds) { + appAtomRegistry.set(gitStatusStateAtom(cwd), EMPTY_GIT_STATUS_STATE); + } + knownGitStatusCwds.clear(); } export function useGitStatus(cwd: string | null): GitStatusState { - useEffect(() => retainGitStatusSync(cwd), [cwd]); - - const atom = useMemo( - () => (cwd === null ? EMPTY_GIT_STATUS_STATE_ATOM : getGitStatusEntry(cwd).atom), - [cwd], - ); + useEffect(() => watchGitStatus(cwd), [cwd]); - return useAtomValue(atom); + return cwd === null ? EMPTY_GIT_STATUS_STATE : useAtomValue(gitStatusStateAtom(cwd)); } -export function useGitStatuses(cwds: ReadonlyArray): ReadonlyMap { - const [statusByCwd, setStatusByCwd] = useState>( - () => new Map(), - ); - const subscriptionKey = getGitStatusSubscriptionKey(cwds); - const trackedCwds = useMemo>( - () => (subscriptionKey.length === 0 ? [] : subscriptionKey.split("\u0000")), - [subscriptionKey], - ); +function ensureGitStatusClient(client: GitStatusClient): void { + if (sharedGitStatusClient === client) { + return; + } - useEffect(() => { - const releaseSubscriptions = trackedCwds.map((cwd) => retainGitStatusSync(cwd)); - setStatusByCwd((current) => pruneStatusByCwd(current, trackedCwds)); - - const cleanups = trackedCwds.map((cwd) => - appAtomRegistry.subscribe( - getGitStatusEntry(cwd).atom, - (state) => { - setStatusByCwd((current) => { - const next = new Map(current); - if (state.data) { - next.set(cwd, state.data); - } else { - next.delete(cwd); - } - return next; - }); - }, - { immediate: true }, - ), - ); - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - for (const release of releaseSubscriptions) { - release(); - } - }; - }, [trackedCwds]); - - return statusByCwd; -} + if (sharedGitStatusClient !== null) { + resetLiveGitStatusSubscriptions(); + } -export function getGitStatusSubscriptionKey(cwds: ReadonlyArray): string { - return normalizeTrackedGitStatusCwds(cwds).join("\u0000"); + sharedGitStatusClient = client; } -function normalizeTrackedGitStatusCwds(cwds: ReadonlyArray): ReadonlyArray { - return [...new Set(cwds)].toSorted(); +function resetLiveGitStatusSubscriptions(): void { + for (const watched of watchedGitStatuses.values()) { + watched.unsubscribe(); + } + watchedGitStatuses.clear(); } -export function pruneStatusByCwd( - current: ReadonlyMap, - cwds: ReadonlyArray, -): ReadonlyMap { - const cwdSet = new Set(cwds); - let shouldPrune = false; - for (const key of current.keys()) { - if (!cwdSet.has(key)) { - shouldPrune = true; - break; - } +function unwatchGitStatus(cwd: string): void { + const watched = watchedGitStatuses.get(cwd); + if (!watched) { + return; + } + + watched.refCount -= 1; + if (watched.refCount > 0) { + return; } - if (!shouldPrune) { - return current; + watched.unsubscribe(); + watchedGitStatuses.delete(cwd); +} + +function subscribeToGitStatus(cwd: string): () => void { + const client = sharedGitStatusClient; + if (!client) { + return NOOP; } - const next = new Map(); - for (const [key, value] of current) { - if (cwdSet.has(key)) { - next.set(key, value); - } + markGitStatusPending(cwd); + return client.onStatus( + { cwd }, + (status) => { + appAtomRegistry.set(gitStatusStateAtom(cwd), { + data: status, + error: null, + cause: null, + isPending: false, + }); + }, + { + onResubscribe: () => { + markGitStatusPending(cwd); + }, + }, + ); +} + +function markGitStatusPending(cwd: string): void { + const atom = gitStatusStateAtom(cwd); + const current = appAtomRegistry.get(atom); + const next = + current.data === null + ? { ...EMPTY_GIT_STATUS_STATE, isPending: true } + : { + ...current, + error: null, + cause: null, + isPending: true, + }; + + if ( + current.data === next.data && + current.error === next.error && + current.cause === next.cause && + current.isPending === next.isPending + ) { + return; } - return next; + + appAtomRegistry.set(atom, next); } From 8b9304ca7c764ed092046800c5cf753d84816d6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 12:27:06 -0700 Subject: [PATCH 05/21] Keep SidebarThreadRow hooks ordered before early return - Compute git status inputs before the null guard - Preserve hook order while rendering thread rows --- apps/web/src/components/Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 65db68e7f8..78b8326af5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -297,13 +297,13 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { (state) => selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); + const gitCwd = thread?.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus(thread?.branch !== null ? gitCwd : null); if (!thread) { return null; } - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useGitStatus(thread.branch !== null ? gitCwd : null); const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; From 60b9b6c54f79b07ca1702922b0266a9cbbfc8c64 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 12:32:26 -0700 Subject: [PATCH 06/21] Remove git status refresh on menu open - Rely on the existing watcher instead of resubscribing when the menu opens - Drop the obsolete refresh helper and its test --- apps/web/src/components/GitActionsControl.tsx | 8 ++------ apps/web/src/lib/gitStatusState.test.ts | 18 ------------------ apps/web/src/lib/gitStatusState.ts | 14 -------------- 3 files changed, 2 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1a30f9b6b0..bd931f517b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -46,7 +46,7 @@ import { gitPullMutationOptions, gitRunStackedActionMutationOptions, } from "~/lib/gitReactQuery"; -import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; +import { useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; @@ -798,11 +798,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions )} - { - if (open) refreshGitStatus(gitCwd); - }} - > + } disabled={isGitActionRunning} diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index eaab931c30..9ea94acd90 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getGitStatusSnapshot, - refreshGitStatus, resetGitStatusStateForTests, watchGitStatus, } from "./gitStatusState"; @@ -76,21 +75,4 @@ describe("gitStatusState", () => { releaseB(); expect(gitStatusListeners.size).toBe(0); }); - - it("restarts the live stream when explicitly refreshed", () => { - const release = watchGitStatus("/repo", gitClient); - - emitGitStatus(BASE_STATUS); - refreshGitStatus("/repo"); - - expect(gitClient.onStatus).toHaveBeenCalledTimes(2); - expect(getGitStatusSnapshot("/repo")).toEqual({ - data: BASE_STATUS, - error: null, - cause: null, - isPending: true, - }); - - release(); - }); }); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index b468686f00..c8df1250ce 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -76,20 +76,6 @@ export function watchGitStatus( return () => unwatchGitStatus(cwd); } -export function refreshGitStatus(cwd: string | null): void { - if (cwd === null) { - return; - } - - const watched = watchedGitStatuses.get(cwd); - if (!watched) { - return; - } - - watched.unsubscribe(); - watched.unsubscribe = subscribeToGitStatus(cwd); -} - export function resetGitStatusStateForTests(): void { for (const watched of watchedGitStatuses.values()) { watched.unsubscribe(); From c7460b860446f9d7054737067f2c1e2584447b08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 14:10:52 -0700 Subject: [PATCH 07/21] Add explicit git status refresh RPC - Refresh git status after pull and stacked actions - Rehydrate status on window focus and menu open - Wire refresh through server, web, and contracts --- .../src/git/Layers/GitStatusBroadcaster.ts | 3 +- apps/server/src/server.test.ts | 88 +++++++++++++++++++ apps/server/src/ws.ts | 18 +++- apps/web/src/components/GitActionsControl.tsx | 33 ++++++- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/lib/gitReactQuery.ts | 4 +- apps/web/src/lib/gitStatusState.test.ts | 25 ++++++ apps/web/src/lib/gitStatusState.ts | 39 +++++++- apps/web/src/wsNativeApi.test.ts | 38 +++++--- apps/web/src/wsNativeApi.ts | 1 + apps/web/src/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 1 + packages/contracts/src/rpc.ts | 8 ++ 13 files changed, 241 insertions(+), 22 deletions(-) diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts index c75326dfbe..5f97954e97 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -127,11 +127,12 @@ export const GitStatusBroadcasterLive = Layer.effect( Effect.gen(function* () { const normalizedCwd = normalizeCwd(input.cwd); yield* ensurePoller(normalizedCwd); + const subscription = yield* PubSub.subscribe(changesPubSub); const initialStatus = yield* getStatus({ cwd: normalizedCwd }); return Stream.concat( Stream.make(initialStatus), - Stream.fromPubSub(changesPubSub).pipe( + Stream.fromEffectRepeat(PubSub.take(subscription)).pipe( Stream.filter((event) => event.cwd === normalizedCwd), Stream.map((event) => event.status), ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 060dddd5b9..a3cc754dfd 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1390,6 +1390,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(pull.status, "pulled"); + const refreshedStatus = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), + ), + ); + assert.equal(refreshedStatus.isRepo, true); + const stackedEvents = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitRunStackedAction]({ @@ -1492,11 +1499,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: "/tmp/repo", detail: "upstream missing", }); + let invalidationCalls = 0; + let statusCalls = 0; yield* buildAppUnderTest({ layers: { gitCore: { pullCurrentBranch: () => Effect.fail(gitError), }, + gitManager: { + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + }, }, }); @@ -1508,6 +1539,63 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertFailure(result, gitError); + assert.equal(invalidationCalls, 1); + assert.equal(statusCalls, 1); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () => + Effect.gen(function* () { + const gitError = new GitCommandError({ + operation: "commit", + command: "git commit", + cwd: "/tmp/repo", + detail: "nothing to commit", + }); + let invalidationCalls = 0; + let statusCalls = 0; + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + runStackedAction: () => Effect.fail(gitError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect, Effect.result), + ), + ); + + assertFailure(result, gitError); + assert.equal(invalidationCalls, 1); + assert.equal(statusCalls, 1); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8514469495..6697411062 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -570,10 +570,20 @@ const WsRpcLayer = WsRpcGroup.toLayer( observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { "rpc.aggregate": "git", }), + [WS_METHODS.gitRefreshStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitRefreshStatus, + gitStatusBroadcaster.refreshStatus(input.cwd), + { + "rpc.aggregate": "git", + }, + ), [WS_METHODS.gitPull]: (input) => observeRpcEffect( WS_METHODS.gitPull, - git.pullCurrentBranch(input.cwd).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + git + .pullCurrentBranch(input.cwd) + .pipe(Effect.ensuring(refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true })))), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitRunStackedAction]: (input) => @@ -589,7 +599,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( }) .pipe( Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), + onFailure: (cause) => + refreshGitStatus(input.cwd).pipe( + Effect.ignore({ log: true }), + Effect.andThen(Queue.failCause(queue, cause)), + ), onSuccess: () => refreshGitStatus(input.cwd).pipe( Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index bd931f517b..36d528b2da 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -46,7 +46,7 @@ import { gitPullMutationOptions, gitRunStackedActionMutationOptions, } from "~/lib/gitReactQuery"; -import { useGitStatus } from "~/lib/gitStatusState"; +import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; @@ -358,6 +358,29 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }; }, [updateActiveProgressToast]); + useEffect(() => { + if (gitCwd === null) { + return; + } + + const refreshCurrentGitStatus = () => { + void refreshGitStatus(gitCwd).catch(() => undefined); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + refreshCurrentGitStatus(); + } + }; + + window.addEventListener("focus", refreshCurrentGitStatus); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.removeEventListener("focus", refreshCurrentGitStatus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [gitCwd]); + const openExistingPr = useCallback(async () => { const api = readNativeApi(); if (!api) { @@ -798,7 +821,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions )} - + { + if (open) { + void refreshGitStatus(gitCwd).catch(() => undefined); + } + }} + > } disabled={isGitActionRunning} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 78b8326af5..d227e3a803 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -298,7 +298,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); const gitCwd = thread?.worktreePath ?? props.projectCwd; - const gitStatus = useGitStatus(thread?.branch !== null ? gitCwd : null); + const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); if (!thread) { return null; diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index ff2d7b2591..a2611ebe25 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -106,7 +106,7 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient: if (!input.cwd) throw new Error("Git init is unavailable."); return api.git.init({ cwd: input.cwd }); }, - onSuccess: async () => { + onSettled: async () => { await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); @@ -123,7 +123,7 @@ export function gitCheckoutMutationOptions(input: { if (!input.cwd) throw new Error("Git checkout is unavailable."); return api.git.checkout({ cwd: input.cwd, branch }); }, - onSuccess: async () => { + onSettled: async () => { await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 9ea94acd90..14f1f355d4 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getGitStatusSnapshot, resetGitStatusStateForTests, + refreshGitStatus, watchGitStatus, } from "./gitStatusState"; @@ -30,6 +31,10 @@ const BASE_STATUS: GitStatusResult = { }; const gitClient = { + refreshStatus: vi.fn(async (input: { cwd: string }) => ({ + ...BASE_STATUS, + branch: `${input.cwd}-refreshed`, + })), onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => registerListener(gitStatusListeners, listener), ), @@ -44,6 +49,7 @@ function emitGitStatus(event: GitStatusResult) { afterEach(() => { gitStatusListeners.clear(); gitClient.onStatus.mockClear(); + gitClient.refreshStatus.mockClear(); resetGitStatusStateForTests(); }); @@ -75,4 +81,23 @@ describe("gitStatusState", () => { releaseB(); expect(gitStatusListeners.size).toBe(0); }); + + it("refreshes git status through the unary RPC without restarting the stream", async () => { + const release = watchGitStatus("/repo", gitClient); + + emitGitStatus(BASE_STATUS); + const refreshed = await refreshGitStatus("/repo", gitClient); + + expect(gitClient.onStatus).toHaveBeenCalledOnce(); + expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: BASE_STATUS, + error: null, + cause: null, + isPending: false, + }); + + release(); + }); }); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index c8df1250ce..9d85205f2a 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -16,7 +16,7 @@ export interface GitStatusState { readonly isPending: boolean; } -type GitStatusClient = Pick; +type GitStatusClient = Pick; interface WatchedGitStatus { refCount: number; @@ -33,6 +33,10 @@ const EMPTY_GIT_STATUS_STATE = Object.freeze({ const NOOP: () => void = () => undefined; const watchedGitStatuses = new Map(); const knownGitStatusCwds = new Set(); +const gitStatusRefreshInFlight = new Map>(); +const gitStatusLastRefreshAtByCwd = new Map(); + +const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; let sharedGitStatusClient: GitStatusClient | null = null; @@ -76,11 +80,41 @@ export function watchGitStatus( return () => unwatchGitStatus(cwd); } +export function refreshGitStatus( + cwd: string | null, + client: GitStatusClient = getWsRpcClient().git, +): Promise { + if (cwd === null) { + return Promise.resolve(null); + } + + ensureGitStatusClient(client); + + const currentInFlight = gitStatusRefreshInFlight.get(cwd); + if (currentInFlight) { + return currentInFlight; + } + + const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0; + if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { + return Promise.resolve(getGitStatusSnapshot(cwd).data); + } + + gitStatusLastRefreshAtByCwd.set(cwd, Date.now()); + const refreshPromise = client.refreshStatus({ cwd }).finally(() => { + gitStatusRefreshInFlight.delete(cwd); + }); + gitStatusRefreshInFlight.set(cwd, refreshPromise); + return refreshPromise; +} + export function resetGitStatusStateForTests(): void { for (const watched of watchedGitStatuses.values()) { watched.unsubscribe(); } watchedGitStatuses.clear(); + gitStatusRefreshInFlight.clear(); + gitStatusLastRefreshAtByCwd.clear(); sharedGitStatusClient = null; for (const cwd of knownGitStatusCwds) { @@ -92,7 +126,8 @@ export function resetGitStatusStateForTests(): void { export function useGitStatus(cwd: string | null): GitStatusState { useEffect(() => watchGitStatus(cwd), [cwd]); - return cwd === null ? EMPTY_GIT_STATUS_STATE : useAtomValue(gitStatusStateAtom(cwd)); + const state = useAtomValue(gitStatusStateAtom(cwd ?? "")); + return cwd === null ? EMPTY_GIT_STATUS_STATE : state; } function ensureGitStatusClient(client: GitStatusClient): void { diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 38712e8eed..ae56f85991 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -56,6 +56,7 @@ const rpcClientMock = { }, git: { pull: vi.fn(), + refreshStatus: vi.fn(), onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => registerListener(gitStatusListeners, listener), ), @@ -172,6 +173,19 @@ const baseServerConfig: ServerConfig = { settings: DEFAULT_SERVER_SETTINGS, }; +const baseGitStatus: GitStatusResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/streamed", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); @@ -256,24 +270,24 @@ describe("wsNativeApi", () => { api.git.onStatus({ cwd: "/repo" }, onStatus); - const gitStatus = { - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/streamed", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - } satisfies GitStatusResult; + const gitStatus = baseGitStatus; emitEvent(gitStatusListeners, gitStatus); expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); expect(onStatus).toHaveBeenCalledWith(gitStatus); }); + it("forwards git status refreshes directly to the RPC client", async () => { + rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + + await api.git.refreshStatus({ cwd: "/repo" }); + + expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + }); + it("forwards orchestration stream subscription options to the RPC client", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 045f6bc9f0..3cfb976e09 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -69,6 +69,7 @@ export function createWsNativeApi(): NativeApi { }, git: { pull: rpcClient.git.pull, + refreshStatus: rpcClient.git.refreshStatus, onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), listBranches: rpcClient.git.listBranches, createWorktree: rpcClient.git.createWorktree, diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 3e099507b5..e8d9486a74 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -65,6 +65,7 @@ export interface WsRpcClient { }; readonly git: { readonly pull: RpcUnaryMethod; + readonly refreshStatus: RpcUnaryMethod; readonly onStatus: ( input: RpcInput, listener: (status: GitStatusResult) => void, @@ -154,6 +155,8 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { }, git: { pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), + refreshStatus: (input) => + transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), onStatus: (input, listener, options) => transport.subscribe( (client) => client[WS_METHODS.subscribeGitStatus](input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 01b6d53f21..93886c746d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -158,6 +158,7 @@ export interface NativeApi { ) => Promise; // Stacked action API pull: (input: GitPullInput) => Promise; + refreshStatus: (input: GitStatusInput) => Promise; onStatus: ( input: GitStatusInput, callback: (status: GitStatusResult) => void, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f86a4c0457..5a48ef7241 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -84,6 +84,7 @@ export const WS_METHODS = { // Git methods gitPull: "git.pull", + gitRefreshStatus: "git.refreshStatus", gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", @@ -176,6 +177,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { error: GitCommandError, }); +export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { + payload: GitStatusInput, + success: GitStatusResult, + error: GitManagerServiceError, +}); + export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, @@ -335,6 +342,7 @@ export const WsRpcGroup = RpcGroup.make( WsShellOpenInEditorRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, + WsGitRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, From 507822b1dbd13569c5888785377f5353404fd95f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 14:32:25 -0700 Subject: [PATCH 08/21] Split local and remote git status streaming Co-authored-by: codex --- apps/server/src/git/Layers/GitCore.ts | 16 +- apps/server/src/git/Layers/GitManager.ts | 120 +++++++---- .../git/Layers/GitStatusBroadcaster.test.ts | 141 +++++++++--- .../src/git/Layers/GitStatusBroadcaster.ts | 204 +++++++++++++----- apps/server/src/git/Services/GitCore.ts | 5 + apps/server/src/git/Services/GitManager.ts | 26 +++ .../src/git/Services/GitStatusBroadcaster.ts | 9 +- apps/server/src/server.test.ts | 76 ++++++- apps/web/src/wsRpcClient.test.ts | 94 ++++++++ apps/web/src/wsRpcClient.ts | 15 +- packages/contracts/src/git.ts | 30 ++- packages/contracts/src/rpc.ts | 3 +- packages/shared/src/git.ts | 62 +++++- 13 files changed, 676 insertions(+), 125 deletions(-) create mode 100644 apps/web/src/wsRpcClient.test.ts diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 529387f978..3bb905f305 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return branchLastCommit; }); - const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); - + const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGit( "GitCore.statusDetails.status", cwd, @@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; }); + const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( + function* (cwd) { + return yield* readStatusDetailsLocal(cwd); + }, + ); + + const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); + return yield* readStatusDetailsLocal(cwd); + }); + const status: GitCoreShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ @@ -2113,6 +2122,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { execute, status, statusDetails, + statusDetailsLocal, prepareCommitContext, commit, pushCurrentBranch, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 8e38a70fba..d5e7eca217 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -8,10 +8,13 @@ import { GitCommandError, GitRunStackedActionResult, GitStackedAction, + type GitStatusLocalResult, + type GitStatusRemoteResult, ModelSelection, } from "@t3tools/contracts"; import { detectGitHostingProviderFromRemoteUrl, + mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -696,34 +699,24 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); - const readStatus = Effect.fn("readStatus")(function* (cwd: string) { - const details = yield* gitCore.statusDetails(cwd).pipe( - Effect.catchIf(isNotGitRepositoryError, () => - Effect.succeed({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - } satisfies GitStatusDetails), - ), - ); - - const pr = - details.isRepo && details.branch !== null - ? yield* findLatestPr(cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => (latest ? toStatusPr(latest) : null)), - Effect.catch(() => Effect.succeed(null)), - ) - : null; + const nonRepositoryStatusDetails = { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails; + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetailsLocal(cwd) + .pipe( + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), + ); const hostingProvider = details.isRepo ? yield* resolveHostingProvider(cwd, details.branch) : null; @@ -736,19 +729,48 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, + } satisfies GitStatusLocalResult; + }); + const localStatusResultCache = yield* Cache.makeWith({ + capacity: STATUS_RESULT_CACHE_CAPACITY, + lookup: readLocalStatus, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateLocalStatusResultCache = (cwd: string) => + Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetails(cwd) + .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); + if (details === null || !details.isRepo) { + return null; + } + + const pr = + details.branch !== null + ? yield* findLatestPr(cwd, { + branch: details.branch, + upstreamRef: details.upstreamRef, + }).pipe( + Effect.map((latest) => (latest ? toStatusPr(latest) : null)), + Effect.catch(() => Effect.succeed(null)), + ) + : null; + + return { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, pr, - }; + } satisfies GitStatusRemoteResult; }); - const statusResultCache = yield* Cache.makeWith({ + const remoteStatusResultCache = yield* Cache.makeWith({ capacity: STATUS_RESULT_CACHE_CAPACITY, - lookup: readStatus, + lookup: readRemoteStatus, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); - const invalidateStatusResultCache = (cwd: string) => - Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); + const invalidateRemoteStatusResultCache = (cwd: string) => + Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); @@ -1331,12 +1353,32 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); + const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { + return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }); + const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input) { + return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }, + ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); + return mergeGitStatusParts(local, remote); + }); + const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + "invalidateLocalStatus", + )(function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + }); + const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + "invalidateRemoteStatus", + )(function* (cwd) { + yield* invalidateRemoteStatusResultCache(cwd); }); const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( function* (cwd) { - yield* invalidateStatusResultCache(cwd); + yield* invalidateLocalStatusResultCache(cwd); + yield* invalidateRemoteStatusResultCache(cwd); }, ); @@ -1513,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { branch: worktree.worktree.branch, worktreePath: worktree.worktree.path, }; - }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -1717,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( - Effect.ensuring(invalidateStatusResultCache(input.cwd)), + Effect.ensuring(invalidateStatus(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ @@ -1732,7 +1774,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); return { + localStatus, + remoteStatus, status, + invalidateLocalStatus, + invalidateRemoteStatus, invalidateStatus, resolvePullRequest, preparePullRequestThread, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts index 1ab64a369b..d7c8ffaa45 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -1,40 +1,72 @@ import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import type { GitStatusResult } from "@t3tools/contracts"; +import { Deferred, Effect, Layer, Stream } from "effect"; +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; import { describe } from "vitest"; import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; -const baseStatus: GitStatusResult = { +const baseLocalStatus: GitStatusLocalResult = { isRepo: true, + hostingProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, hasOriginRemote: true, isDefaultBranch: false, branch: "feature/status-broadcast", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: GitStatusRemoteResult = { hasUpstream: true, aheadCount: 0, behindCount: 0, pr: null, }; +const baseStatus: GitStatusResult = { + ...baseLocalStatus, + ...baseRemoteStatus, +}; + function makeTestLayer(state: { - currentStatus: GitStatusResult; - statusCalls: number; - invalidationCalls: number; + currentLocalStatus: GitStatusLocalResult; + currentRemoteStatus: GitStatusRemoteResult | null; + localStatusCalls: number; + remoteStatusCalls: number; + localInvalidationCalls: number; + remoteInvalidationCalls: number; }) { const gitManager: GitManagerShape = { - status: () => + localStatus: () => Effect.sync(() => { - state.statusCalls += 1; - return state.currentStatus; + state.localStatusCalls += 1; + return state.currentLocalStatus; }), - invalidateStatus: () => + remoteStatus: () => Effect.sync(() => { - state.invalidationCalls += 1; + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; }), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), preparePullRequestThread: () => Effect.die("preparePullRequestThread should not be called in this test"), @@ -47,9 +79,12 @@ function makeTestLayer(state: { describe("GitStatusBroadcasterLive", () => { it.effect("reuses the cached git status across repeated reads", () => { const state = { - currentStatus: baseStatus, - statusCalls: 0, - invalidationCalls: 0, + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, }; return Effect.gen(function* () { @@ -60,35 +95,91 @@ describe("GitStatusBroadcasterLive", () => { assert.deepStrictEqual(first, baseStatus); assert.deepStrictEqual(second, baseStatus); - assert.equal(state.statusCalls, 1); - assert.equal(state.invalidationCalls, 1); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); }).pipe(Effect.provide(makeTestLayer(state))); }); it.effect("refreshes the cached snapshot after explicit invalidation", () => { const state = { - currentStatus: baseStatus, - statusCalls: 0, - invalidationCalls: 0, + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, }; return Effect.gen(function* () { const broadcaster = yield* GitStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); - state.currentStatus = { - ...baseStatus, + state.currentLocalStatus = { + ...baseLocalStatus, branch: "feature/updated-status", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, aheadCount: 2, }; const refreshed = yield* broadcaster.refreshStatus("/repo"); const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); assert.deepStrictEqual(initial, baseStatus); - assert.deepStrictEqual(refreshed, state.currentStatus); - assert.deepStrictEqual(cached, state.currentStatus); - assert.equal(state.statusCalls, 2); - assert.equal(state.invalidationCalls, 2); + assert.deepStrictEqual(refreshed, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("streams a local snapshot first and remote updates later", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred); + yield* broadcaster.refreshStatus("/repo"); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies GitStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies GitStatusStreamEvent); }).pipe(Effect.provide(makeTestLayer(state))); }); }); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts index 5f97954e97..8468f7e77c 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -1,7 +1,13 @@ import { realpathSync } from "node:fs"; import { Duration, Effect, Exit, Layer, PubSub, Ref, Scope, Stream } from "effect"; -import type { GitStatusInput, GitStatusResult } from "@t3tools/contracts"; +import type { + GitStatusInput, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; import { GitStatusBroadcaster, @@ -13,12 +19,17 @@ const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); interface GitStatusChange { readonly cwd: string; - readonly status: GitStatusResult; + readonly event: GitStatusStreamEvent; } -interface CachedGitStatus { +interface CachedValue { readonly fingerprint: string; - readonly status: GitStatusResult; + readonly value: T; +} + +interface CachedGitStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; } function normalizeCwd(cwd: string): string { @@ -29,7 +40,7 @@ function normalizeCwd(cwd: string): string { } } -function fingerprintStatus(status: GitStatusResult): string { +function fingerprintStatusPart(status: unknown): string { return JSON.stringify(status); } @@ -47,48 +58,135 @@ export const GitStatusBroadcasterLive = Layer.effect( const cacheRef = yield* Ref.make(new Map()); const pollersRef = yield* Ref.make(new Set()); - const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( - function* (cwd) { - const normalizedCwd = normalizeCwd(cwd); - yield* gitManager.invalidateStatus(normalizedCwd); - const nextStatus = yield* gitManager.status({ cwd: normalizedCwd }); - const nextFingerprint = fingerprintStatus(nextStatus); - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(normalizedCwd); - const nextCache = new Map(cache); - nextCache.set(normalizedCwd, { - fingerprint: nextFingerprint, - status: nextStatus, - }); - return [previous?.fingerprint !== nextFingerprint, nextCache] as const; + const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( + cwd: string, + local: GitStatusLocalResult, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); - if (shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd: normalizedCwd, - status: nextStatus, - }); - } + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } - return nextStatus; - }, - ); + return local; + }); + + const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( + cwd: string, + remote: GitStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }); + + const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { + const local = yield* gitManager.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote); + }); + + const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(cwd); + }); const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( input: GitStatusInput, ) { const normalizedCwd = normalizeCwd(input.cwd); - const cached = yield* Ref.get(cacheRef).pipe( - Effect.map((cache) => cache.get(normalizedCwd)?.status ?? null), - ); - if (cached) { - return cached; - } + const [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(normalizedCwd), + getOrLoadRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }); - return yield* refreshStatus(normalizedCwd); + const refreshLocalStatus = Effect.fn("refreshLocalStatus")(function* (cwd: string) { + yield* gitManager.invalidateLocalStatus(cwd); + const local = yield* gitManager.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); }); - const ensurePoller = Effect.fn("ensurePoller")(function* (cwd: string) { + const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { + yield* gitManager.invalidateRemoteStatus(cwd); + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( + function* (cwd) { + const normalizedCwd = normalizeCwd(cwd); + const [local, remote] = yield* Effect.all([ + refreshLocalStatus(normalizedCwd), + refreshRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }, + ); + + const ensureRemotePoller = Effect.fn("ensureRemotePoller")(function* (cwd: string) { const normalizedCwd = normalizeCwd(cwd); const shouldStart = yield* Ref.modify(pollersRef, (activePollers) => { if (activePollers.has(normalizedCwd)) { @@ -104,15 +202,18 @@ export const GitStatusBroadcasterLive = Layer.effect( return; } - const refreshLoop = Effect.forever( - Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( - Effect.andThen( - refreshStatus(normalizedCwd).pipe( - Effect.catch((error) => - Effect.logWarning("git status refresh failed", { - cwd: normalizedCwd, - detail: error.message, - }), + const logRefreshFailure = (error: Error) => + Effect.logWarning("git remote status refresh failed", { + cwd: normalizedCwd, + detail: error.message, + }); + const refreshLoop = refreshRemoteStatus(normalizedCwd).pipe( + Effect.catch(logRefreshFailure), + Effect.andThen( + Effect.forever( + Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( + Effect.andThen( + refreshRemoteStatus(normalizedCwd).pipe(Effect.catch(logRefreshFailure)), ), ), ), @@ -126,15 +227,20 @@ export const GitStatusBroadcasterLive = Layer.effect( Stream.unwrap( Effect.gen(function* () { const normalizedCwd = normalizeCwd(input.cwd); - yield* ensurePoller(normalizedCwd); const subscription = yield* PubSub.subscribe(changesPubSub); - const initialStatus = yield* getStatus({ cwd: normalizedCwd }); + const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); + const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; + yield* ensureRemotePoller(normalizedCwd); return Stream.concat( - Stream.make(initialStatus), + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), Stream.fromEffectRepeat(PubSub.take(subscription)).pipe( Stream.filter((event) => event.cwd === normalizedCwd), - Stream.map((event) => event.status), + Stream.map((event) => event.event), ), ); }), diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 4e43df8587..32cd8f6160 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -157,6 +157,11 @@ export interface GitCoreShape { */ readonly statusDetails: (cwd: string) => Effect.Effect; + /** + * Read detailed working tree / branch status without refreshing remote tracking refs. + */ + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + /** * Build staged change context for commit generation. */ diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 2c13b3d82d..0e04ceedcb 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -14,6 +14,8 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, + GitStatusLocalResult, + GitStatusRemoteResult, GitStatusInput, GitStatusResult, } from "@t3tools/contracts"; @@ -41,6 +43,30 @@ export interface GitManagerShape { input: GitStatusInput, ) => Effect.Effect; + /** + * Read local repository status without remote hosting enrichment. + */ + readonly localStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Read remote tracking / PR status for a repository. + */ + readonly remoteStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Clear any cached local status snapshot for a repository. + */ + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + + /** + * Clear any cached remote status snapshot for a repository. + */ + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + /** * Clear any cached status snapshot for a repository so the next read is fresh. */ diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts index c7562d850b..0f3f622d17 100644 --- a/apps/server/src/git/Services/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -1,6 +1,11 @@ import { ServiceMap } from "effect"; import type { Effect, Stream } from "effect"; -import type { GitManagerServiceError, GitStatusInput, GitStatusResult } from "@t3tools/contracts"; +import type { + GitManagerServiceError, + GitStatusInput, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; export interface GitStatusBroadcasterShape { readonly getStatus: ( @@ -9,7 +14,7 @@ export interface GitStatusBroadcasterShape { readonly refreshStatus: (cwd: string) => Effect.Effect; readonly streamStatus: ( input: GitStatusInput, - ) => Stream.Stream; + ) => Stream.Stream; } export class GitStatusBroadcaster extends ServiceMap.Service< diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a3cc754dfd..cd89a10b00 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1262,7 +1262,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, invalidateStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.succeed({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), status: () => Effect.succeed({ isRepo: true, @@ -1507,10 +1525,37 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pullCurrentBranch: () => Effect.fail(gitError), }, gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), invalidateStatus: () => Effect.sync(() => { invalidationCalls += 1; }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), status: () => Effect.sync(() => { statusCalls += 1; @@ -1539,7 +1584,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertFailure(result, gitError); - assert.equal(invalidationCalls, 1); + assert.equal(invalidationCalls, 2); assert.equal(statusCalls, 1); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1557,10 +1602,37 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), invalidateStatus: () => Effect.sync(() => { invalidationCalls += 1; }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), status: () => Effect.sync(() => { statusCalls += 1; @@ -1594,7 +1666,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertFailure(result, gitError); - assert.equal(invalidationCalls, 1); + assert.equal(invalidationCalls, 2); assert.equal(statusCalls, 1); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/web/src/wsRpcClient.test.ts b/apps/web/src/wsRpcClient.test.ts new file mode 100644 index 0000000000..36467eed9a --- /dev/null +++ b/apps/web/src/wsRpcClient.test.ts @@ -0,0 +1,94 @@ +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; + +import { createWsRpcClient } from "./wsRpcClient"; +import { type WsTransport } from "./wsTransport"; + +const baseLocalStatus: GitStatusLocalResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +describe("wsRpcClient", () => { + it("reduces git status stream events into flat status snapshots", () => { + const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { + for (const event of [ + { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + }, + { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + }, + { + _tag: "localUpdated", + local: { + ...baseLocalStatus, + hasWorkingTreeChanges: true, + }, + }, + ] satisfies GitStatusStreamEvent[]) { + listener(event as TValue); + } + return () => undefined; + }); + + const transport = { + dispose: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + request: vi.fn(), + requestStream: vi.fn(), + subscribe, + } satisfies Pick< + WsTransport, + "dispose" | "reconnect" | "request" | "requestStream" | "subscribe" + >; + + const client = createWsRpcClient(transport as unknown as WsTransport); + const listener = vi.fn(); + + client.git.onStatus({ cwd: "/repo" }, listener); + + expect(listener.mock.calls).toEqual([ + [ + { + ...baseLocalStatus, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }, + ], + [ + { + ...baseLocalStatus, + ...baseRemoteStatus, + }, + ], + [ + { + ...baseLocalStatus, + ...baseRemoteStatus, + hasWorkingTreeChanges: true, + }, + ], + ]); + }); +}); diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index e8d9486a74..997b83d2d7 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -3,11 +3,13 @@ import { type GitRunStackedActionInput, type GitRunStackedActionResult, type GitStatusResult, + type GitStatusStreamEvent, type NativeApi, ORCHESTRATION_WS_METHODS, type ServerSettingsPatch, WS_METHODS, } from "@t3tools/contracts"; +import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import { Effect, Stream } from "effect"; import { type WsRpcProtocolClient } from "./rpc/protocol"; @@ -157,12 +159,17 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), refreshStatus: (input) => transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), - onStatus: (input, listener, options) => - transport.subscribe( + onStatus: (input, listener, options) => { + let current: GitStatusResult | null = null; + return transport.subscribe( (client) => client[WS_METHODS.subscribeGitStatus](input), - listener, + (event: GitStatusStreamEvent) => { + current = applyGitStatusStreamEvent(current, event); + listener(current); + }, options, - ), + ); + }, runStackedAction: async (input, options) => { let result: GitRunStackedActionResult | null = null; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b342159666..1703251e17 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -194,7 +194,7 @@ const GitStatusPr = Schema.Struct({ state: GitStatusPrState, }); -export const GitStatusResult = Schema.Struct({ +const GitStatusLocalShape = { isRepo: Schema.Boolean, hostingProvider: Schema.optional(GitHostingProvider), hasOriginRemote: Schema.Boolean, @@ -212,13 +212,41 @@ export const GitStatusResult = Schema.Struct({ insertions: NonNegativeInt, deletions: NonNegativeInt, }), +}; + +const GitStatusRemoteShape = { hasUpstream: Schema.Boolean, aheadCount: NonNegativeInt, behindCount: NonNegativeInt, pr: Schema.NullOr(GitStatusPr), +}; + +export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape); +export type GitStatusLocalResult = typeof GitStatusLocalResult.Type; + +export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape); +export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type; + +export const GitStatusResult = Schema.Struct({ + ...GitStatusLocalShape, + ...GitStatusRemoteShape, }); export type GitStatusResult = typeof GitStatusResult.Type; +export const GitStatusStreamEvent = Schema.Union([ + Schema.TaggedStruct("snapshot", { + local: GitStatusLocalResult, + remote: Schema.NullOr(GitStatusRemoteResult), + }), + Schema.TaggedStruct("localUpdated", { + local: GitStatusLocalResult, + }), + Schema.TaggedStruct("remoteUpdated", { + remote: Schema.NullOr(GitStatusRemoteResult), + }), +]); +export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type; + export const GitListBranchesResult = Schema.Struct({ branches: Schema.Array(GitBranch), isRepo: Schema.Boolean, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a48ef7241..e7fcee847f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -25,6 +25,7 @@ import { GitRunStackedActionInput, GitStatusInput, GitStatusResult, + GitStatusStreamEvent, } from "./git"; import { KeybindingsConfigError } from "./keybindings"; import { @@ -166,7 +167,7 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, - success: GitStatusResult, + success: GitStatusStreamEvent, error: GitManagerServiceError, stream: true, }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index a7530274ec..c2d4bcd1df 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,4 +1,11 @@ -import type { GitBranch, GitHostingProvider } from "@t3tools/contracts"; +import type { + GitBranch, + GitHostingProvider, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. @@ -184,3 +191,56 @@ export function detectGitHostingProviderFromRemoteUrl( baseUrl: toBaseUrl(host), }; } + +const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +export function mergeGitStatusParts( + local: GitStatusLocalResult, + remote: GitStatusRemoteResult | null, +): GitStatusResult { + return { + ...local, + ...(remote ?? EMPTY_GIT_STATUS_REMOTE), + }; +} + +function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { + return { + hasUpstream: status.hasUpstream, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + pr: status.pr, + }; +} + +export function applyGitStatusStreamEvent( + current: GitStatusResult | null, + event: GitStatusStreamEvent, +): GitStatusResult { + switch (event._tag) { + case "snapshot": + return mergeGitStatusParts(event.local, event.remote); + case "localUpdated": + return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null); + case "remoteUpdated": + if (current === null) { + return mergeGitStatusParts( + { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + event.remote, + ); + } + return mergeGitStatusParts(current, event.remote); + } +} From 0a3812afb334f9b0a28e60dfa0f95708914a0b7a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 14:33:37 -0700 Subject: [PATCH 09/21] Avoid phantom null git status atoms Co-authored-by: codex --- apps/web/src/lib/gitStatusState.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 9d85205f2a..73e6974da0 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -29,6 +29,10 @@ const EMPTY_GIT_STATUS_STATE = Object.freeze({ cause: null, isPending: false, }); +const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("git-status:null"), +); const NOOP: () => void = () => undefined; const watchedGitStatuses = new Map(); @@ -126,7 +130,7 @@ export function resetGitStatusStateForTests(): void { export function useGitStatus(cwd: string | null): GitStatusState { useEffect(() => watchGitStatus(cwd), [cwd]); - const state = useAtomValue(gitStatusStateAtom(cwd ?? "")); + const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM); return cwd === null ? EMPTY_GIT_STATUS_STATE : state; } From f3bc116544d877182d1d2ea5fd8a90682a5a223a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 14:39:12 -0700 Subject: [PATCH 10/21] Fix git status browser test mocks Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 3 ++- apps/web/src/components/GitActionsControl.browser.tsx | 3 ++- apps/web/src/components/KeybindingsToast.browser.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 26a98f1aad..7fc9f621b0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -43,7 +43,8 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; vi.mock("../lib/gitStatusState", () => ({ useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), useGitStatuses: () => new Map(), - refreshGitStatus: () => undefined, + refreshGitStatus: () => Promise.resolve(null), + resetGitStatusStateForTests: () => undefined, })); const THREAD_ID = "thread-browser-test" as ThreadId; diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index b651fb02cb..d2249ae18d 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -19,7 +19,7 @@ const { toastUpdateSpy, } = vi.hoisted(() => ({ invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), - refreshGitStatusSpy: vi.fn(), + refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), @@ -107,6 +107,7 @@ vi.mock("~/lib/gitReactQuery", () => ({ vi.mock("~/lib/gitStatusState", () => ({ refreshGitStatus: refreshGitStatusSpy, + resetGitStatusStateForTests: () => undefined, useGitStatus: vi.fn(() => ({ data: { branch: BRANCH_NAME, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 26e3cfaded..fbbf9782b6 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -28,7 +28,8 @@ import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; vi.mock("../lib/gitStatusState", () => ({ useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), useGitStatuses: () => new Map(), - refreshGitStatus: () => undefined, + refreshGitStatus: () => Promise.resolve(null), + resetGitStatusStateForTests: () => undefined, })); const THREAD_ID = "thread-kb-toast-test" as ThreadId; From 564e94dccc7a9346e119f92dbf2f1da21e4d9dc0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 15:58:16 -0700 Subject: [PATCH 11/21] Background git status refresh after successful mutations Co-authored-by: codex --- .../src/git/Layers/GitStatusBroadcaster.ts | 19 +++ .../src/git/Services/GitStatusBroadcaster.ts | 1 + apps/server/src/server.test.ts | 130 +++++++++++++++++- apps/server/src/ws.ts | 33 +++-- 4 files changed, 165 insertions(+), 18 deletions(-) diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts index 8468f7e77c..f0f72eb41a 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -7,6 +7,7 @@ import type { GitStatusRemoteResult, GitStatusStreamEvent, } from "@t3tools/contracts"; +import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { mergeGitStatusParts } from "@t3tools/shared/git"; import { @@ -186,6 +187,23 @@ export const GitStatusBroadcasterLive = Layer.effect( }, ); + const refreshWorker = yield* makeKeyedCoalescingWorker({ + merge: () => undefined, + process: (cwd) => + refreshStatus(cwd).pipe( + Effect.catchCause((cause) => + Effect.logWarning("git status refresh failed", { + cwd, + cause, + }), + ), + Effect.asVoid, + ), + }); + + const enqueueRefreshStatus: GitStatusBroadcasterShape["enqueueRefreshStatus"] = (cwd) => + refreshWorker.enqueue(normalizeCwd(cwd), undefined); + const ensureRemotePoller = Effect.fn("ensureRemotePoller")(function* (cwd: string) { const normalizedCwd = normalizeCwd(cwd); const shouldStart = yield* Ref.modify(pollersRef, (activePollers) => { @@ -248,6 +266,7 @@ export const GitStatusBroadcasterLive = Layer.effect( return { getStatus, + enqueueRefreshStatus, refreshStatus, streamStatus, } satisfies GitStatusBroadcasterShape; diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts index 0f3f622d17..d809528d8b 100644 --- a/apps/server/src/git/Services/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -11,6 +11,7 @@ export interface GitStatusBroadcasterShape { readonly getStatus: ( input: GitStatusInput, ) => Effect.Effect; + readonly enqueueRefreshStatus: (cwd: string) => Effect.Effect; readonly refreshStatus: (cwd: string) => Effect.Effect; readonly streamStatus: ( input: GitStatusInput, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cd89a10b00..e35886e163 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -22,7 +22,7 @@ import { } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; -import { Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect"; +import { Duration, Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect"; import { FetchHttpClient, HttpBody, @@ -1584,8 +1584,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertFailure(result, gitError); - assert.equal(invalidationCalls, 2); - assert.equal(statusCalls, 1); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1666,11 +1666,131 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertFailure(result, gitError); - assert.equal(invalidationCalls, 2); - assert.equal(statusCalls, 1); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("completes websocket rpc git.pull before background git status refresh finishes", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitCore: { + pullCurrentBranch: () => + Effect.succeed({ + status: "pulled" as const, + branch: "main", + upstreamBranch: "origin/main", + }), + }, + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + ); + const elapsedMs = Date.now() - startedAt; + + assert.equal(result.status, "pulled"); + assertTrue(elapsedMs < 1_000); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "completes websocket rpc git.runStackedAction before background git status refresh finishes", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + const elapsedMs = Date.now() - startedAt; + + assertTrue(elapsedMs < 1_000); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { const now = new Date().toISOString(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6697411062..c8e52bb719 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -67,6 +67,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const services = yield* Effect.services(); + const runPromise = Effect.runPromiseWith(services); const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -351,9 +353,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( }); const refreshGitStatus = (cwd: string) => - gitStatusBroadcaster - .refreshStatus(cwd) - .pipe(Effect.ignoreCause({ log: true }), Effect.asVoid); + Effect.sync(() => { + setTimeout(() => { + void runPromise(gitStatusBroadcaster.enqueueRefreshStatus(cwd)); + }, 0); + }); return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => @@ -581,9 +585,13 @@ const WsRpcLayer = WsRpcGroup.toLayer( [WS_METHODS.gitPull]: (input) => observeRpcEffect( WS_METHODS.gitPull, - git - .pullCurrentBranch(input.cwd) - .pipe(Effect.ensuring(refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true })))), + git.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitRunStackedAction]: (input) => @@ -599,14 +607,13 @@ const WsRpcLayer = WsRpcGroup.toLayer( }) .pipe( Effect.matchCauseEffect({ - onFailure: (cause) => - refreshGitStatus(input.cwd).pipe( - Effect.ignore({ log: true }), - Effect.andThen(Queue.failCause(queue, cause)), - ), + onFailure: (cause) => Queue.failCause(queue, cause), onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + Queue.end(queue).pipe( + Effect.asVoid, + Effect.andThen( + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true })), + ), ), }), ), From 6840b709ad9bf5688370cf545061a2281d9dc635 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 16:17:06 -0700 Subject: [PATCH 12/21] Stop idle git remote pollers on disconnect Co-authored-by: codex --- .../git/Layers/GitStatusBroadcaster.test.ts | 93 ++++++++++++++- .../src/git/Layers/GitStatusBroadcaster.ts | 108 +++++++++++++----- 2 files changed, 173 insertions(+), 28 deletions(-) diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts index d7c8ffaa45..fbe418ab7d 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, Stream } from "effect"; +import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; import type { GitStatusLocalResult, GitStatusRemoteResult, @@ -182,4 +182,95 @@ describe("GitStatusBroadcasterLive", () => { } satisfies GitStatusStreamEvent); }).pipe(Effect.provide(makeTestLayer(state))); }); + + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let remoteInterruptedDeferred: Deferred.Deferred | null = null; + let remoteStartedDeferred: Deferred.Deferred | null = null; + const testLayer = GitStatusBroadcasterLive.pipe( + Layer.provide( + Layer.succeed(GitManager, { + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + }).pipe( + Effect.andThen( + remoteStartedDeferred + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Effect.never as Effect.Effect), + Effect.onInterrupt(() => + remoteInterruptedDeferred + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), + resolvePullRequest: () => + Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + } satisfies GitManagerShape), + ), + ); + + return Effect.gen(function* () { + const remoteInterrupted = yield* Deferred.make(); + const remoteStarted = yield* Deferred.make(); + remoteInterruptedDeferred = remoteInterrupted; + remoteStartedDeferred = remoteStarted; + + const broadcaster = yield* GitStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(firstScope)); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(secondScope)); + + yield* Deferred.await(firstSnapshot); + yield* Deferred.await(secondSnapshot); + yield* Deferred.await(remoteStarted); + + assert.equal(state.remoteStatusCalls, 1); + + yield* Scope.close(firstScope, Exit.void); + assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); + + yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); + yield* Deferred.await(remoteInterrupted); + assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); + }).pipe(Effect.provide(testLayer)); + }); }); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts index f0f72eb41a..e84c05ae1c 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -1,6 +1,17 @@ import { realpathSync } from "node:fs"; -import { Duration, Effect, Exit, Layer, PubSub, Ref, Scope, Stream } from "effect"; +import { + Duration, + Effect, + Exit, + Fiber, + Layer, + PubSub, + Ref, + Scope, + Stream, + SynchronizedRef, +} from "effect"; import type { GitStatusInput, GitStatusLocalResult, @@ -33,6 +44,11 @@ interface CachedGitStatus { readonly remote: CachedValue | null; } +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + function normalizeCwd(cwd: string): string { try { return realpathSync.native(cwd); @@ -57,7 +73,7 @@ export const GitStatusBroadcasterLive = Layer.effect( Scope.close(scope, Exit.void), ); const cacheRef = yield* Ref.make(new Map()); - const pollersRef = yield* Ref.make(new Set()); + const pollersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); @@ -204,41 +220,77 @@ export const GitStatusBroadcasterLive = Layer.effect( const enqueueRefreshStatus: GitStatusBroadcasterShape["enqueueRefreshStatus"] = (cwd) => refreshWorker.enqueue(normalizeCwd(cwd), undefined); - const ensureRemotePoller = Effect.fn("ensureRemotePoller")(function* (cwd: string) { - const normalizedCwd = normalizeCwd(cwd); - const shouldStart = yield* Ref.modify(pollersRef, (activePollers) => { - if (activePollers.has(normalizedCwd)) { - return [false, activePollers] as const; - } - - const nextPollers = new Set(activePollers); - nextPollers.add(normalizedCwd); - return [true, nextPollers] as const; - }); - - if (!shouldStart) { - return; - } - + const makeRemoteRefreshLoop = (cwd: string) => { const logRefreshFailure = (error: Error) => Effect.logWarning("git remote status refresh failed", { - cwd: normalizedCwd, + cwd, detail: error.message, }); - const refreshLoop = refreshRemoteStatus(normalizedCwd).pipe( + + return refreshRemoteStatus(cwd).pipe( Effect.catch(logRefreshFailure), Effect.andThen( Effect.forever( Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( - Effect.andThen( - refreshRemoteStatus(normalizedCwd).pipe(Effect.catch(logRefreshFailure)), - ), + Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), ), ), ), ); + }; - yield* Effect.forkIn(refreshLoop, broadcasterScope); + const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { + const normalizedCwd = normalizeCwd(cwd); + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(normalizedCwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(normalizedCwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } + + return makeRemoteRefreshLoop(normalizedCwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(normalizedCwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextPollers] as const; + }), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { + const normalizedCwd = normalizeCwd(cwd); + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(normalizedCwd); + if (!existing) { + return [null, activePollers] as const; + } + + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(normalizedCwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; + } + + const nextPollers = new Map(activePollers); + nextPollers.delete(normalizedCwd); + return [existing.fiber, nextPollers] as const; + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } }); const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => @@ -248,7 +300,9 @@ export const GitStatusBroadcasterLive = Layer.effect( const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; - yield* ensureRemotePoller(normalizedCwd); + yield* retainRemotePoller(normalizedCwd); + + const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ @@ -256,11 +310,11 @@ export const GitStatusBroadcasterLive = Layer.effect( local: initialLocal, remote: initialRemote, }), - Stream.fromEffectRepeat(PubSub.take(subscription)).pipe( + Stream.fromSubscription(subscription).pipe( Stream.filter((event) => event.cwd === normalizedCwd), Stream.map((event) => event.event), ), - ); + ).pipe(Stream.ensuring(release)); }), ); From fc50e65e553eda1e9058fa4501e439cacd05f36a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 16:26:27 -0700 Subject: [PATCH 13/21] Fix remote-only git status fallback state Co-authored-by: codex --- packages/shared/src/git.test.ts | 28 ++++++++++++++++++++++++++++ packages/shared/src/git.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/git.test.ts diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts new file mode 100644 index 0000000000..17e20ddde8 --- /dev/null +++ b/packages/shared/src/git.test.ts @@ -0,0 +1,28 @@ +import type { GitStatusRemoteResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { applyGitStatusStreamEvent } from "./git"; + +describe("applyGitStatusStreamEvent", () => { + it("treats a remote-only update as a repository when local state is missing", () => { + const remote: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }; + + expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ + isRepo: true, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index c2d4bcd1df..b8b79fc141 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -231,7 +231,7 @@ export function applyGitStatusStreamEvent( if (current === null) { return mergeGitStatusParts( { - isRepo: false, + isRepo: true, hasOriginRemote: false, isDefaultBranch: false, branch: null, From 5cf6f7f3e4d6f21b80c30d59c0776f3ebe294c49 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 17:46:12 -0700 Subject: [PATCH 14/21] Run git status refresh in server background scope Co-authored-by: codex --- apps/server/src/ws.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c8e52bb719..5bd18b2f24 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Queue, Ref, Schema, Scope, Stream } from "effect"; import { CommandId, EventId, @@ -67,8 +67,9 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const services = yield* Effect.services(); - const runPromise = Effect.runPromiseWith(services); + const wsBackgroundScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -353,11 +354,9 @@ const WsRpcLayer = WsRpcGroup.toLayer( }); const refreshGitStatus = (cwd: string) => - Effect.sync(() => { - setTimeout(() => { - void runPromise(gitStatusBroadcaster.enqueueRefreshStatus(cwd)); - }, 0); - }); + gitStatusBroadcaster + .enqueueRefreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(wsBackgroundScope), Effect.asVoid); return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => From f5bec5e585d525dadbd8f12374a8cfff88b0afe1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 17:50:36 -0700 Subject: [PATCH 15/21] Use Scope.close for WS background scope - Replace manual release callback with `Scope.close` - Co-authored-by: codex --- apps/server/src/ws.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5bd18b2f24..02f3f6018a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit, Layer, Option, Queue, Ref, Schema, Scope, Stream } from "effect"; +import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Scope, Stream } from "effect"; import { CommandId, EventId, @@ -67,9 +67,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const wsBackgroundScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); + const wsBackgroundScope = yield* Effect.acquireRelease(Scope.make(), Scope.close); const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); From 5326507ff5ebb31f472fc66126716a4bcbb3bf35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 17:51:58 -0700 Subject: [PATCH 16/21] Debounce git status refreshes on window focus - Avoid duplicate refreshes when focus and visibility events fire together - Add coverage for the 250ms debounce --- .../components/GitActionsControl.browser.tsx | 38 +++++++++++++++++++ apps/web/src/components/GitActionsControl.tsx | 22 ++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index d2249ae18d..92874f7404 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => { host.remove(); } }); + + it("debounces focus-driven git status refreshes", async () => { + vi.useFakeTimers(); + + const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + let visibilityState: DocumentVisibilityState = "hidden"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render(, { + container: host, + }); + + try { + window.dispatchEvent(new Event("focus")); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(249); + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); + expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD); + } finally { + if (originalVisibilityState) { + Object.defineProperty(document, "visibilityState", originalVisibilityState); + } + await screen.unmount(); + host.remove(); + } + }); }); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 36d528b2da..d641d4c36b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -91,6 +91,8 @@ interface RunGitActionWithToastInput { filePaths?: string[]; } +const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; + function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -363,20 +365,30 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions return; } - const refreshCurrentGitStatus = () => { - void refreshGitStatus(gitCwd).catch(() => undefined); + let refreshTimeout: number | null = null; + const scheduleRefreshCurrentGitStatus = () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + refreshTimeout = window.setTimeout(() => { + refreshTimeout = null; + void refreshGitStatus(gitCwd).catch(() => undefined); + }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") { - refreshCurrentGitStatus(); + scheduleRefreshCurrentGitStatus(); } }; - window.addEventListener("focus", refreshCurrentGitStatus); + window.addEventListener("focus", scheduleRefreshCurrentGitStatus); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { - window.removeEventListener("focus", refreshCurrentGitStatus); + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [gitCwd]); From a49910639c17949c12407b4447ff6c8b156883a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 18:02:52 -0700 Subject: [PATCH 17/21] Detach git status refresh after git actions Co-authored-by: codex --- apps/server/src/server.test.ts | 87 +++++++++++++++++++++++++++++++++- apps/server/src/ws.ts | 15 ++---- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e35886e163..a93f545d8d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -22,7 +22,16 @@ import { } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; -import { Duration, Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect"; +import { + Deferred, + Duration, + Effect, + FileSystem, + Layer, + ManagedRuntime, + Path, + Stream, +} from "effect"; import { FetchHttpClient, HttpBody, @@ -1791,6 +1800,82 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "starts a background local git status refresh after a successful git.runStackedAction", + () => + Effect.gen(function* () { + const localRefreshStarted = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Deferred.succeed(localRefreshStarted, undefined).pipe( + Effect.ignore, + Effect.andThen( + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + ), + ), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + + yield* Deferred.await(localRefreshStarted); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { const now = new Date().toISOString(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 02f3f6018a..ca096bff33 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Scope, Stream } from "effect"; +import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; import { CommandId, EventId, @@ -67,8 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const wsBackgroundScope = yield* Effect.acquireRelease(Scope.make(), Scope.close); - const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -353,8 +351,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( const refreshGitStatus = (cwd: string) => gitStatusBroadcaster - .enqueueRefreshStatus(cwd) - .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(wsBackgroundScope), Effect.asVoid); + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => @@ -606,11 +604,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), onSuccess: () => - Queue.end(queue).pipe( - Effect.asVoid, - Effect.andThen( - refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true })), - ), + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), ), }), ), From acb9bf105408e7b3b09e8ae6132c32733a14990e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 18:33:01 -0700 Subject: [PATCH 18/21] Scope branch query invalidation to current cwd - Invalidate only the active branch search query after branch actions - Avoid broad git query refreshes --- .../src/components/BranchToolbarBranchSelector.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index dfc3b34805..bf96b89f93 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -14,11 +14,7 @@ import { useTransition, } from "react"; -import { - gitBranchSearchInfiniteQueryOptions, - gitQueryKeys, - invalidateGitQueries, -} from "../lib/gitReactQuery"; +import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; @@ -188,7 +184,9 @@ export function BranchToolbarBranchSelector({ const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); - await invalidateGitQueries(queryClient).catch(() => undefined); + await queryClient + .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) + .catch(() => undefined); }); }; @@ -232,7 +230,6 @@ export function BranchToolbarBranchSelector({ cwd: selectionTarget.checkoutCwd, branch: branch.name, }); - await invalidateGitQueries(queryClient); const nextBranchName = branch.isRemote ? (checkoutResult.branch ?? selectedBranchName) : selectedBranchName; From 5efdbb0d4c601dd6fe984e8e0d9a921f025bc548 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 18:41:48 -0700 Subject: [PATCH 19/21] Restore branch selector state after checkout failures - Revert optimistic branch selection when checkout fails - Co-authored-by: codex --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index bf96b89f93..0d3ed6a659 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -224,6 +224,7 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest?.(); runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { const checkoutResult = await api.git.checkout({ @@ -236,6 +237,7 @@ export function BranchToolbarBranchSelector({ setOptimisticBranch(nextBranchName); onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { + setOptimisticBranch(previousBranch); toastManager.add({ type: "error", title: "Failed to checkout branch.", From 2c200cee78f9bc9cc82855c67dc8de25f3aff803 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 18:52:05 -0700 Subject: [PATCH 20/21] Preserve local git status fields on remote refresh - Keep working tree and branch metadata intact when applying remote updates - Remove the unused enqueueRefreshStatus API --- .../src/git/Layers/GitStatusBroadcaster.ts | 19 --------- .../src/git/Services/GitStatusBroadcaster.ts | 1 - packages/shared/src/git.test.ts | 41 ++++++++++++++++++- packages/shared/src/git.ts | 14 ++++++- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts index e84c05ae1c..992b91e143 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -18,7 +18,6 @@ import type { GitStatusRemoteResult, GitStatusStreamEvent, } from "@t3tools/contracts"; -import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { mergeGitStatusParts } from "@t3tools/shared/git"; import { @@ -203,23 +202,6 @@ export const GitStatusBroadcasterLive = Layer.effect( }, ); - const refreshWorker = yield* makeKeyedCoalescingWorker({ - merge: () => undefined, - process: (cwd) => - refreshStatus(cwd).pipe( - Effect.catchCause((cause) => - Effect.logWarning("git status refresh failed", { - cwd, - cause, - }), - ), - Effect.asVoid, - ), - }); - - const enqueueRefreshStatus: GitStatusBroadcasterShape["enqueueRefreshStatus"] = (cwd) => - refreshWorker.enqueue(normalizeCwd(cwd), undefined); - const makeRemoteRefreshLoop = (cwd: string) => { const logRefreshFailure = (error: Error) => Effect.logWarning("git remote status refresh failed", { @@ -320,7 +302,6 @@ export const GitStatusBroadcasterLive = Layer.effect( return { getStatus, - enqueueRefreshStatus, refreshStatus, streamStatus, } satisfies GitStatusBroadcasterShape; diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts index d809528d8b..0f3f622d17 100644 --- a/apps/server/src/git/Services/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -11,7 +11,6 @@ export interface GitStatusBroadcasterShape { readonly getStatus: ( input: GitStatusInput, ) => Effect.Effect; - readonly enqueueRefreshStatus: (cwd: string) => Effect.Effect; readonly refreshStatus: (cwd: string) => Effect.Effect; readonly streamStatus: ( input: GitStatusInput, diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 17e20ddde8..7beb7a75de 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusRemoteResult } from "@t3tools/contracts"; +import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { applyGitStatusStreamEvent } from "./git"; @@ -25,4 +25,43 @@ describe("applyGitStatusStreamEvent", () => { pr: null, }); }); + + it("preserves local-only fields when applying a remote update", () => { + const current: GitStatusResult = { + isRepo: true, + hostingProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + + const remote: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }; + + expect(applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote })).toEqual({ + ...current, + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }); + }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index b8b79fc141..16171315b7 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -218,6 +218,18 @@ function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { }; } +function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult { + return { + isRepo: status.isRepo, + ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}), + hasOriginRemote: status.hasOriginRemote, + isDefaultBranch: status.isDefaultBranch, + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + workingTree: status.workingTree, + }; +} + export function applyGitStatusStreamEvent( current: GitStatusResult | null, event: GitStatusStreamEvent, @@ -241,6 +253,6 @@ export function applyGitStatusStreamEvent( event.remote, ); } - return mergeGitStatusParts(current, event.remote); + return mergeGitStatusParts(toLocalStatusPart(current), event.remote); } } From e234ed097cb323954e457fac1daa800a03a301bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 19:16:57 -0700 Subject: [PATCH 21/21] Fix git status polling keys and pending snapshots - Keep remote pollers keyed by the original cwd - Initialize new cwd snapshots as pending in the web state --- .../src/git/Layers/GitStatusBroadcaster.ts | 16 +++++++--------- apps/web/src/lib/gitStatusState.test.ts | 9 +++++++++ apps/web/src/lib/gitStatusState.ts | 10 +++++++--- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts index 992b91e143..78d4abf40d 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -222,23 +222,22 @@ export const GitStatusBroadcasterLive = Layer.effect( }; const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { - const normalizedCwd = normalizeCwd(cwd); yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(normalizedCwd); + const existing = activePollers.get(cwd); if (existing) { const nextPollers = new Map(activePollers); - nextPollers.set(normalizedCwd, { + nextPollers.set(cwd, { ...existing, subscriberCount: existing.subscriberCount + 1, }); return Effect.succeed([undefined, nextPollers] as const); } - return makeRemoteRefreshLoop(normalizedCwd).pipe( + return makeRemoteRefreshLoop(cwd).pipe( Effect.forkIn(broadcasterScope), Effect.map((fiber) => { const nextPollers = new Map(activePollers); - nextPollers.set(normalizedCwd, { + nextPollers.set(cwd, { fiber, subscriberCount: 1, }); @@ -249,16 +248,15 @@ export const GitStatusBroadcasterLive = Layer.effect( }); const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { - const normalizedCwd = normalizeCwd(cwd); const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { - const existing = activePollers.get(normalizedCwd); + const existing = activePollers.get(cwd); if (!existing) { return [null, activePollers] as const; } if (existing.subscriberCount > 1) { const nextPollers = new Map(activePollers); - nextPollers.set(normalizedCwd, { + nextPollers.set(cwd, { ...existing, subscriberCount: existing.subscriberCount - 1, }); @@ -266,7 +264,7 @@ export const GitStatusBroadcasterLive = Layer.effect( } const nextPollers = new Map(activePollers); - nextPollers.delete(normalizedCwd); + nextPollers.delete(cwd); return [existing.fiber, nextPollers] as const; }); diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 14f1f355d4..757130db9b 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -54,6 +54,15 @@ afterEach(() => { }); describe("gitStatusState", () => { + it("starts fresh cwd state in a pending state", () => { + expect(getGitStatusSnapshot("/fresh")).toEqual({ + data: null, + error: null, + cause: null, + isPending: true, + }); + }); + it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { const releaseA = watchGitStatus("/repo", gitClient); const releaseB = watchGitStatus("/repo", gitClient); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 73e6974da0..1c1cf00864 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -29,6 +29,10 @@ const EMPTY_GIT_STATUS_STATE = Object.freeze({ cause: null, isPending: false, }); +const INITIAL_GIT_STATUS_STATE = Object.freeze({ + ...EMPTY_GIT_STATUS_STATE, + isPending: true, +}); const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( Atom.keepAlive, Atom.withLabel("git-status:null"), @@ -46,7 +50,7 @@ let sharedGitStatusClient: GitStatusClient | null = null; const gitStatusStateAtom = Atom.family((cwd: string) => { knownGitStatusCwds.add(cwd); - return Atom.make(EMPTY_GIT_STATUS_STATE).pipe( + return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( Atom.keepAlive, Atom.withLabel(`git-status:${cwd}`), ); @@ -122,7 +126,7 @@ export function resetGitStatusStateForTests(): void { sharedGitStatusClient = null; for (const cwd of knownGitStatusCwds) { - appAtomRegistry.set(gitStatusStateAtom(cwd), EMPTY_GIT_STATUS_STATE); + appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE); } knownGitStatusCwds.clear(); } @@ -198,7 +202,7 @@ function markGitStatusPending(cwd: string): void { const current = appAtomRegistry.get(atom); const next = current.data === null - ? { ...EMPTY_GIT_STATUS_STATE, isPending: true } + ? INITIAL_GIT_STATUS_STATE : { ...current, error: null,