From f187ab02e77cce16be6261ad583e6ae4c01348a9 Mon Sep 17 00:00:00 2001 From: flexi767 Date: Thu, 12 Nov 2026 14:41:41 +0100 Subject: [PATCH] feat(web): show checkout details when hovering a sidebar thread The thread hover card names a branch, but not which version a worktree is on or how stale it is. VCS status can now include checkout details (head commit, its date, when the checkout last moved, and how far it trails the local origin/main), and the hover card shows them for the thread's worktree and the project's main checkout. Only the hover card asks for them, so ordinary status reads stay as they were. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.ts | 13 ++- apps/server/src/git/checkoutDetails.test.ts | 45 +++++++++ apps/server/src/git/checkoutDetails.ts | 27 ++++++ .../src/vcs/VcsStatusBroadcaster.test.ts | 62 +++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 97 +++++++++++++------ .../sidebar/SessionWorkspaceDetails.tsx | 96 ++++++++++++++++++ .../components/sidebar/SidebarThreadRow.tsx | 2 + packages/contracts/src/git.ts | 10 ++ packages/shared/src/git.test.ts | 7 ++ packages/shared/src/git.ts | 1 + 10 files changed, 325 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/git/checkoutDetails.test.ts create mode 100644 apps/server/src/git/checkoutDetails.ts create mode 100644 apps/web/src/components/sidebar/SessionWorkspaceDetails.tsx diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d10b09b1be961..c3df35b036f9a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -86,6 +86,7 @@ import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { readCheckoutDetails } from "./checkoutDetails.ts"; import { isNonRepositoryGitStderr } from "../vcs/GitVcsDriverCore.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; @@ -2465,7 +2466,17 @@ export const make = Effect.gen(function* () { const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - return yield* Cache.get(localStatusResultCache, cacheKey); + const status = yield* Cache.get(localStatusResultCache, cacheKey); + if (!input.includeCheckoutDetails || !status.isRepo) return status; + const checkoutDetails = yield* readCheckoutDetails(cacheKey).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, gitCore), + Effect.catch((error) => + Effect.logWarning("GitManager.localStatus checkout details failed", { + errorTag: error._tag, + }).pipe(Effect.as(undefined)), + ), + ); + return { ...status, ...(checkoutDetails ? { checkoutDetails } : {}) }; }, ); const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( diff --git a/apps/server/src/git/checkoutDetails.test.ts b/apps/server/src/git/checkoutDetails.test.ts new file mode 100644 index 0000000000000..27546efa46683 --- /dev/null +++ b/apps/server/src/git/checkoutDetails.test.ts @@ -0,0 +1,45 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ServerConfig from "../config.ts"; +import { readCheckoutDetails } from "./checkoutDetails.ts"; + +it.live("reports the checked out revision, HEAD update time and exact origin/main divergence", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped(); + const git = yield* GitVcsDriver.GitVcsDriver; + const run = (args: readonly string[]) => + git.execute({ cwd, operation: "checkout-details-test", args }); + yield* run(["init", "--initial-branch=main"]); + yield* run(["config", "user.name", "Test"]); + yield* run(["config", "user.email", "test@example.com"]); + yield* run(["commit", "--allow-empty", "-m", "first"]); + const first = (yield* run(["rev-parse", "HEAD"])).stdout.trim(); + assert.isNull((yield* readCheckoutDetails(cwd))?.behindOriginMain); + yield* run(["commit", "--allow-empty", "-m", "second"]); + yield* run(["update-ref", "refs/remotes/origin/main", "HEAD"]); + yield* run(["checkout", "--detach", first]); + const details = yield* readCheckoutDetails(cwd); + assert.strictEqual(details?.commit, first); + assert.strictEqual(details?.behindOriginMain, 1); + assert.isTrue(Number.isFinite(Date.parse(details?.committedAt ?? ""))); + assert.isTrue(Number.isFinite(Date.parse(details?.headUpdatedAt ?? ""))); + yield* run(["checkout", "main"]); + assert.strictEqual((yield* readCheckoutDetails(cwd))?.behindOriginMain, 0); + }).pipe( + Effect.scoped, + Effect.provide( + GitVcsDriver.layer.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "checkout-details-test-" }), + ), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); diff --git a/apps/server/src/git/checkoutDetails.ts b/apps/server/src/git/checkoutDetails.ts new file mode 100644 index 0000000000000..3b8e8c6ad575d --- /dev/null +++ b/apps/server/src/git/checkoutDetails.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +export const readCheckoutDetails = Effect.fn("GitManager.readCheckoutDetails")(function* ( + cwd: string, +) { + const git = yield* GitVcsDriver.GitVcsDriver; + const read = (args: readonly string[]) => + git.execute({ operation: "GitManager.readCheckoutDetails", cwd, args, allowNonZeroExit: true }); + const head = yield* read(["log", "-1", "--format=%H%n%cI", "HEAD", "--"]); + const [commit = "", committedAt = ""] = head.stdout.trim().split("\n"); + if (head.exitCode !== 0 || !commit) return undefined; + const reflog = yield* read(["reflog", "-1", "--format=%gD", "--date=iso-strict", "HEAD"]); + const origin = yield* read(["rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"]); + const originMainCommit = origin.exitCode === 0 ? origin.stdout.trim() : null; + const behind = originMainCommit + ? yield* read(["rev-list", "--count", `HEAD..${originMainCommit}`, "--"]) + : null; + const count = behind?.exitCode === 0 ? Number(behind.stdout.trim()) : NaN; + return { + commit, + committedAt, + headUpdatedAt: /\{([^}]+)\}/.exec(reflog.stdout)?.[1] ?? null, + originMainCommit, + behindOriginMain: Number.isSafeInteger(count) && count >= 0 ? count : null, + }; +}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index e70afb2ddde02..22c47f8184b56 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -81,6 +81,7 @@ function layerTestFor(state: { automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; beforeLocalStatus?: Effect.Effect; + localStatusIncludeCheckoutDetailsValues?: Array; beforeCurrentBranch?: Effect.Effect; beforeInvalidate?: Effect.Effect; branchRead?: () => Effect.Effect; @@ -106,9 +107,10 @@ function layerTestFor(state: { ), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => + localStatus: (input) => Effect.suspend(() => { state.localStatusCalls += 1; + state.localStatusIncludeCheckoutDetailsValues?.push(input.includeCheckoutDetails); if (state.failLocalStatus) { return (state.onLocalStatusFailure ?? Effect.void).pipe( Effect.andThen( @@ -122,8 +124,10 @@ function layerTestFor(state: { ), ); } + const { checkoutDetails: _checkoutDetails, ...ordinaryStatus } = + state.currentLocalStatus; return (state.beforeLocalStatus ?? Effect.void).pipe( - Effect.as(state.currentLocalStatus), + Effect.as(input.includeCheckoutDetails ? state.currentLocalStatus : ordinaryStatus), ); }), repositoryRoot: () => Effect.sync(() => (state.currentLocalStatus.isRepo ? "/repo" : null)), @@ -201,6 +205,60 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b } describe("VcsStatusBroadcaster", () => { + it.effect("publishes checkout metadata changes to detail subscribers", () => { + const firstDetails = { + commit: "1111111111111111111111111111111111111111", + committedAt: "2026-11-04T10:00:00.000Z", + headUpdatedAt: "2026-11-04T10:00:00.000Z", + originMainCommit: null, + behindOriginMain: null, + }; + const state: Parameters[0] = { + currentLocalStatus: { ...baseLocalStatus, checkoutDetails: firstDetails }, + currentRemoteStatus: null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + localStatusIncludeCheckoutDetailsValues: [], + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.refreshLocalStatus("/repo"); + const subscribed = yield* Deferred.make(); + const published = yield* Deferred.make(); + yield* broadcaster + .streamStatus({ cwd: "/repo", includeRemote: false, includeCheckoutDetails: true }) + .pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated") return Deferred.succeed(published, event.local); + return Effect.void; + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + state.localStatusIncludeCheckoutDetailsValues = []; + state.currentLocalStatus = { + ...baseLocalStatus, + checkoutDetails: { + ...firstDetails, + commit: "2222222222222222222222222222222222222222", + committedAt: "2026-11-04T11:00:00.000Z", + }, + }; + yield* broadcaster.refreshLocalStatus("/repo"); + + assert.isTrue(state.localStatusIncludeCheckoutDetailsValues?.at(-1)); + assert.deepStrictEqual((yield* Deferred.await(published)).checkoutDetails, { + ...firstDetails, + commit: "2222222222222222222222222222222222222222", + committedAt: "2026-11-04T11:00:00.000Z", + }); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("refreshes queued behind a running one share the next git status read", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index ffe685ef60d57..53af6f3d00780 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -308,6 +308,31 @@ export const make = Effect.gen(function* () { const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => remoteWriteLocks.withLock(cwd, effect); const pollersRef = yield* SynchronizedRef.make(new Map()); + const checkoutDetailDemandRef = yield* Ref.make(new Map()); + + const retainCheckoutDetails = (cwd: string) => + Ref.update(checkoutDetailDemandRef, (current) => + new Map(current).set(cwd, (current.get(cwd) ?? 0) + 1), + ); + const releaseCheckoutDetails = (cwd: string) => + Ref.update(checkoutDetailDemandRef, (current) => { + const next = new Map(current); + const count = next.get(cwd) ?? 0; + if (count <= 1) next.delete(cwd); + else next.set(cwd, count - 1); + return next; + }); + // Published local status must keep checkout details while a stream asks for them. + const readPublishedLocalStatus = (cwd: string) => + Ref.get(checkoutDetailDemandRef).pipe( + Effect.flatMap((demand) => + workflow.localStatus({ + cwd, + ...(demand.has(cwd) ? { includeCheckoutDetails: true } : {}), + }), + ), + ); + const headWatchersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( @@ -378,7 +403,7 @@ export const make = Effect.gen(function* () { const branch = yield* workflow.currentBranch(cwd); if (branch === cached.local.value.refName) return cached.local.value; yield* workflow.invalidateLocalStatus(cwd); - return yield* updateCachedLocalStatus(cwd, yield* workflow.localStatus({ cwd }), { + return yield* updateCachedLocalStatus(cwd, yield* readPublishedLocalStatus(cwd), { publish: true, }); } @@ -439,7 +464,7 @@ export const make = Effect.gen(function* () { if (shared !== undefined && shared.startedAt >= requestedAt) return shared.local; const startedAt = localRefreshRequests.get(cwd) ?? requestedAt; yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); + const local = yield* readPublishedLocalStatus(cwd); localRefreshResults.set(cwd, { startedAt, local }); return yield* updateCachedLocalStatus(cwd, local, { publish: true }); }), @@ -790,7 +815,7 @@ export const make = Effect.gen(function* () { return previous ? new Map(cache).set(cwd, { ...previous, local: null }) : cache; }); if (detailedSubscribers.has(cwd)) { - const local = yield* workflow.localStatus({ cwd }); + const local = yield* readPublishedLocalStatus(cwd); yield* updateCachedLocalStatus(cwd, local, { publish: true }); return { refName: local.refName, @@ -917,35 +942,43 @@ export const make = Effect.gen(function* () { else detailedSubscribers.set(cwd, remaining); }), ); - const subscription = yield* PubSub.subscribe(changesPubSub); - // Watch before reading so a checkout in between still publishes. - yield* Effect.acquireRelease(retainHeadWatcher(cwd), () => releaseHeadWatcher(cwd)); - const initialLocal = yield* getOrLoadLocalStatus(cwd); - const cachedStatus = yield* getCachedStatus(cwd); - const initialRemote = cachedStatus?.remote?.value ?? null; - if (input.includeRemote !== false) { - yield* retainRemotePoller( - cwd, - input.cwd, - cachedStatus?.remote === null || cachedStatus?.remote === undefined, - ); - } - const release = - input.includeRemote === false - ? Effect.void - : releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); - - return Stream.concat( - Stream.make({ - _tag: "snapshot" as const, - local: initialLocal, - remote: initialRemote, - }), - Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === cwd), - Stream.map((event) => event.event), - ), - ).pipe(Stream.ensuring(release)); + if (input.includeCheckoutDetails) yield* retainCheckoutDetails(cwd); + const releaseCheckoutDetailDemand = input.includeCheckoutDetails + ? releaseCheckoutDetails(cwd) + : Effect.void; + return yield* Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changesPubSub); + // Watch before reading so a checkout in between still publishes. + yield* Effect.acquireRelease(retainHeadWatcher(cwd), () => releaseHeadWatcher(cwd)); + const initialLocal = input.includeCheckoutDetails + ? yield* workflow.localStatus({ cwd, includeCheckoutDetails: true }) + : yield* getOrLoadLocalStatus(cwd); + const cachedStatus = yield* getCachedStatus(cwd); + const initialRemote = cachedStatus?.remote?.value ?? null; + if (input.includeRemote !== false) { + yield* retainRemotePoller( + cwd, + input.cwd, + cachedStatus?.remote === null || cachedStatus?.remote === undefined, + ); + } + const release = + input.includeRemote === false + ? Effect.void + : releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(Effect.all([release, releaseCheckoutDetailDemand]))); + }).pipe(Effect.onError(() => releaseCheckoutDetailDemand)); }), ); diff --git a/apps/web/src/components/sidebar/SessionWorkspaceDetails.tsx b/apps/web/src/components/sidebar/SessionWorkspaceDetails.tsx new file mode 100644 index 0000000000000..0ef8872dd3953 --- /dev/null +++ b/apps/web/src/components/sidebar/SessionWorkspaceDetails.tsx @@ -0,0 +1,96 @@ +import { type EnvironmentId } from "@t3tools/contracts"; +import { GitBranchIcon } from "lucide-react"; +import { useEnvironmentQuery } from "../../state/query"; +import { vcsEnvironment } from "../../state/vcs"; +import type { SidebarThreadSummary } from "../../types"; + +function CheckoutDetails({ + environmentId, + cwd, + heading, +}: { + environmentId: EnvironmentId; + cwd: string; + heading: string; +}) { + const query = useEnvironmentQuery( + vcsEnvironment.status({ + environmentId, + input: { cwd, includeRemote: false, includeCheckoutDetails: true }, + }), + ); + const status = query.data; + const details = status?.checkoutDetails; + const date = (value: string | null) => (value ? new Date(value).toLocaleString() : "Unavailable"); + return ( +
+ +
{heading}
+
+
{cwd}
+ {query.error ? ( +
{query.error}
+ ) : status === null ? ( +
Loading checkout details…
+ ) : status.isRepo === false ? ( +
No Git repository
+ ) : ( + <> +
Branch: {status?.refName ?? "Loading…"}
+
+ Version:{" "} + {details?.commit.slice(0, 10) ?? "Unavailable"} +
+
Version committed: {date(details?.committedAt ?? null)}
+
Checkout last updated: {date(details?.headUpdatedAt ?? null)}
+
+ {details?.behindOriginMain == null + ? "origin/main comparison unavailable" + : details.behindOriginMain === 0 + ? "Up to date with origin/main" + : `${details.behindOriginMain} commits behind origin/main`} +
+ {status?.hasWorkingTreeChanges ?
Uncommitted changes
: null} +
+ Compared with the locally fetched origin/main. +
+ + )} +
+
+ ); +} + +export function SessionWorkspaceDetails({ + thread, + workspaceRoot, +}: { + thread: SidebarThreadSummary; + workspaceRoot: string | null; +}) { + const cwd = thread.worktreePath ?? workspaceRoot; + return ( +
+ {cwd ? ( + + ) : ( + Workspace unavailable + )} + {thread.worktreePath && workspaceRoot && workspaceRoot !== cwd ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 89ab26f51b9a0..11a771422b1c7 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,6 +1,7 @@ import { useAtomMount } from "@effect/atom-react"; import { asLaunchingThread } from "@t3tools/client-runtime/state/thread-inbox"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { SessionWorkspaceDetails } from "./SessionWorkspaceDetails"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; @@ -330,6 +331,7 @@ function SidebarThreadTooltip({ ) : null} + {previousProviderNames.length > 0 ? (
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 45d68c5e9ca44..041fa68048633 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -107,6 +107,7 @@ const GitResolvedPullRequest = Schema.Struct({ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; export const VcsStatusInput = Schema.Struct({ + includeCheckoutDetails: Schema.optional(Schema.Boolean), cwd: TrimmedNonEmptyStringSchema, }); export type VcsStatusInput = typeof VcsStatusInput.Type; @@ -221,6 +222,15 @@ const VcsStatusChangeRequest = Schema.Struct({ }); const VcsStatusLocalShape = { + checkoutDetails: Schema.optional( + Schema.Struct({ + commit: Schema.String, + committedAt: Schema.String, + headUpdatedAt: Schema.NullOr(Schema.String), + behindOriginMain: Schema.NullOr(NonNegativeInt), + originMainCommit: Schema.NullOr(Schema.String), + }), + ), isRepo: Schema.Boolean, sourceControlProvider: Schema.optional(SourceControlProviderInfo), hasPrimaryRemote: Schema.Boolean, diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index b549228712301..aa5662f3fd0c6 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -277,6 +277,13 @@ describe("applyGitStatusStreamEvent", () => { it("preserves local-only fields when applying a remote update", () => { const current: VcsStatusResult = { + checkoutDetails: { + commit: "0123456789abcdef", + committedAt: "2026-11-04T09:10:28+01:00", + headUpdatedAt: "2026-11-04T09:15:00+01:00", + behindOriginMain: 15, + originMainCommit: "abcdef0123456789", + }, isRepo: true, sourceControlProvider: { kind: SourceControlProviderKind.make("github"), diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index ee045c35bdd2f..e84d3c447342f 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -341,6 +341,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { return { + ...(status.checkoutDetails ? { checkoutDetails: status.checkoutDetails } : {}), isRepo: status.isRepo, ...(status.sourceControlProvider ? { sourceControlProvider: status.sourceControlProvider }