diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f3e3dcfd8ce5..98c1c3b20224 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -66,7 +66,7 @@ Use these client origins: - Android Emulator: `http://10.0.2.2:` - Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin -Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. +Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. ## Start or reuse Metro safely diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..57be2187d987 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -324,6 +324,84 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("re-resolves the advertised LAN host after the machine changes networks", () => { + // `read` hands back this same object every call, so reassigning `en0` models the + // machine roaming to another network while the app stays open. + const roamingNetworkInterfaces: Record< + string, + DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[] + > = { + en0: [{ address: "192.168.2.37", family: "IPv4", internal: false }], + }; + + return withHarness( + roamingNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 3773 }); + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.state.endpointUrl, "http://192.168.2.37:3773"); + + roamingNetworkInterfaces.en0 = [ + { address: "192.168.1.21", family: "IPv4", internal: false }, + ]; + + const state = yield* serverExposure.getState; + assert.equal(state.mode, "network-accessible"); + assert.equal(state.advertisedHost, "192.168.1.21"); + assert.equal(state.endpointUrl, "http://192.168.1.21:3773"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:3773/", "http://192.168.1.21:3773/"], + ); + + // The backend is already bound to every interface, so the new address needs + // no relaunch — binding and loopback URL must stay put. + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "0.0.0.0"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:3773/"); + }), + ); + }); + + it.effect("keeps a local-only fallback unadvertised when a LAN address appears later", () => { + const lateNetworkInterfaces: Record< + string, + DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[] + > = {}; + + return withHarness( + lateNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + const state = yield* serverExposure.configureFromSettings({ port: 3773 }); + assert.equal(state.mode, "local-only"); + + lateNetworkInterfaces.en0 = [{ address: "192.168.1.21", family: "IPv4", internal: false }]; + + // The fallback bound loopback only, so advertising the address that showed up + // afterwards would point clients at a socket this process never listens on. + const refreshed = yield* serverExposure.getState; + assert.equal(refreshed.mode, "local-only"); + assert.equal(refreshed.advertisedHost, null); + assert.equal(refreshed.endpointUrl, null); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:3773/"], + ); + }), + {}, + dieOnSpawnLayer(), + ); + }); + it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => withHarness( lanNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..721c07bbb206 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -399,6 +399,44 @@ function resolveRuntimeState(input: { }; } +/** + * Recompute the advertised LAN host from the current interfaces. + * + * The LAN address is resolved once while starting up, but laptops move between + * networks (and DHCP leases rotate) while the app stays open for days. The + * backend is bound to `0.0.0.0`, so the new address already serves traffic — + * only the advertised host goes stale, and a stale host sends pairing links and + * QR codes to an IP nobody answers on. Rebinding is never needed here, so this + * refresh deliberately leaves `bindHost`/`localHttpUrl`/`port` untouched. + */ +const withRefreshedAdvertisedHost = (input: { + readonly state: RuntimeState; + readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; + readonly advertisedHostOverride: Option.Option; +}): RuntimeState => { + // Only network-accessible runs bind the LAN interface; a local-only run (including + // one that fell back for want of an address) has nothing reachable to advertise. + if (input.state.mode !== "network-accessible") { + return input.state; + } + + const advertisedHost = resolveLanAdvertisedHost( + input.networkInterfaces, + Option.getOrUndefined(input.advertisedHostOverride), + ); + if (advertisedHost === Option.getOrNull(input.state.advertisedHost)) { + return input.state; + } + + return { + ...input.state, + endpointUrl: Option.fromNullishOr( + advertisedHost === null ? null : `http://${advertisedHost}:${input.state.port}`, + ), + advertisedHost: Option.fromNullishOr(advertisedHost), + }; +}; + const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => previous.port !== next.port || previous.bindHost !== next.bindHost || @@ -426,7 +464,18 @@ export const make = Effect.gen(function* () { const readNetworkInterfaces = networkInterfaces.read; - const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); + const refreshAdvertisedHost = Effect.gen(function* () { + const currentNetworkInterfaces = yield* readNetworkInterfaces; + return yield* Ref.updateAndGet(stateRef, (state) => + withRefreshedAdvertisedHost({ + state, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }), + ); + }); + + const getState = refreshAdvertisedHost.pipe(Effect.map(toContractState)); const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( @@ -522,8 +571,14 @@ export const make = Effect.gen(function* () { ); const getAdvertisedEndpoints = Effect.gen(function* () { - const state = yield* Ref.get(stateRef); const currentNetworkInterfaces = yield* readNetworkInterfaces; + const state = yield* Ref.updateAndGet(stateRef, (current) => + withRefreshedAdvertisedHost({ + state: current, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }), + ); const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, exposure: toResolvedExposure(state), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..2e8568f170d2 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + diffTheme: "pierre-dark", environmentIdentificationMode: "artwork", favorites: [], glassOpacity: 80, @@ -32,6 +33,7 @@ const clientSettings: ClientSettings = { sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, sidebarV2ConfiguredByUser: false, + sidebarV2GroupByProject: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 697d13e7c472..30ef7c7babbc 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -14,6 +14,9 @@ function makeProject( workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index de3799ac8a8e..ee5f8303db84 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,7 +3,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useState } from "react"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -76,9 +76,21 @@ export function ConnectionsNewRouteScreen({ return; } + if (permission.canAskAgain) { + Alert.alert( + "Camera access needed", + "Allow camera access to scan an environment pairing QR code.", + ); + return; + } + Alert.alert( "Camera access needed", - "Allow camera access to scan an environment pairing QR code.", + "Camera access was denied for this app. Open Settings to enable it.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], ); }, [cameraPermission?.granted, requestCameraPermission]); diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 75f78738ade5..6d07f40a52dd 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; -import { splitEnvironmentSections } from "./environmentSections"; +import { relayManagedEnvironmentIds, splitEnvironmentSections } from "./environmentSections"; function connectedEnvironment( input: Omit, "environmentId"> & { @@ -34,6 +34,17 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { }; } +describe("relayManagedEnvironmentIds", () => { + it("leaves out a backend that was saved directly", () => { + const ids = relayManagedEnvironmentIds([ + connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false }), + connectedEnvironment({ environmentId: "environment-cloud", isRelayManaged: true }), + ]); + + expect([...ids]).toEqual([EnvironmentId.make("environment-cloud")]); + }); +}); + describe("mobile environment settings sections", () => { it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ @@ -111,6 +122,24 @@ describe("mobile environment settings sections", () => { expect(sections.availableCloudEnvironments).toEqual([]); }); + it("still offers a cloud environment saved directly as a local backend", () => { + const local = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: false, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [local], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-cloud")]); + }); + it("keeps failed relay environments in the local connection row", () => { const cloud = connectedEnvironment({ environmentId: "environment-cloud", diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts index fc6db479c2ff..10ba636dc576 100644 --- a/apps/mobile/src/features/connection/environmentSections.ts +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -12,10 +13,25 @@ export interface EnvironmentSections { readonly availableCloudEnvironments: ReadonlyArray; } -export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { - const savedEnvironmentIds = new Set( - input.connectedEnvironments.map((environment) => environment.environmentId), +/** + * Ids of the environments that already occupy a T3 Connect slot. A backend saved directly is + * not one of them, so it must not suppress the cloud environment that happens to share its id. + */ +export function relayManagedEnvironmentIds( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly isRelayManaged: boolean; + }>, +): ReadonlySet { + return new Set( + environments + .filter((environment) => environment.isRelayManaged) + .map((environment) => environment.environmentId), ); +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments); return { localEnvironments: input.connectedEnvironments.filter( diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 18b6c71a293a..193927684794 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,11 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, } from "./pairing"; +describe("buildPairingUrl", () => { + it("uses HTTP for a schemeless IP address", () => { + expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( + "http://192.168.1.100:3773/#token=pairing-token", + ); + }); + + it("keeps HTTPS as the default for a schemeless hostname", () => { + expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe( + "https://remote.example.com/#token=pairing-token", + ); + }); + + it("preserves an explicit scheme for an IP address", () => { + expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe( + "https://192.168.1.100:3773/#token=pairing-token", + ); + }); +}); + describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 910efa7f2565..569d00cbdd36 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -3,6 +3,21 @@ import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +function isIpLiteral(host: string): boolean { + try { + const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); + if (hostname.includes(":")) return true; + + const octets = hostname.split("."); + return ( + octets.length === 4 && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); + } catch { + return false; + } +} + export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( "PairingQrPayloadEmptyError", {}, @@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `https://${h}`); + const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index bad6b6f17209..faa34477569d 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -20,6 +20,7 @@ import { useEnvironments } from "../../state/environments"; import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; +import { relayManagedEnvironmentIds } from "./environmentSections"; export interface RelayEnvironmentView { readonly environment: RelayClientEnvironmentRecord; @@ -49,7 +50,7 @@ export function useConnectionController() { [environments], ); const registeredIds = useMemo( - () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + () => relayManagedEnvironmentIds(connectedEnvironments), [connectedEnvironments], ); const relayEnvironments = useMemo>( diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index c5a9f2c6bbcb..6500d7e7d12d 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -26,6 +26,9 @@ function makeProject(id: string, title: string): EnvironmentProject { workspaceRoot: `/workspaces/${id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index e791cd3b36af..f5f9a74c26db 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -18,6 +18,9 @@ function makeProject( workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 21084f0f5fe5..d8c7694119ab 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -306,6 +306,9 @@ export function buildHomeThreadGroups(input: { pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId), repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: pendingTask.message.createdAt, updatedAt: pendingTask.message.createdAt, diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d1..f1382589120f 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -5,7 +5,7 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; -export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; +export type ReviewSectionKind = "turn" | "working-tree-all" | "working-tree" | "branch-range"; const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; const DIRTY_WORKTREE_TITLE = "Dirty worktree"; @@ -546,14 +546,18 @@ export function buildReviewSectionItems(input: { }, ); - const gitItems = input.gitSections.map((section) => ({ - id: `git:${section.kind}`, - kind: section.kind, - title: section.title, - subtitle: gitSubtitle(section), - diff: section.diff, - isLoading: false, - })); + const gitItems = input.gitSections + // The combined "working tree" (all changes) source backs the web diff panel + // only; mobile keeps its dirty-worktree and branch-range sections. + .filter((section) => section.kind !== "working-tree-all") + .map((section) => ({ + id: `git:${section.kind}`, + kind: section.kind, + title: section.title, + subtitle: gitSubtitle(section), + diff: section.diff, + isLoading: false, + })); const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID); const visibleGitItems = input.loadingGitSections && !hasDirtyWorktreeItem diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts index 8ab043d7cd8b..4ae4ac54598b 100644 --- a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -15,6 +15,9 @@ const projects: ReadonlyArray = [ workspaceRoot: "/workspace/t3code", repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: "2026-07-16T08:00:00.000Z", updatedAt: "2026-07-16T08:00:00.000Z", @@ -26,6 +29,9 @@ const projects: ReadonlyArray = [ workspaceRoot: "/workspace/react", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: "2026-07-16T08:00:00.000Z", updatedAt: "2026-07-16T08:00:00.000Z", diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 74fe2f4852a8..dc0e9912c3e8 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -260,6 +260,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceRoot: creation.projectCwd ?? "", repositoryIdentity: null, defaultModelSelection: editingPendingTask.modelSelection ?? null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: editingPendingTask.createdAt, updatedAt: editingPendingTask.createdAt, @@ -611,13 +614,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // The default may only exist as origin/ (isRemote), which // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = + allBranchRefs.find((branch) => branch.name === selectedProject?.defaultWorktreeBranch) ?? allBranchRefs.find((branch) => branch.isDefault) ?? availableBranches.find((branch) => branch.current) ?? null; if (preferredBranch) { selectBranch(preferredBranch); } - }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [ + allBranchRefs, + availableBranches, + selectBranch, + selectedBranchName, + selectedProject?.defaultWorktreeBranch, + workspaceMode, + ]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1b15905ef7be..01baeb116a2a 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -96,13 +96,28 @@ describe("resolveThreadListV2Status", () => { }); describe("sortThreadsForListV2", () => { - it("orders by creation time, newest first, ignoring activity", () => { + it("orders by latest activity, newest first", () => { const sorted = sortThreadsForListV2([ - { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, - { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, - { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + { + id: "oldest", + createdAt: "2026-06-01T08:00:00.000Z", + updatedAt: "2026-06-01T08:00:00.000Z", + latestUserMessageAt: "2026-06-01T13:00:00.000Z", + }, + { + id: "newest", + createdAt: "2026-06-01T12:00:00.000Z", + updatedAt: "2026-06-01T12:00:00.000Z", + latestUserMessageAt: null, + }, + { + id: "middle", + createdAt: "2026-06-01T10:00:00.000Z", + updatedAt: "2026-06-01T10:00:00.000Z", + latestUserMessageAt: null, + }, ]); - expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + expect(sorted.map((thread) => thread.id)).toEqual(["oldest", "newest", "middle"]); }); }); @@ -130,9 +145,8 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - // Same createdAt → static sort tiebreaks by id; the point is the woken - // thread is BACK in the card block and the snoozed one is gone. - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); + // The woken thread is BACK in the card block and the snoozed one is gone. + expect(layout.items.map((item) => item.thread.id)).toEqual(["woken", "active"]); expect(layout.snoozedCount).toBe(1); }); @@ -217,14 +231,14 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => item.isLast)).toEqual([false, false, true]); }); - it("keeps cards in creation order while settled sorts by recency", () => { + it("promotes the most recently active card", () => { const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("older-created"), title: "Older", createdAt: "2026-06-01T08:00:00.000Z", - updatedAt: NOW, // recent activity must NOT promote it + updatedAt: NOW, }), makeThread({ id: ThreadId.make("newer-created"), @@ -237,7 +251,7 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); + expect(items.map((item) => item.thread.id)).toEqual(["older-created", "newer-created"]); }); it("keeps settled threads in the tail and filters by search query", () => { @@ -303,7 +317,7 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]); + expect(items.map((item) => item.thread.id)).toEqual(["remote", "local"]); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ab955d16d4d1..282a6e08c9b1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,4 +1,5 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { sortThreads, type ThreadSortInput } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -76,22 +77,12 @@ function firstValidTimestampMs(...candidates: ReadonlyArray( +/** Keep the chats users touched most recently at the top, matching web's + sortThreadsForSidebarV2. */ +export function sortThreadsForListV2( threads: readonly T[], ): T[] { - // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 - // change-by-copy array methods. - return [...threads].sort( - (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || - left.id.localeCompare(right.id), - ); + return sortThreads(threads, "updated_at"); } export interface ThreadListV2Item { diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a93..d048a77c4fe9 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -74,7 +74,10 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildTemporaryWorktreeBranchName( + randomHex, + input.project.worktreeBranchPrefix, + ), }), }); if (AsyncResult.isFailure(result)) { diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index ab4311524ce4..382787111cf2 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -12,6 +12,9 @@ function makeProject( workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, scripts: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710d..b1fbddb23de7 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -271,6 +271,7 @@ export function useSelectedThreadGitActions() { cwd: project.workspaceRoot, refName: nextWorktree.baseBranch, newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), + baseRefName: nextWorktree.baseBranch, path: null, }, }); @@ -327,6 +328,9 @@ export function useSelectedThreadGitActions() { const result = await runStackedAction({ actionId, action: input.action, + ...(selectedThreadProject?.defaultWorktreeBranch + ? { baseBranch: selectedThreadProject.defaultWorktreeBranch } + : {}), ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), @@ -367,6 +371,7 @@ export function useSelectedThreadGitActions() { runStackedAction, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, + selectedThreadProject?.defaultWorktreeBranch, selectedThreadWorktreePath, syncSelectedThreadBranchState, ], diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 3559fa140fe8..b27413a76cf1 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -270,6 +270,9 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, + // Queued creations don't carry the project's prefix; this is only the + // temporary branch, which the server renames to the project's + // configured `/` on the first turn. worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..9ad51f04fa1c 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -54,6 +54,20 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string return match[1]?.toLowerCase() ?? null; } +const SAFE_DOCUMENT_EXTENSION_PATTERN = /^[a-z0-9]{1,16}$/; + +// Preserve the uploaded document's own extension (e.g. .pdf/.xlsx) so the file +// written into the worktree keeps a name the agent — and the user — recognize. +// Anything unusual collapses to .bin, matching the image path's safe fallback. +export function inferDocumentExtension(fileName: string): string { + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === fileName.length - 1) { + return ".bin"; + } + const raw = fileName.slice(dotIndex + 1).toLowerCase(); + return SAFE_DOCUMENT_EXTENSION_PATTERN.test(raw) ? `.${raw}` : ".bin"; +} + export function attachmentRelativePath(attachment: ChatAttachment): string { switch (attachment.type) { case "image": { @@ -63,6 +77,9 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "document": { + return `${attachment.id}${inferDocumentExtension(attachment.name)}`; + } } } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5655f260bc95..1231ddbeac66 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -26,6 +26,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.generateContinuationSummary]: AuthOrchestrationOperateScope, [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, @@ -64,6 +65,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, + [WS_METHODS.gitMergePullRequest]: AuthOrchestrationOperateScope, [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d51..c3254663ff9d 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -81,11 +81,13 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => @@ -189,11 +191,13 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), @@ -272,11 +276,13 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), @@ -340,11 +346,13 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), @@ -393,11 +401,13 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 695c7b76f648..f518db5b5667 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -34,6 +34,7 @@ import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceContr import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProjectWorktreeFileCopier from "../project/ProjectWorktreeFileCopier.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; @@ -292,6 +293,10 @@ function createTextGeneration( Effect.succeed({ title: "Update workflow", }), + generateContinuationSummary: () => + Effect.succeed({ + summary: "Continue the stacked git actions work.", + }), ...overrides, }; @@ -340,6 +345,17 @@ function createTextGeneration( }), ), ), + generateContinuationSummary: (input) => + implementation.generateContinuationSummary(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateContinuationSummary", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } @@ -492,6 +508,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { execute, + // The fake gh transcripts don't model GitHub's lazily-computed merge state, + // so leave the listed summaries untouched. + readPullRequestReviewState: () => Effect.succeed(null), listOpenPullRequests: (input) => execute({ cwd: input.cwd, @@ -572,6 +591,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "merge", input.reference, "--merge"], + }).pipe(Effect.asVoid), }, ghCalls, }; @@ -583,6 +607,7 @@ function runStackedAction( cwd: string; action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; actionId?: string; + baseBranch?: string; commitMessage?: string; featureBranch?: boolean; filePaths?: readonly string[]; @@ -617,6 +642,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + worktreeFileCopier?: ProjectWorktreeFileCopier.ProjectWorktreeFileCopier["Service"]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -657,6 +683,12 @@ function makeManager(input?: { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, ), + Layer.succeed( + ProjectWorktreeFileCopier.ProjectWorktreeFileCopier, + input?.worktreeFileCopier ?? { + copyForThread: () => Effect.succeed({ entries: [], copiedCount: 0 }), + }, + ), vcsDriverLayer, serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); @@ -1053,7 +1085,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,mergeable,mergeStateStatus,statusCheckRollup", ); }), 20_000, @@ -2712,6 +2744,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("targets the project's preferred base branch when creating a PR", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "staging"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "staging.txt"), "staging\n"); + yield* runGit(repoDir, ["add", "staging.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Staging commit"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/project-base"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + yield* runGit(repoDir, ["add", "feature.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/project-base"]); + // Prove the project preference wins over branch-local merge-base metadata. + yield* runGit(repoDir, ["config", "branch.feature/project-base.gh-merge-base", "main"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 89, + title: "Target staging", + url: "https://github.com/pingdotgg/codething-mvp/pull/89", + baseRefName: "staging", + headRefName: "feature/project-base", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + baseBranch: "staging", + }); + + expect(result.pr.baseBranch).toBe("staging"); + expect( + ghCalls.some((call) => + call.includes("pr create --base staging --head feature/project-base"), + ), + ).toBe(true); + }), + ); + it.effect("generates PR content against the remote base when the local base is stale", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index da002df5e6c4..27a7f90f5dd7 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -16,6 +16,7 @@ import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, + GitMergePullRequestResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, @@ -51,6 +52,7 @@ import { repositoryConventionsTextGenerationPolicy, } from "../textGeneration/TextGenerationPresets.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProjectWorktreeFileCopier from "../project/ProjectWorktreeFileCopier.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -67,6 +69,14 @@ export interface GitActionProgressReporter { export interface GitRunStackedActionOptions { readonly actionId?: string; readonly progressReporter?: GitActionProgressReporter; + /** + * The owning project's configured model/account, resolved by the caller from + * the action's `cwd`. When set (and its provider instance is usable), it is + * preferred over the server-wide source-control writer selection for commit + * message / PR / branch text generation, so generation runs on the same + * account the project's agents use. + */ + readonly projectModelSelection?: ModelSelection | null; } interface SourceControlTextGenerationSettings { @@ -93,6 +103,9 @@ export class GitManager extends Context.Service< readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; + readonly mergePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; @@ -130,6 +143,10 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; + mergeability?: "clean" | "conflicting" | "blocked" | "unknown"; + checks?: "passing" | "failing" | "pending" | "unknown"; + failedCheckCount?: number; + unresolvedReviewThreadCount?: number; updatedAt: Option.Option; } @@ -364,6 +381,16 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { headRefName: summary.headRefName, state: summary.state ?? "open", updatedAt: summary.updatedAt, + // Merge/review state drives the merge button, so it has to survive the hop + // from the provider's change request into the status payload. + ...(summary.mergeability ? { mergeability: summary.mergeability } : {}), + ...(summary.checks ? { checks: summary.checks } : {}), + ...(summary.failedCheckCount !== undefined + ? { failedCheckCount: summary.failedCheckCount } + : {}), + ...(summary.unresolvedReviewThreadCount !== undefined + ? { unresolvedReviewThreadCount: summary.unresolvedReviewThreadCount } + : {}), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -517,6 +544,10 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + mergeability?: "clean" | "conflicting" | "blocked" | "unknown"; + checks?: "passing" | "failing" | "pending" | "unknown"; + failedCheckCount?: number; + unresolvedReviewThreadCount?: number; } { return { number: pr.number, @@ -525,6 +556,12 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(pr.mergeability ? { mergeability: pr.mergeability } : {}), + ...(pr.checks ? { checks: pr.checks } : {}), + ...(pr.failedCheckCount !== undefined ? { failedCheckCount: pr.failedCheckCount } : {}), + ...(pr.unresolvedReviewThreadCount !== undefined + ? { unresolvedReviewThreadCount: pr.unresolvedReviewThreadCount } + : {}), }; } @@ -580,6 +617,7 @@ export const make = Effect.gen(function* () { const textGeneration = yield* TextGeneration.TextGeneration; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const projectWorktreeFileCopier = yield* ProjectWorktreeFileCopier.ProjectWorktreeFileCopier; const crypto = yield* Crypto.Crypto; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); @@ -1194,11 +1232,7 @@ export const make = Effect.gen(function* () { ); if (firstPullRequest) { return { - number: firstPullRequest.number, - title: firstPullRequest.title, - url: firstPullRequest.url, - baseRefName: firstPullRequest.baseRefName, - headRefName: firstPullRequest.headRefName, + ...firstPullRequest, state: "open", updatedAt: Option.none(), } satisfies PullRequestInfo; @@ -1550,6 +1584,7 @@ export const make = Effect.gen(function* () { cwd: string, fallbackBranch: string | null, emit: GitActionProgressEmitter, + preferredBaseBranch?: string, ) { const provider = yield* sourceControlProvider(cwd); const terms = getChangeRequestTerminologyForKind(provider.kind); @@ -1587,7 +1622,9 @@ export const make = Effect.gen(function* () { }; } - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); + const baseBranch = + preferredBaseBranch ?? + (yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext)); yield* emit({ kind: "phase_started", phase: "pr", @@ -1718,9 +1755,47 @@ export const make = Effect.gen(function* () { return { pullRequest }; }); + const mergePullRequest: GitManager["Service"]["mergePullRequest"] = Effect.fn("mergePullRequest")( + function* (input) { + const normalizedReference = normalizePullRequestReference(input.reference); + const provider = yield* sourceControlProvider(input.cwd); + const resolved = yield* provider + .getChangeRequest({ cwd: input.cwd, reference: normalizedReference }) + .pipe(Effect.map((changeRequest) => toResolvedPullRequest(changeRequest))); + + return yield* provider + .mergeChangeRequest({ cwd: input.cwd, reference: normalizedReference }) + .pipe( + Effect.as({ pullRequest: { ...resolved, state: "merged" as const } }), + Effect.ensuring(invalidateStatus(input.cwd)), + ); + }, + ); + const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { + const maybeCopyProjectFiles = (worktreePath: string) => { + if (!input.threadId) { + return Effect.void; + } + return projectWorktreeFileCopier + .copyForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("GitManager.preparePullRequestThread file copy failed", { + threadId: input.threadId, + worktreePath, + cause: error, + }).pipe(Effect.asVoid), + ), + Effect.asVoid, + ); + }; const maybeRunSetupScript = (worktreePath: string) => { if (!input.threadId) { return Effect.void; @@ -1879,6 +1954,7 @@ export const make = Effect.gen(function* () { path: null, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + yield* maybeCopyProjectFiles(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); return { @@ -1916,7 +1992,12 @@ export const make = Effect.gen(function* () { const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); - yield* gitCore.createRef({ cwd, refName: resolvedBranch }); + yield* gitCore.createRef({ + cwd, + refName: resolvedBranch, + // The branch we're forking from becomes the pull request's base. + ...(branch ? { baseRefName: branch } : {}), + }); yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); return { @@ -1991,23 +2072,19 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( - Effect.flatMap((settings) => - settings.sourceControlWriterModelSelection === null - ? Effect.succeed({ - modelSelection: settings.textGenerationModelSelection, - style: settings.sourceControlWritingStyle, - }) - : providerRegistry.getProviders.pipe( - Effect.map((providers) => ({ - modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( - settings, - providers, - ), - style: settings.sourceControlWritingStyle, - })), - ), - ), + const textGenerationSettings = yield* Effect.all([ + serverSettingsService.getSettings, + providerRegistry.getProviders, + ]).pipe( + Effect.map(([settings, providers]) => ({ + modelSelection: + ServerSettings.resolveSourceControlWriterModelSelectionWithProjectPreference( + settings, + providers, + options?.projectModelSelection ?? null, + ), + style: settings.sourceControlWritingStyle, + })), Effect.mapError( (cause) => new GitManagerError({ @@ -2090,7 +2167,13 @@ export const make = Effect.gen(function* () { .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), Effect.flatMap(() => - runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit), + runPrStep( + textGenerationSettings, + input.cwd, + currentBranch, + progress.emit, + input.baseBranch, + ), ), ) : { status: "skipped_not_requested" as const }; @@ -2141,6 +2224,7 @@ export const make = Effect.gen(function* () { invalidateRemoteStatus, invalidateStatus, resolvePullRequest, + mergePullRequest, preparePullRequestThread, runStackedAction, }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadbad..b4ee0082a745 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -17,6 +17,7 @@ import { type GitPreparePullRequestThreadInput, type GitPreparePullRequestThreadResult, type GitPullRequestRefInput, + type GitMergePullRequestResult, type VcsPullResult, type VcsRemoveWorktreeInput, type GitResolvePullRequestResult, @@ -56,6 +57,9 @@ export class GitWorkflowService extends Context.Service< readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; + readonly mergePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; @@ -285,6 +289,10 @@ export const make = Effect.gen(function* () { "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), + mergePullRequest: routeGitManager( + "GitWorkflowService.mergePullRequest", + gitManager.mergePullRequest, + ), preparePullRequestThread: routeGitManager( "GitWorkflowService.preparePullRequestThread", gitManager.preparePullRequestThread, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 707c87c43c99..6bca7b5732d0 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -888,6 +888,90 @@ describe("CheckpointReactor", () => { ).toBe(true); }); + it("captures the pre-turn baseline in the session runtime cwd so the inline turn diff is scoped to the turn", async () => { + // The session runtime works in a different git repo than the thread's + // worktree. The baseline (`from`) must land in the same repo the completion + // snapshot + diff (`to`) use; otherwise the inline turn diff compares two + // unrelated trees and lists files the turn never touched. + const sessionRepo = createGitRepository(); + tempDirs.push(sessionRepo); + + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerSessionCwd: sessionRepo, + }); + const createdAt = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-cross-repo"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-turn-started-cross-repo"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-cross-repo"), + }); + // The baseline lands in the session repo, not the worktree. + await waitForGitRefExists( + sessionRepo, + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), + ); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), + ).toBe(false); + + // The turn edits a file in the session repo. + NodeFS.writeFileSync(NodePath.join(sessionRepo, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-cross-repo"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-cross-repo"), + payload: { state: "completed" }, + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + + // from/to both live in the session repo, so the diff is computable and + // scoped to exactly the turn's edit (README v1 -> v2). + expect( + gitRefExists(sessionRepo, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), + ).toBe(true); + expect( + gitShowFileAtRef( + sessionRepo, + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), + "README.md", + ), + ).toBe("v1\n"); + expect( + gitShowFileAtRef( + sessionRepo, + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + "README.md", + ), + ).toBe("v2\n"); + }); + it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { const harness = await createHarness(); const createdAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 3ba244ddf2c4..ed8e675a3fb6 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -489,12 +489,27 @@ const make = Effect.gen(function* () { } const projects = yield* resolveThreadProjects(thread.projectId); - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId: thread.id, - thread, - projects, - preferSessionRuntime: false, - }); + // The pre-turn baseline is the `from` side of the inline turn diff, and the + // completion snapshot + diff resolve cwd with preferSessionRuntime: true. + // If the baseline picks a different cwd (e.g. the thread worktree while the + // session runtime works in another repo), `from` and `to` snapshot + // different working trees and the diff lists every file that differs + // between them — unrelated to what the turn changed. Prefer the session + // runtime cwd so both snapshots come from the same tree, falling back to + // the worktree only when the session cwd isn't a usable git workspace. + const checkpointCwd = + (yield* resolveCheckpointCwd({ + threadId: thread.id, + thread, + projects, + preferSessionRuntime: true, + })) ?? + (yield* resolveCheckpointCwd({ + threadId: thread.id, + thread, + projects, + preferSessionRuntime: false, + })); if (!checkpointCwd) { return; } @@ -571,12 +586,24 @@ const make = Effect.gen(function* () { } const projects = yield* resolveThreadProjects(thread.projectId); - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId, - thread, - projects, - preferSessionRuntime: false, - }); + // Prefer the completion capture's cwd resolution (preferSessionRuntime: true) + // so the baseline `from` snapshot and the turn's `to` snapshot describe the + // same working tree; otherwise the inline turn diff lists unrelated files. + // Fall back to the worktree when the session cwd isn't a usable git + // workspace. + const checkpointCwd = + (yield* resolveCheckpointCwd({ + threadId, + thread, + projects, + preferSessionRuntime: true, + })) ?? + (yield* resolveCheckpointCwd({ + threadId, + thread, + projects, + preferSessionRuntime: false, + })); if (!checkpointCwd) { return; } diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea7830..f03b7d1c4516 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -123,6 +123,11 @@ describe("OrchestrationEngine", () => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: "2026-03-03T00:00:00.000Z", updatedAt: "2026-03-03T00:00:01.000Z", @@ -185,6 +190,7 @@ describe("OrchestrationEngine", () => { threads: [], updatedAt: projectionSnapshot.updatedAt, }), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence, @@ -196,6 +202,7 @@ describe("OrchestrationEngine", () => { Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200c..c9ae9684a40b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -495,6 +495,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, + reviewModelSelection: event.payload.reviewModelSelection ?? null, + gitHubAccount: event.payload.gitHubAccount ?? null, + worktreeBranchPrefix: event.payload.worktreeBranchPrefix ?? null, + defaultWorktreeBranch: event.payload.defaultWorktreeBranch ?? null, + previewPort: event.payload.previewPort ?? null, + worktreeCopyFiles: event.payload.worktreeCopyFiles ?? [], scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -518,6 +524,24 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), + ...(event.payload.reviewModelSelection !== undefined + ? { reviewModelSelection: event.payload.reviewModelSelection } + : {}), + ...(event.payload.gitHubAccount !== undefined + ? { gitHubAccount: event.payload.gitHubAccount } + : {}), + ...(event.payload.worktreeBranchPrefix !== undefined + ? { worktreeBranchPrefix: event.payload.worktreeBranchPrefix } + : {}), + ...(event.payload.defaultWorktreeBranch !== undefined + ? { defaultWorktreeBranch: event.payload.defaultWorktreeBranch } + : {}), + ...(event.payload.previewPort !== undefined + ? { previewPort: event.payload.previewPort } + : {}), + ...(event.payload.worktreeCopyFiles !== undefined + ? { worktreeCopyFiles: event.payload.worktreeCopyFiles } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad7..59c0137d94c4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -11,6 +11,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -266,6 +267,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + reviewModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [ { id: "script-1", @@ -381,6 +388,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + reviewModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [ { id: "script-1", @@ -570,6 +583,190 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("maps workspace roots and worktrees (incl. archived threads) to accounts", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + + // Two projects: one with an account attached, one without. + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, + default_model_selection_json, scripts_json, + github_account_json, created_at, updated_at, deleted_at + ) + VALUES + ( + 'project-account', + 'Account Project', + '/repos/account-project', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '{"host":"github.com","login":"octo"}', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ), + ( + 'project-none', + 'No Account Project', + '/repos/no-account', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + NULL, + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, + runtime_mode, interaction_mode, branch, worktree_path, + latest_turn_id, latest_user_message_at, + pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, + archived_at, deleted_at + ) + VALUES + ( + 'thread-active', 'project-account', 'Active', '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', 'default', NULL, '/state/worktrees/active', + NULL, NULL, 0, 0, 0, + '2026-04-06T00:00:02.000Z', '2026-04-06T00:00:03.000Z', NULL, NULL + ), + ( + 'thread-archived', 'project-account', 'Archived', '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', 'default', NULL, '/state/worktrees/archived', + NULL, NULL, 0, 0, 0, + '2026-04-06T00:00:04.000Z', '2026-04-06T00:00:05.000Z', '2026-04-06T00:00:06.000Z', NULL + ), + ( + 'thread-deleted', 'project-account', 'Deleted', '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', 'default', NULL, '/state/worktrees/deleted', + NULL, NULL, 0, 0, 0, + '2026-04-06T00:00:04.000Z', '2026-04-06T00:00:05.000Z', NULL, '2026-04-06T00:00:07.000Z' + ) + `; + + const routes = yield* snapshotQuery.listAccountRoutes(); + const byPath = new Map(routes.map((route) => [route.path, route.account] as const)); + + const account = { host: "github.com", login: "octo" }; + // Workspace root + BOTH the active and the archived thread's worktree + // resolve to the account. + assert.deepEqual(byPath.get("/repos/account-project"), account); + assert.deepEqual(byPath.get("/state/worktrees/active"), account); + assert.deepEqual(byPath.get("/state/worktrees/archived"), account); + // The deleted thread and the account-less project never appear. + assert.equal(byPath.has("/state/worktrees/deleted"), false); + assert.equal(byPath.has("/repos/no-account"), false); + }), + ); + + it.effect( + "resolves the owning project's default model selection for a cwd (workspace root, worktree, longest match)", + () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + + // One project with a default model selection, one without. + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, + default_model_selection_json, scripts_json, + github_account_json, created_at, updated_at, deleted_at + ) + VALUES + ( + 'project-model', + 'Model Project', + '/repos/model-project', + '{"instanceId":"codex_work","model":"gpt-5-codex"}', + '[]', + NULL, + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ), + ( + 'project-none', + 'No Model Project', + '/repos/no-model', + NULL, + '[]', + NULL, + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + // An archived thread's worktree still resolves to its project's model. + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, + runtime_mode, interaction_mode, branch, worktree_path, + latest_turn_id, latest_user_message_at, + pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, + archived_at, deleted_at + ) + VALUES + ( + 'thread-archived', 'project-model', 'Archived', '{"instanceId":"codex_work","model":"gpt-5-codex"}', + 'full-access', 'default', NULL, '/state/worktrees/archived', + NULL, NULL, 0, 0, 0, + '2026-04-06T00:00:04.000Z', '2026-04-06T00:00:05.000Z', '2026-04-06T00:00:06.000Z', NULL + ) + `; + + // Exact workspace root and a file nested beneath it both resolve. + const atRoot = yield* snapshotQuery.getDefaultModelSelectionForCwd("/repos/model-project"); + assert.deepEqual(Option.getOrNull(atRoot), { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }); + const nested = yield* snapshotQuery.getDefaultModelSelectionForCwd( + "/repos/model-project/packages/app", + ); + assert.deepEqual(Option.getOrNull(nested), { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }); + + // A worktree path (even for an archived thread) resolves too. + const fromWorktree = yield* snapshotQuery.getDefaultModelSelectionForCwd( + "/state/worktrees/archived/src", + ); + assert.deepEqual(Option.getOrNull(fromWorktree), { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }); + + // A sibling directory that only shares a path prefix does NOT match. + const sibling = + yield* snapshotQuery.getDefaultModelSelectionForCwd("/repos/model-project-2"); + assert.equal(Option.isNone(sibling), true); + + // A project without a default model selection yields None. + const noModel = yield* snapshotQuery.getDefaultModelSelectionForCwd("/repos/no-model"); + assert.equal(Option.isNone(noModel), true); + + // A cwd that maps to no project yields None. + const unknown = yield* snapshotQuery.getDefaultModelSelectionForCwd("/somewhere/else"); + assert.equal(Option.isNone(unknown), true); + }), + ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf4..d7fb1ea19019 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,6 +1,7 @@ import { ChatAttachment, CheckpointRef, + GitHubAccountRef, IsoDateTime, MessageId, NonNegativeInt, @@ -11,6 +12,7 @@ import { OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, + ProjectWorktreeCopyFiles, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -65,7 +67,10 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + reviewModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + gitHubAccount: Schema.NullOr(Schema.fromJsonString(GitHubAccountRef)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + worktreeCopyFiles: Schema.fromJsonString(ProjectWorktreeCopyFiles), }), ); const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( @@ -234,6 +239,12 @@ function mapProjectShellRow( workspaceRoot: row.workspaceRoot, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, + reviewModelSelection: row.reviewModelSelection, + gitHubAccount: row.gitHubAccount, + worktreeBranchPrefix: row.worktreeBranchPrefix, + defaultWorktreeBranch: row.defaultWorktreeBranch, + previewPort: row.previewPort, + worktreeCopyFiles: row.worktreeCopyFiles, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -254,6 +265,27 @@ function mapProposedPlanRow( }; } +const ProjectionAccountRouteRowSchema = Schema.Struct({ + path: Schema.String, + account: Schema.fromJsonString(GitHubAccountRef), +}); + +const ProjectionModelSelectionRouteRowSchema = Schema.Struct({ + path: Schema.String, + modelSelection: Schema.fromJsonString(ModelSelection), +}); + +/** + * True when `cwd` is `base` itself or lives beneath it. Compares normalized, + * separator-terminated paths so `/a/repo` does not match `/a/repo-2`. + */ +function isPathWithin(cwd: string, base: string): boolean { + if (base.length === 0) return false; + const normalizedBase = base.replace(/[/\\]+$/, ""); + if (cwd === normalizedBase) return true; + return cwd.startsWith(`${normalizedBase}/`) || cwd.startsWith(`${normalizedBase}\\`); +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -307,6 +339,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + review_model_selection_json AS "reviewModelSelection", + github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", + default_worktree_branch AS "defaultWorktreeBranch", + preview_port AS "previewPort", + COALESCE(worktree_copy_files_json, '[]') AS "worktreeCopyFiles", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -316,6 +354,57 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listAccountRouteRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionAccountRouteRowSchema, + // Union of project workspace roots and thread worktree paths that have an + // account attached. Threads are NOT filtered by archived_at — an archived + // thread's worktree still exists on disk, and a git/gh command run there + // must resolve to its project's account rather than silently using the + // machine's active account. + execute: () => + sql` + SELECT workspace_root AS "path", github_account_json AS "account" + FROM projection_projects + WHERE deleted_at IS NULL + AND github_account_json IS NOT NULL + UNION ALL + SELECT threads.worktree_path AS "path", projects.github_account_json AS "account" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.worktree_path IS NOT NULL + AND projects.deleted_at IS NULL + AND projects.github_account_json IS NOT NULL + `, + }); + + const listModelSelectionRouteRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionModelSelectionRouteRowSchema, + // Union of project workspace roots and thread worktree paths for projects + // that have a default model selection configured. Threads are NOT filtered + // by archived_at — an archived thread's worktree still exists on disk, and a + // git/gh command run there must resolve to its project's model selection. + execute: () => + sql` + SELECT workspace_root AS "path", default_model_selection_json AS "modelSelection" + FROM projection_projects + WHERE deleted_at IS NULL + AND default_model_selection_json IS NOT NULL + UNION ALL + SELECT threads.worktree_path AS "path", projects.default_model_selection_json AS "modelSelection" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.worktree_path IS NOT NULL + AND projects.deleted_at IS NULL + AND projects.default_model_selection_json IS NOT NULL + `, + }); + const listThreadRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadDbRowSchema, @@ -680,6 +769,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + review_model_selection_json AS "reviewModelSelection", + github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", + default_worktree_branch AS "defaultWorktreeBranch", + preview_port AS "previewPort", + COALESCE(worktree_copy_files_json, '[]') AS "worktreeCopyFiles", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -702,6 +797,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + review_model_selection_json AS "reviewModelSelection", + github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", + default_worktree_branch AS "defaultWorktreeBranch", + preview_port AS "previewPort", + COALESCE(worktree_copy_files_json, '[]') AS "worktreeCopyFiles", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1183,6 +1284,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, + reviewModelSelection: row.reviewModelSelection, + gitHubAccount: row.gitHubAccount, + worktreeBranchPrefix: row.worktreeBranchPrefix, + defaultWorktreeBranch: row.defaultWorktreeBranch, + previewPort: row.previewPort, + worktreeCopyFiles: row.worktreeCopyFiles, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1309,6 +1416,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, + reviewModelSelection: row.reviewModelSelection, + gitHubAccount: row.gitHubAccount, + worktreeBranchPrefix: row.worktreeBranchPrefix, + defaultWorktreeBranch: row.defaultWorktreeBranch, + previewPort: row.previewPort, + worktreeCopyFiles: row.worktreeCopyFiles, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1757,6 +1870,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: option.value.workspaceRoot, repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, + reviewModelSelection: option.value.reviewModelSelection, + gitHubAccount: option.value.gitHubAccount, + worktreeBranchPrefix: option.value.worktreeBranchPrefix, + defaultWorktreeBranch: option.value.defaultWorktreeBranch, + previewPort: option.value.previewPort, + worktreeCopyFiles: option.value.worktreeCopyFiles, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, @@ -2103,14 +2222,51 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const listAccountRoutes: ProjectionSnapshotQueryShape["listAccountRoutes"] = () => + listAccountRouteRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.listAccountRoutes:query", + "ProjectionSnapshotQuery.listAccountRoutes:decodeRows", + ), + ), + ); + + const getDefaultModelSelectionForCwd: ProjectionSnapshotQueryShape["getDefaultModelSelectionForCwd"] = + (cwd) => + listModelSelectionRouteRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getDefaultModelSelectionForCwd:query", + "ProjectionSnapshotQuery.getDefaultModelSelectionForCwd:decodeRows", + ), + ), + Effect.map((routes) => { + let bestPathLength = -1; + let bestSelection: ModelSelection | null = null; + for (const route of routes) { + if (!isPathWithin(cwd, route.path)) continue; + if (route.path.length > bestPathLength) { + bestPathLength = route.path.length; + bestSelection = route.modelSelection; + } + } + return bestSelection === null + ? Option.none() + : Option.some(bestSelection); + }), + ); + return { getCommandReadModel, getSnapshot, getShellSnapshot, getArchivedShellSnapshot, + listAccountRoutes, getSnapshotSequence, getCounts, getActiveProjectByWorkspaceRoot, + getDefaultModelSelectionForCwd, getProjectShellById, getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3044cc6029d8..11148f17fc11 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,19 +12,23 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { isTemporaryWorktreeBranch, sanitizeWorktreeBranchPrefix } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; @@ -60,6 +64,18 @@ type ProviderIntentEvent = Extract< } >; +// A worktree-safe basename for a dropped document: no path separators, only +// portable filename characters, bounded length. Each document lives in its own +// id-named subdir so distinct uploads never collide even with the same name. +function safeDocumentBaseName(name: string): string { + const base = name.split(/[\\/]/).pop() ?? name; + const cleaned = base + .replace(/[^a-zA-Z0-9._-]+/g, "_") + .replace(/^\.+/, "") + .slice(0, 200); + return cleaned.length > 0 ? cleaned : "file"; +} + function toNonEmptyProviderInput(value: string | undefined): string | undefined { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : undefined; @@ -166,15 +182,16 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function buildGeneratedWorktreeBranchName(raw: string): string { +function buildGeneratedWorktreeBranchName(raw: string, prefix?: string | null): string { + const resolvedPrefix = sanitizeWorktreeBranchPrefix(prefix); const normalized = raw .trim() .toLowerCase() .replace(/^refs\/heads\//, "") .replace(/['"`]/g, ""); - const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) - ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) + const withoutPrefix = normalized.startsWith(`${resolvedPrefix}/`) + ? normalized.slice(`${resolvedPrefix}/`.length) : normalized; const branchFragment = withoutPrefix @@ -186,11 +203,14 @@ function buildGeneratedWorktreeBranchName(raw: string): string { .replace(/[./_-]+$/g, ""); const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + return `${resolvedPrefix}/${safeFragment}`; } const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; @@ -611,6 +631,54 @@ const make = Effect.gen(function* () { return startedSession.threadId; }); + // Copy each persisted document attachment into /.t3/attachments// + // (git-ignored) and return the worktree-relative paths to reference in the + // prompt. Best-effort: a file that fails to write is skipped, never aborting + // the turn. Images never reach here — they stay on the adapter content path. + const writeWorktreeDocuments = Effect.fnUntraced(function* ( + cwd: string, + documents: ReadonlyArray, + ) { + const written: string[] = []; + const attachmentsRoot = path.join(cwd, ".t3", "attachments"); + const rootReady = yield* fileSystem.makeDirectory(attachmentsRoot, { recursive: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!rootReady) return written; + // Keep every attachment out of git without touching the user's own ignores. + yield* fileSystem + .writeFileString(path.join(attachmentsRoot, ".gitignore"), "*\n") + .pipe(Effect.ignore); + for (const document of documents) { + if (document.type !== "document") continue; + const sourcePath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment: document, + }); + if (!sourcePath) continue; + const bytes = yield* fileSystem.readFile(sourcePath).pipe(Effect.orElseSucceed(() => null)); + if (!bytes) continue; + const destinationDir = path.join(attachmentsRoot, document.id); + const destinationName = safeDocumentBaseName(document.name); + const destinationReady = yield* fileSystem + .makeDirectory(destinationDir, { recursive: true }) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!destinationReady) continue; + const wrote = yield* fileSystem + .writeFile(path.join(destinationDir, destinationName), bytes) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (wrote) written.push(`.t3/attachments/${document.id}/${destinationName}`); + } + return written; + }); + const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; readonly messageText: string; @@ -634,6 +702,31 @@ const make = Effect.gen(function* () { } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; + // Documents are delivered as files in the worktree, not as model content; + // images stay on the adapter attachment path. + const documentAttachments = normalizedAttachments.filter( + (attachment) => attachment.type === "document", + ); + const providerAttachments = normalizedAttachments.filter( + (attachment) => attachment.type === "image", + ); + let providerInput = normalizedInput; + if (documentAttachments.length > 0) { + const project = yield* resolveProject(thread.projectId); + const cwd = resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [] }); + if (cwd) { + const relativePaths = yield* writeWorktreeDocuments(cwd, documentAttachments); + if (relativePaths.length > 0) { + const block = [ + relativePaths.length === 1 + ? "The user attached a file, saved in the worktree at:" + : "The user attached files, saved in the worktree at:", + ...relativePaths.map((relativePath) => `- ${relativePath}`), + ].join("\n"); + providerInput = providerInput ? `${providerInput}\n\n${block}` : block; + } + } + } const activeSession = yield* providerService .listSessions() .pipe( @@ -664,8 +757,8 @@ const make = Effect.gen(function* () { return { threadId: input.threadId, - ...(normalizedInput ? { input: normalizedInput } : {}), - ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), + ...(providerInput ? { input: providerInput } : {}), + ...(providerAttachments.length > 0 ? { attachments: providerAttachments } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }; @@ -677,6 +770,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; + readonly worktreeBranchPrefix?: string | null; readonly messageText: string; readonly attachments?: ReadonlyArray; }) { @@ -708,7 +802,10 @@ const make = Effect.gen(function* () { }); if (!generated) return; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + // Prefer the project's prefix, then the global default setting; an empty + // string or null resolves to the built-in default inside the builder. + const effectivePrefix = input.worktreeBranchPrefix ?? settings.worktreeBranchPrefix; + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch, effectivePrefix); if (targetBranch === oldBranch) return; const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); @@ -822,6 +919,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, branch: thread.branch, worktreePath: thread.worktreePath, + worktreeBranchPrefix: project?.worktreeBranchPrefix ?? null, ...generationInput, }).pipe(Effect.forkScoped); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..9fe09e43ea91 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -7,6 +7,7 @@ import { type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, + PROVIDER_SEND_TURN_MAX_DOCUMENT_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; @@ -109,16 +110,22 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { + // Images must be image/*; documents (pdf/docx/xlsx/…) accept any mime + // because they are handed to the agent as files, not model content. + if (!parsed || (attachment.type === "image" && !parsed.mimeType.startsWith("image/"))) { return yield* new OrchestrationDispatchCommandError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, + message: `Invalid attachment payload for '${attachment.name}'.`, }); } const bytes = Buffer.from(parsed.base64, "base64"); - if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + const maxBytes = + attachment.type === "image" + ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + : PROVIDER_SEND_TURN_MAX_DOCUMENT_BYTES; + if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) { return yield* new OrchestrationDispatchCommandError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, + message: `Attachment '${attachment.name}' is empty or too large.`, }); } @@ -129,13 +136,22 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }); } - const persistedAttachment = { - type: "image" as const, - id: attachmentId, - name: attachment.name, - mimeType: parsed.mimeType.toLowerCase(), - sizeBytes: bytes.byteLength, - }; + const persistedAttachment = + attachment.type === "image" + ? { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + } + : { + type: "document" as const, + id: attachmentId, + name: attachment.name, + mimeType: (parsed.mimeType || attachment.mimeType).toLowerCase(), + sizeBytes: bytes.byteLength, + }; const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778a..cc639b037a96 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -8,6 +8,8 @@ */ import type { CheckpointRef, + GitHubAccountRef, + ModelSelection, OrchestrationCheckpointSummary, OrchestrationProject, OrchestrationProjectShell, @@ -30,6 +32,17 @@ export interface ProjectionSnapshotCounts { readonly threadCount: number; } +/** + * A filesystem path that a `git`/`gh` command's cwd can be matched against to + * discover the GitHub account it should act as. Covers both project workspace + * roots and thread worktree paths (active AND archived — worktrees for archived + * threads still exist on disk and must resolve to their project's account). + */ +export interface ProjectionAccountRoute { + readonly path: string; + readonly account: GitHubAccountRef; +} + export interface ProjectionSnapshotSequence { readonly snapshotSequence: number; } @@ -94,6 +107,17 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read every workspace-root / worktree-path → GitHub account mapping for + * projects that have an account attached. Used to resolve which account a + * `git`/`gh` command running in a given directory should act as. Includes + * archived (but not deleted) threads so their worktrees still resolve. + */ + readonly listAccountRoutes: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Read the latest projection snapshot sequence without hydrating read-model * entities. @@ -115,6 +139,17 @@ export interface ProjectionSnapshotQueryShape { workspaceRoot: string, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Resolve the `defaultModelSelection` of the project owning `cwd`, matching + * the longest project workspace root or thread worktree path that contains it + * (so a worktree checked out outside the project root still resolves). Returns + * `None` when `cwd` maps to no project or the owning project has no default + * model selection configured. + */ + readonly getDefaultModelSelectionForCwd: ( + cwd: string, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read a single active project shell row by id. */ diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9531cd5c3afb..9454b9a52567 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -13,11 +13,13 @@ import * as Effect from "effect/Effect"; import { findThreadById, + isThreadAlreadyExistsInvariantError, listThreadsByProjectId, requireNonNegativeInteger, requireThread, requireThreadAbsent, } from "./commandInvariants.ts"; +import { OrchestrationCommandInvariantError } from "./Errors.ts"; const now = "2026-01-01T00:00:00.000Z"; @@ -33,6 +35,11 @@ const readModel: OrchestrationReadModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: now, updatedAt: now, @@ -46,6 +53,11 @@ const readModel: OrchestrationReadModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: now, updatedAt: now, @@ -201,6 +213,55 @@ describe("commandInvariants", () => { ).rejects.toThrow("already exists"); }); + it("detects the thread-already-exists invariant so bootstrap can treat it as idempotent", async () => { + const error = await Effect.runPromise( + Effect.flip( + requireThreadAbsent({ + readModel, + command: { + type: "thread.create", + commandId: CommandId.make("cmd-4"), + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), + title: "dup", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + }, + threadId: ThreadId.make("thread-1"), + }), + ), + ); + + expect(isThreadAlreadyExistsInvariantError(error, ThreadId.make("thread-1"))).toBe(true); + // A different threadId or a different invariant must not be mistaken for it. + expect(isThreadAlreadyExistsInvariantError(error, ThreadId.make("thread-3"))).toBe(false); + expect( + isThreadAlreadyExistsInvariantError( + new OrchestrationCommandInvariantError({ + commandType: "thread.create", + detail: "Project 'project-a' does not exist for command 'thread.create'.", + }), + ThreadId.make("thread-1"), + ), + ).toBe(false); + expect( + isThreadAlreadyExistsInvariantError( + new OrchestrationCommandInvariantError({ + commandType: "thread.turn.start", + detail: "Thread 'thread-1' already exists and cannot be created twice.", + }), + ThreadId.make("thread-1"), + ), + ).toBe(false); + }); + it("requires non-negative integers", async () => { await Effect.runPromise( requireNonNegativeInteger({ diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index b59ded77f4f4..7ba31f0d97a2 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -151,6 +151,10 @@ export function requireThreadNotArchived(input: { ); } +function threadAlreadyExistsDetail(threadId: ThreadId): string { + return `Thread '${threadId}' already exists and cannot be created twice.`; +} + export function requireThreadAbsent(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; @@ -159,11 +163,21 @@ export function requireThreadAbsent(input: { if (!findThreadById(input.readModel, input.threadId)) { return Effect.void; } - return Effect.fail( - invariantError( - input.command.type, - `Thread '${input.threadId}' already exists and cannot be created twice.`, - ), + return Effect.fail(invariantError(input.command.type, threadAlreadyExistsDetail(input.threadId))); +} + +/** + * True when `error` is the specific invariant raised by `requireThreadAbsent` + * for `threadId` — i.e. a `thread.create` that failed only because the thread + * already exists. Lets idempotent callers (e.g. bootstrapping a draft that was + * already promoted) treat the duplicate create as a no-op instead of a failure. + */ +export function isThreadAlreadyExistsInvariantError( + error: OrchestrationCommandInvariantError, + threadId: ThreadId, +): boolean { + return ( + error.commandType === "thread.create" && error.detail === threadAlreadyExistsDetail(threadId) ); } diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e36..077ffbf9f8cf 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -250,6 +250,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, + reviewModelSelection: command.reviewModelSelection ?? null, + gitHubAccount: command.gitHubAccount ?? null, + worktreeBranchPrefix: command.worktreeBranchPrefix ?? null, + defaultWorktreeBranch: command.defaultWorktreeBranch ?? null, + previewPort: command.previewPort ?? null, + worktreeCopyFiles: command.worktreeCopyFiles ?? [], scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -287,6 +293,20 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), + ...(command.reviewModelSelection !== undefined + ? { reviewModelSelection: command.reviewModelSelection } + : {}), + ...(command.gitHubAccount !== undefined ? { gitHubAccount: command.gitHubAccount } : {}), + ...(command.worktreeBranchPrefix !== undefined + ? { worktreeBranchPrefix: command.worktreeBranchPrefix } + : {}), + ...(command.defaultWorktreeBranch !== undefined + ? { defaultWorktreeBranch: command.defaultWorktreeBranch } + : {}), + ...(command.previewPort !== undefined ? { previewPort: command.previewPort } : {}), + ...(command.worktreeCopyFiles !== undefined + ? { worktreeCopyFiles: command.worktreeCopyFiles } + : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a7..37dba5956668 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -211,6 +211,12 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, + reviewModelSelection: payload.reviewModelSelection ?? null, + gitHubAccount: payload.gitHubAccount ?? null, + worktreeBranchPrefix: payload.worktreeBranchPrefix ?? null, + defaultWorktreeBranch: payload.defaultWorktreeBranch ?? null, + previewPort: payload.previewPort ?? null, + worktreeCopyFiles: payload.worktreeCopyFiles ?? [], scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -243,6 +249,24 @@ export function projectEvent( ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), + ...(payload.reviewModelSelection !== undefined + ? { reviewModelSelection: payload.reviewModelSelection } + : {}), + ...(payload.gitHubAccount !== undefined + ? { gitHubAccount: payload.gitHubAccount } + : {}), + ...(payload.worktreeBranchPrefix !== undefined + ? { worktreeBranchPrefix: payload.worktreeBranchPrefix } + : {}), + ...(payload.defaultWorktreeBranch !== undefined + ? { defaultWorktreeBranch: payload.defaultWorktreeBranch } + : {}), + ...(payload.previewPort !== undefined + ? { previewPort: payload.previewPort } + : {}), + ...(payload.worktreeCopyFiles !== undefined + ? { worktreeCopyFiles: payload.worktreeCopyFiles } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index c1ca6d3104e6..813eadd5587b 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -5,7 +5,12 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { + GitHubAccountRef, + ModelSelection, + ProjectScript, + ProjectWorktreeCopyFiles, +} from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -18,7 +23,10 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + reviewModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + gitHubAccount: Schema.NullOr(Schema.fromJsonString(GitHubAccountRef)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + worktreeCopyFiles: Schema.fromJsonString(ProjectWorktreeCopyFiles), }), ); type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; @@ -35,6 +43,12 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root, default_model_selection_json, + review_model_selection_json, + github_account_json, + worktree_branch_prefix, + default_worktree_branch, + preview_port, + worktree_copy_files_json, scripts_json, created_at, updated_at, @@ -45,6 +59,12 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.title}, ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${row.reviewModelSelection !== null ? JSON.stringify(row.reviewModelSelection) : null}, + ${row.gitHubAccount !== null ? JSON.stringify(row.gitHubAccount) : null}, + ${row.worktreeBranchPrefix}, + ${row.defaultWorktreeBranch}, + ${row.previewPort}, + ${JSON.stringify(row.worktreeCopyFiles)}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -55,6 +75,12 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title = excluded.title, workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, + review_model_selection_json = excluded.review_model_selection_json, + github_account_json = excluded.github_account_json, + worktree_branch_prefix = excluded.worktree_branch_prefix, + default_worktree_branch = excluded.default_worktree_branch, + preview_port = excluded.preview_port, + worktree_copy_files_json = excluded.worktree_copy_files_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -72,6 +98,12 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + review_model_selection_json AS "reviewModelSelection", + github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", + default_worktree_branch AS "defaultWorktreeBranch", + preview_port AS "previewPort", + COALESCE(worktree_copy_files_json, '[]') AS "worktreeCopyFiles", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -91,6 +123,12 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + review_model_selection_json AS "reviewModelSelection", + github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", + default_worktree_branch AS "defaultWorktreeBranch", + preview_port AS "previewPort", + COALESCE(worktree_copy_files_json, '[]') AS "worktreeCopyFiles", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4763f5656538..88902cee0658 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -33,6 +33,15 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, + reviewModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", @@ -41,8 +50,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const rows = yield* sql<{ readonly defaultModelSelection: string | null; + readonly reviewModelSelection: string | null; }>` - SELECT default_model_selection_json AS "defaultModelSelection" + SELECT + default_model_selection_json AS "defaultModelSelection", + review_model_selection_json AS "reviewModelSelection" FROM projection_projects WHERE project_id = 'project-null-options' `; @@ -59,6 +71,14 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "gpt-5.4", }), ); + assert.strictEqual( + row.reviewModelSelection, + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }), + ); const persisted = yield* projects.getById({ projectId: ProjectId.make("project-null-options"), @@ -67,6 +87,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.reviewModelSelection, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }); }), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d25895671a91..8e37dbb5ac00 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,12 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_ProjectionProjectsGitHubAccount.ts"; +import Migration0036 from "./Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts"; +import Migration0037 from "./Migrations/037_ProjectionProjectsReviewModelSelection.ts"; +import Migration0038 from "./Migrations/038_ProjectionProjectsDefaultWorktreeBranch.ts"; +import Migration0039 from "./Migrations/039_ProjectionProjectsPreviewPort.ts"; +import Migration0040 from "./Migrations/040_ProjectionProjectsWorktreeCopyFiles.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +99,12 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "ProjectionProjectsGitHubAccount", Migration0035], + [36, "ProjectionProjectsWorktreeBranchPrefix", Migration0036], + [37, "ProjectionProjectsReviewModelSelection", Migration0037], + [38, "ProjectionProjectsDefaultWorktreeBranch", Migration0038], + [39, "ProjectionProjectsPreviewPort", Migration0039], + [40, "ProjectionProjectsWorktreeCopyFiles", Migration0040], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_ProjectionProjectsGitHubAccount.ts b/apps/server/src/persistence/Migrations/035_ProjectionProjectsGitHubAccount.ts new file mode 100644 index 000000000000..2b6d15da1db5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionProjectsGitHubAccount.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "github_account_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN github_account_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts b/apps/server/src/persistence/Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts new file mode 100644 index 000000000000..81a36d476c17 --- /dev/null +++ b/apps/server/src/persistence/Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "worktree_branch_prefix")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN worktree_branch_prefix TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/037_ProjectionProjectsReviewModelSelection.ts b/apps/server/src/persistence/Migrations/037_ProjectionProjectsReviewModelSelection.ts new file mode 100644 index 000000000000..5a0399465daa --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_ProjectionProjectsReviewModelSelection.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "review_model_selection_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN review_model_selection_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionProjectsDefaultWorktreeBranch.ts b/apps/server/src/persistence/Migrations/038_ProjectionProjectsDefaultWorktreeBranch.ts new file mode 100644 index 000000000000..65f9f8b0e33d --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionProjectsDefaultWorktreeBranch.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "default_worktree_branch")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_worktree_branch TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/039_ProjectionProjectsPreviewPort.ts b/apps/server/src/persistence/Migrations/039_ProjectionProjectsPreviewPort.ts new file mode 100644 index 000000000000..e62fea299207 --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_ProjectionProjectsPreviewPort.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "preview_port")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN preview_port INTEGER + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectsWorktreeCopyFiles.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectsWorktreeCopyFiles.ts new file mode 100644 index 000000000000..adf2b78d81f4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectsWorktreeCopyFiles.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "worktree_copy_files_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN worktree_copy_files_json TEXT NOT NULL DEFAULT '[]' + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a2699..c0bec8ad3cd5 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,15 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { + GitHubAccountRef, + IsoDateTime, + ModelSelection, + PortSchema, + ProjectId, + ProjectScript, + ProjectWorktreeCopyFiles, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -19,6 +27,12 @@ export const ProjectionProject = Schema.Struct({ title: Schema.String, workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), + reviewModelSelection: Schema.NullOr(ModelSelection), + gitHubAccount: Schema.NullOr(GitHubAccountRef), + worktreeBranchPrefix: Schema.NullOr(Schema.String), + defaultWorktreeBranch: Schema.NullOr(Schema.String), + previewPort: Schema.NullOr(PortSchema), + worktreeCopyFiles: ProjectWorktreeCopyFiles, scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079a..22eda5759fb3 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -18,6 +18,11 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -29,6 +34,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), @@ -36,6 +42,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Effect.succeed( workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), ), + getDefaultModelSelectionForCwd: () => Effect.die("unused"), getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), diff --git a/apps/server/src/project/ProjectWorktreeFileCopier.test.ts b/apps/server/src/project/ProjectWorktreeFileCopier.test.ts new file mode 100644 index 000000000000..ce5bfe738d11 --- /dev/null +++ b/apps/server/src/project/ProjectWorktreeFileCopier.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "@effect/vitest"; +import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as ProjectWorktreeFileCopier from "./ProjectWorktreeFileCopier.ts"; + +const makeProject = (input: { + readonly workspaceRoot: string; + readonly worktreeCopyFiles: ReadonlyArray; +}): OrchestrationProject => ({ + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: input.worktreeCopyFiles, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + listAccountRoutes: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), + ), + getDefaultModelSelectionForCwd: () => Effect.die("unused"), + getProjectShellById: (projectId) => + Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + }); + +const testLayer = (project: OrchestrationProject) => + ProjectWorktreeFileCopier.layer.pipe( + Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(NodeServices.layer), + ); + +/** Create an isolated project root + worktree pair under a temp directory. */ +const makeWorkspace = Effect.fn("makeWorkspace")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-copy-files-" }); + const projectRoot = path.join(root, "project"); + const worktreePath = path.join(root, "worktree"); + yield* fileSystem.makeDirectory(projectRoot, { recursive: true }); + yield* fileSystem.makeDirectory(worktreePath, { recursive: true }); + return { fileSystem, path, projectRoot, worktreePath }; +}); + +describe("ProjectWorktreeFileCopier", () => { + it.effect("copies configured files into the worktree", () => + Effect.gen(function* () { + const { fileSystem, path, projectRoot, worktreePath } = yield* makeWorkspace(); + yield* fileSystem.writeFileString(path.join(projectRoot, ".env.local"), "TOKEN=abc\n"); + yield* fileSystem.makeDirectory(path.join(projectRoot, "config"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(projectRoot, "config/local.json"), "{}\n"); + + const project = makeProject({ + workspaceRoot: projectRoot, + worktreeCopyFiles: [".env.local", "config/local.json"], + }); + + const result = yield* Effect.gen(function* () { + const copier = yield* ProjectWorktreeFileCopier.ProjectWorktreeFileCopier; + return yield* copier.copyForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath, + }); + }).pipe(Effect.provide(testLayer(project))); + + expect(result.copiedCount).toBe(2); + expect(yield* fileSystem.readFileString(path.join(worktreePath, ".env.local"))).toBe( + "TOKEN=abc\n", + ); + // Nested destinations are created rather than skipped. + expect(yield* fileSystem.readFileString(path.join(worktreePath, "config/local.json"))).toBe( + "{}\n", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("dereferences a symlinked source into an independent copy", () => + Effect.gen(function* () { + const { fileSystem, path, projectRoot, worktreePath } = yield* makeWorkspace(); + // A project sharing `.env.local` by symlink is the exact case this feature + // replaces: the worktree must get real content it can edit in isolation. + const shared = path.join(projectRoot, "..", "shared.env"); + yield* fileSystem.writeFileString(shared, "SHARED=1\n"); + yield* fileSystem.symlink(shared, path.join(projectRoot, ".env.local")); + + const project = makeProject({ + workspaceRoot: projectRoot, + worktreeCopyFiles: [".env.local"], + }); + + const result = yield* Effect.gen(function* () { + const copier = yield* ProjectWorktreeFileCopier.ProjectWorktreeFileCopier; + return yield* copier.copyForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath, + }); + }).pipe(Effect.provide(testLayer(project))); + + expect(result.copiedCount).toBe(1); + const destination = path.join(worktreePath, ".env.local"); + expect(yield* fileSystem.readFileString(destination)).toBe("SHARED=1\n"); + // Not a link: editing the worktree copy must not touch the shared original. + const info = yield* fileSystem.stat(destination); + expect(info.type).toBe("File"); + yield* fileSystem.writeFileString(destination, "SHARED=2\n"); + expect(yield* fileSystem.readFileString(shared)).toBe("SHARED=1\n"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("skips missing sources and rejects paths outside the project", () => + Effect.gen(function* () { + const { fileSystem, path, projectRoot, worktreePath } = yield* makeWorkspace(); + const outside = path.join(projectRoot, "..", "secret.txt"); + yield* fileSystem.writeFileString(outside, "secret\n"); + + const project = makeProject({ + workspaceRoot: projectRoot, + worktreeCopyFiles: ["missing.env", "../secret.txt"], + }); + + const result = yield* Effect.gen(function* () { + const copier = yield* ProjectWorktreeFileCopier.ProjectWorktreeFileCopier; + return yield* copier.copyForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath, + }); + }).pipe(Effect.provide(testLayer(project))); + + expect(result.copiedCount).toBe(0); + expect(result.entries).toEqual([ + { relativePath: "missing.env", copied: false, reason: "source-missing" }, + { relativePath: "../secret.txt", copied: false, reason: "path-outside-project" }, + ]); + // The traversal attempt must not have written anything into the worktree. + expect(yield* fileSystem.exists(path.join(worktreePath, "secret.txt"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("returns an empty result when no files are configured", () => + Effect.gen(function* () { + const { projectRoot, worktreePath } = yield* makeWorkspace(); + const project = makeProject({ workspaceRoot: projectRoot, worktreeCopyFiles: [] }); + + const result = yield* Effect.gen(function* () { + const copier = yield* ProjectWorktreeFileCopier.ProjectWorktreeFileCopier; + return yield* copier.copyForThread({ + threadId: "thread-1", + projectCwd: projectRoot, + worktreePath, + }); + }).pipe(Effect.provide(testLayer(project))); + + expect(result).toEqual({ entries: [], copiedCount: 0 }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/project/ProjectWorktreeFileCopier.ts b/apps/server/src/project/ProjectWorktreeFileCopier.ts new file mode 100644 index 000000000000..9daa28c0554a --- /dev/null +++ b/apps/server/src/project/ProjectWorktreeFileCopier.ts @@ -0,0 +1,220 @@ +/** + * ProjectWorktreeFileCopier - copies a project's configured local files into a + * freshly created worktree. + * + * Projects often keep untracked local files at the project root (`.env.local`, + * service-account JSON, …) that every worktree needs. Configuring them here + * avoids hand-writing a setup script, and each worktree receives an independent + * *copy* rather than a symlink back to the project root — so editing the file in + * one worktree can never change it in another. + * + * Copy failures are reported per entry and never abort worktree creation. + * + * @module ProjectWorktreeFileCopier + */ +import { ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; + +/** Why a configured entry was not copied. */ +export type ProjectWorktreeFileCopySkipReason = + | "source-missing" + | "path-outside-project" + | "copy-failed"; + +export interface ProjectWorktreeFileCopyEntryResult { + readonly relativePath: string; + readonly copied: boolean; + readonly reason?: ProjectWorktreeFileCopySkipReason; +} + +export interface ProjectWorktreeFileCopierResult { + readonly entries: ReadonlyArray; + readonly copiedCount: number; +} + +export interface ProjectWorktreeFileCopierInput { + readonly threadId: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; +} + +export class ProjectWorktreeFileCopyOperationError extends Schema.TaggedErrorClass()( + "ProjectWorktreeFileCopyOperationError", + { + threadId: Schema.String, + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + operation: Schema.Literals(["resolveProject"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Project worktree file copy operation '${this.operation}' failed for thread '${this.threadId}' in '${this.worktreePath}'.`; + } +} + +export class ProjectWorktreeFileCopyProjectNotFoundError extends Schema.TaggedErrorClass()( + "ProjectWorktreeFileCopyProjectNotFoundError", + { + threadId: Schema.String, + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + }, +) { + override get message(): string { + return `Project was not found for worktree file copy for thread '${this.threadId}' in '${this.worktreePath}'.`; + } +} + +export const ProjectWorktreeFileCopierError = Schema.Union([ + ProjectWorktreeFileCopyOperationError, + ProjectWorktreeFileCopyProjectNotFoundError, +]); +export type ProjectWorktreeFileCopierError = typeof ProjectWorktreeFileCopierError.Type; + +export class ProjectWorktreeFileCopier extends Context.Service< + ProjectWorktreeFileCopier, + { + readonly copyForThread: ( + input: ProjectWorktreeFileCopierInput, + ) => Effect.Effect; + } +>()("t3/project/ProjectWorktreeFileCopier") {} + +export const make = Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const copyForThread: ProjectWorktreeFileCopier["Service"]["copyForThread"] = Effect.fn( + "ProjectWorktreeFileCopier.copyForThread", + )(function* (input) { + const errorContext = { + threadId: input.threadId, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; + const projectById = input.projectId + ? yield* projectionSnapshotQuery.getProjectShellById(ProjectId.make(input.projectId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectWorktreeFileCopyOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) + : null; + const project = + projectById ?? + (input.projectCwd + ? yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(input.projectCwd).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectWorktreeFileCopyOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) + : null); + + if (!project) { + return yield* new ProjectWorktreeFileCopyProjectNotFoundError(errorContext); + } + + const configured = project.worktreeCopyFiles; + const entries: Array = []; + for (const configuredPath of configured) { + // Resolve on both sides so a crafted entry (absolute path, `..`) can + // neither read outside the project nor write outside the worktree. + const source = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: project.workspaceRoot, + relativePath: configuredPath, + }) + .pipe(Effect.option); + const destination = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: input.worktreePath, + relativePath: configuredPath, + }) + .pipe(Effect.option); + if (Option.isNone(source) || Option.isNone(destination)) { + entries.push({ + relativePath: configuredPath, + copied: false, + reason: "path-outside-project", + }); + continue; + } + + // `stat` follows symlinks, so a symlinked source (a common way projects + // share `.env.local` today) is copied as real content rather than as a + // link — and a broken link is skipped instead of failing the worktree. + const sourceInfo = yield* fileSystem.stat(source.value.absolutePath).pipe(Effect.option); + if (Option.isNone(sourceInfo)) { + entries.push({ relativePath: configuredPath, copied: false, reason: "source-missing" }); + continue; + } + + const copied = yield* Effect.gen(function* () { + const parent = path.dirname(destination.value.absolutePath); + yield* fileSystem.makeDirectory(parent, { recursive: true }); + if (sourceInfo.value.type === "Directory") { + yield* fileSystem.copy(source.value.absolutePath, destination.value.absolutePath, { + overwrite: true, + }); + } else { + // copyFile reads through to the target's contents, giving the worktree + // an independent file even when the source is a symlink. + yield* fileSystem.copyFile(source.value.absolutePath, destination.value.absolutePath); + } + return true; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to copy project file into worktree", { + threadId: input.threadId, + projectId: project.id, + worktreePath: input.worktreePath, + relativePath: configuredPath, + cause, + }).pipe(Effect.as(false)), + ), + ); + + entries.push( + copied + ? { relativePath: configuredPath, copied: true } + : { relativePath: configuredPath, copied: false, reason: "copy-failed" }, + ); + } + + return { + entries, + copiedCount: entries.filter((entry) => entry.copied).length, + } satisfies ProjectWorktreeFileCopierResult; + }); + + return ProjectWorktreeFileCopier.of({ copyForThread }); +}); + +export const layer = Layer.effect(ProjectWorktreeFileCopier, make); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..eef740558f12 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -173,6 +173,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), + Effect.provideService(HttpClient.HttpClient, httpClient), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index a7ff41fc89ea..9b6838dcfb33 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -6,9 +7,12 @@ import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import { + CLAUDE_KEYCHAIN_SERVICE, makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, + makeClaudeCredentialsServiceName, makeClaudeEnvironment, + resolveClaudeConfigDir, resolveClaudeHomePath, } from "./ClaudeHome.ts"; @@ -59,4 +63,60 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); }); + + describe("Claude config dir and credentials service", () => { + it.effect("resolves the default config dir to ~/.claude when no override is set", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(yield* resolveClaudeConfigDir({ homePath: "" })).toBe( + path.join(NodeOS.homedir(), ".claude"), + ); + }), + ); + + it.effect("resolves a custom config dir from the configured home path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(yield* resolveClaudeConfigDir({ homePath: "~/.claude-personal" })).toBe( + path.resolve(NodeOS.homedir(), ".claude-personal"), + ); + }), + ); + + it.effect("uses the bare keychain service for the default account", () => + Effect.gen(function* () { + expect(yield* makeClaudeCredentialsServiceName({ homePath: "" })).toBe( + CLAUDE_KEYCHAIN_SERVICE, + ); + // An explicit ~/.claude override still counts as the default account. + expect(yield* makeClaudeCredentialsServiceName({ homePath: "~/.claude" })).toBe( + CLAUDE_KEYCHAIN_SERVICE, + ); + }), + ); + + it.effect("suffixes the keychain service with sha256(configDir)[:8] per instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const configDir = path.resolve(NodeOS.homedir(), ".claude-personal"); + const suffix = createHash("sha256").update(configDir).digest("hex").slice(0, 8); + + expect(yield* makeClaudeCredentialsServiceName({ homePath: "~/.claude-personal" })).toBe( + `${CLAUDE_KEYCHAIN_SERVICE}-${suffix}`, + ); + }), + ); + + it.effect("gives distinct keychain services to distinct instances", () => + Effect.gen(function* () { + const bare = yield* makeClaudeCredentialsServiceName({ homePath: "" }); + const personal = yield* makeClaudeCredentialsServiceName({ + homePath: "~/.claude-personal", + }); + const work = yield* makeClaudeCredentialsServiceName({ homePath: "~/.claude-work" }); + + expect(new Set([bare, personal, work]).size).toBe(3); + }), + ); + }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index b3ef22b640cf..19275571ab3e 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as NodeOS from "node:os"; import type { ClaudeSettings } from "@t3tools/contracts"; @@ -14,6 +15,46 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); }); +/** + * Resolve the effective `CLAUDE_CONFIG_DIR` for an instance. This is the + * directory Claude Code reads/writes its `.claude.json`, `.credentials.json` + * and session state from. A custom `homePath` maps to it directly; an empty + * `homePath` maps to the CLI default `~/.claude` (note: NOT `resolveClaudeHomePath`, + * which returns the plain home dir for the empty case). + */ +export const resolveClaudeConfigDir = Effect.fn("resolveClaudeConfigDir")(function* ( + config: Pick, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + return homePath.length > 0 + ? path.resolve(expandHomePath(homePath)) + : path.join(NodeOS.homedir(), ".claude"); +}); + +/** Base macOS keychain service name Claude Code stores OAuth credentials under. */ +export const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; + +/** + * The macOS login-keychain service name that holds an instance's OAuth + * credentials. Claude Code stores the default `~/.claude` account under the + * bare {@link CLAUDE_KEYCHAIN_SERVICE}, but every non-default `CLAUDE_CONFIG_DIR` + * gets its own entry suffixed with the first 8 hex chars of `sha256(configDir)`. + * Without this suffix, every instance whose credentials live only in the + * keychain (the macOS default — no on-disk `.credentials.json`) would read the + * default account's token and report identical usage/limits. + */ +export const makeClaudeCredentialsServiceName = Effect.fn("makeClaudeCredentialsServiceName")( + function* (config: Pick): Effect.fn.Return { + const path = yield* Path.Path; + const configDir = yield* resolveClaudeConfigDir(config); + const defaultConfigDir = path.join(NodeOS.homedir(), ".claude"); + if (configDir === defaultConfigDir) return CLAUDE_KEYCHAIN_SERVICE; + const suffix = createHash("sha256").update(configDir).digest("hex").slice(0, 8); + return `${CLAUDE_KEYCHAIN_SERVICE}-${suffix}`; + }, +); + export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( config: Pick, baseEnv?: NodeJS.ProcessEnv, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 96202ecd952b..f28733dc7220 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient } from "effect/unstable/http"; import { createModelCapabilities, getModelSelectionStringOptionValue, @@ -42,6 +43,7 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { fetchClaudeAccountUsage } from "../providerUsage.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -788,7 +790,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -927,6 +932,28 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + + // Subscription usage windows come from the claude.ai OAuth usage endpoint + // and are meaningful for any first-party OAuth login. We deliberately do + // NOT gate on `capabilities.subscriptionType`: the CLI init payload often + // omits it for genuine OAuth subscriptions, which previously hid usage even + // though the account had 5-hour/weekly caps (Codex, gated differently, still + // showed — the visible asymmetry). Skip only API-key auth and third-party + // backends (Bedrock / Vertex), which have no OAuth usage endpoint. The fetch + // is best-effort and resolves to `undefined` when no OAuth token is present, + // so attempting it for a non-subscription first-party account is harmless. + const isApiKeyAuth = normalizeClaudeAuthMethod(capabilities.tokenSource) === "apiKey"; + const isThirdPartyBackend = + capabilities.apiProvider === "bedrock" || capabilities.apiProvider === "vertex"; + const usage = + !isApiKeyAuth && !isThirdPartyBackend + ? yield* fetchClaudeAccountUsage({ + claudeSettings, + cliVersion: parsedVersion, + fetchedAt: checkedAt, + }).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined; + return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -934,6 +961,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( models, slashCommands: dedupedSlashCommands, skills, + ...(usage ? { usage } : {}), probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1ed9c750c186..ac66d791abc9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -33,6 +33,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; +import { normalizeCodexRateLimits } from "../providerUsage.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -48,6 +49,7 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse | undefined; } const REASONING_EFFORT_LABELS: Readonly> = { @@ -389,12 +391,16 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + // Best-effort: never fail the probe if the usage lookup is unavailable. + client + .request("account/rateLimits/read", undefined) + .pipe(Effect.orElseSucceed(() => undefined)), ], { concurrency: "unbounded" }, ); @@ -406,6 +412,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun appendCustomCodexModels(models, input.customModels ?? []), ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + rateLimits, } satisfies CodexAppServerProviderSnapshot; }); @@ -588,6 +595,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); + const usage = snapshot.rateLimits + ? (normalizeCodexRateLimits(snapshot.rateLimits, checkedAt) ?? undefined) + : undefined; return buildServerProvider({ presentation: CODEX_PRESENTATION, @@ -595,6 +605,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, models: snapshot.models, skills: snapshot.skills, + ...(usage ? { usage } : {}), probe: { installed: true, version: snapshot.version ?? null, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3843c8acbcd9..c4c5519d0aed 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -195,11 +195,13 @@ describe("ProviderSessionReaper", () => { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getDefaultModelSelectionForCwd: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 011572780666..4dc0acc9732c 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -10,7 +10,9 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { + buildServerProvider, isCommandMissingCause, + isProbeTimedOut, providerModelsFromSettings, spawnAndCollect, } from "./providerSnapshot.ts"; @@ -134,3 +136,101 @@ describe("ProviderCommandNotFoundError", () => { }); }); }); + +describe("isProbeTimedOut / buildServerProvider timeout flag", () => { + const presentation = { displayName: "Codex" }; + + it("infers a timeout from the shared 'timed out' message phrasing", () => { + expect( + isProbeTimedOut({ + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Timed out while checking Codex app-server provider status.", + }), + ).toBe(true); + }); + + it("prefers the explicit flag over the message heuristic", () => { + expect( + isProbeTimedOut({ + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Something timed out", + timedOut: false, + }), + ).toBe(false); + }); + + it("marks a timed-out probe on the built snapshot while enabled", () => { + const provider = buildServerProvider({ + presentation, + enabled: true, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Timed out while checking Codex app-server provider status.", + }, + }); + + expect(provider.timedOut).toBe(true); + expect(provider.status).toBe("error"); + }); + + it("does not mark a ready or non-timeout snapshot", () => { + const ready = buildServerProvider({ + presentation, + enabled: true, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + }, + }); + const failed = buildServerProvider({ + presentation, + enabled: true, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Provider failed its startup checks.", + }, + }); + + expect(ready).not.toHaveProperty("timedOut"); + expect(failed).not.toHaveProperty("timedOut"); + }); + + it("never marks a disabled provider as timed out", () => { + const provider = buildServerProvider({ + presentation, + enabled: false, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Timed out while checking Codex app-server provider status.", + }, + }); + + expect(provider).not.toHaveProperty("timedOut"); + expect(provider.status).toBe("disabled"); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d0..c83152f48326 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ModelCapabilities, + ProviderUsage, ServerProvider, ServerProviderAuth, ServerProviderSkill, @@ -50,6 +51,25 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + /** + * Set explicitly by probe branches that gave up on a timeout. When absent, + * `buildServerProvider` still infers a timeout from a "timed out" message so + * every timeout branch is covered without each having to opt in. + */ + readonly timedOut?: boolean; +} + +/** Message fragment every provider timeout branch shares (case-insensitive). */ +const TIMEOUT_MESSAGE_PATTERN = /timed out/i; + +/** + * Decide whether a probe result represents a timeout. Prefers the explicit + * flag and falls back to the shared "timed out" message phrasing so a branch + * that only sets the message is still treated as a timeout by consumers. + */ +export function isProbeTimedOut(probe: ProviderProbeResult): boolean { + if (probe.timedOut !== undefined) return probe.timedOut; + return probe.message !== undefined && TIMEOUT_MESSAGE_PATTERN.test(probe.message); } export interface ServerProviderPresentation { @@ -216,6 +236,7 @@ export function buildServerProvider(input: { slashCommands?: ReadonlyArray; skills?: ReadonlyArray; probe: ProviderProbeResult; + usage?: ProviderUsage; }): ServerProviderDraft { const versionAdvisory = input.driver ? createProviderVersionAdvisory({ @@ -237,6 +258,11 @@ export function buildServerProvider(input: { installed: input.probe.installed, version: input.probe.version, status: input.enabled ? input.probe.status : "disabled", + // Only meaningful while enabled and not already ready: a disabled or ready + // provider never surfaces the timeout affordance. + ...(input.enabled && input.probe.status !== "ready" && isProbeTimedOut(input.probe) + ? { timedOut: true } + : {}), auth: input.probe.auth, checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), @@ -244,6 +270,7 @@ export function buildServerProvider(input: { slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], ...(versionAdvisory ? { versionAdvisory } : {}), + ...(input.usage ? { usage: input.usage } : {}), }; } diff --git a/apps/server/src/provider/providerUsage.test.ts b/apps/server/src/provider/providerUsage.test.ts new file mode 100644 index 000000000000..065c9c0e0124 --- /dev/null +++ b/apps/server/src/provider/providerUsage.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { parseClaudeUsageResponse } from "./providerUsage.ts"; + +const FETCHED_AT = "2026-08-03T15:00:00.000Z"; + +describe("parseClaudeUsageResponse", () => { + // Regression: the live endpoint returns `null` for numeric fields it isn't + // currently metering (here `extra_usage.utilization`). A schema that only + // accepted `number | undefined` failed the whole decode, silently dropping + // every Claude usage window while Codex kept showing. + it.effect("keeps windows when a numeric field is null", () => + Effect.gen(function* () { + const usage = yield* parseClaudeUsageResponse( + { + five_hour: { utilization: 28, resets_at: "2026-08-03T17:40:00Z", limit_dollars: null }, + seven_day: { utilization: 12, resets_at: "2026-08-10T06:00:00Z" }, + seven_day_opus: null, + seven_day_sonnet: null, + extra_usage: { + is_enabled: true, + monthly_limit: null, + used_credits: 6914, + utilization: null, + }, + // Unknown forward-compatible fields must be ignored, not rejected. + nimbus_quill: null, + spend: { amount_minor: 6914 }, + }, + FETCHED_AT, + ); + + expect(usage).toBeDefined(); + expect(usage?.source).toBe("claude"); + const kinds = usage?.windows.map((window) => window.kind); + expect(kinds).toContain("five_hour"); + expect(kinds).toContain("seven_day"); + expect(usage?.windows.find((window) => window.kind === "five_hour")?.usedPercent).toBe(28); + expect(usage?.credits?.used).toBe(6914); + }), + ); + + it.effect("returns undefined for an undecodable body", () => + Effect.gen(function* () { + const usage = yield* parseClaudeUsageResponse("not an object", FETCHED_AT); + expect(usage).toBeUndefined(); + }), + ); + + it.effect("returns undefined when no windows or credits are reported", () => + Effect.gen(function* () { + const usage = yield* parseClaudeUsageResponse({}, FETCHED_AT); + expect(usage).toBeUndefined(); + }), + ); +}); diff --git a/apps/server/src/provider/providerUsage.ts b/apps/server/src/provider/providerUsage.ts new file mode 100644 index 000000000000..27b98d196dff --- /dev/null +++ b/apps/server/src/provider/providerUsage.ts @@ -0,0 +1,341 @@ +import type { ClaudeSettings, ProviderUsage, ProviderUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import * as CodexSchema from "effect-codex-app-server/schema"; + +import { makeClaudeCredentialsServiceName, resolveClaudeConfigDir } from "./Drivers/ClaudeHome.ts"; +import { spawnAndCollect } from "./providerSnapshot.ts"; + +// ── Shared helpers ────────────────────────────────────────────────── + +/** + * Convert an upstream epoch reset timestamp to an ISO-8601 string. Providers + * report these in seconds; values that look like milliseconds (>1e12) are + * handled defensively. Returns `null` for missing/invalid input so the field + * degrades gracefully rather than throwing. + */ +function epochToIso(value: number | null | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return null; + } + const millis = value > 1_000_000_000_000 ? value : value * 1_000; + const dt = DateTime.makeUnsafe(millis); + return DateTime.formatIso(dt); +} + +/** Clamp an arbitrary number into the 0–100 percentage range. */ +function clampPercent(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} + +/** + * Human-friendly label for a window given its nominal length in minutes. + * Falls back to the supplied default when the length is unknown. + */ +function labelFromMinutes(minutes: number | null | undefined, fallback: string): string { + if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) { + return fallback; + } + if (minutes < 60) return `${Math.round(minutes)}m`; + const hours = minutes / 60; + if (hours < 24) { + const rounded = Number.isInteger(hours) ? `${hours}` : hours.toFixed(1); + return `${rounded}-hour`; + } + const days = hours / 24; + if (days === 1) return "Daily"; + if (days === 7) return "Weekly"; + if (days >= 28 && days <= 31) return "Monthly"; + const rounded = Number.isInteger(days) ? `${days}` : days.toFixed(1); + return `${rounded}-day`; +} + +// ── Codex normalization ───────────────────────────────────────────── + +const CODEX_PLAN_LABELS: Readonly< + Record +> = { + free: "ChatGPT Free", + go: "ChatGPT Go", + plus: "ChatGPT Plus", + pro: "ChatGPT Pro 20x", + prolite: "ChatGPT Pro 5x", + team: "ChatGPT Team", + self_serve_business_usage_based: "ChatGPT Business", + business: "ChatGPT Business", + enterprise_cbp_usage_based: "ChatGPT Enterprise", + enterprise: "ChatGPT Enterprise", + edu: "ChatGPT Edu", + unknown: "ChatGPT", +}; + +/** + * Normalize a Codex `account/rateLimits/read` response into the shared + * `ProviderUsage` shape. Returns `null` when there is nothing meaningful to + * show (no windows and no credit info). + */ +export function normalizeCodexRateLimits( + response: CodexSchema.V2GetAccountRateLimitsResponse, + fetchedAt: string, +): ProviderUsage | null { + const rl = response.rateLimits; + const windows: ProviderUsageWindow[] = []; + + if (rl.primary) { + windows.push({ + kind: "primary", + label: labelFromMinutes(rl.primary.windowDurationMins, "Current"), + usedPercent: clampPercent(rl.primary.usedPercent), + resetsAt: epochToIso(rl.primary.resetsAt), + windowMinutes: rl.primary.windowDurationMins ?? null, + }); + } + if (rl.secondary) { + windows.push({ + kind: "secondary", + label: labelFromMinutes(rl.secondary.windowDurationMins, "Weekly"), + usedPercent: clampPercent(rl.secondary.usedPercent), + resetsAt: epochToIso(rl.secondary.resetsAt), + windowMinutes: rl.secondary.windowDurationMins ?? null, + }); + } + + const planLabel = rl.limitName ?? (rl.planType ? CODEX_PLAN_LABELS[rl.planType] : null) ?? null; + + const credits = rl.credits + ? { + balance: rl.credits.balance ?? null, + hasCredits: rl.credits.hasCredits, + unlimited: rl.credits.unlimited, + monthlyLimit: null, + used: null, + } + : undefined; + + if (windows.length === 0 && !credits) { + return null; + } + + return { + source: "codex", + fetchedAt, + planLabel, + windows, + ...(credits ? { credits } : {}), + } satisfies ProviderUsage; +} + +// ── Claude normalization + fetch ──────────────────────────────────── + +// The usage endpoint returns `null` for numeric fields that aren't currently +// populated (e.g. `utilization: null` on `extra_usage` when credit usage isn't +// metered as a percentage). Every number must therefore accept null — a single +// unexpected null previously failed the whole decode, silently dropping all +// Claude usage. `clampPercent` already normalizes null/undefined to 0. +const ClaudeUsageWindow = Schema.Struct({ + utilization: Schema.optional(Schema.NullOr(Schema.Number)), + resets_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const ClaudeExtraUsage = Schema.Struct({ + is_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), + monthly_limit: Schema.optional(Schema.NullOr(Schema.Number)), + used_credits: Schema.optional(Schema.NullOr(Schema.Number)), + utilization: Schema.optional(Schema.NullOr(Schema.Number)), +}); + +const ClaudeOAuthUsageResponse = Schema.Struct({ + five_hour: Schema.optional(Schema.NullOr(ClaudeUsageWindow)), + seven_day: Schema.optional(Schema.NullOr(ClaudeUsageWindow)), + seven_day_opus: Schema.optional(Schema.NullOr(ClaudeUsageWindow)), + seven_day_sonnet: Schema.optional(Schema.NullOr(ClaudeUsageWindow)), + extra_usage: Schema.optional(Schema.NullOr(ClaudeExtraUsage)), +}); +type ClaudeOAuthUsageResponse = typeof ClaudeOAuthUsageResponse.Type; + +const ClaudeCredentialsFile = Schema.Struct({ + claudeAiOauth: Schema.optional( + Schema.NullOr( + Schema.Struct({ + accessToken: Schema.optional(Schema.String), + }), + ), + ), +}); +const ClaudeCredentialsFileJson = Schema.fromJsonString(ClaudeCredentialsFile); + +/** + * Normalize an Anthropic `/api/oauth/usage` response into `ProviderUsage`. + * `utilization` is already on a 0–100 scale and `resets_at` is ISO-8601. + */ +function normalizeClaudeUsage( + response: ClaudeOAuthUsageResponse, + fetchedAt: string, +): ProviderUsage | null { + const windows: ProviderUsageWindow[] = []; + + const push = ( + kind: ProviderUsageWindow["kind"], + label: string, + minutes: number, + window: typeof ClaudeUsageWindow.Type | null | undefined, + ) => { + if (!window) return; + windows.push({ + kind, + label, + usedPercent: clampPercent(window.utilization), + resetsAt: window.resets_at ?? null, + windowMinutes: minutes, + }); + }; + + push("five_hour", "5-hour", 300, response.five_hour); + push("seven_day", "Weekly", 10_080, response.seven_day); + push("seven_day_opus", "Weekly (Opus)", 10_080, response.seven_day_opus); + push("seven_day_sonnet", "Weekly (Sonnet)", 10_080, response.seven_day_sonnet); + + const extra = response.extra_usage; + const credits = + extra && extra.is_enabled + ? { + balance: null, + hasCredits: (extra.used_credits ?? 0) < (extra.monthly_limit ?? 0), + unlimited: false, + monthlyLimit: extra.monthly_limit ?? null, + used: extra.used_credits ?? null, + } + : undefined; + + if (windows.length === 0 && !credits) { + return null; + } + + return { + source: "claude", + fetchedAt, + planLabel: null, + windows, + ...(credits ? { credits } : {}), + } satisfies ProviderUsage; +} + +/** + * Resolve the Claude OAuth access token for an instance. Prefers the + * on-disk `.credentials.json` inside the instance's `CLAUDE_CONFIG_DIR` + * (correct per-instance, used on Linux and by disk-backed instances) and + * falls back to the macOS login keychain entry, which Claude Code uses by + * default on macOS. The keychain service name is derived per-instance + * (`makeClaudeCredentialsServiceName`) so each account reads its own token + * rather than sharing the default account's. Returns `null` when no token + * can be recovered. + */ +const resolveClaudeAccessToken = Effect.fn("resolveClaudeAccessToken")(function* ( + claudeSettings: Pick, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = yield* resolveClaudeConfigDir(claudeSettings); + const credentialsPath = path.join(configDir, ".credentials.json"); + + const decodeToken = (raw: string) => + Schema.decodeUnknownEffect(ClaudeCredentialsFileJson)(raw).pipe( + Effect.map((parsed) => parsed.claudeAiOauth?.accessToken?.trim() || null), + Effect.orElseSucceed(() => null), + ); + + const fromFile = yield* fs.exists(credentialsPath).pipe( + Effect.flatMap((exists) => + exists + ? fs.readFileString(credentialsPath).pipe(Effect.flatMap(decodeToken)) + : Effect.succeed(null), + ), + Effect.orElseSucceed(() => null), + ); + if (fromFile) return fromFile; + + if (process.platform !== "darwin") return null; + + // Each non-default CLAUDE_CONFIG_DIR gets its own keychain entry + // (`Claude Code-credentials-`); the default account + // uses the bare service name. Querying the correct per-instance service is + // what keeps two accounts from reporting the same usage/limits. + const service = yield* makeClaudeCredentialsServiceName(claudeSettings); + const keychain = yield* spawnAndCollect( + "security", + ChildProcess.make("security", ["find-generic-password", "-s", service, "-w"]), + // `catchCause` (not `orElseSucceed`) so a spawn defect — e.g. `security` + // missing, or a stubbed spawner throwing — degrades to "no token" instead + // of surfacing as a fiber defect. + ).pipe(Effect.catchCause(() => Effect.succeed(null))); + + if (!keychain || keychain.code !== 0) return null; + return yield* decodeToken(keychain.stdout); +}); + +/** + * Best-effort fetch of Claude subscription usage windows via the Anthropic + * `/api/oauth/usage` endpoint. Never fails: any error (missing token, network + * failure, unexpected shape) resolves to `undefined` so it cannot break a + * provider status probe. Only meaningful for OAuth subscription accounts — + * callers should skip API-key / Bedrock auth. + */ +export const fetchClaudeAccountUsage = Effect.fn("fetchClaudeAccountUsage")(function* (input: { + readonly claudeSettings: Pick; + readonly cliVersion: string | null; + readonly fetchedAt: string; +}) { + const token = yield* resolveClaudeAccessToken(input.claudeSettings).pipe( + Effect.orElseSucceed(() => null), + ); + if (!token) return undefined; + + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get("https://api.anthropic.com/api/oauth/usage").pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeader("anthropic-beta", "oauth-2025-04-20"), + HttpClientRequest.setHeader("user-agent", `claude-code/${input.cliVersion ?? "2.0.0"}`), + HttpClientRequest.setHeader("accept", "application/json"), + ); + + const response = yield* client.execute(request).pipe( + Effect.timeoutOption(8_000), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(response)) return undefined; + + const httpResponse = response.value; + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return undefined; + } + + const rawJson = yield* httpResponse.json.pipe(Effect.orElseSucceed(() => undefined)); + if (rawJson === undefined) return undefined; + + return yield* parseClaudeUsageResponse(rawJson, input.fetchedAt); +}); + +/** + * Decode a raw `/api/oauth/usage` body and normalize it into `ProviderUsage`. + * Returns `undefined` when the body can't be decoded or carries no usable + * windows/credits. Extracted from the fetch so the decode contract (which the + * live endpoint stresses with unexpected `null`s) is unit-testable. + */ +export const parseClaudeUsageResponse = Effect.fn("parseClaudeUsageResponse")(function* ( + raw: unknown, + fetchedAt: string, +) { + const payload = yield* Schema.decodeUnknownEffect(ClaudeOAuthUsageResponse)(raw).pipe( + Effect.orElseSucceed(() => null), + ); + if (!payload) return undefined; + + return normalizeClaudeUsage(payload, fetchedAt) ?? undefined; +}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a15..ebe2cee03bed 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -428,6 +428,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { workspaceRoot: "/workspace", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: now, updatedAt: now, @@ -586,6 +591,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { workspaceRoot: "/workspace", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0b233d7d9f7a..3513b63594fe 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -90,6 +90,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -97,6 +98,7 @@ import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as ProjectWorktreeFileCopier from "./project/ProjectWorktreeFileCopier.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; @@ -153,6 +155,11 @@ const makeDefaultOrchestrationReadModel = () => { title: "Default Project", workspaceRoot: "/tmp/default-project", defaultModelSelection, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: now, updatedAt: now, @@ -344,6 +351,9 @@ const buildAppUnderTest = (options?: { projectSetupScriptRunner?: Partial< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; + projectWorktreeFileCopier?: Partial< + ProjectWorktreeFileCopier.ProjectWorktreeFileCopier["Service"] + >; terminalManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; @@ -668,15 +678,24 @@ const buildAppUnderTest = (options?: { ), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( - Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ - runForThread: () => Effect.succeed({ status: "no-script" as const }), - ...options?.layers?.projectSetupScriptRunner, - }), + Layer.mergeAll( + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" as const }), + ...options?.layers?.projectSetupScriptRunner, + }), + Layer.mock(ProjectWorktreeFileCopier.ProjectWorktreeFileCopier)({ + copyForThread: () => Effect.succeed({ entries: [], copiedCount: 0 }), + ...options?.layers?.projectWorktreeFileCopier, + }), + ), ), Layer.provide( - Layer.mock(TerminalManager.TerminalManager)({ - ...options?.layers?.terminalManager, - }), + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({ + ...options?.layers?.terminalManager, + }), + Layer.mock(TextGeneration.TextGeneration)({}), + ), ), Layer.provide( Layer.mergeAll( @@ -722,6 +741,7 @@ const buildAppUnderTest = (options?: { threads: [], updatedAt: "1970-01-01T00:00:00.000Z", }), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: 0, @@ -736,6 +756,7 @@ const buildAppUnderTest = (options?: { getThreadDetailSnapshot: () => Effect.succeed(Option.none()), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, @@ -5687,6 +5708,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { title: "Project A", workspaceRoot: "/tmp/project-a", defaultModelSelection, + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0d36e99bc96..d12dabbb60de 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GitHubAccountResolver from "./sourceControl/GitHubAccountResolver.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -74,6 +75,7 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as ProjectWorktreeFileCopier from "./project/ProjectWorktreeFileCopier.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; @@ -248,6 +250,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer), + Layer.provideMerge(ProjectWorktreeFileCopier.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -558,6 +561,12 @@ export const makeServerLayer = Layer.unwrap( ); return serverApplicationLayer.pipe( + // Ambient per-project GitHub account resolver. Placed above + // `RuntimeServicesLive` (which provides `ProjectionSnapshotQuery`) and + // the global `VcsProcess.layer` so it can consume both; kept in the + // merged runtime context so `GitHubCli.execute` finds it via + // `Effect.serviceOption`. + Layer.provideMerge(GitHubAccountResolver.layer), Layer.provideMerge(RuntimeServicesLive), Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpResponseCompressionLive), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9add..13612b9ff934 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -80,6 +80,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => @@ -90,6 +91,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), @@ -137,6 +139,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -147,12 +150,18 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa title: "Startup Project", workspaceRoot: "/tmp/startup-project", defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + gitHubAccount: null, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }), ), + getDefaultModelSelectionForCwd: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), getThreadCheckpointContext: () => Effect.succeed(Option.none()), @@ -193,10 +202,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), @@ -243,10 +254,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + listAccountRoutes: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getDefaultModelSelectionForCwd: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 2798faf6f006..d5d531649eb3 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -52,7 +52,10 @@ import { } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; -export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; +export { + resolveSourceControlWriterModelSelection, + resolveSourceControlWriterModelSelectionWithProjectPreference, +} from "@t3tools/shared/serverSettings"; const encodeServerSettings = Schema.encodeEffect(ServerSettings); const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 609efe4df4c9..21dae33babac 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -246,6 +246,11 @@ export class AzureDevOpsCli extends Context.Service< readonly reference: string; readonly remoteName?: string; }) => Effect.Effect; + + readonly mergePullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; } >()("t3/sourceControl/AzureDevOpsCli") {} @@ -530,6 +535,24 @@ export const make = Effect.gen(function* () { input.remoteName ?? "origin", ], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + "--only-show-errors", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + "--status", + "completed", + "--delete-source-branch", + "false", + ], + }).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac9829275..dd685052dd09 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -223,6 +223,28 @@ export const make = Effect.gen(function* () { }), ), ), + mergeChangeRequest: (input) => + azure + .mergePullRequest({ + cwd: input.cwd, + reference: input.reference, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "mergeChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index f7d7f6671a46..93aeb9120e80 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -45,6 +45,7 @@ const BitbucketApiOperation = Schema.Literals([ "listPullRequests", "createRepository", "createPullRequest", + "mergePullRequest", "probeAuth", "checkoutPullRequest", ]); @@ -283,6 +284,11 @@ export class BitbucketApi extends Context.Service< readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; }) => Effect.Effect; + readonly mergePullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; @@ -817,6 +823,26 @@ export const make = Effect.gen(function* () { ), Effect.map(defaultChangeRequestTargetBranch), ), + mergePullRequest: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => + executeJson( + "mergePullRequest", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(input.reference))}/merge`, + ), + ).pipe( + HttpClientRequest.bodyJsonUnsafe({ + merge_strategy: "merge_commit", + close_source_branch: false, + }), + ), + BitbucketPullRequestSchema, + ), + ), + Effect.asVoid, + ), // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide // an official checkout CLI. This provider-local path uses GitVcsDriver as a // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 974fbb94a393..0bbd51800bd5 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -182,6 +182,28 @@ export const make = Effect.gen(function* () { }), ), ), + mergeChangeRequest: (input) => + bitbucket + .mergePullRequest({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + reference: input.reference, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "mergeChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to merge change request.", + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.test.ts b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts new file mode 100644 index 000000000000..2499b19961b7 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts @@ -0,0 +1,509 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, afterEach, describe, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + ProjectId, + ThreadId, + type GitHubAccountRef, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { + GitHubAccountResolver, + gitHubAccountAuthEnv, + gitHubAccountGhEnv, + layer as resolverLayer, +} from "./GitHubAccountResolver.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +const projectShell = (input: { + readonly id: string; + readonly workspaceRoot: string; + readonly gitHubAccount: GitHubAccountRef | null; +}): OrchestrationProjectShell => ({ + id: ProjectId.make(input.id), + title: input.id, + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + gitHubAccount: input.gitHubAccount, + worktreeBranchPrefix: null, + defaultWorktreeBranch: null, + previewPort: null, + worktreeCopyFiles: [], + scripts: [], + createdAt: NOW, + updatedAt: NOW, +}); + +const threadShell = (input: { + readonly id: string; + readonly projectId: string; + readonly worktreePath: string | null; +}): OrchestrationThreadShell => + ({ + id: ThreadId.make(input.id), + projectId: ProjectId.make(input.projectId), + worktreePath: input.worktreePath, + branch: null, + latestTurn: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + createdAt: NOW, + updatedAt: NOW, + // Fields the resolver never reads are omitted; cast keeps the fixture terse. + }) as unknown as OrchestrationThreadShell; + +const shellSnapshot = (input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): OrchestrationShellSnapshot => ({ + snapshotSequence: 1, + projects: input.projects, + threads: input.threads, + updatedAt: NOW, +}); + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn(); +const mockListAccountRoutes = vi.fn(); + +// Derive the flat path→account routes the resolver consumes from the same +// projects/threads fixtures the tests express, mirroring the SQL union in +// ProjectionSnapshotQuery.listAccountRoutes. +const routesFromSnapshot = ( + snapshot: OrchestrationShellSnapshot, +): ReadonlyArray<{ readonly path: string; readonly account: GitHubAccountRef }> => { + const accountByProjectId = new Map( + snapshot.projects + .filter((project) => project.gitHubAccount !== null) + .map((project) => [project.id, project.gitHubAccount as GitHubAccountRef] as const), + ); + const routes: Array<{ path: string; account: GitHubAccountRef }> = []; + for (const project of snapshot.projects) { + if (project.gitHubAccount !== null) { + routes.push({ path: project.workspaceRoot, account: project.gitHubAccount }); + } + } + for (const thread of snapshot.threads) { + if (thread.worktreePath === null) continue; + const account = accountByProjectId.get(thread.projectId); + if (account !== undefined) { + routes.push({ path: thread.worktreePath, account }); + } + } + return routes; +}; + +const layerFor = (snapshot: OrchestrationShellSnapshot) => { + mockListAccountRoutes.mockReturnValue(Effect.succeed(routesFromSnapshot(snapshot))); + return resolverLayer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockRun })), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ listAccountRoutes: mockListAccountRoutes }), + ), + // Real filesystem: the resolver resolves symlinks when no route matches + // literally, and the fixtures' fake paths must survive that lookup failing. + Layer.provide(NodeServices.layer), + ); +}; + +const routesLayerFor = (routes: ReadonlyArray<{ path: string; account: GitHubAccountRef }>) => { + mockListAccountRoutes.mockReturnValue(Effect.succeed(routes)); + return resolverLayer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockRun })), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ listAccountRoutes: mockListAccountRoutes }), + ), + Layer.provide(NodeServices.layer), + ); +}; + +afterEach(() => { + mockRun.mockReset(); + mockListAccountRoutes.mockReset(); +}); + +describe("gitHubAccountGhEnv", () => { + it("uses GH_TOKEN for github.com", () => { + assert.deepEqual( + gitHubAccountGhEnv({ account: { host: "github.com", login: "octo" }, token: "t0ken" }), + { GH_HOST: "github.com", GH_TOKEN: "t0ken" }, + ); + }); + + it("uses GH_ENTERPRISE_TOKEN for enterprise hosts", () => { + assert.deepEqual( + gitHubAccountGhEnv({ account: { host: "ghe.corp", login: "octo" }, token: "t0ken" }), + { GH_HOST: "ghe.corp", GH_ENTERPRISE_TOKEN: "t0ken" }, + ); + }); +}); + +describe("gitHubAccountAuthEnv", () => { + it("pins git credentials for the account host to gh's helper", () => { + assert.deepEqual( + gitHubAccountAuthEnv({ account: { host: "github.com", login: "octo" }, token: "t0ken" }, {}), + { + GH_HOST: "github.com", + GH_TOKEN: "t0ken", + GIT_CONFIG_COUNT: "4", + // An empty helper resets the list, so the machine-global helper (usually + // an OS keychain) cannot answer for this host first. + GIT_CONFIG_KEY_0: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_0: "", + GIT_CONFIG_KEY_1: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_1: "!gh auth git-credential", + // SSH remotes for the host are rewritten to HTTPS so the token applies. + GIT_CONFIG_KEY_2: "url.https://github.com/.insteadOf", + GIT_CONFIG_VALUE_2: "git@github.com:", + GIT_CONFIG_KEY_3: "url.https://github.com/.insteadOf", + GIT_CONFIG_VALUE_3: "ssh://git@github.com/", + }, + ); + }); + + it("scopes the helper to enterprise hosts", () => { + assert.deepEqual( + gitHubAccountAuthEnv({ account: { host: "ghe.corp", login: "octo" }, token: "t0ken" }, {}), + { + GH_HOST: "ghe.corp", + GH_ENTERPRISE_TOKEN: "t0ken", + GIT_CONFIG_COUNT: "4", + GIT_CONFIG_KEY_0: "credential.https://ghe.corp.helper", + GIT_CONFIG_VALUE_0: "", + GIT_CONFIG_KEY_1: "credential.https://ghe.corp.helper", + GIT_CONFIG_VALUE_1: "!gh auth git-credential", + GIT_CONFIG_KEY_2: "url.https://ghe.corp/.insteadOf", + GIT_CONFIG_VALUE_2: "git@ghe.corp:", + GIT_CONFIG_KEY_3: "url.https://ghe.corp/.insteadOf", + GIT_CONFIG_VALUE_3: "ssh://git@ghe.corp/", + }, + ); + }); + + it("appends after runtime config pairs already in the environment", () => { + const env = gitHubAccountAuthEnv( + { account: { host: "github.com", login: "octo" }, token: "t0ken" }, + { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.pager", GIT_CONFIG_VALUE_0: "cat" }, + ); + assert.equal(env.GIT_CONFIG_COUNT, "5"); + // Index 0 stays with the inherited pair instead of being overwritten. + assert.equal(env.GIT_CONFIG_KEY_0, undefined); + assert.equal(env.GIT_CONFIG_KEY_1, "credential.https://github.com.helper"); + assert.equal(env.GIT_CONFIG_VALUE_1, ""); + assert.equal(env.GIT_CONFIG_KEY_2, "credential.https://github.com.helper"); + assert.equal(env.GIT_CONFIG_VALUE_2, "!gh auth git-credential"); + assert.equal(env.GIT_CONFIG_KEY_3, "url.https://github.com/.insteadOf"); + assert.equal(env.GIT_CONFIG_VALUE_3, "git@github.com:"); + assert.equal(env.GIT_CONFIG_KEY_4, "url.https://github.com/.insteadOf"); + assert.equal(env.GIT_CONFIG_VALUE_4, "ssh://git@github.com/"); + }); + + it("ignores a malformed inherited GIT_CONFIG_COUNT", () => { + const env = gitHubAccountAuthEnv( + { account: { host: "github.com", login: "octo" }, token: "t0ken" }, + { GIT_CONFIG_COUNT: "not-a-number" }, + ); + assert.equal(env.GIT_CONFIG_COUNT, "4"); + assert.equal(env.GIT_CONFIG_KEY_0, "credential.https://github.com.helper"); + }); +}); + +describe("GitHubAccountResolver.resolveForCwd", () => { + it.effect("resolves to ambient when the owning project has no account", () => + Effect.gen(function* () { + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app/src"); + assert.deepEqual(resolved, { _tag: "ambient" }); + assert.equal(mockRun.mock.calls.length, 0); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ id: "app", workspaceRoot: "/repos/app", gitHubAccount: null }), + ], + threads: [], + }), + ), + ), + ), + ); + + it.effect("resolves the account and token when cwd is inside the workspace root", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("gho_secret\n"))); + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app/packages/x"); + assert.deepEqual(resolved, { + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + token: "gho_secret", + }); + const call = mockRun.mock.calls[0]?.[0]; + assert.deepEqual(call?.args, ["auth", "token", "--user", "octo", "--hostname", "github.com"]); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [], + }), + ), + ), + ), + ); + + it.effect("resolves via a thread worktree that lives outside the workspace root", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("gho_wt\n"))); + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/state/worktrees/wt-1/sub"); + assert.deepEqual(resolved, { + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + token: "gho_wt", + }); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [ + threadShell({ id: "t1", projectId: "app", worktreePath: "/state/worktrees/wt-1" }), + ], + }), + ), + ), + ), + ); + + it.effect("resolves a cwd that reaches the workspace root through a symlink", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "gh-account-resolver-test-", + }); + const workspaceRoot = pathService.join(baseDir, "repo"); + const linkedRoot = pathService.join(baseDir, "linked-repo"); + yield* fileSystem.makeDirectory(pathService.join(workspaceRoot, "src"), { recursive: true }); + yield* fileSystem.symlink(workspaceRoot, linkedRoot); + mockRun.mockReturnValue(Effect.succeed(processOutput("gho_link\n"))); + + // The project stored the real root; the command runs under the symlink. + const resolved = yield* Effect.gen(function* () { + const resolver = yield* GitHubAccountResolver; + return yield* resolver.resolveForCwd(pathService.join(linkedRoot, "src")); + }).pipe( + Effect.provide( + routesLayerFor([{ path: workspaceRoot, account: { host: "github.com", login: "octo" } }]), + ), + ); + + assert.deepEqual(resolved, { + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + token: "gho_link", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not match a sibling path with a shared prefix", () => + Effect.gen(function* () { + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app-2/src"); + assert.deepEqual(resolved, { _tag: "ambient" }); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [], + }), + ), + ), + ), + ); + + it.effect("reports unavailable when the attached account can't mint a token", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput(""))); + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app"); + assert.deepEqual(resolved, { + _tag: "unavailable", + account: { host: "github.com", login: "octo" }, + }); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [], + }), + ), + ), + ), + ); +}); + +const attachedProjectSnapshot = (account: GitHubAccountRef) => + shellSnapshot({ + projects: [projectShell({ id: "app", workspaceRoot: "/repos/app", gitHubAccount: account })], + threads: [], + }); + +describe("GitHubAccountResolver.resolveCommitIdentityForCwd", () => { + it.effect("resolves to ambient when the owning project has no account", () => + Effect.gen(function* () { + const resolver = yield* GitHubAccountResolver; + const identity = yield* resolver.resolveCommitIdentityForCwd("/repos/app/src"); + assert.deepEqual(identity, { _tag: "ambient" }); + assert.equal(mockRun.mock.calls.length, 0); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ id: "app", workspaceRoot: "/repos/app", gitHubAccount: null }), + ], + threads: [], + }), + ), + ), + ), + ); + + it.effect("uses the account's numeric id and display name from gh api user", () => + Effect.gen(function* () { + // First call mints the token, second reads the profile. + mockRun + .mockReturnValueOnce(Effect.succeed(processOutput("gho_secret\n"))) + .mockReturnValueOnce( + Effect.succeed(processOutput(`{"login":"octo","id":12345,"name":"Octo Cat"}`)), + ); + const resolver = yield* GitHubAccountResolver; + const identity = yield* resolver.resolveCommitIdentityForCwd("/repos/app/src"); + assert.deepEqual(identity, { + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + identity: { + name: "Octo Cat", + email: "12345+octo@users.noreply.github.com", + }, + }); + const apiCall = mockRun.mock.calls[1]?.[0]; + assert.deepEqual(apiCall?.args, ["api", "user", "--hostname", "github.com"]); + // The profile read must act as the account, not the machine-active one. + assert.equal(apiCall?.env?.GH_TOKEN, "gho_secret"); + assert.equal(apiCall?.env?.GH_HOST, "github.com"); + }).pipe( + Effect.provide(layerFor(attachedProjectSnapshot({ host: "github.com", login: "octo" }))), + ), + ); + + it.effect("falls back to the login no-reply email when the token can't be minted", () => + Effect.gen(function* () { + // Empty token → cannot act as the account, so no profile call is made. + mockRun.mockReturnValue(Effect.succeed(processOutput(""))); + const resolver = yield* GitHubAccountResolver; + const identity = yield* resolver.resolveCommitIdentityForCwd("/repos/app/src"); + assert.deepEqual(identity, { + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + identity: { + name: "octo", + email: "octo@users.noreply.github.com", + }, + }); + // Only the token mint runs; the profile read is skipped without a token. + assert.equal(mockRun.mock.calls.length, 1); + }).pipe( + Effect.provide(layerFor(attachedProjectSnapshot({ host: "github.com", login: "octo" }))), + ), + ); + + it.effect("falls back to the login no-reply email when the profile read fails", () => + Effect.gen(function* () { + mockRun + .mockReturnValueOnce(Effect.succeed(processOutput("gho_secret\n"))) + .mockReturnValueOnce(Effect.fail(new Error("network down") as never)); + const resolver = yield* GitHubAccountResolver; + const identity = yield* resolver.resolveCommitIdentityForCwd("/repos/app/src"); + assert.deepEqual(identity, { + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + identity: { + name: "octo", + email: "octo@users.noreply.github.com", + }, + }); + }).pipe( + Effect.provide(layerFor(attachedProjectSnapshot({ host: "github.com", login: "octo" }))), + ), + ); + + it.effect("derives the enterprise no-reply host for a GHE account", () => + Effect.gen(function* () { + mockRun + .mockReturnValueOnce(Effect.succeed(processOutput("ghe_secret\n"))) + .mockReturnValueOnce(Effect.succeed(processOutput(`{"login":"octo","id":42}`))); + const resolver = yield* GitHubAccountResolver; + const identity = yield* resolver.resolveCommitIdentityForCwd("/repos/app/src"); + assert.deepEqual(identity, { + _tag: "resolved", + account: { host: "ghe.corp", login: "octo" }, + identity: { + name: "octo", + email: "42+octo@users.noreply.ghe.corp", + }, + }); + assert.equal(mockRun.mock.calls[1]?.[0]?.env?.GH_ENTERPRISE_TOKEN, "ghe_secret"); + }).pipe(Effect.provide(layerFor(attachedProjectSnapshot({ host: "ghe.corp", login: "octo" })))), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.ts b/apps/server/src/sourceControl/GitHubAccountResolver.ts new file mode 100644 index 000000000000..7c2f75af28a6 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAccountResolver.ts @@ -0,0 +1,514 @@ +/** + * GitHubAccountResolver - resolves the per-project GitHub account (if any) + * that a `gh`/`git` command running in a given `cwd` should act as. + * + * Projects can attach one of the accounts already authenticated in the `gh` + * CLI (see `gh auth status`). This service maps a working directory back to + * its project — either directly by workspace root or via a thread worktree — + * reads the attached account, and materializes the token for that account with + * `gh auth token --user --hostname `. The token is fetched on + * demand (never persisted by T3) and cached briefly so back-to-back commands + * don't re-shell for every invocation. + * + * Callers merge {@link gitHubAccountGhEnv} into their `gh` process env. Because + * this is resolved through {@link Effect.serviceOption}, `gh` commands work + * unchanged when the resolver is not provided (tests, minimal layers) — they + * simply fall back to the machine-global active account. + * + * @module GitHubAccountResolver + */ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import type { GitHubAccountRef } from "@t3tools/contracts"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +const TOKEN_TTL_MS = 5 * 60_000; +const GH_AUTH_TOKEN_TIMEOUT_MS = 10_000; +// A GitHub account's profile (numeric id / display name) effectively never +// changes, so the commit identity can be cached far more generously than tokens. +const IDENTITY_TTL_MS = 30 * 60_000; +const GH_API_USER_TIMEOUT_MS = 10_000; +// A directory's resolved real path effectively never changes either, but a path +// that does not exist yet must not stay pinned to its unresolved form forever. +const REAL_PATH_TTL_MS = 5 * 60_000; + +export interface ResolvedGitHubAccount { + readonly account: GitHubAccountRef; + readonly token: string; +} + +/** + * The git author/committer identity a commit made "as" a GitHub account should + * record. `email` is the account's no-reply email so GitHub attributes the + * commit to that account regardless of the machine's ambient `git config`. + */ +export interface GitHubCommitIdentity { + readonly name: string; + readonly email: string; +} + +/** + * Outcome of resolving the commit identity for a `cwd`. + * + * - `resolved`: an account is attached — the caller records `identity` as the + * commit author/committer so the commit is attributed to it, not the ambient + * `git config user.*` (which may belong to a different, active account). + * - `ambient`: `cwd` maps to no project, or its project has no account attached + * — the caller leaves the ambient identity untouched. + * + * Unlike {@link GitHubAccountResolution} there is no `unavailable` case: a local + * commit never touches the network, and the login-based no-reply email still + * attributes the commit to the right account even when its token can't be + * minted. A later push is where a logged-out account fails loudly. + */ +export type GitHubCommitIdentityResolution = + | { + readonly _tag: "resolved"; + readonly account: GitHubAccountRef; + readonly identity: GitHubCommitIdentity; + } + | { readonly _tag: "ambient" }; + +/** + * Outcome of mapping a `cwd` to the GitHub account its commands should act as. + * + * - `resolved`: an account is attached and its token was materialized — the + * caller injects {@link gitHubAccountAuthEnv} so `gh`/`git` act as it. + * - `ambient`: `cwd` maps to no project, or the owning project has no account + * attached — the caller uses the machine-global active account. This is the + * correct behavior for cloning (no project yet) and for projects that never + * picked an account. + * - `unavailable`: the owning project HAS an account attached, but its token + * could not be minted (the account isn't logged in to `gh`, or `gh` failed). + * Silently falling back to the active account here would run the command as + * the WRONG user, so the caller MUST refuse the action with an actionable + * "account not logged in" error instead. + */ +export type GitHubAccountResolution = + | { readonly _tag: "resolved"; readonly account: GitHubAccountRef; readonly token: string } + | { readonly _tag: "ambient" } + | { readonly _tag: "unavailable"; readonly account: GitHubAccountRef }; + +export class GitHubAccountResolver extends Context.Service< + GitHubAccountResolver, + { + /** + * Resolve the GitHub account a command running in `cwd` should act as. + * Distinguishes "no account attached / unknown cwd" (fall back to the + * ambient account) from "account attached but its token can't be minted" + * (the caller must fail loudly rather than act as the wrong account). + */ + readonly resolveForCwd: (cwd: string) => Effect.Effect; + /** + * Resolve the git author/committer identity a commit running in `cwd` + * should record, so the commit is attributed to the project's selected + * GitHub account rather than the machine's ambient `git config user.*`. + * Never fails: falls back to the login-based no-reply email when the + * account's profile can't be read (e.g. it isn't logged in, or offline). + */ + readonly resolveCommitIdentityForCwd: ( + cwd: string, + ) => Effect.Effect; + } +>()("t3/sourceControl/GitHubAccountResolver") {} + +/** + * Environment overrides that make a `gh` invocation act as `resolved`'s + * account. `github.com` uses `GH_TOKEN`; any other host is treated as a GitHub + * Enterprise host and uses `GH_ENTERPRISE_TOKEN`. Merged onto the parent env by + * the process runner, so only the delta is returned. + */ +export function gitHubAccountGhEnv(resolved: ResolvedGitHubAccount): NodeJS.ProcessEnv { + const host = resolved.account.host; + const tokenVar = host === "github.com" ? "GH_TOKEN" : "GH_ENTERPRISE_TOKEN"; + return { + GH_HOST: host, + [tokenVar]: resolved.token, + }; +} + +/** + * Environment that makes BOTH `gh` and plain `git` (push/fetch over HTTPS) act + * as `resolved`'s account, for a single process (e.g. an interactive terminal + * or a one-off git command) — without touching global config or running + * `gh auth switch`. + * + * On top of {@link gitHubAccountGhEnv} (which points `gh` at the account via + * `GH_TOKEN`/`GH_ENTERPRISE_TOKEN` + `GH_HOST`), this wires git's credential + * helper to GitHub CLI via `GIT_CONFIG_*` env keys — equivalent to + * `gh auth setup-git` but scoped to this process only. The leading empty helper + * resets any inherited helper (OS keychain, global config) so the selected + * account wins; `!gh auth git-credential` then returns the `GH_TOKEN` above. + * This is the same mechanism CI uses (`GH_TOKEN` + gh credential helper). + * + * It also rewrites the account host's SSH remote forms to HTTPS + * (`url.https:///.insteadOf`), so a repo whose remote is + * `git@:owner/repo` still authenticates as the selected account. Without + * it, an SSH remote bypasses the token entirely and uses the machine's SSH key + * — i.e. account selection would silently do nothing. The rewrite is scoped to + * this process and never touches the stored remote URL. + * + * Pairs are appended after any `GIT_CONFIG_COUNT` already present in `baseEnv`, + * so a runtime git config the process inherited keeps working instead of being + * silently overwritten by our pairs claiming indices 0 and 1. + */ +export function gitHubAccountAuthEnv( + resolved: ResolvedGitHubAccount, + baseEnv: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const host = resolved.account.host; + const helperKey = `credential.https://${host}.helper`; + const insteadOfKey = `url.https://${host}/.insteadOf`; + const offset = parseGitConfigCount(baseEnv.GIT_CONFIG_COUNT); + return { + ...gitHubAccountGhEnv(resolved), + GIT_CONFIG_COUNT: String(offset + 4), + [`GIT_CONFIG_KEY_${offset}`]: helperKey, + [`GIT_CONFIG_VALUE_${offset}`]: "", + [`GIT_CONFIG_KEY_${offset + 1}`]: helperKey, + [`GIT_CONFIG_VALUE_${offset + 1}`]: "!gh auth git-credential", + [`GIT_CONFIG_KEY_${offset + 2}`]: insteadOfKey, + [`GIT_CONFIG_VALUE_${offset + 2}`]: `git@${host}:`, + [`GIT_CONFIG_KEY_${offset + 3}`]: insteadOfKey, + [`GIT_CONFIG_VALUE_${offset + 3}`]: `ssh://git@${host}/`, + }; +} + +function parseGitConfigCount(value: string | undefined): number { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0; +} + +/** + * True when `cwd` is `base` itself or lives beneath it. Compares normalized, + * separator-terminated paths so `/a/repo` does not match `/a/repo-2`. + */ +function isWithin(cwd: string, base: string): boolean { + if (base.length === 0) return false; + const normalizedBase = base.replace(/[/\\]+$/, ""); + if (cwd === normalizedBase) return true; + return cwd.startsWith(`${normalizedBase}/`) || cwd.startsWith(`${normalizedBase}\\`); +} + +/** One path that resolves to an account: a project root or a thread worktree. */ +interface AccountRoute { + readonly path: string; + readonly account: GitHubAccountRef; +} + +/** + * The account of the longest route containing `cwd`, so a worktree nested under + * another project's root resolves to the worktree's own project. + */ +function matchLongestRoute( + cwd: string, + routes: ReadonlyArray, +): GitHubAccountRef | null { + let bestPathLength = -1; + let bestAccount: GitHubAccountRef | null = null; + for (const route of routes) { + if (!isWithin(cwd, route.path)) continue; + if (route.path.length > bestPathLength) { + bestPathLength = route.path.length; + bestAccount = route.account; + } + } + return bestAccount; +} + +interface CachedToken { + readonly token: string; + readonly expiresAtMs: number; +} + +interface CachedRealPath { + readonly realPath: string; + readonly expiresAtMs: number; +} + +interface CachedIdentity { + readonly identity: GitHubCommitIdentity; + readonly expiresAtMs: number; +} + +function cacheKey(account: GitHubAccountRef): string { + return `${account.host}\n${account.login}`; +} + +/** + * The no-reply email domain for a host. `github.com` uses + * `users.noreply.github.com`; a GitHub Enterprise host `ghe.corp` uses + * `users.noreply.ghe.corp`. GitHub attributes any commit with a + * `@` or `+@` email to that account. + */ +function noReplyEmailHost(host: string): string { + return `users.noreply.${host}`; +} + +/** + * The commit identity derivable from the account ref alone, without touching + * the network. `@users.noreply.` attributes the commit to the + * account; used as a fallback when the account's numeric id / display name + * can't be read (logged out, offline, or `gh` failed). + */ +function loginIdentity(account: GitHubAccountRef): GitHubCommitIdentity { + return { name: account.login, email: `${account.login}@${noReplyEmailHost(account.host)}` }; +} + +/** + * Build the canonical commit identity from a `gh api user` JSON response: the + * account's display name (falling back to its login) and the id-based no-reply + * email `+@users.noreply.`. Returns `null` when the payload + * lacks a usable numeric id, so the caller falls back to {@link loginIdentity}. + */ +function parseCommitIdentity( + stdout: string, + account: GitHubAccountRef, +): GitHubCommitIdentity | null { + let payload: unknown; + try { + payload = JSON.parse(stdout); + } catch { + return null; + } + if (typeof payload !== "object" || payload === null) { + return null; + } + const record = payload as Record; + const id = record.id; + if (typeof id !== "number" && typeof id !== "string") { + return null; + } + const idText = String(id).trim(); + if (idText.length === 0) { + return null; + } + const login = + typeof record.login === "string" && record.login.trim().length > 0 + ? record.login.trim() + : account.login; + const name = + typeof record.name === "string" && record.name.trim().length > 0 ? record.name.trim() : login; + return { name, email: `${idText}+${login}@${noReplyEmailHost(account.host)}` }; +} + +export const make = Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const process = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const tokenCache = yield* Ref.make(new Map()); + const identityCache = yield* Ref.make(new Map()); + const realPathCache = yield* Ref.make(new Map()); + + /** + * `target` with its symlinks resolved, or `target` itself when it can't be + * resolved (the directory is gone, or unreadable). Cached so the extra + * syscalls are paid once per path rather than once per command. + */ + const resolveRealPath = (target: string): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cache = yield* Ref.get(realPathCache); + const cached = cache.get(target); + if (cached !== undefined && cached.expiresAtMs > now) { + return cached.realPath; + } + const realPath = yield* fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => target)); + yield* Ref.update(realPathCache, (current) => { + const next = new Map(current); + next.set(target, { realPath, expiresAtMs: now + REAL_PATH_TTL_MS }); + return next; + }); + return realPath; + }); + + /** + * Find the account attached to the project owning `cwd`. Matches the longest + * project workspace root or thread worktree path that contains `cwd`, so a + * worktree checked out outside the project root — including one belonging to + * an archived thread — still resolves. + */ + const findAccountForCwd = (cwd: string): Effect.Effect => + snapshotQuery.listAccountRoutes().pipe( + // A projection read failure must not break the underlying git/gh command; + // fall back to the ambient account. + Effect.orElseSucceed((): ReadonlyArray => []), + Effect.flatMap((routes) => + Effect.gen(function* () { + if (routes.length === 0) { + return null; + } + const literalMatch = matchLongestRoute(cwd, routes); + if (literalMatch !== null) { + return literalMatch; + } + // No literal match, which is not the same as no account: a cwd and the + // stored root routinely name one directory through different symlinks + // (/tmp vs /private/tmp on macOS, a symlinked home or worktree dir). + // Missing that match silently runs the command as the machine's + // ambient account, so resolving both sides is worth the syscalls — + // paid only on the miss, and cached. + const [realCwd, realRoutes] = yield* Effect.all( + [ + resolveRealPath(cwd), + Effect.forEach( + routes, + (route) => + resolveRealPath(route.path).pipe( + Effect.map((path): AccountRoute => ({ ...route, path })), + ), + { concurrency: 8 }, + ), + ], + { concurrency: 2 }, + ); + return matchLongestRoute(realCwd, realRoutes); + }), + ), + ); + + const resolveToken = (cwd: string, account: GitHubAccountRef): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cache = yield* Ref.get(tokenCache); + const cached = cache.get(cacheKey(account)); + if (cached !== undefined && cached.expiresAtMs > now) { + return cached.token; + } + + const result = yield* process + .run({ + operation: "GitHubAccountResolver.resolveToken", + command: "gh", + args: ["auth", "token", "--user", account.login, "--hostname", account.host], + cwd, + timeoutMs: GH_AUTH_TOKEN_TIMEOUT_MS, + }) + .pipe(Effect.option); + + // git and gh commands for this project refuse rather than act as the + // machine's ambient account, so this warning is the only place the reason + // is recorded — leave a breadcrumb either way. (A commit identity still + // resolves, via the account's login-based no-reply email.) + if (result._tag === "None") { + yield* Effect.logWarning( + "Could not mint a token for the project's GitHub account; git and gh commands for this project will fail until it is logged in to the GitHub CLI", + { host: account.host, login: account.login, cwd }, + ); + return null; + } + const token = result.value.stdout.trim(); + if (token.length === 0) { + yield* Effect.logWarning( + "gh returned an empty token for the project's GitHub account; git and gh commands for this project will fail until it is logged in to the GitHub CLI", + { host: account.host, login: account.login, cwd }, + ); + return null; + } + + yield* Ref.update(tokenCache, (current) => { + const next = new Map(current); + next.set(cacheKey(account), { token, expiresAtMs: now + TOKEN_TTL_MS }); + return next; + }); + return token; + }); + + const resolveForCwd: GitHubAccountResolver["Service"]["resolveForCwd"] = (cwd) => + Effect.gen(function* () { + const account = yield* findAccountForCwd(cwd); + if (account === null) { + return { _tag: "ambient" } as const; + } + const token = yield* resolveToken(cwd, account); + if (token === null) { + // The project explicitly selected this account but we couldn't act as + // it. Report it so the caller refuses rather than silently using the + // wrong (ambient) account. + return { _tag: "unavailable", account } as const; + } + return { _tag: "resolved", account, token } as const; + }); + + /** + * Resolve the commit identity for an attached account. Prefers the canonical + * id-based no-reply email + display name read from `gh api user` (acting as + * the account via its minted token), and falls back to the login-based + * no-reply email whenever the profile can't be read — so a local commit is + * always attributed to the account and never blocked when it's logged out or + * offline. + */ + const resolveIdentity = ( + cwd: string, + account: GitHubAccountRef, + ): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cache = yield* Ref.get(identityCache); + const cached = cache.get(cacheKey(account)); + if (cached !== undefined && cached.expiresAtMs > now) { + return cached.identity; + } + + const fallback = loginIdentity(account); + // Without the account's token we can't query `gh api user` as it — the + // ambient (possibly different) account would answer — so skip the call + // and attribute via the login-based no-reply email instead. + const token = yield* resolveToken(cwd, account); + if (token === null) { + return fallback; + } + + const result = yield* process + .run({ + operation: "GitHubAccountResolver.resolveIdentity", + command: "gh", + args: ["api", "user", "--hostname", account.host], + cwd, + env: gitHubAccountGhEnv({ account, token }), + timeoutMs: GH_API_USER_TIMEOUT_MS, + }) + .pipe(Effect.option); + if (result._tag === "None") { + yield* Effect.logWarning( + "Could not read the GitHub account profile for the commit identity; attributing the commit via the login-based no-reply email", + { host: account.host, login: account.login, cwd }, + ); + return fallback; + } + + const identity = parseCommitIdentity(result.value.stdout, account); + if (identity === null) { + return fallback; + } + + yield* Ref.update(identityCache, (current) => { + const next = new Map(current); + next.set(cacheKey(account), { identity, expiresAtMs: now + IDENTITY_TTL_MS }); + return next; + }); + return identity; + }); + + const resolveCommitIdentityForCwd: GitHubAccountResolver["Service"]["resolveCommitIdentityForCwd"] = + (cwd) => + Effect.gen(function* () { + const account = yield* findAccountForCwd(cwd); + if (account === null) { + return { _tag: "ambient" } as const; + } + const identity = yield* resolveIdentity(cwd, account); + return { _tag: "resolved", account, identity } as const; + }); + + return GitHubAccountResolver.of({ resolveForCwd, resolveCommitIdentityForCwd }); +}); + +export const layer = Layer.effect(GitHubAccountResolver, make); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5daf7676d60c..e6275143576e 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,11 +1,14 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { GitHubAccountResolver } from "./GitHubAccountResolver.ts"; import * as GitHubCli from "./GitHubCli.ts"; const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ @@ -52,6 +55,64 @@ describe("GitHubCli.layer", () => { assert.notProperty(commandFailure, "operation"); }); + it("classifies temporary provider failures", () => { + const context = { command: "gh", cwd: "/repo" } as const; + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: context.cwd, + exitCode: 1, + detail: "The source control provider is temporarily unavailable.", + failureKind: "provider-unavailable", + }); + + const error = GitHubCli.fromVcsError(context, cause); + + assert.equal(error._tag, "GitHubProviderUnavailableError"); + assert.equal(error.detail, "GitHub is temporarily unavailable. Wait a moment and try again."); + assert.strictEqual(error.cause, cause); + }); + + it.effect("retries pull request reads while GitHub is temporarily unavailable", () => + Effect.gen(function* () { + const unavailable = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + detail: "The source control provider is temporarily unavailable.", + failureKind: "provider-unavailable", + }); + mockRun.mockReturnValueOnce(Effect.fail(unavailable)); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 42, + title: "Recovered PR lookup", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/retry", + state: "OPEN", + mergedAt: null, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const lookup = yield* gh + .getPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(lookup); + + assert.equal(result.number, 42); + expect(mockRun).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(layer)), + ); + it.effect("parses pull request view output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -66,6 +127,9 @@ describe("GitHubCli.layer", () => { headRefName: "feature/pr-threads", state: "OPEN", mergedAt: null, + mergeable: "CONFLICTING", + mergeStateStatus: "DIRTY", + statusCheckRollup: [{ status: "COMPLETED", conclusion: "FAILURE" }], isCrossRepository: true, headRepository: { nameWithOwner: "octocat/codething-mvp", @@ -91,6 +155,9 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + mergeability: "conflicting", + checks: "failing", + failedCheckCount: 1, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -103,7 +170,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,mergeable,mergeStateStatus,statusCheckRollup", ], cwd: "/repo", timeoutMs: 30_000, @@ -373,4 +440,526 @@ describe("GitHubCli.layer", () => { assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); + + it.effect("surfaces a friendly error when the directory has no GitHub repository", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + failureKind: "repository-not-found", + detail: + "No repository found for this directory. It may not be a git repository, or its remotes don't point to the expected host.", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .getPullRequest({ + cwd: "/repo", + reference: "33", + }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubRepositoryNotFoundError"); + assert.equal(error.message.includes("No GitHub repository was found"), true); + assert.strictEqual(error.command, "gh"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("merges with a real merge commit when the repository allows it", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }), + ), + ), + ); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", reference: "#42" }); + + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "repo", + "view", + "--json", + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "#42", "--json", "mergeable,mergeStateStatus"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("falls back to squash when merge commits are disallowed", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }), + ), + ), + ); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", reference: "#42" }); + + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--squash"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("falls back to rebase when only rebase merges are allowed", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: false, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }), + ), + ), + ); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", reference: "#42" }); + + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--rebase"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("falls back to a plain merge when repository settings can't be read", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not json"))); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }), + ), + ), + ); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", reference: "#42" }); + + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("polls until GitHub finishes computing mergeability before merging", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + }), + ), + ), + ); + // First read: still computing. Second read: settled. + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }), + ), + ), + ); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const merge = yield* gh + .mergePullRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.forkChild({ startImmediately: true })); + // Let the one-second poll backoff elapse so the second read runs. + yield* TestClock.adjust("1 second"); + yield* Fiber.join(merge); + + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "#42", "--json", "mergeable,mergeStateStatus"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "#42", "--json", "mergeable,mergeStateStatus"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(4, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("retries a transient merge-blocked failure", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }), + ), + ), + ); + // First merge attempt: transient rejection. Second: success. + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + detail: "blocked", + failureKind: "merge-blocked", + }), + ), + ); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const merge = yield* gh + .mergePullRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.forkChild({ startImmediately: true })); + // Let the one-second retry backoff elapse so the second attempt runs. + yield* TestClock.adjust("1 second"); + yield* Fiber.join(merge); + + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(4, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("retries a merge when GitHub is temporarily unavailable", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + detail: "The source control provider is temporarily unavailable.", + failureKind: "provider-unavailable", + }), + ), + ); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const merge = yield* gh + .mergePullRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(merge); + + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(4, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "#42", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("names merge conflicts when GitHub refuses a conflicting PR", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + }), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" }), + ), + ), + ); + // Every merge attempt is refused because the PR conflicts. + mockRun.mockReturnValue( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + detail: "blocked", + failureKind: "merge-blocked", + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const merge = yield* gh + .mergePullRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + // Let the retry backoffs elapse so every attempt runs and the final + // failure is returned. + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(merge); + + assert.strictEqual(error._tag, "GitHubMergeBlockedError"); + if (error._tag !== "GitHubMergeBlockedError") throw error; + assert.strictEqual(error.mergeability, "conflicting"); + assert.equal(error.detail.includes("merge conflicts"), true); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("parseRepositoryFromPullRequestUrl", () => { + it("reads owner and repo from a pull request URL", () => { + assert.deepStrictEqual( + GitHubCli.parseRepositoryFromPullRequestUrl("https://github.com/enisze/t3code/pull/30"), + { owner: "enisze", repo: "t3code" }, + ); + }); + + it("supports GitHub Enterprise hosts and trailing URL segments", () => { + assert.deepStrictEqual( + GitHubCli.parseRepositoryFromPullRequestUrl("https://gh.corp.example/team/app/pull/7/files"), + { owner: "team", repo: "app" }, + ); + }); + + it("returns null for URLs that are not pull requests", () => { + assert.strictEqual( + GitHubCli.parseRepositoryFromPullRequestUrl("https://github.com/enisze/t3code/issues/30"), + null, + ); + assert.strictEqual(GitHubCli.parseRepositoryFromPullRequestUrl("not a url"), null); + }); +}); + +describe("GitHubCli account scoping", () => { + const resolverLayer = Layer.succeed( + GitHubAccountResolver, + GitHubAccountResolver.of({ + resolveForCwd: () => + Effect.succeed({ + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + token: "gho_project", + }), + resolveCommitIdentityForCwd: () => Effect.succeed({ _tag: "ambient" }), + }), + ); + + it.effect("gives gh the credential config its child git processes need", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput(""))); + const gh = yield* GitHubCli.GitHubCli; + yield* gh.execute({ cwd: "/repo", args: ["pr", "create"] }); + + const env = mockRun.mock.calls[0]?.[0]?.env; + assert.equal(env?.GH_TOKEN, "gho_project"); + // Without these, `gh pr create` pushing a new branch falls back to the + // machine credential helper and acts as the wrong account. + assert.equal(env?.GIT_CONFIG_KEY_1, "credential.https://github.com.helper"); + assert.equal(env?.GIT_CONFIG_VALUE_1, "!gh auth git-credential"); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(resolverLayer)))), + ); + + it.effect("leaves the environment alone when no account is attached", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput(""))); + const gh = yield* GitHubCli.GitHubCli; + yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }); + + assert.equal(mockRun.mock.calls[0]?.[0]?.env, undefined); + }).pipe(Effect.provide(layer)), + ); + + it.effect("refuses to run gh as the wrong account when the selected one is unavailable", () => + Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .execute({ cwd: "/repo", args: ["pr", "merge", "#42", "--merge"] }) + .pipe(Effect.flip); + + // No gh command runs — we fail before acting as the ambient account. + assert.equal(mockRun.mock.calls.length, 0); + assert.strictEqual(error._tag, "GitHubAccountNotLoggedInError"); + assert.equal(error.message.includes("octo"), true); + assert.equal(error.message.includes("gh auth login"), true); + }).pipe( + Effect.provide( + layer.pipe( + Layer.provideMerge( + Layer.succeed( + GitHubAccountResolver, + GitHubAccountResolver.of({ + resolveForCwd: () => + Effect.succeed({ + _tag: "unavailable", + account: { host: "github.com", login: "octo" }, + }), + resolveCommitIdentityForCwd: () => Effect.succeed({ _tag: "ambient" }), + }), + ), + ), + ), + ), + ), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5e..d44955220780 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,6 +1,8 @@ import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -13,6 +15,12 @@ import { import * as VcsProcess from "../vcs/VcsProcess.ts"; import { + GitHubAccountResolver, + gitHubAccountAuthEnv, + type GitHubAccountResolution, +} from "./GitHubAccountResolver.ts"; +import { + classifyMergeability, decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, } from "./gitHubPullRequests.ts"; @@ -64,6 +72,19 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubRepositoryNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "No GitHub repository was found for this directory. Make sure it's a git repository whose remote points to GitHub, then try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubCliCommandError extends Schema.TaggedErrorClass()( "GitHubCliCommandError", gitHubCliFailureFields, @@ -77,6 +98,83 @@ export class GitHubCliCommandError extends Schema.TaggedErrorClass()( + "GitHubProviderUnavailableError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub is temporarily unavailable. Wait a moment and try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + +export class GitHubPermissionError extends Schema.TaggedErrorClass()( + "GitHubPermissionError", + gitHubCliFailureFields, +) { + get detail(): string { + return "The selected GitHub account doesn't have permission for this action. Check the account attached to this project in its settings."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + +const gitHubAccountFailureFields = { + command: Schema.Literal("gh"), + cwd: Schema.String, + host: Schema.String, + login: Schema.String, + cause: Schema.Defect(), +} as const; + +/** + * The project has a GitHub account attached, but that account isn't logged in + * to `gh` (so no token could be minted for it). We refuse rather than fall back + * to the machine's active account, which would run the command as the wrong + * user. + */ +export class GitHubAccountNotLoggedInError extends Schema.TaggedErrorClass()( + "GitHubAccountNotLoggedInError", + gitHubAccountFailureFields, +) { + get detail(): string { + return `The GitHub account "${this.login}" is selected for this project but isn't logged in to the GitHub CLI on ${this.host}. Run \`gh auth login\` for that account, or change the account in the project's settings.`; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + +export class GitHubMergeBlockedError extends Schema.TaggedErrorClass()( + "GitHubMergeBlockedError", + { + ...gitHubCliFailureFields, + // The mergeability GitHub reported just before the merge was refused, so we + // can name the concrete blocker instead of listing every possibility. + mergeability: Schema.optional(Schema.Literals(["clean", "conflicting", "blocked", "unknown"])), + }, +) { + get detail(): string { + if (this.mergeability === "conflicting") { + return "GitHub couldn't merge this pull request because it has merge conflicts with the base branch. Resolve the conflicts, then try merging again."; + } + if (this.mergeability === "blocked") { + return "GitHub blocked this merge because required status checks are failing or branch protection rules haven't been satisfied yet."; + } + return "GitHub wouldn't merge this pull request. It may have merge conflicts, failing required status checks, or branch protection rules that block the merge."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + const gitHubCliDecodeFields = { command: Schema.Literal("gh"), cwd: Schema.String, @@ -138,7 +236,12 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass Effect.Effect; + /** + * Resolve the merge/review state GitHub computes only when a pull request is + * queried on its own. Yields `null` when it can't be determined, so callers + * keep whatever the cheaper list call reported. + */ + readonly readPullRequestReviewState: (input: { + readonly cwd: string; + readonly url: string; + readonly number: number; + }) => Effect.Effect< + { + readonly mergeability: "clean" | "conflicting" | "blocked" | "unknown"; + readonly unresolvedReviewThreadCount?: number; + } | null, + never + >; + readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; @@ -244,6 +380,11 @@ export class GitHubCli extends Context.Service< readonly reference: string; readonly force?: boolean; }) => Effect.Effect; + + readonly mergePullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; } >()("t3/sourceControl/GitHubCli") {} @@ -252,6 +393,130 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); + +const RawGitHubMergeMethodsSchema = Schema.Struct({ + mergeCommitAllowed: Schema.Boolean, + squashMergeAllowed: Schema.Boolean, + rebaseMergeAllowed: Schema.Boolean, +}); +const decodeRawGitHubMergeMethods = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubMergeMethodsSchema), +); + +const RawGitHubMergeabilitySchema = Schema.Struct({ + mergeable: Schema.String, + mergeStateStatus: Schema.String, +}); +const decodeRawGitHubMergeability = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubMergeabilitySchema), +); +const UNKNOWN_MERGEABILITY = { + mergeable: "UNKNOWN", + mergeStateStatus: "UNKNOWN", +} as const; + +/** + * `gh pr list` never resolves the lazy `mergeable` / `mergeStateStatus` fields — + * it always reports `UNKNOWN`, so a conflicting PR looks mergeable in the status + * we drive the merge button from. Resolving the pull request on its own (as + * GraphQL below, or `gh pr view`) makes GitHub compute the real state. The same + * query also returns review-thread resolution, which no `gh pr view --json` + * field exposes, so one round trip covers both. + */ +const PULL_REQUEST_REVIEW_STATE_QUERY = `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + pullRequest(number:$number){ + mergeable + mergeStateStatus + reviewThreads(first:100){ nodes { isResolved } } + } + } +}`; + +const RawGitHubReviewStateSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), + reviewThreads: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ isResolved: Schema.optional(Schema.NullOr(Schema.Boolean)) }), + ), + ), + ), + }), + ), + ), + }), + }), + }), +}); +const decodeRawGitHubReviewState = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubReviewStateSchema), +); + +/** + * Pull request web URLs are `https://///pull/`, so the + * repository the PR belongs to comes straight off the summary we already have — + * no extra `gh repo view` round trip just to build a GraphQL query. + */ +export function parseRepositoryFromPullRequestUrl( + url: string, +): { readonly owner: string; readonly repo: string } | null { + const match = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\/pull\/\d+/.exec(url.trim()); + const owner = match?.[1]; + const repo = match?.[2]; + return owner && repo ? { owner, repo } : null; +} + +/** + * Right after a pull request is opened (or its branch is pushed), GitHub reports + * the mergeable state as `UNKNOWN` while it recomputes the merge in the + * background. A merge attempted inside that window is rejected with a transient + * "not mergeable" / "base branch was modified" error that our classifier can + * only see as a hard `merge-blocked` failure. Reading the state forces GitHub to + * compute it, so we poll (bounded) until it settles before merging. + */ +const MERGEABILITY_POLL_ATTEMPTS = 6; +const MERGEABILITY_POLL_INTERVAL = Duration.seconds(1); + +/** + * Even once mergeability is computed, GitHub can briefly return a transient + * `merge-blocked` failure. Retry the merge itself a few times; a genuine + * conflict, permission problem, or protection rule won't clear on retry, so we + * only retry that one classified kind. + */ +const MERGE_ATTEMPTS = 3; +const MERGE_RETRY_INTERVAL = Duration.seconds(1); +const TRANSIENT_ATTEMPTS = 3; +const TRANSIENT_RETRY_INTERVAL = Duration.seconds(1); + +/** + * `gh pr merge` requires an explicit merge method; without one it drops into an + * interactive prompt that has no TTY here and fails. Hardcoding `--merge` breaks + * on the many repositories that disallow merge commits (squash- or rebase-only), + * so pick a method the repository actually permits, preferring a real merge + * commit, then squash, then rebase. + */ +function mergeMethodFlag( + methods: Schema.Schema.Type, +): "--merge" | "--squash" | "--rebase" | null { + if (methods.mergeCommitAllowed) { + return "--merge"; + } + if (methods.squashMergeAllowed) { + return "--squash"; + } + if (methods.rebaseMergeAllowed) { + return "--rebase"; + } + return null; +} const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); @@ -307,15 +572,174 @@ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; const execute: GitHubCli["Service"]["execute"] = (input) => - process - .run({ - operation: "GitHubCli.execute", - command: "gh", - args: input.args, + Effect.serviceOption(GitHubAccountResolver).pipe( + Effect.flatMap( + (resolverOption): Effect.Effect => + Option.isNone(resolverOption) + ? Effect.succeed({ _tag: "ambient" }) + : resolverOption.value.resolveForCwd(input.cwd), + ), + Effect.flatMap((resolution) => { + // The project selected an account we can't act as. Refuse instead of + // silently running `gh` as the machine's active (wrong) account. + if (resolution._tag === "unavailable") { + return Effect.fail( + new GitHubAccountNotLoggedInError({ + command: "gh", + cwd: input.cwd, + host: resolution.account.host, + login: resolution.account.login, + cause: new Error("gh could not mint a token for the selected account"), + }), + ); + } + return process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + // The full auth env, not the `gh`-only one: `gh` shells out to `git` + // for the network half of `pr create` (pushing a branch with no + // upstream) and `pr checkout`, and those children would otherwise + // fall back to the machine credential helper. + ...(resolution._tag === "resolved" + ? { env: gitHubAccountAuthEnv(resolution, globalThis.process.env) } + : {}), + }) + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + }), + ); + + const retryProviderUnavailable = ( + effect: () => Effect.Effect, + attemptsLeft = TRANSIENT_ATTEMPTS, + ): Effect.Effect => + effect().pipe( + Effect.catchTag("GitHubProviderUnavailableError", (error) => + attemptsLeft <= 1 + ? Effect.fail(error) + : Effect.sleep(TRANSIENT_RETRY_INTERVAL).pipe( + Effect.flatMap(() => retryProviderUnavailable(effect, attemptsLeft - 1)), + ), + ), + ); + + const resolveMergeMethodFlag = (cwd: string) => + retryProviderUnavailable(() => + execute({ + cwd, + args: [ + "repo", + "view", + "--json", + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed", + ], + }), + ).pipe( + Effect.flatMap((output) => + decodeRawGitHubMergeMethods(output.stdout).pipe( + // If the settings can't be read, fall back to a plain merge commit + // and let GitHub surface the real reason if that method is refused. + Effect.orElseSucceed(() => ({ + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: false, + })), + ), + ), + Effect.map(mergeMethodFlag), + ); + + /** + * Resolve the review/merge state GitHub only computes on demand. Best-effort: + * a failure (old `gh`, missing scope, unparseable URL) leaves the summary as + * the list reported it rather than failing the whole status read. + */ + const readPullRequestReviewState = (input: { + readonly cwd: string; + readonly url: string; + readonly number: number; + }) => + Effect.gen(function* () { + const repository = parseRepositoryFromPullRequestUrl(input.url); + if (!repository) return null; + + const output = yield* execute({ cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }) - .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + args: [ + "api", + "graphql", + "-f", + `query=${PULL_REQUEST_REVIEW_STATE_QUERY}`, + "-F", + `owner=${repository.owner}`, + "-F", + `repo=${repository.repo}`, + "-F", + `number=${input.number}`, + ], + }); + const decoded = yield* decodeRawGitHubReviewState(output.stdout); + const pullRequest = decoded.data.repository.pullRequest; + const threads = pullRequest.reviewThreads?.nodes ?? null; + return { + mergeability: classifyMergeability(pullRequest), + ...(threads + ? { + unresolvedReviewThreadCount: threads.filter((thread) => thread.isResolved !== true) + .length, + } + : {}), + }; + }).pipe(Effect.orElseSucceed(() => null)); + + const awaitMergeabilityComputed = (cwd: string, reference: string) => + Effect.gen(function* () { + let mergeability: "clean" | "conflicting" | "blocked" | "unknown" = "unknown"; + for (let attempt = 0; attempt < MERGEABILITY_POLL_ATTEMPTS; attempt++) { + const state = yield* retryProviderUnavailable(() => + execute({ + cwd, + args: ["pr", "view", reference, "--json", "mergeable,mergeStateStatus"], + }), + ).pipe( + Effect.flatMap((output) => + decodeRawGitHubMergeability(output.stdout).pipe( + Effect.orElseSucceed(() => UNKNOWN_MERGEABILITY), + ), + ), + // A failed read must not abort the merge; fall through and let the + // merge itself report any real problem. + Effect.orElseSucceed(() => UNKNOWN_MERGEABILITY), + ); + mergeability = classifyMergeability(state); + if (state.mergeable !== "UNKNOWN") { + return mergeability; + } + if (attempt < MERGEABILITY_POLL_ATTEMPTS - 1) { + yield* Effect.sleep(MERGEABILITY_POLL_INTERVAL); + } + } + return mergeability; + }); + + const attemptMerge = ( + cwd: string, + args: ReadonlyArray, + attemptsLeft: number, + ): Effect.Effect => + retryProviderUnavailable(() => execute({ cwd, args })).pipe( + Effect.asVoid, + Effect.catchTag("GitHubMergeBlockedError", (error) => + attemptsLeft <= 1 + ? Effect.fail(error) + : Effect.sleep(MERGE_RETRY_INTERVAL).pipe( + Effect.flatMap(() => attemptMerge(cwd, args, attemptsLeft - 1)), + ), + ), + ); return GitHubCli.of({ execute, @@ -332,7 +756,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,mergeable,mergeStateStatus,statusCheckRollup", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -358,17 +782,20 @@ export const make = Effect.gen(function* () { ), ), ), + readPullRequestReviewState, getPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - input.reference, - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( + retryProviderUnavailable(() => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,mergeable,mergeStateStatus,statusCheckRollup", + ], + }), + ).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( @@ -450,6 +877,31 @@ export const make = Effect.gen(function* () { cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + Effect.gen(function* () { + const flag = yield* resolveMergeMethodFlag(input.cwd); + // Force GitHub to finish computing mergeability before we merge, so a + // merge fired right after create/push doesn't race a transient + // "not mergeable" rejection. The settled verdict also lets us name the + // concrete blocker (conflicts vs. checks) if the merge is refused. + const mergeability = yield* awaitMergeabilityComputed(input.cwd, input.reference); + yield* attemptMerge( + input.cwd, + ["pr", "merge", input.reference, ...(flag ? [flag] : ["--merge"])], + MERGE_ATTEMPTS, + ).pipe( + Effect.catchTag("GitHubMergeBlockedError", (error) => + Effect.fail( + new GitHubMergeBlockedError({ + command: error.command, + cwd: error.cwd, + cause: error.cause, + mergeability, + }), + ), + ), + ); + }), }); }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a68295667..3241a9e16e73 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,mergeable,mergeStateStatus,statusCheckRollup", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); @@ -207,33 +207,22 @@ it.effect("creates GitHub PRs through provider-neutral input names", () => }), ); +const GH_AUTH_STATUS_ACTIVE_PLUS_STALE = `github.com + ✓ Logged in to github.com account active-user (keyring) + - Active account: true + - Git operations protocol: ssh + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo' + + X Failed to log in to github.com account stale-user (keyring) + - The token in keyring is invalid. + - To re-authenticate, run: gh auth login -h github.com + - To forget about this account, run: gh auth logout -h github.com -u stale-user +`; + it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - { - state: "error", - active: false, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - error: "The token in keyring is invalid.", - }, - ], - }, - }), - ), + processResult(GH_AUTH_STATUS_ACTIVE_PLUS_STALE), ); assert.deepStrictEqual( @@ -250,25 +239,14 @@ it("accepts active authenticated GitHub accounts when another account fails", () ); }); -it("parses GitHub auth JSON from stdout when stderr has warnings", () => { +it("parses GitHub auth status from stderr when stdout is empty (older gh)", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - { stderr: "warning: ignored diagnostic from gh\n" }, - ), + processResult("", { + stderr: `github.com + ✓ Logged in to github.com account active-user (keyring) + - Active account: true +`, + }), ); assert.deepStrictEqual( @@ -288,38 +266,19 @@ it("parses GitHub auth JSON from stdout when stderr has warnings", () => { it("parses GitHub auth status accounts by host and active state", () => { assert.deepStrictEqual( parseGitHubAuthStatus( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - { - state: "error", - active: false, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - "github.example.test": [ - { - state: "success", - active: false, - host: "github.example.test", - login: "enterprise-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), + `github.com + ✓ Logged in to github.com account active-user (keyring) + - Active account: true + - Git operations protocol: ssh + + X Failed to log in to github.com account stale-user (keyring) + - The token in keyring is invalid. + +github.example.test + ✓ Logged in to github.example.test account enterprise-user (keyring) + - Active account: false + - Git operations protocol: ssh +`, ).accounts, [ { @@ -334,7 +293,7 @@ it("parses GitHub auth status accounts by host and active state", () => { account: "stale-user", authenticated: false, active: false, - error: null, + error: "The token in keyring is invalid.", }, { host: "github.example.test", @@ -347,24 +306,15 @@ it("parses GitHub auth status accounts by host and active state", () => { ); }); -it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => { +it("reports unauthenticated when GitHub accounts exist but none are valid", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "error", - active: true, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - error: "The token in keyring is invalid.", - }, - ], - }, - }), + `github.com + X Failed to log in to github.com account stale-user (keyring) + - The token in keyring is invalid. + - To re-authenticate, run: gh auth login -h github.com +`, + { exitCode: ChildProcessSpawner.ExitCode(1) }, ), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8f..cc5218d891fc 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -29,6 +29,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.mergeability ? { mergeability: summary.mergeability } : {}), + ...(summary.checks ? { checks: summary.checks } : {}), + ...(summary.failedCheckCount !== undefined + ? { failedCheckCount: summary.failedCheckCount } + : {}), + ...(summary.unresolvedReviewThreadCount !== undefined + ? { unresolvedReviewThreadCount: summary.unresolvedReviewThreadCount } + : {}), updatedAt: Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -44,7 +52,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); - const authStatus = parseGitHubAuthStatus(input.stdout); + const authStatus = parseGitHubAuthStatus(output); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; @@ -82,14 +90,25 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { }); } +function parseGitHubAccounts(input: SourceControlAuthProbeInput) { + return parseGitHubAuthStatus(combinedAuthOutput(input)).accounts.map((account) => ({ + host: account.host, + login: account.account, + authenticated: account.authenticated, + active: account.active, + ...(account.error === null ? {} : { authError: account.error }), + })); +} + export const discovery = { type: "cli", kind: "github", label: "GitHub", executable: "gh", versionArgs: ["--version"], - authArgs: ["auth", "status", "--json", "hosts"], + authArgs: ["auth", "status"], parseAuth: parseGitHubAuth, + parseAccounts: parseGitHubAccounts, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; @@ -97,6 +116,32 @@ export const discovery = { export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; + /** + * `gh pr list` always reports mergeability as UNKNOWN and can't report review + * threads at all, so the merge button could never see a conflict. Resolve the + * real state per open change request. Only open ones are enriched: a + * merged/closed PR can't be merged, so the extra round trip would be wasted. + */ + const withReviewState = (cwd: string, changeRequests: ReadonlyArray) => + Effect.forEach( + changeRequests, + (changeRequest) => + changeRequest.state !== "open" + ? Effect.succeed(changeRequest) + : github + .readPullRequestReviewState({ + cwd, + url: changeRequest.url, + number: changeRequest.number, + }) + .pipe( + Effect.map((reviewState) => + reviewState ? { ...changeRequest, ...reviewState } : changeRequest, + ), + ), + { concurrency: 4 }, + ); + const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { if (input.state === "open") { @@ -107,7 +152,7 @@ export const make = Effect.gen(function* () { ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( - Effect.map((items) => items.map(toChangeRequest)), + Effect.flatMap((items) => withReviewState(input.cwd, items.map(toChangeRequest))), Effect.mapError( (error) => new SourceControlProviderError({ @@ -139,7 +184,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,mergeable,mergeStateStatus,statusCheckRollup", ], }) .pipe( @@ -151,7 +196,8 @@ export const make = Effect.gen(function* () { return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => Result.isSuccess(decoded) - ? Effect.succeed( + ? withReviewState( + input.cwd, decoded.success.map((item) => ({ ...toChangeRequest(item), updatedAt: item.updatedAt, @@ -295,6 +341,23 @@ export const make = Effect.gen(function* () { }), ), ), + mergeChangeRequest: (input) => + github.mergePullRequest({ cwd: input.cwd, reference: input.reference }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "mergeChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index a2926afd0efb..c9ce7e2a2626 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -127,6 +127,10 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass Effect.Effect; + + readonly mergeMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; } >()("t3/sourceControl/GitLabCli") {} @@ -630,6 +639,12 @@ export const make = Effect.gen(function* () { reference: input.reference, args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), + mergeMergeRequest: (input) => + executeMergeRequest({ + cwd: input.cwd, + reference: input.reference, + args: ["mr", "merge", input.reference, "--yes"], + }).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b3f7..afc819379bda 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -245,6 +245,23 @@ export const make = Effect.gen(function* () { }), ), ), + mergeChangeRequest: (input) => + gitlab.mergeMergeRequest({ cwd: input.cwd, reference: input.reference }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "mergeChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04cd..71396db99737 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -59,24 +59,13 @@ it.effect("reports implemented tools separately from locally available executabl if (input.command === "gh" && input.args[0] === "--version") { return Effect.succeed(processOutput("gh version 2.83.0\n")); } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + if (input.command === "gh" && input.args.join(" ") === "auth status") { return Effect.succeed( - processOutput( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "juliusmarminge", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), + processOutput(`github.com + ✓ Logged in to github.com account juliusmarminge (keyring) + - Active account: true + - Git operations protocol: ssh +`), ); } return Effect.fail( @@ -175,24 +164,13 @@ it.effect("probes provider authentication without exposing token details", () => if (input.args[0] === "--version") { return Effect.succeed(processOutput(`${input.command} version test\n`)); } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + if (input.command === "gh" && input.args.join(" ") === "auth status") { return Effect.succeed( - processOutput( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "octocat", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), + processOutput(`github.com + ✓ Logged in to github.com account octocat (keyring) + - Active account: true + - Git operations protocol: ssh +`), ); } if (input.command === "glab" && input.args.join(" ") === "auth status") { diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa425..749c40148bcc 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -126,5 +126,10 @@ export class SourceControlProvider extends Context.Service< readonly reference: string; readonly force?: boolean; }) => Effect.Effect; + readonly mergeChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; } >()("t3/sourceControl/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb205..7f12eaf1fba7 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -1,4 +1,5 @@ import type { + SourceControlAccountInfo, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, SourceControlProviderInfo, @@ -34,6 +35,14 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; + /** + * Parse all accounts the CLI knows about (e.g. every `gh auth status` + * account), so a project can be attached to a specific one. Optional — only + * providers with multi-account support implement it. + */ + readonly parseAccounts?: ( + input: SourceControlAuthProbeInput, + ) => ReadonlyArray; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, ) => SourceControlProviderInfo | null; @@ -254,6 +263,9 @@ export function probeSourceControlProvider(input: { ({ ...item, auth: spec.parseAuth(result), + ...(spec.parseAccounts !== undefined + ? { accounts: spec.parseAccounts(result) } + : {}), }) satisfies SourceControlProviderDiscoveryItem, ), Effect.catch((cause) => diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e435..5d85e5885996 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -120,6 +120,14 @@ function unsupportedProvider( reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), detail: `No ${kind} source control provider is registered.`, }), + mergeChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "mergeChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), + detail: `No ${kind} source control provider is registered.`, + }), }); } @@ -190,6 +198,11 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + mergeChangeRequest: (input) => + provider.mergeChangeRequest({ + ...input, + context: input.context ?? context, + }), }); } diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e05..ac94edcd7f39 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -39,6 +39,7 @@ function makeProvider( createRepository: () => Effect.succeed(CLONE_URLS), getDefaultBranch: () => Effect.succeed(null), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), + mergeChangeRequest: () => unsupported("mergeChangeRequest"), ...overrides, }; } diff --git a/apps/server/src/sourceControl/gitHubAuthStatus.ts b/apps/server/src/sourceControl/gitHubAuthStatus.ts index d58909c560c2..4e234882ff64 100644 --- a/apps/server/src/sourceControl/gitHubAuthStatus.ts +++ b/apps/server/src/sourceControl/gitHubAuthStatus.ts @@ -1,22 +1,3 @@ -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -const GitHubAuthStatusAccountSchema = Schema.Struct({ - state: Schema.String, - error: Schema.optional(Schema.String), - active: Schema.Boolean, - host: Schema.String, - login: Schema.String, -}); - -const GitHubAuthStatusSchema = Schema.Struct({ - hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), -}); - -const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( - Schema.fromJsonString(GitHubAuthStatusSchema), -); - export interface GitHubAuthStatusAccount { readonly host: string; readonly account: string; @@ -30,36 +11,101 @@ export interface GitHubAuthStatus { readonly accounts: ReadonlyArray; } +interface MutableGitHubAuthStatusAccount { + host: string; + account: string; + authenticated: boolean; + active: boolean; + error: string | null; +} + function nonEmptyString(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } +// `gh auth status` has no `--json` flag; it only prints a human-readable +// report. Each account is introduced by a header line and followed by +// indented `- key: value` detail lines, e.g.: +// +// github.com +// ✓ Logged in to github.com account octocat (keyring) +// - Active account: true +// - Git operations protocol: https +// - Token: gho_************************************ +// - Token scopes: 'gist', 'read:org', 'repo' +// +// X Failed to log in to github.com account stale-user (keyring) +// - The token in keyring is invalid. +// - To re-authenticate, run: gh auth login -h github.com +// +// We match on the stable "Logged in to" / "Failed to log in to" phrases +// rather than the colored status glyph, which varies by terminal/version. +const LOGGED_IN_PATTERN = /Logged in to (\S+) account (\S+)/; +const FAILED_PATTERN = /Failed to log in to (\S+) account (\S+)/; +const ACTIVE_ACCOUNT_PATTERN = /^-\s*Active account:\s*(true|false)/i; +const KNOWN_DETAIL_PATTERN = + /^(?:Active account:|Git operations protocol:|Token(?:\s+scopes)?:|To (?:re-authenticate|forget))/i; + export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { - return Option.match(decodeGitHubAuthStatusJson(text), { - onNone: () => ({ parsed: false, accounts: [] }), - onSome: (status) => - ({ - parsed: true, - accounts: Object.values(status.hosts).flatMap((accounts) => - accounts.flatMap((account) => { - const host = nonEmptyString(account.host); - const login = nonEmptyString(account.login); - if (host === null || login === null) return []; + const accounts: MutableGitHubAuthStatusAccount[] = []; + let current: MutableGitHubAuthStatusAccount | null = null; + let sawAccountHeader = false; + + const finalize = () => { + if (current !== null) { + accounts.push(current); + current = null; + } + }; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0) continue; + + const loggedIn = LOGGED_IN_PATTERN.exec(line); + const failed = loggedIn ? null : FAILED_PATTERN.exec(line); + const header = loggedIn ?? failed; + if (header) { + finalize(); + sawAccountHeader = true; + const host = nonEmptyString(header[1] ?? ""); + const login = nonEmptyString(header[2] ?? ""); + current = + host === null || login === null + ? null + : { + host: host.toLowerCase(), + account: login, + authenticated: loggedIn !== null, + active: false, + error: null, + }; + continue; + } + + if (current === null) continue; + + const activeMatch = ACTIVE_ACCOUNT_PATTERN.exec(line); + if (activeMatch) { + current.active = activeMatch[1]?.toLowerCase() === "true"; + continue; + } + + // The first descriptive detail line under a failed account is its reason. + if (!current.authenticated && current.error === null && line.startsWith("-")) { + const detail = line.replace(/^-\s*/, "").trim(); + if (detail.length > 0 && !KNOWN_DETAIL_PATTERN.test(detail)) { + current.error = detail; + } + } + } + finalize(); - return [ - { - host: host.toLowerCase(), - account: login, - authenticated: account.state === "success", - active: account.active, - error: account.error?.trim() || null, - }, - ]; - }), - ), - }) satisfies GitHubAuthStatus, - }); + return { + parsed: sawAccountHeader, + accounts: accounts.map((account) => ({ ...account })), + }; } export function findAuthenticatedGitHubAccount( diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index ded3c0a90b08..ba5c5c035e5a 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -14,6 +14,9 @@ export interface NormalizedGitHubPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly mergeability?: "clean" | "conflicting" | "blocked" | "unknown"; + readonly checks?: "passing" | "failing" | "pending" | "unknown"; + readonly failedCheckCount?: number; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -28,6 +31,18 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), + statusCheckRollup: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was @@ -55,6 +70,26 @@ function trimOptionalString(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +/** + * Map GitHub's `mergeable` / `mergeStateStatus` pair to a single mergeability + * verdict. Shared between PR normalization and the merge path so a blocked + * merge can report the concrete reason (conflicts vs. checks/protection). + */ +export function classifyMergeability(input: { + readonly mergeable?: string | null | undefined; + readonly mergeStateStatus?: string | null | undefined; +}): "clean" | "conflicting" | "blocked" | "unknown" { + const mergeable = input.mergeable?.toUpperCase(); + const mergeStateStatus = input.mergeStateStatus?.toUpperCase(); + return mergeable === "CONFLICTING" || mergeStateStatus === "DIRTY" + ? "conflicting" + : mergeable === "MERGEABLE" && mergeStateStatus === "CLEAN" + ? "clean" + : mergeStateStatus === "BLOCKED" || mergeStateStatus === "UNSTABLE" + ? "blocked" + : "unknown"; +} + function normalizeGitHubPullRequestState(input: { state?: string | null | undefined; mergedAt?: string | null | undefined; @@ -85,6 +120,41 @@ function normalizeGitHubPullRequestRecord( (headRepositoryOwnerLogin && headRepositoryName ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); + const mergeability = classifyMergeability({ + mergeable: raw.mergeable, + mergeStateStatus: raw.mergeStateStatus, + }); + const failingConclusions = new Set([ + "FAILURE", + "CANCELLED", + "TIMED_OUT", + "ACTION_REQUIRED", + "STARTUP_FAILURE", + ]); + const failedCheckCount = (raw.statusCheckRollup ?? []).filter((check) => + failingConclusions.has(check.conclusion?.toUpperCase() ?? ""), + ).length; + const checks = + raw.statusCheckRollup == null + ? undefined + : (() => { + const rollup = raw.statusCheckRollup ?? []; + if (rollup.length === 0) return "passing" as const; + if (failedCheckCount > 0) { + return "failing" as const; + } + if ( + rollup.some( + (check) => + check.status?.toUpperCase() !== "COMPLETED" || + !check.conclusion || + check.conclusion.toUpperCase() === "NEUTRAL", + ) + ) { + return "pending" as const; + } + return "passing" as const; + })(); return { number: raw.number, @@ -93,6 +163,9 @@ function normalizeGitHubPullRequestRecord( baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), + ...(raw.mergeable != null || raw.mergeStateStatus != null ? { mergeability } : {}), + ...(checks ? { checks } : {}), + ...(raw.statusCheckRollup != null ? { failedCheckCount } : {}), updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 1cf7e8dffeca..4fca67ca97e1 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -27,6 +27,7 @@ import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; +import { GitHubAccountResolver } from "../sourceControl/GitHubAccountResolver.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -1260,6 +1261,54 @@ it.layer( }), ); + it.effect("pins the shell to the GitHub account the project selected", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { PATH: "/usr/bin", SHELL: "/bin/bash" }, + }); + + // The resolver is ambient in the server runtime, so it is read from the + // fiber that opens the terminal rather than captured at manager creation. + yield* manager.open(openInput()).pipe( + Effect.provideService( + GitHubAccountResolver, + GitHubAccountResolver.of({ + resolveForCwd: () => + Effect.succeed({ + _tag: "resolved", + account: { host: "github.com", login: "octo" }, + token: "gho_project", + }), + resolveCommitIdentityForCwd: () => Effect.succeed({ _tag: "ambient" }), + }), + ), + ); + + const env = ptyAdapter.spawnInputs[0]?.env; + assert.equal(env?.GH_HOST, "github.com"); + assert.equal(env?.GH_TOKEN, "gho_project"); + assert.equal(env?.GIT_CONFIG_COUNT, "4"); + assert.equal(env?.GIT_CONFIG_KEY_1, "credential.https://github.com.helper"); + assert.equal(env?.GIT_CONFIG_VALUE_1, "!gh auth git-credential"); + assert.equal(env?.GIT_CONFIG_KEY_2, "url.https://github.com/.insteadOf"); + assert.equal(env?.GIT_CONFIG_VALUE_2, "git@github.com:"); + }), + ); + + it.effect("leaves the shell environment alone without a project account", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { PATH: "/usr/bin", SHELL: "/bin/bash" }, + }); + + yield* manager.open(openInput()); + + const env = ptyAdapter.spawnInputs[0]?.env; + assert.equal(env?.GH_TOKEN, undefined); + assert.equal(env?.GIT_CONFIG_COUNT, undefined); + }), + ); + it.effect("prefers PowerShell over ComSpec for Windows terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index caa5106bb9fd..789d871dde25 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -59,6 +59,10 @@ import { } from "../observability/Metrics.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; +import { + GitHubAccountResolver, + gitHubAccountAuthEnv, +} from "../sourceControl/GitHubAccountResolver.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; export { @@ -1845,6 +1849,27 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); }); + /** + * Env that makes `gh`/`git` in the spawned shell act as the GitHub account + * selected for the project owning `cwd`. Returns `{}` when no account is + * attached or the resolver isn't provided, leaving ambient auth untouched. + */ + const resolveTerminalAccountEnv = ( + cwd: string, + spawnEnv: NodeJS.ProcessEnv, + ): Effect.Effect => + Effect.gen(function* () { + const resolverOption = yield* Effect.serviceOption(GitHubAccountResolver); + if (Option.isNone(resolverOption)) { + return {}; + } + const resolution = yield* resolverOption.value.resolveForCwd(cwd); + // In an interactive shell we inject the selected account when we can, but + // never fail the spawn: if the account isn't logged in ("unavailable"), + // leave ambient auth so the user can `gh auth login` in the terminal. + return resolution._tag === "resolved" ? gitHubAccountAuthEnv(resolution, spawnEnv) : {}; + }); + const startSession = Effect.fn("terminal.startSession")(function* ( session: TerminalSessionState, input: TerminalStartInput, @@ -1885,7 +1910,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); + // Make `gh` and `git` in this terminal act as the project's selected + // GitHub account (if any), so pushes/PRs use the right identity + // without the user running `gh auth switch`. + const accountEnv = yield* resolveTerminalAccountEnv(session.cwd, terminalEnv); + const spawnResult = yield* trySpawn( + shellCandidates, + { ...terminalEnv, ...accountEnv }, + session, + ); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 0f3905a0cb11..04a46822e0e3 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -342,4 +342,34 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { }), ), ); + + it.effect("generates a trimmed continuation summary through the Claude provider", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + summary: " ## Goal\nShip the sidebar rename.\n\n## Next steps\n- Add tests ", + }, + }), + stdinMustContain: + "You write a concise handoff brief so a fresh coding session can continue prior work.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateContinuationSummary({ + cwd: process.cwd(), + sourceTitle: "Inline provider rename", + transcript: "User: rename providers inline\n\nAssistant: done, PR merged", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.summary).toBe( + "## Goal\nShip the sidebar rename.\n\n## Next steps\n- Add tests", + ); + }), + ), + ); }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index a27b2c8bd3ee..c78b922f27b2 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -22,6 +22,7 @@ import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, + buildContinuationSummaryPrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -85,7 +86,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateContinuationSummary", value: unknown, detail: string, ): Effect.Effect => @@ -115,7 +117,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateContinuationSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -358,10 +361,31 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }; }); + const generateContinuationSummary: TextGeneration.TextGeneration["Service"]["generateContinuationSummary"] = + Effect.fn("ClaudeTextGeneration.generateContinuationSummary")(function* (input) { + const { prompt, outputSchema } = buildContinuationSummaryPrompt({ + sourceTitle: input.sourceTitle, + transcript: input.transcript, + }); + + const generated = yield* runClaudeJson({ + operation: "generateContinuationSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: generated.summary.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateContinuationSummary, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6ea710cd5a39..461c5adb2fa5 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -20,6 +20,7 @@ import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, + buildContinuationSummaryPrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -98,7 +99,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateContinuationSummary", value: unknown, ): Effect.Effect => encodeJsonString(value).pipe( @@ -159,7 +161,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateContinuationSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -401,10 +404,31 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateContinuationSummary: TextGeneration.TextGeneration["Service"]["generateContinuationSummary"] = + Effect.fn("CodexTextGeneration.generateContinuationSummary")(function* (input) { + const { prompt, outputSchema } = buildContinuationSummaryPrompt({ + sourceTitle: input.sourceTitle, + transcript: input.transcript, + }); + + const generated = yield* runCodexJson({ + operation: "generateContinuationSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: generated.summary.trim(), + } satisfies TextGeneration.ContinuationSummaryGenerationResult; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateContinuationSummary, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index be0fc858ac5b..4c575721dd28 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -14,6 +14,7 @@ import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, + buildContinuationSummaryPrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -54,7 +55,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateContinuationSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -258,10 +260,31 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateContinuationSummary: TextGeneration.TextGeneration["Service"]["generateContinuationSummary"] = + Effect.fn("CursorTextGeneration.generateContinuationSummary")(function* (input) { + const { prompt, outputSchema } = buildContinuationSummaryPrompt({ + sourceTitle: input.sourceTitle, + transcript: input.transcript, + }); + + const generated = yield* runCursorJson({ + operation: "generateContinuationSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: generated.summary.trim(), + } satisfies TextGeneration.ContinuationSummaryGenerationResult; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateContinuationSummary, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index d26a2ebe01b6..b93ac2877cc0 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -15,6 +15,7 @@ import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, + buildContinuationSummaryPrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -52,7 +53,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateContinuationSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -250,10 +252,31 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateContinuationSummary: TextGeneration.TextGeneration["Service"]["generateContinuationSummary"] = + Effect.fn("GrokTextGeneration.generateContinuationSummary")(function* (input) { + const { prompt, outputSchema } = buildContinuationSummaryPrompt({ + sourceTitle: input.sourceTitle, + transcript: input.transcript, + }); + + const generated = yield* runGrokJson({ + operation: "generateContinuationSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: generated.summary.trim(), + } satisfies TextGeneration.ContinuationSummaryGenerationResult; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateContinuationSummary, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 2adbb829b8d6..e9f2d8f81fa7 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -21,6 +21,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, + buildContinuationSummaryPrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -39,6 +40,7 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ "generatePrContent", "generateBranchName", "generateThreadTitle", + "generateContinuationSummary", ]); type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; @@ -253,7 +255,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateContinuationSummary"; }) => sharedServerMutex.withPermit( Effect.gen(function* () { @@ -614,10 +617,30 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; }); + const generateContinuationSummary: TextGeneration.TextGeneration["Service"]["generateContinuationSummary"] = + Effect.fn("OpenCodeTextGeneration.generateContinuationSummary")(function* (input) { + const { prompt, outputSchema } = buildContinuationSummaryPrompt({ + sourceTitle: input.sourceTitle, + transcript: input.transcript, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateContinuationSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: generated.summary.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateContinuationSummary, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 9bccb9c1fc5b..380c9a978b63 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -21,6 +21,8 @@ const makeStubTextGeneration = ( generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateContinuationSummary: () => + Effect.die("generateContinuationSummary stub not configured for this test"), ...overrides, }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index ead09638776f..45245db9018f 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -71,6 +71,20 @@ export interface ThreadTitleGenerationResult { title: string; } +export interface ContinuationSummaryGenerationInput { + cwd: string; + /** Title of the finished chat being continued from. */ + sourceTitle: string; + /** Windowed role-tagged transcript of the finished conversation. */ + transcript: string; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface ContinuationSummaryGenerationResult { + summary: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, @@ -78,6 +92,9 @@ export interface TextGenerationService { generatePrContent(input: PrContentGenerationInput): Promise; generateBranchName(input: BranchNameGenerationInput): Promise; generateThreadTitle(input: ThreadTitleGenerationInput): Promise; + generateContinuationSummary( + input: ContinuationSummaryGenerationInput, + ): Promise; } /** @@ -113,6 +130,14 @@ export class TextGeneration extends Context.Service< readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** + * Generate a compact handoff brief that lets a fresh session continue a + * finished (merged) chat's work. + */ + readonly generateContinuationSummary: ( + input: ContinuationSummaryGenerationInput, + ) => Effect.Effect; } >()("t3/textGeneration/TextGeneration") {} @@ -123,7 +148,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateContinuationSummary"; const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], @@ -163,6 +189,12 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), ), + generateContinuationSummary: (input) => + resolveInstance( + registry, + "generateContinuationSummary", + input.modelSelection.instanceId, + ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateContinuationSummary(input))), }); export const make = Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index efa251963a53..6d090f844e9b 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -231,3 +231,41 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Continuation handoff summary +// --------------------------------------------------------------------------- + +export interface ContinuationSummaryPromptInput { + /** Title of the finished chat being continued from. */ + sourceTitle: string; + /** Windowed role-tagged transcript of the finished conversation. */ + transcript: string; +} + +export function buildContinuationSummaryPrompt(input: ContinuationSummaryPromptInput) { + const prompt = [ + "You write a concise handoff brief so a fresh coding session can continue prior work.", + "The previous chat's pull request has been merged; a new session on a new worktree will build on it.", + "Return a JSON object with key: summary.", + "Rules:", + "- summary must be markdown a new session can act on, not a transcript replay", + "- capture, as short sections or bullets: the original goal, the key decisions made,", + " which files/areas were changed, the current state (what shipped/merged), and concrete", + " suggested next steps", + "- be faithful to what actually happened; do not invent work that was not done", + "- keep it tight (aim for under ~400 words); prefer specifics over generic phrasing", + "- do not include a top-level heading or restate these instructions", + "", + `Previous chat title: ${input.sourceTitle}`, + "", + "Conversation transcript:", + limitSection(input.transcript, 40_000), + ].join("\n"); + + const outputSchema = Schema.Struct({ + summary: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index aab69de6a128..7636936f1256 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -16,7 +16,12 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { GitHubAccountResolver } from "../sourceControl/GitHubAccountResolver.ts"; +import { + describeGitRemoteRejection, + makeGitVcsDriverCore, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -702,6 +707,30 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("review diff previews", () => { + it.effect("includes omitted source lines only when expandable context is requested", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const original = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n"); + yield* writeTextFile(cwd, "context.txt", `${original}\n`); + yield* git(cwd, ["add", "context.txt"]); + yield* git(cwd, ["commit", "-m", "add context fixture"]); + const changed = original.replace("line 2", "line two changed"); + yield* writeTextFile(cwd, "context.txt", `${changed}\n`); + + const compact = yield* driver.getReviewDiffPreview({ cwd }); + const expandable = yield* driver.getReviewDiffPreview({ cwd, fullContext: true }); + const compactDiff = compact.sources.find((source) => source.kind === "working-tree")?.diff; + const expandableDiff = expandable.sources.find( + (source) => source.kind === "working-tree", + )?.diff; + + assert.notInclude(compactDiff ?? "", "line 40"); + assert.include(expandableDiff ?? "", "line 40"); + }), + ); + it.effect("drops an unterminated path from truncated NUL-separated git output", () => Effect.sync(() => { const paths = splitNullSeparatedGitStdoutPaths({ @@ -724,6 +753,34 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("explains a workflow-scope push rejection", () => + Effect.sync(() => { + const hint = describeGitRemoteRejection( + "To https://github.com/LOBECOStudio/corporate-gpt.git\n" + + " ! [remote rejected] HEAD -> feature/fix-user-invite-error (refusing to allow an OAuth App to create or update workflow `.github/workflows/preview.yaml` without `workflow` scope)\n", + ); + + assert.include(hint ?? "", "`workflow` OAuth scope"); + assert.include(hint ?? "", "gh auth refresh -s workflow"); + }), + ); + + it.effect("explains a non-fast-forward push rejection", () => + Effect.sync(() => { + const hint = describeGitRemoteRejection( + "! [rejected] main -> main (non-fast-forward)\nhint: Updates were rejected", + ); + + assert.include(hint ?? "", "Pull or rebase"); + }), + ); + + it.effect("leaves unrecognized git failures to git's own output", () => + Effect.sync(() => { + assert.strictEqual(describeGitRemoteRejection("fatal: could not read from remote"), null); + }), + ); + it.effect("honors whitespace filtering for worktree and branch previews", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -758,6 +815,124 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }), ); + + it.effect("separates committed branch changes from working-tree changes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/uncommitted"]); + // A committed branch change plus work the agent left behind: an + // uncommitted edit to a tracked file and a brand-new untracked file. + yield* writeTextFile(cwd, "committed.txt", "committed\n"); + yield* git(cwd, ["add", "committed.txt"]); + yield* git(cwd, ["commit", "-m", "add committed file"]); + yield* writeTextFile(cwd, "README.md", "# edited uncommitted\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + const branchDiff = + preview.sources.find((source) => source.kind === "branch-range")?.diff ?? ""; + const workingTreeDiff = + preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + const allChangesDiff = + preview.sources.find((source) => source.kind === "working-tree-all")?.diff ?? ""; + + assert.include(branchDiff, "committed.txt"); + assert.notInclude(branchDiff, "edited uncommitted"); + assert.notInclude(branchDiff, "untracked.txt"); + assert.include(workingTreeDiff, "README.md"); + assert.include(workingTreeDiff, "edited uncommitted"); + assert.include(workingTreeDiff, "untracked.txt"); + assert.notInclude(workingTreeDiff, "committed.txt"); + // The combined "working tree" view spans committed branch history *and* + // uncommitted work: every change since the fork point in one diff. + assert.include(allChangesDiff, "committed.txt"); + assert.include(allChangesDiff, "edited uncommitted"); + assert.include(allChangesDiff, "untracked.txt"); + }), + ); + + it.effect("compares against the origin base branch, never a stale local copy", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const origin = yield* makeTmpDir("git-vcs-driver-origin-"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["init", "--bare", origin]); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["remote", "add", "origin", origin]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); + // Advance origin/main one commit ahead of the local `main`, so the local + // branch is a stale copy of the base. + yield* writeTextFile(cwd, "on-origin.txt", "on origin\n"); + yield* git(cwd, ["add", "on-origin.txt"]); + yield* git(cwd, ["commit", "-m", "commit only on origin main"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", "HEAD~1"]); + // Work happens on a feature branch forked from the stale local main. + yield* git(cwd, ["checkout", "-b", "feature/work"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + // Change-request providers report an unqualified target branch. The + // review diff must still use its remote-tracking copy. + baseRef: "main", + preferRemoteBaseRef: true, + ignoreWhitespace: false, + }); + const branchSource = preview.sources.find((source) => source.kind === "branch-range"); + const workingTreeSource = preview.sources.find( + (source) => source.kind === "working-tree-all", + ); + + // The resolved base is the remote-tracking ref, not the local branch. + assert.equal(branchSource?.baseRef, "origin/main"); + // The branch diff is taken against origin's tip: the commit that only + // exists on origin/main is part of the base, so it never leaks in as a + // branch change. + assert.notInclude(branchSource?.diff ?? "", "on-origin.txt"); + assert.include(branchSource?.diff ?? "", "feature.txt"); + assert.notInclude(workingTreeSource?.diff ?? "", "on-origin.txt"); + assert.include(workingTreeSource?.diff ?? "", "feature.txt"); + }), + ); + + it.effect("skips a local-only base candidate in favor of the origin default", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const origin = yield* makeTmpDir("git-vcs-driver-origin-"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["init", "--bare", origin]); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["remote", "add", "origin", origin]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); + // A base branch that only ever existed locally — it was never published, + // so it cannot be an "against origin" comparison target. + yield* git(cwd, ["branch", "local-only-base"]); + yield* git(cwd, ["checkout", "-b", "feature/work"]); + // Point the recorded PR base at the local-only branch. + yield* git(cwd, ["config", "branch.feature/work.gh-merge-base", "local-only-base"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const branchSource = preview.sources.find((source) => source.kind === "branch-range"); + + assert.equal(branchSource?.baseRef, "origin/main"); + }), + ); }); describe("repository status", () => { @@ -790,6 +965,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("marks unmerged working-tree paths as conflicted", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "feature/conflict"]); + yield* writeTextFile(cwd, "README.md", "# feature\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "feature change"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "README.md", "# base\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "base change"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.execute({ + operation: "GitVcsDriver.test.merge-conflict", + cwd, + args: ["merge", "feature/conflict"], + allowNonZeroExit: true, + }); + + const status = yield* driver.statusDetails(cwd); + + assert.deepInclude( + status.workingTree.files.find((file) => file.path === "README.md"), + { path: "README.md", conflicted: true }, + ); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1146,6 +1350,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(result.branch, current); }), ); + + it.effect("records the base branch a new ref forks from as gh-merge-base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createRef({ + cwd, + refName: "feature/stacked", + baseRefName: initialBranch, + }); + + assert.equal( + yield* driver.readConfigValue(cwd, "branch.feature/stacked.gh-merge-base"), + initialBranch, + ); + }), + ); + + it.effect("does not record a self-referential base when forking from itself", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createRef({ + cwd, + refName: "feature/self", + baseRefName: "feature/self", + }); + + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/self.gh-merge-base"), null); + }), + ); }); describe("worktree operations", () => { @@ -1203,6 +1442,164 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); + + it.effect("reuses the worktree that already holds the branch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "held-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/held", + }); + + const reused = yield* driver.createWorktree({ + cwd, + path: null, + refName: "feature/held", + }); + + assert.equal(reused.worktree.refName, "feature/held"); + assert.equal( + yield* fileSystem.realPath(reused.worktree.path), + yield* fileSystem.realPath(worktreePath), + ); + }), + ); + + it.effect("explains a branch that only the requesting checkout holds", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver + .createWorktree({ cwd, path: null, refName: initialBranch }) + .pipe(Effect.flip); + + assert.equal(error.operation, "GitVcsDriver.createWorktree"); + assert.include(error.detail, initialBranch); + assert.include(error.detail, "already checked out"); + }), + ); + + it.effect("creates a sibling worktree when the derived directory is taken", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: null, + refName: initialBranch, + newRefName: "feature/shop", + }); + // Switching the worktree off its branch frees the branch but keeps the + // directory named after it, which is what collides on the next open. + yield* git(created.worktree.path, ["switch", "-c", "feature/other"]); + + const reopened = yield* driver.createWorktree({ + cwd, + path: null, + refName: "feature/shop", + }); + + assert.equal(reopened.worktree.refName, "feature/shop"); + assert.equal(reopened.worktree.path, `${created.worktree.path}-2`); + assert.equal( + yield* git(reopened.worktree.path, ["branch", "--show-current"]), + "feature/shop", + ); + }), + ); + + it.effect("reopens a branch whose worktree directory was deleted", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const created = yield* driver.createWorktree({ + cwd, + path: null, + refName: initialBranch, + newRefName: "feature/deleted", + }); + // Removing the directory behind git's back leaves the branch claimed by + // a prunable worktree, which is what a hand-removed worktree looks like. + yield* fileSystem.remove(created.worktree.path, { recursive: true }); + + const reopened = yield* driver.createWorktree({ + cwd, + path: null, + refName: "feature/deleted", + }); + + assert.equal(reopened.worktree.path, created.worktree.path); + assert.equal( + yield* git(reopened.worktree.path, ["branch", "--show-current"]), + "feature/deleted", + ); + }), + ); + + it.effect("checks out a branch a deleted worktree still claims", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "stale-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/stale", + }); + + // Deleting the directory behind git's back leaves the branch claimed by + // a prunable worktree, which is exactly what a hand-removed worktree + // looks like. + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(worktreePath, { recursive: true }); + + const switched = yield* driver.switchRef({ cwd, refName: "feature/stale" }); + + assert.equal(switched.refName, "feature/stale"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/stale"); + }), + ); + + it.effect("names the worktree holding a branch that cannot be checked out", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "live-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/held", + }); + + const error = yield* driver.switchRef({ cwd, refName: "feature/held" }).pipe(Effect.flip); + + assert.equal(error.operation, "GitVcsDriver.switchRef.checkout"); + assert.include(error.detail, "feature/held"); + assert.include(error.detail, worktreePath); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + }), + ); }); describe("remote operations", () => { @@ -1413,6 +1810,78 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("surfaces git's stderr when a push fails", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const pathService = yield* Path.Path; + // A remote pointing at a path with no repository: git's push fails with + // an actionable stderr the user needs to see, not a bare exit code. + const missingRemote = pathService.join(yield* makeTmpDir("git-remote-"), "does-not-exist"); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["remote", "add", "origin", missingRemote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ cwd, refName: "feature/push" }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ cwd, refName: "feature/push" }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + const error = yield* (yield* GitVcsDriver.GitVcsDriver) + .pushCurrentBranch(cwd, null) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitCommandError"); + assert.equal(error.operation, "GitVcsDriver.pushCurrentBranch.pushWithUpstream"); + // The reason git failed, not just "exited with a non-zero status". + assert.match(error.detail, /does not appear to be a git repository/i); + }), + ); + + it.effect("refuses to push when the selected account can't be applied", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ cwd, refName: "feature/push" }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ cwd, refName: "feature/push" }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + const error = yield* (yield* GitVcsDriver.GitVcsDriver) + .pushCurrentBranch(cwd, null) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitCommandError"); + assert.equal(error.detail.includes("octo"), true); + assert.equal(error.detail.includes("gh auth login"), true); + // Nothing was pushed — the remote has no feature branch. + const remoteHasBranch = yield* git(remote, [ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/push", + ]).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + assert.equal(remoteHasBranch, false); + }).pipe( + Effect.provideService( + GitHubAccountResolver, + GitHubAccountResolver.of({ + resolveForCwd: () => + Effect.succeed({ + _tag: "unavailable", + account: { host: "github.com", login: "octo" }, + }), + resolveCommitIdentityForCwd: () => Effect.succeed({ _tag: "ambient" }), + }), + ), + ), + ); + it.effect( "pushes upstream branches to the remote branch name, not the upstream shorthand", () => @@ -1487,5 +1956,94 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notEqual(originMain.exitCode, 0); }), ); + + it.effect("auto-rebases onto the remote and retries when the remote moved ahead", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + const base = yield* git(cwd, ["rev-parse", "HEAD"]); + + // Advance the remote by one commit, then rewind our checkout to the base + // and commit something else — the branches now genuinely diverge, which + // is the classic non-fast-forward push rejection. + yield* writeTextFile(cwd, "remote.txt", "remote\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Add remote update"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", base]); + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add local update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin/main", + setUpstream: false, + }); + // The remote now carries both commits, with ours rebased on top. + const remoteLog = yield* git(remote, ["log", "--pretty=%s", "main"]); + assert.match(remoteLog, /Add local update/); + assert.match(remoteLog, /Add remote update/); + assert.equal( + remoteLog.indexOf("Add local update") < remoteLog.indexOf("Add remote update"), + true, + ); + // History stayed linear — our commit sits directly on the remote one. + assert.equal( + yield* git(cwd, ["log", "--pretty=%s", "-2"]), + "Add local update\nAdd remote update", + ); + }), + ); + + it.effect("aborts the auto-rebase and leaves a clean tree when it conflicts", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + const base = yield* git(cwd, ["rev-parse", "HEAD"]); + + // Both the remote commit and our commit edit the SAME file, so rebasing + // our commit onto the remote can't apply cleanly. + yield* writeTextFile(cwd, "README.md", "# remote\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Remote edits readme"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", base]); + yield* writeTextFile(cwd, "README.md", "# local\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Local edits readme", ""); + + const error = yield* driver.pushCurrentBranch(cwd, null).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitCommandError"); + assert.match(error.detail, /rebasing onto it hit conflicts/i); + // The abort restored a clean checkout: not mid-rebase, our commit intact. + const rebaseInProgress = yield* driver.execute({ + operation: "GitVcsDriver.test.rebaseState", + cwd, + args: ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(rebaseInProgress.exitCode, 0); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "main"); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Local edits readme"); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3aa7575d5909..92198590c484 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -35,6 +35,10 @@ import { parseRemoteNamesInGitOrder, parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; +import { + GitHubAccountResolver, + gitHubAccountAuthEnv, +} from "../sourceControl/GitHubAccountResolver.ts"; import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -44,9 +48,15 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; -const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; -const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; -const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; +// The working-tree and branch review previews back the diff panel's default +// scopes, so they must render as completely as the per-turn checkpoint diff +// (capped at 10 MB in GitVcsDriver.ts). The old 120 KB / 80 KB caps truncated +// large branches well before the turn diff did, so the panel showed a +// "truncated" banner and dropped files that the turn diff still listed. Match +// the checkpoint diff budget so a full branch's changes fit. +const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 10_000_000; +const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 10_000_000; +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 10_000_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -233,15 +243,22 @@ function paginateBranches(input: { }; } -function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { - const worktreePaths = new Map(); +interface WorktreeBranchEntry { + readonly path: string; + /** null for a detached worktree, which still owns its directory. */ + readonly branch: string | null; + readonly prunable: boolean; +} + +function parseWorktreeBranchEntries(stdout: string): ReadonlyArray { + const entries: WorktreeBranchEntry[] = []; let currentPath: string | null = null; let currentBranch: string | null = null; let currentPrunable = false; const flush = () => { - if (currentPath !== null && currentBranch !== null && !currentPrunable) { - worktreePaths.set(currentBranch, currentPath); + if (currentPath !== null) { + entries.push({ path: currentPath, branch: currentBranch, prunable: currentPrunable }); } currentPath = null; currentBranch = null; @@ -261,6 +278,16 @@ function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { } flush(); + return entries; +} + +function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { + const worktreePaths = new Map(); + for (const entry of parseWorktreeBranchEntries(stdout)) { + if (entry.prunable || entry.branch === null) continue; + worktreePaths.set(entry.branch, entry.path); + } + return worktreePaths; } @@ -382,6 +409,67 @@ function gitCommandContext( } as const; } +// Git writes the actionable reason for a failed remote op (push/fetch) to +// stderr — "Permission denied", "403", "could not read Username", "remote: +// Repository not found". Surfacing it turns the opaque "exited with a non-zero +// status" into something a user can act on (e.g. the selected GitHub account +// lacks push access). Callers gate this to commands whose args are constructed +// internally (remote/branch names, never user input), so — unlike the general +// executeGit path, which stays redacted because git echoes argument values into +// stderr — there is no argument secret to leak. The account token is passed via +// the credential helper's stdin protocol and git redacts URL credentials, so +// the token never appears in this output either. +function appendGitOutputToDetail(baseDetail: string, stdout: string, stderr: string): string { + const gitOutput = stderr.trim() || stdout.trim(); + return gitOutput.length > 0 ? `${baseDetail}\n${gitOutput}` : baseDetail; +} + +/** + * Explain the remote rejections whose raw git output doesn't tell the user what + * to actually do. These all hinge on *which account* the command authenticated + * as, which is invisible in git's output — so the caller pairs this with + * {@link describeAuthAccountForCwd}. + * + * Returns null when git's own output is already the clearest explanation. + */ +export function describeGitRemoteRejection(output: string): string | null { + const normalized = output.toLowerCase(); + + // GitHub refuses to accept a push that adds/changes anything under + // .github/workflows/ unless the token carries the `workflow` OAuth scope. The + // `repo` scope alone is not enough, so this bites accounts that otherwise + // have full push access. + if ( + normalized.includes("without `workflow` scope") || + normalized.includes("without 'workflow' scope") || + (normalized.includes("workflow") && + normalized.includes("scope") && + normalized.includes("refusing to allow")) + ) { + return "GitHub rejected the push because the account it authenticated as is missing the `workflow` OAuth scope, which is required to add or change files under `.github/workflows/`. Run `gh auth refresh -s workflow` for that account, then push again."; + } + + if (normalized.includes("protected branch") || normalized.includes("refusing to allow")) { + return "GitHub rejected the push because a branch protection rule or a missing permission on the authenticated account blocks it."; + } + + if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) { + return "The remote branch has commits this branch doesn't. Pull or rebase onto the remote branch, then push again."; + } + + return null; +} + +/** + * A push was rejected only because the remote moved ahead of us (the classic + * "non-fast-forward" / "fetch first" rejection). This is the recoverable case + * the push path auto-rebases and retries; auth/protection failures are not. + */ +export function isNonFastForwardRejection(error: GitCommandError): boolean { + const normalized = error.detail.toLowerCase(); + return normalized.includes("non-fast-forward") || normalized.includes("fetch first"); +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -904,6 +992,150 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); + /** + * Resolve the env that authenticates git/gh as the project's selected GitHub + * account for `cwd`. Returns `{}` when no account is attached or the resolver + * isn't provided (tests/minimal layers), leaving ambient auth untouched. + * + * Fails with a `GitCommandError` when the project HAS an account attached but + * its token can't be minted (the account isn't logged in to `gh`) — pushing + * or fetching as the machine's active account would act as the wrong user, so + * we refuse instead of silently falling back. + */ + const resolveAccountAuthEnv = ( + operation: string, + cwd: string, + ): Effect.Effect => + Effect.gen(function* () { + const resolverOption = yield* Effect.serviceOption(GitHubAccountResolver); + if (Option.isNone(resolverOption)) { + return {}; + } + const resolution = yield* resolverOption.value.resolveForCwd(cwd); + if (resolution._tag === "unavailable") { + return yield* Effect.fail( + new GitCommandError({ + operation, + command: "git", + cwd, + detail: `The GitHub account "${resolution.account.login}" is selected for this project but isn't logged in to the GitHub CLI on ${resolution.account.host}. Run \`gh auth login\` for that account, or change the account in the project's settings.`, + }), + ); + } + return resolution._tag === "resolved" ? gitHubAccountAuthEnv(resolution, process.env) : {}; + }); + + /** + * Env that makes a local `git commit` record the project's selected GitHub + * account as the author and committer, instead of the machine's ambient + * `git config user.*` (which may belong to a different, currently-active + * account). Returns `{}` when no account is attached or the resolver isn't + * provided (tests/minimal layers), leaving the ambient identity untouched. + * + * Unlike remote auth this never fails: a local commit doesn't touch the + * network, and the login-based no-reply email still attributes the commit to + * the right account even when its token can't be minted. A later push is + * where a logged-out account surfaces loudly. + */ + const resolveCommitIdentityEnv = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const resolverOption = yield* Effect.serviceOption(GitHubAccountResolver); + if (Option.isNone(resolverOption)) { + return {}; + } + const resolution = yield* resolverOption.value.resolveCommitIdentityForCwd(cwd); + if (resolution._tag === "ambient") { + return {}; + } + const { name, email } = resolution.identity; + return { + GIT_AUTHOR_NAME: name, + GIT_AUTHOR_EMAIL: email, + GIT_COMMITTER_NAME: name, + GIT_COMMITTER_EMAIL: email, + }; + }); + + /** + * Env for a git command that talks to a remote (fetch/pull/push). Combines + * the non-interactive base — so the server never blocks on a credential + * prompt — with the project's selected-account auth env (token + gh + * credential helper + SSH→HTTPS rewrite). When no account is attached, or the + * resolver isn't provided, this is just the non-interactive base and git uses + * ambient auth. + * + * Every remote-touching git command routes through this so account selection + * can't be silently skipped by a call site that forgot to opt in — the class + * of bug that made "set an account for a project" only half-work. + */ + const resolveNetworkGitEnv = ( + operation: string, + cwd: string, + ): Effect.Effect => + resolveAccountAuthEnv(operation, cwd).pipe( + Effect.map((authEnv) => ({ ...STATUS_UPSTREAM_REFRESH_ENV, ...authEnv })), + ); + + /** + * Name the identity a remote git command acted as, for error messages. Git + * never reports this, so a rejection caused by acting as the wrong account is + * otherwise undiagnosable. Crucially it calls out the ambient fallback: with + * no account attached to the project, git silently uses whichever `gh` account + * happens to be active, which is the usual cause of "it pushed as the wrong + * user (sometimes)". + */ + const describeAuthAccountForCwd = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const resolverOption = yield* Effect.serviceOption(GitHubAccountResolver); + if (Option.isNone(resolverOption)) { + return null; + } + const resolution = yield* resolverOption.value.resolveForCwd(cwd); + if (resolution._tag === "ambient") { + return "No GitHub account is attached to this project, so the command ran as whichever account is currently active in the GitHub CLI. Attach the intended account in the project's settings to stop this depending on the machine's active account."; + } + return `The command ran as the GitHub account "${resolution.account.login}" selected for this project.`; + }).pipe(Effect.orElseSucceed(() => null)); + + // Remote-touching git commands (push/fetch) whose args are built internally. + // On failure the git stderr IS the actionable reason (auth denied, no remote, + // wrong account), so — unlike the redacted general executeGit path — surface + // it in the error detail. Runs with allowNonZeroExit so we can read stderr + // before deciding to fail. + const runGitWithEnv = ( + operation: string, + cwd: string, + args: readonly string[], + env: NodeJS.ProcessEnv, + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit: true, env }).pipe( + Effect.flatMap((result) => { + if (allowNonZeroExit || result.exitCode === 0) { + return Effect.void; + } + return Effect.gen(function* () { + // Only on the failure path, so the happy path stays a single spawn. + const rejection = describeGitRemoteRejection(result.stderr || result.stdout); + const account = rejection === null ? null : yield* describeAuthAccountForCwd(cwd); + const baseDetail = [ + "Git command exited with a non-zero status.", + ...(rejection ? [rejection] : []), + ...(account ? [account] : []), + ].join("\n"); + return yield* Effect.fail( + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + detail: appendGitOutputToDetail(baseDetail, result.stdout, result.stderr), + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + }); + }), + ); + const runGitStdout = ( operation: string, cwd: string, @@ -992,16 +1224,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return executeGit( - "GitVcsDriver.fetchRemoteForStatus", - fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], - { - env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); + return resolveNetworkGitEnv("GitVcsDriver.fetchRemoteForStatus", fetchCwd).pipe( + Effect.flatMap((env) => + executeGit( + "GitVcsDriver.fetchRemoteForStatus", + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + { + env, + fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ), + ), + Effect.asVoid, + ); }; const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( @@ -1337,35 +1574,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const defaultBranch = primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); - const candidates = [ + // Ordered by the spec: the PR base (what this branch merges into) wins, then + // the repository's default branch (the branch it was most likely taken + // from), then the conventional names. + const remotePrefix = + primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; + const normalizedCandidates = [ configuredBaseBranch.length > 0 ? configuredBaseBranch : null, defaultBranch, ...DEFAULT_BASE_BRANCH_CANDIDATES, - ]; - - for (const candidate of candidates) { - if (!candidate) { - continue; - } - - const remotePrefix = - primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; - const normalizedCandidate = candidate.startsWith("origin/") + ].flatMap((candidate) => { + if (!candidate) return []; + const normalized = candidate.startsWith("origin/") ? candidate.slice("origin/".length) : remotePrefix && candidate.startsWith(remotePrefix) ? candidate.slice(remotePrefix.length) : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { - continue; - } + if (normalized.length === 0 || normalized === refName) return []; + return [normalized]; + }); - if ( - primaryRemoteName && - (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) - ) { - return `${primaryRemoteName}/${normalizedCandidate}`; + // Always diff against origin: try every candidate as a remote-tracking ref + // first, so an earlier candidate that only exists locally never shadows a + // later one that is published on the remote. A stale local branch is never + // preferred over the remote it tracks. + if (primaryRemoteName) { + for (const normalizedCandidate of normalizedCandidates) { + if (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) { + return `${primaryRemoteName}/${normalizedCandidate}`; + } } + } + // Fall back to a local branch only when no remote-tracking base exists (a + // remoteless repo, or a base that has never been pushed) so the branch diff + // still works instead of collapsing to nothing. + for (const normalizedCandidate of normalizedCandidates) { if (yield* branchExists(cwd, normalizedCandidate)) { return normalizedCandidate; } @@ -1597,6 +1841,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let aheadOfDefaultCount = 0; let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); + const conflictedFiles = new Set(); for (const line of statusStdout.split(/\r?\n/g)) { if (line.startsWith("# branch.head ")) { @@ -1619,7 +1864,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (line.trim().length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); + if (pathValue) { + changedFilesWithoutNumstat.add(pathValue); + if (line.startsWith("u ")) conflictedFiles.add(pathValue); + } } } @@ -1656,13 +1904,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .map(([filePath, stat]) => { insertions += stat.insertions; deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + return { + path: filePath, + insertions: stat.insertions, + deletions: stat.deletions, + ...(conflictedFiles.has(filePath) ? { conflicted: true } : {}), + }; }) .toSorted((a, b) => a.path.localeCompare(b.path)); for (const filePath of changedFilesWithoutNumstat) { if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); + files.push({ + path: filePath, + insertions: 0, + deletions: 0, + ...(conflictedFiles.has(filePath) ? { conflicted: true } : {}), + }); } files.sort((a, b) => a.path.localeCompare(b.path)); @@ -1800,9 +2058,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; + // Author/commit as the project's selected GitHub account (if any) rather + // than the machine's ambient git identity, so commits are attributed to the + // account in the project's settings — not whichever account happens to be + // active on the machine. + const identityEnv = yield* resolveCommitIdentityEnv(cwd); yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), + env: identityEnv, }).pipe(Effect.asVoid); const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", @@ -1812,6 +2076,63 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); + /** + * Run a push and, if it is rejected solely because the remote moved ahead, + * fetch that remote branch, rebase our commits onto it, and retry the push + * once. This makes an ordinary push self-heal the common "remote branch has + * commits this branch doesn't" case instead of failing and asking the user to + * reconcile by hand. A rebase that hits conflicts is aborted so the working + * tree is never left mid-rebase, and the original non-fast-forward is + * surfaced with actionable detail. Only non-fast-forward rejections are + * retried; auth/protection failures still fail fast. + */ + const runPushWithAutoRebase = ( + operation: string, + cwd: string, + pushArgs: readonly string[], + authEnv: NodeJS.ProcessEnv, + remoteName: string, + remoteBranch: string, + ): Effect.Effect => + runGitWithEnv(operation, cwd, pushArgs, authEnv).pipe( + Effect.catchIf(isNonFastForwardRejection, () => + Effect.gen(function* () { + // Refresh the exact remote branch we are pushing to; FETCH_HEAD then + // points at its current tip regardless of local tracking config. + yield* runGitWithEnv( + `${operation}.autoRebaseFetch`, + cwd, + ["fetch", remoteName, remoteBranch], + authEnv, + ); + const rebaseResult = yield* executeGit( + `${operation}.autoRebase`, + cwd, + ["rebase", "FETCH_HEAD"], + { allowNonZeroExit: true, env: authEnv, timeoutMs: 120_000 }, + ); + if (rebaseResult.exitCode !== 0) { + // Abort so the repo is left clean rather than mid-rebase; ignore the + // abort's own exit code (nothing more we can do if it fails). + yield* executeGit(`${operation}.autoRebaseAbort`, cwd, ["rebase", "--abort"], { + allowNonZeroExit: true, + }); + return yield* new GitCommandError({ + ...gitCommandContext({ operation, cwd, args: pushArgs }), + detail: appendGitOutputToDetail( + "The remote branch has commits this branch doesn't, and automatically rebasing onto it hit conflicts. Resolve the conflicts locally, then push again.", + rebaseResult.stdout, + rebaseResult.stderr, + ), + }); + } + // History is now linear on top of the remote; retry once (no further + // auto-rebase, so a second rejection fails fast). + yield* runGitWithEnv(operation, cwd, pushArgs, authEnv); + }), + ), + ); + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { @@ -1828,15 +2149,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } + // Authenticate the push as the project's selected GitHub account (if any), + // non-interactively so it fails fast instead of prompting on the server. + const authEnv = yield* resolveNetworkGitEnv("GitVcsDriver.pushCurrentBranch", cwd); + const requestedRemoteName = options?.remoteName?.trim() || null; if (requestedRemoteName) { const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ - "push", - "-u", + yield* runPushWithAutoRebase( + "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", + cwd, + ["push", "-u", requestedRemoteName, `HEAD:refs/heads/${publishBranch}`], + authEnv, requestedRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); + publishBranch, + ); return { status: "pushed" as const, branch, @@ -1894,12 +2221,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", + yield* runPushWithAutoRebase( + "GitVcsDriver.pushCurrentBranch.pushWithUpstream", + cwd, + ["push", "-u", publishRemoteName, `HEAD:refs/heads/${publishBranch}`], + authEnv, publishRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); + publishBranch, + ); return { status: "pushed" as const, branch, @@ -1912,11 +2241,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { - yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ - "push", + yield* runPushWithAutoRebase( + "GitVcsDriver.pushCurrentBranch.pushUpstream", + cwd, + ["push", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], + authEnv, currentUpstream.remoteName, - `HEAD:refs/heads/${currentUpstream.branchName}`, - ]); + currentUpstream.branchName, + ); return { status: "pushed" as const, branch, @@ -1925,7 +2257,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + yield* runGitWithEnv("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"], authEnv); return { status: "pushed" as const, branch, @@ -1965,7 +2297,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["rev-parse", "HEAD"], true, ).pipe(Effect.map((stdout) => stdout.trim())); + const pullEnv = yield* resolveNetworkGitEnv("GitVcsDriver.pullCurrentBranch", cwd); yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + env: pullEnv, timeoutMs: 30_000, fallbackErrorDetail: "git pull failed", }); @@ -2081,6 +2415,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { + const contextArgs = input.fullContext ? ["--unified=2147483647"] : []; const details = yield* statusDetailsLocal(input.cwd); if (!details.isRepo) { return { @@ -2091,8 +2426,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const branch = details.branch; + const requestedBaseRef = input.baseRef; + const remoteRequestedBaseRef = + requestedBaseRef && input.preferRemoteBaseRef + ? yield* Effect.gen(function* () { + const primaryRemoteName = yield* resolvePrimaryRemoteName(input.cwd).pipe( + Effect.orElseSucceed(() => null), + ); + if (!primaryRemoteName || requestedBaseRef.startsWith(`${primaryRemoteName}/`)) { + return requestedBaseRef; + } + return (yield* remoteBranchExists(input.cwd, primaryRemoteName, requestedBaseRef)) + ? `${primaryRemoteName}/${requestedBaseRef}` + : requestedBaseRef; + }) + : requestedBaseRef; const baseRef = - input.baseRef ?? + remoteRequestedBaseRef ?? (branch ? yield* resolveBaseBranchForNoUpstream(input.cwd, branch).pipe( Effect.orElseSucceed(() => null), @@ -2109,6 +2459,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...contextArgs, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), "HEAD", "--", @@ -2133,36 +2484,100 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .filter((diff) => diff.length > 0) .join("\n"); - const baseResult = + // Resolve the fork point so the branch view contains committed branch history + // only. Working-tree and untracked changes belong exclusively to the separate + // working-tree source above. + const mergeBase = baseRef && branch ? yield* executeGit( - "GitVcsDriver.getReviewDiffPreview.base", + "GitVcsDriver.getReviewDiffPreview.mergeBase", input.cwd, - [ - "diff", - "--patch", - "--no-color", - "--no-ext-diff", - "--no-textconv", - "--minimal", - ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), - `${baseRef}...HEAD`, - ], + ["merge-base", baseRef, "HEAD"], { + allowNonZeroExit: true, maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, }, ).pipe( - Effect.orElseSucceed(() => ({ - exitCode: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), + Effect.map((result) => + result.exitCode === 0 && result.stdout.trim().length > 0 + ? result.stdout.trim() + : null, + ), + Effect.orElseSucceed(() => null), ) : null; - const baseDiff = baseResult?.stdout ?? ""; + // Fall back to the base ref itself when the merge-base can't be resolved. + const baseDiffRef = mergeBase ?? (branch ? baseRef : null); + const baseTrackedResult = baseDiffRef + ? yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.base", + input.cwd, + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...contextArgs, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + baseDiffRef, + "HEAD", + "--", + ], + { + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ).pipe( + Effect.orElseSucceed(() => ({ + exitCode: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + })), + ) + : null; + const baseDiff = baseTrackedResult?.stdout.trimEnd() ?? ""; + // The combined "working tree" view: every change since the fork point, + // committed branch history *and* uncommitted working-tree edits in one + // diff. Diffing the base ref against the working tree (no `HEAD` endpoint) + // spans both. With no base ref to fork from, it degrades to the dirty + // working-tree diff, which is the most it can meaningfully show. + const allTrackedResult = baseDiffRef + ? yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.all", + input.cwd, + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...contextArgs, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + baseDiffRef, + "--", + ], + { + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ).pipe( + Effect.orElseSucceed(() => ({ + exitCode: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + })), + ) + : dirtyTrackedResult; + const allDiff = [allTrackedResult.stdout.trimEnd(), dirtyUntracked.diff.trimEnd()] + .filter((diff) => diff.length > 0) + .join("\n"); const hashDiff = (diff: string) => crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( Effect.map(Encoding.encodeHex), @@ -2177,12 +2592,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ), ); - const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ + const [dirtyDiffHash, baseDiffHash, allDiffHash] = yield* Effect.all([ hashDiff(dirtyDiff), hashDiff(baseDiff), + hashDiff(allDiff), ]); const sources: ReviewDiffPreviewSource[] = [ + { + id: "working-tree-all", + kind: "working-tree-all", + title: baseRef ? `All changes vs ${baseRef}` : "All changes", + baseRef: baseDiffRef, + headRef: null, + diff: allDiff, + diffHash: allDiffHash, + truncated: allTrackedResult.stdoutTruncated || dirtyUntracked.truncated, + }, { id: "working-tree", kind: "working-tree", @@ -2201,7 +2627,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* headRef: branch ?? "HEAD", diff: baseDiff, diffHash: baseDiffHash, - truncated: baseResult?.stdoutTruncated ?? false, + truncated: baseTrackedResult?.stdoutTruncated ?? false, }, ]; @@ -2518,46 +2944,194 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const listWorktreeEntries = Effect.fn("listWorktreeEntries")(function* ( + operation: string, + cwd: string, + ) { + const result = yield* executeGit(operation, cwd, ["worktree", "list", "--porcelain", "-z"], { + timeoutMs: 10_000, + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + }); + return result.exitCode === 0 ? parseWorktreeBranchEntries(result.stdout) : []; + }); + + const normalizeWorktreePath = (worktreePath: string) => + path.normalize(path.resolve(worktreePath)); + + /** + * `git worktree add` refuses two situations the caller can neither see nor fix + * by hand, and both surface as an opaque "git worktree add failed": + * + * - the branch is already checked out in another worktree. That worktree — and + * the threads rooted at it — is what the user asked for, so hand it back + * instead of failing. + * - the derived directory is taken. Directory names come from the branch, but a + * worktree keeps its directory when its branch is switched, so reopening the + * original branch collides with a directory that now holds something else. + * A sibling `-2` keeps the request working. + * + * A worktree whose directory is gone still claims its branch until its + * administrative entry is pruned, so a stale claim is pruned and retried once. + * `git worktree prune` only drops entries whose working tree is already gone, + * so it never discards work. All of this runs on the failure path only, keeping + * a successful add a single spawn. + */ const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] - : ["worktree", "add", worktreePath, input.refName]; + const requestedPath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + + const buildArgs = (worktreePath: string) => + input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + + // Stable diagnostics so the surfaced git output is the C-locale text + // regardless of the machine's language. The args are worktree paths and + // branch names built here, so there is no argument secret to leak by + // including git's own output. + const runAdd = (worktreePath: string) => + executeGitWithStableDiagnostics( + "GitVcsDriver.createWorktree", + input.cwd, + buildArgs(worktreePath), + { allowNonZeroExit: true }, + ); + + const failAdd = ( + worktreePath: string, + result: GitVcsDriver.ExecuteGitResult, + detail = appendGitOutputToDetail("git worktree add failed", result.stdout, result.stderr), + ) => + Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.createWorktree", + cwd: input.cwd, + args: buildArgs(worktreePath), + }), + detail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + + const finish = Effect.fn("createWorktree.finish")(function* (worktreePath: string) { + if (input.newRefName && input.baseRefName) { + const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); + const parsedBaseRef = parseRemoteRefWithRemoteNames( + input.baseRefName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; + yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ + "config", + `branch.${input.newRefName}.gh-merge-base`, + baseBranch, + ]); + } - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorDetail: "git worktree add failed", + return { + worktree: { + path: worktreePath, + refName: targetBranch, + }, + }; }); - if (input.newRefName && input.baseRefName) { - const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); - const parsedBaseRef = parseRemoteRefWithRemoteNames( - input.baseRefName, - remoteNames.toSorted((left, right) => right.length - left.length), + let lastResult = yield* runAdd(requestedPath); + if (lastResult.exitCode === 0) { + return yield* finish(requestedPath); + } + + const entries = yield* listWorktreeEntries( + "GitVcsDriver.createWorktree.worktreeList", + input.cwd, + ); + // Only a plain checkout can land on an existing worktree: `-b` demands a + // branch that does not exist yet, so a claim on that name is a failure the + // caller has to see. + const claim = input.newRefName + ? null + : (entries.find((entry) => entry.branch === targetBranch) ?? null); + + if (claim !== null && !claim.prunable) { + // git lists the main worktree first, and that checkout is the project + // itself rather than something a worktree thread can open. + if (claim === entries[0]) { + return yield* failAdd( + requestedPath, + lastResult, + `Branch "${targetBranch}" is already checked out in the main repo at ${claim.path}, which cannot also become a worktree. Open the branch there, or switch that checkout to another branch first.`, + ); + } + return { worktree: { path: claim.path, refName: targetBranch } }; + } + + if (claim !== null) { + yield* executeGit( + "GitVcsDriver.createWorktree.pruneWorktrees", + input.cwd, + ["worktree", "prune"], + { timeoutMs: 10_000, allowNonZeroExit: true }, ); - const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; - yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ - "config", - `branch.${input.newRefName}.gh-merge-base`, - baseBranch, - ]); + lastResult = yield* runAdd(requestedPath); + if (lastResult.exitCode === 0) { + return yield* finish(requestedPath); + } } - return { - worktree: { - path: worktreePath, - refName: targetBranch, - }, - }; + // Only a path this driver derived may move; an explicitly requested path is + // the caller's choice to keep. + if (input.path !== null) { + return yield* failAdd(requestedPath, lastResult); + } + + const occupiedPaths = new Set( + entries.filter((entry) => !entry.prunable).map((entry) => normalizeWorktreePath(entry.path)), + ); + const isPathTaken = Effect.fn("createWorktree.isPathTaken")(function* ( + worktreePath: string, + unknownIsTaken: boolean, + ) { + if (occupiedPaths.has(normalizeWorktreePath(worktreePath))) { + return true; + } + return yield* fileSystem + .exists(worktreePath) + .pipe(Effect.orElseSucceed(() => unknownIsTaken)); + }); + + // Retrying elsewhere only makes sense for a directory collision; any other + // failure repeats at the next path and buries git's real complaint. + if (!(yield* isPathTaken(requestedPath, false))) { + return yield* failAdd(requestedPath, lastResult); + } + + for (let suffix = 2; suffix <= 100; suffix += 1) { + const candidatePath = `${requestedPath}-${suffix}`; + if (yield* isPathTaken(candidatePath, true)) { + continue; + } + const candidateResult = yield* runAdd(candidatePath); + if (candidateResult.exitCode === 0) { + return yield* finish(candidatePath); + } + return yield* failAdd(candidatePath, candidateResult); + } + + return yield* failAdd(requestedPath, lastResult); }); const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = Effect.fn("fetchPullRequestBranch")(function* (input) { const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + const env = yield* resolveNetworkGitEnv("GitVcsDriver.fetchPullRequestBranch", input.cwd); yield* executeGit( "GitVcsDriver.fetchPullRequestBranch", input.cwd, @@ -2569,6 +3143,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, ], { + env, fallbackErrorDetail: "git fetch pull request branch failed", }, ); @@ -2576,12 +3151,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { + const env = yield* resolveNetworkGitEnv("GitVcsDriver.fetchRemote", input.cwd); yield* executeGit( "GitVcsDriver.fetchRemote", input.cwd, ["fetch", "--quiet", input.remoteName], { - env: STATUS_UPSTREAM_REFRESH_ENV, + env, fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }, ); @@ -2609,13 +3185,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); + const authEnv = yield* resolveNetworkGitEnv("GitVcsDriver.fetchRemoteBranch", input.cwd); + yield* runGitWithEnv( + "GitVcsDriver.fetchRemoteBranch.fetch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ], + authEnv, + ); const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); const targetRef = `${input.remoteName}/${input.remoteBranch}`; @@ -2630,13 +3212,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = Effect.fn("fetchRemoteTrackingBranch")(function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); + const env = yield* resolveNetworkGitEnv("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd); + yield* runGitWithEnv( + "GitVcsDriver.fetchRemoteTrackingBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ], + env, + ); }); const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => @@ -2682,6 +3270,83 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { branch: targetBranch }; }); + /** + * Git refuses to check out a branch that another worktree holds, and it keeps + * refusing for worktrees whose directory is gone until their administrative + * entry is pruned. Those stale claims are invisible to the caller — `listRefs` + * drops prunable worktrees, so the branch looks free right up to the point the + * checkout fails with an opaque "git checkout failed". + * + * So: prune and retry once when the holder is stale, and otherwise name the + * worktree that actually holds the branch. Everything runs on the failure path + * only, keeping a successful checkout a single spawn. + * + * `git worktree prune` only drops entries whose working tree is already gone, + * so it never discards work. + */ + const checkoutWithWorktreeClaimRecovery = Effect.fn("checkoutWithWorktreeClaimRecovery")( + function* (cwd: string, checkoutArgs: readonly string[], checkoutBranch: string | null) { + // Stable diagnostics so the surfaced git output is the C-locale text + // regardless of the machine's language. The args here are branch names + // built from the ref list, so — as with the remote-touching commands — + // there is no argument secret to leak by including git's own output. + const runCheckout = () => + executeGitWithStableDiagnostics("GitVcsDriver.switchRef.checkout", cwd, checkoutArgs, { + timeoutMs: 10_000, + allowNonZeroExit: true, + }); + + const failCheckout = (result: GitVcsDriver.ExecuteGitResult, baseDetail: string) => + Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.switchRef.checkout", + cwd, + args: checkoutArgs, + }), + detail: appendGitOutputToDetail(baseDetail, result.stdout, result.stderr), + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + + const result = yield* runCheckout(); + if (result.exitCode === 0) { + return; + } + if (checkoutBranch === null) { + return yield* failCheckout(result, "git checkout failed"); + } + + const worktreeEntries = yield* listWorktreeEntries( + "GitVcsDriver.switchRef.worktreeList", + cwd, + ); + const claim = worktreeEntries.find((entry) => entry.branch === checkoutBranch) ?? null; + + if (claim === null) { + return yield* failCheckout(result, "git checkout failed"); + } + if (!claim.prunable) { + return yield* failCheckout( + result, + `Branch "${claim.branch}" is already checked out in the worktree at ${claim.path}. Open the branch there, or switch that worktree to another branch first.`, + ); + } + + yield* executeGit("GitVcsDriver.switchRef.pruneWorktrees", cwd, ["worktree", "prune"], { + timeoutMs: 10_000, + allowNonZeroExit: true, + }); + + const retryResult = yield* runCheckout(); + if (retryResult.exitCode !== 0) { + return yield* failCheckout(retryResult, "git checkout failed"); + } + }, + ); + const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")( function* (input) { const [localInputExists, remoteExists] = yield* Effect.all( @@ -2750,10 +3415,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? ["checkout", localTrackingBranch] : ["checkout", input.refName]; - yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorDetail: "git checkout failed", - }); + // The local branch the checkout lands on, which is what another worktree + // can be holding. For the `--track` case the branch does not exist yet, so + // nothing can hold it. + const checkoutBranch = localInputExists + ? input.refName + : remoteExists + ? (localTrackingBranch ?? localTrackedBranchCandidate) + : input.refName; + + yield* checkoutWithWorktreeClaimRecovery(input.cwd, checkoutArgs, checkoutBranch); const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ "branch", @@ -2770,6 +3441,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* timeoutMs: 10_000, fallbackErrorDetail: "git branch create failed", }); + // Record the branch this ref forks from so a later pull request targets + // it instead of the repo default. Mirrors createWorktree's handling. + if (input.baseRefName) { + const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); + const parsedBaseRef = parseRemoteRefWithRemoteNames( + input.baseRefName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; + if (baseBranch !== input.refName) { + yield* runGit("GitVcsDriver.createRef.configureBaseRef", input.cwd, [ + "config", + `branch.${input.refName}.gh-merge-base`, + baseBranch, + ]); + } + } if (input.switchRef) { yield* switchRef({ cwd: input.cwd, refName: input.refName }); } diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 675d20cb82c9..f9adf6f8071a 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -140,6 +140,92 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("classifies repository-resolution failures without retaining stderr", () => + Effect.gen(function* () { + const secretStderr = + "none of the git remotes configured for this repository point to a known GitHub host super-secret-token"; + const error = yield* run({ + operation: "test.repository", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + detail: + "No repository found for this directory. It may not be a git repository, or its remotes don't point to the expected host.", + failureKind: "repository-not-found", + stderrLength: secretStderr.length, + }); + expect(error.message).not.toContain(secretStderr); + expect(error.message).not.toContain("super-secret-token"); + }).pipe(provideLive), + ); + + it.effect("classifies permission failures without retaining stderr", () => + Effect.gen(function* () { + const secretStderr = "must have write access to run this command super-secret-token"; + const error = yield* run({ + operation: "test.permission", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + detail: "The authenticated account lacks permission for this operation.", + failureKind: "permission-denied", + stderrLength: secretStderr.length, + }); + expect(error.message).not.toContain(secretStderr); + expect(error.message).not.toContain("super-secret-token"); + }).pipe(provideLive), + ); + + it.effect("classifies merge-blocked failures without retaining stderr", () => + Effect.gen(function* () { + const secretStderr = "Pull request is not mergeable super-secret-token"; + const error = yield* run({ + operation: "test.merge", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + failureKind: "merge-blocked", + stderrLength: secretStderr.length, + }); + expect(error.message).not.toContain(secretStderr); + expect(error.message).not.toContain("super-secret-token"); + }).pipe(provideLive), + ); + + it.effect("classifies temporary provider failures without retaining stderr", () => + Effect.gen(function* () { + const secretStderr = + "HTTP 503: No server is currently available to service your request super-secret-token"; + const error = yield* run({ + operation: "test.provider-unavailable", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + detail: "The source control provider is temporarily unavailable.", + failureKind: "provider-unavailable", + stderrLength: secretStderr.length, + }); + expect(error.message).not.toContain(secretStderr); + expect(error.message).not.toContain("super-secret-token"); + }).pipe(provideLive), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 52db6f9b1fb2..dc86b39cef88 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -66,6 +66,23 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "authentication"; } + // The command ran, but its working directory couldn't be resolved to a + // repository the provider can act on: not a git repository, no remotes, or no + // remote pointing at the provider's host. The CLIs word this as "not a git + // repository", "no git remotes found", or (gh) "none of the git remotes … + // point to a known GitHub host". Classified above `not-found` so a bare + // "not found" in these messages can't misroute it to the PR-missing bucket. + if ( + normalized.includes("not a git repository") || + normalized.includes("no git remote") || + normalized.includes("none of the git remotes") || + normalized.includes("no github remotes found") || + normalized.includes("no gitlab remotes found") || + normalized.includes("no default remote repository") + ) { + return "repository-not-found"; + } + if ( (command === "gh" && (normalized.includes("could not resolve to a pullrequest") || @@ -83,6 +100,53 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "not-found"; } + // Valid identity, insufficient rights — the common multi-account footgun, + // where the account chosen for a project isn't a collaborator on its repo. + if ( + normalized.includes("permission") || + normalized.includes("must have write access") || + normalized.includes("must have admin") || + normalized.includes("forbidden") || + normalized.includes("403") || + normalized.includes("resource not accessible") || + normalized.includes("not authorized") + ) { + return "permission-denied"; + } + + // The change request exists and the caller may merge, but the platform + // refuses this merge right now. + if ( + normalized.includes("not mergeable") || + normalized.includes("is not mergeable") || + normalized.includes("merge conflict") || + normalized.includes("has conflicts") || + normalized.includes("not allowed") || + normalized.includes("merge commits are not allowed") || + normalized.includes("squash merges are not allowed") || + normalized.includes("rebase merges are not allowed") || + normalized.includes("required status") || + normalized.includes("branch protection") || + normalized.includes("at least") || // "at least N approving review(s)" + normalized.includes("changes must be made through a pull request") || + normalized.includes("base branch was modified") || + normalized.includes("not up to date") + ) { + return "merge-blocked"; + } + + if ( + /\bhttp (?:500|502|503|504)\b/.test(normalized) || + normalized.includes("internal server error") || + normalized.includes("server is currently unavailable") || + normalized.includes("temporarily unavailable") || + normalized.includes("service unavailable") || + normalized.includes("bad gateway") || + normalized.includes("gateway timeout") + ) { + return "provider-unavailable"; + } + return "command-failed"; }; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 9b7ed4e2453f..1d7e49af4d6d 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,3 +1,7 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { FileFinder } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -122,6 +126,50 @@ it.effect("preserves search and refresh failures with operation context", () => ), ); +it.effect("surfaces gitignored .env files that the native finder omits", () => + Effect.scoped( + Effect.gen(function* () { + // The fff finder honors .gitignore and never returns `.env*`. We create a + // real workspace on disk and mock the finder to return nothing, so the + // only entries that can appear come from the supplemental env walk. + const cwd = yield* Effect.acquireRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-env-walk-"))), + (dir) => Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })), + ); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(NodePath.join(cwd, ".env"), "SECRET=1"); + await NodeFSP.writeFile(NodePath.join(cwd, ".env.local"), "SECRET=2"); + await NodeFSP.mkdir(NodePath.join(cwd, "apps", "web"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(cwd, "apps", "web", ".env.staging"), "SECRET=3"); + // Heavy directories must be skipped by the walk. + await NodeFSP.mkdir(NodePath.join(cwd, "node_modules", "pkg"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(cwd, "node_modules", "pkg", ".env"), "SECRET=4"); + }); + + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => ({ ok: true, value: { items: [], totalMatched: 0 } })), + scanFiles: vi.fn(), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const result = yield* searchIndex.list(); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain(".env"); + expect(paths).toContain(".env.local"); + expect(paths).toContain("apps/web/.env.staging"); + // Directory ancestors of nested env files are materialized for the tree. + expect(paths).toContain("apps"); + expect(paths).toContain("apps/web"); + // node_modules is never walked, so its .env stays hidden. + expect(paths).not.toContain("node_modules/pkg/.env"); + }), + ), +); + it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7b..0348d4052841 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -169,6 +173,59 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } +/** + * The fff finder honors `.gitignore`, so `.env*` files (which are almost always + * ignored) never appear in the file tree. We supplement the listing with a + * bounded filesystem walk that surfaces them anyway, while still skipping the + * heavy directories a walk would otherwise drown in. + */ +const ENV_WALK_SKIP_DIRS = new Set([ + "node_modules", + ".git", + ".hg", + ".svn", + "dist", + "build", + "out", + "coverage", + ".next", + ".turbo", + ".cache", + "target", +]); +const ENV_WALK_MAX_DIRS = 5_000; + +function isEnvFileName(name: string): boolean { + return name === ".env" || name.startsWith(".env."); +} + +async function collectEnvEntries(cwd: string): Promise { + const entries: ProjectEntry[] = []; + const stack: string[] = [cwd]; + let visited = 0; + while (stack.length > 0 && visited < ENV_WALK_MAX_DIRS) { + const dir = stack.pop(); + if (dir === undefined) break; + visited += 1; + const dirents = await NodeFSP.readdir(dir, { withFileTypes: true }).catch(() => null); + if (!dirents) continue; + for (const dirent of dirents) { + const fullPath = NodePath.join(dir, dirent.name); + if (dirent.isDirectory()) { + if (!ENV_WALK_SKIP_DIRS.has(dirent.name)) { + stack.push(fullPath); + } + } else if (isEnvFileName(dirent.name)) { + const relativePath = toPosixPath(NodePath.relative(cwd, fullPath)); + if (relativePath && !relativePath.startsWith("..")) { + entries.push({ path: relativePath, kind: "file" }); + } + } + } + } + return entries; +} + const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { const result = yield* Effect.try({ try: () => @@ -289,8 +346,11 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin function* () { const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); - const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => - left.path.localeCompare(right.path), + const envEntries = yield* Effect.tryPromise(() => collectEnvEntries(cwd)).pipe( + Effect.orElseSucceed(() => [] as ProjectEntry[]), + ); + const sortedEntries = withDirectoryAncestors([...mapped.entries, ...envEntries]).toSorted( + (left, right) => left.path.localeCompare(right.path), ); const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); return { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6c021c9af803..459da5eb0a41 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -26,6 +26,7 @@ import { type OrchestrationShellStreamEvent, type OrchestrationShellStreamItem, type OrchestrationThreadStreamItem, + OrchestrationGenerateContinuationSummaryError, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -66,6 +67,7 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; +import { isThreadAlreadyExistsInvariantError } from "./orchestration/commandInvariants.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -80,6 +82,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -93,6 +96,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as ProjectWorktreeFileCopier from "./project/ProjectWorktreeFileCopier.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -334,6 +338,50 @@ function toAuthAccessStreamEvent( } } +/** Max characters of role-tagged transcript fed to the continuation summarizer. */ +const CONTINUATION_TRANSCRIPT_MAX_CHARS = 24_000; + +/** + * Build a compact role-tagged transcript for the continuation summarizer. + * + * When the conversation is long, keep the opening message (the original goal) + * plus the most recent exchanges rather than shipping the entire transcript to + * the model — the summary needs intent and current state, not every turn, and + * this keeps the one-time generation cost bounded. + */ +function buildContinuationTranscript( + messages: ReadonlyArray<{ readonly role: string; readonly text: string }>, +): string { + const lines = messages + .map((message) => { + const text = message.text.trim(); + if (text.length === 0) return null; + const label = + message.role === "user" + ? "User" + : message.role === "assistant" + ? "Assistant" + : message.role; + return `${label}: ${text}`; + }) + .filter((line): line is string => line !== null); + if (lines.length === 0) return ""; + + const joined = lines.join("\n\n"); + if (joined.length <= CONTINUATION_TRANSCRIPT_MAX_CHARS) return joined; + + const head = lines[0] ?? ""; + let budget = CONTINUATION_TRANSCRIPT_MAX_CHARS - head.length; + const tail: string[] = []; + for (let index = lines.length - 1; index >= 1; index--) { + const line = lines[index] ?? ""; + if (budget - line.length < 0) break; + budget -= line.length; + tail.unshift(line); + } + return [head, "[… earlier conversation omitted …]", ...tail].join("\n\n"); +} + const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], @@ -360,10 +408,12 @@ const makeWsRpcLayer = ( const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; + const textGeneration = yield* TextGeneration.TextGeneration; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const projectWorktreeFileCopier = yield* ProjectWorktreeFileCopier.ProjectWorktreeFileCopier; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -876,43 +926,107 @@ const makeWsRpcLayer = ( ); }); + // Copy the project's configured local files (e.g. `.env.local`) into the + // fresh worktree before the setup script runs, so the script can rely on + // them. Copy problems are logged per file and never fail the bootstrap. + const runCopyProjectFilesProgram = () => + Effect.gen(function* () { + if (!bootstrap?.runSetupScript || !targetWorktreePath) { + return; + } + yield* projectWorktreeFileCopier + .copyForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath: targetWorktreePath, + }) + .pipe( + Effect.tapError((error) => + Effect.logWarning("Failed to copy project files into worktree", { + threadId: command.threadId, + worktreePath: targetWorktreePath, + detail: error.message, + }), + ), + Effect.ignore, + ); + }); + const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: yield* serverCommandId("bootstrap-thread-create"), - threadId: command.threadId, - projectId: bootstrap.createThread.projectId, - title: bootstrap.createThread.title, - modelSelection: bootstrap.createThread.modelSelection, - runtimeMode: bootstrap.createThread.runtimeMode, - interactionMode: bootstrap.createThread.interactionMode, - branch: bootstrap.createThread.branch, - worktreePath: bootstrap.createThread.worktreePath, - createdAt: bootstrap.createThread.createdAt, - }); - createdThread = true; + // Bootstrapping a draft is a "create if absent" operation: the + // client generated this threadId before the thread existed, so a + // duplicate send (or a draft that was already promoted) can target + // a threadId the server already has. Treat that specific invariant + // as a no-op and continue with the turn against the existing + // thread instead of failing the whole send — the client then + // navigates to the already-created chat. `createdThread` stays + // false so cleanup never deletes a thread we did not create. + const created = yield* orchestrationEngine + .dispatch({ + type: "thread.create", + commandId: yield* serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }) + .pipe( + Effect.as(true), + Effect.catchTag("OrchestrationCommandInvariantError", (error) => + isThreadAlreadyExistsInvariantError(error, command.threadId) + ? Effect.succeed(false) + : Effect.fail(error), + ), + ); + createdThread = created; } if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { - yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, - remoteName: "origin", - }); - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - fallbackRemoteName: "origin", - }); - worktreeBaseRef = resolvedRemoteBase.commitSha; + const prepareWorktree = bootstrap.prepareWorktree; + let worktreeBaseRef = prepareWorktree.baseBranch; + if (prepareWorktree.startFromOrigin) { + // Basing the new worktree on the freshest origin commit is a + // best-effort optimization. If origin is unreachable (offline, + // auth failure, no remote), `git fetch origin` errors — but that + // must not block getting into the worktree. Fall back to the + // local base branch and continue instead of failing the turn. + worktreeBaseRef = yield* Effect.gen(function* () { + yield* gitWorkflow.fetchRemote({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + }); + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + return resolvedRemoteBase.commitSha; + }).pipe( + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start could not start worktree from origin; using local base branch instead", + { + threadId: command.threadId, + projectCwd: prepareWorktree.projectCwd, + baseBranch: prepareWorktree.baseBranch, + detail: error.message, + }, + ).pipe(Effect.as(prepareWorktree.baseBranch)), + ), + ); } const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path; @@ -926,6 +1040,7 @@ const makeWsRpcLayer = ( yield* refreshGitStatus(targetWorktreePath); } + yield* runCopyProjectFilesProgram(); yield* runSetupProgram(); return yield* orchestrationEngine.dispatch(finalTurnStartCommand); @@ -1225,6 +1340,95 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.generateContinuationSummary]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.generateContinuationSummary, + Effect.gen(function* () { + const threadOption = yield* projectionSnapshotQuery + .getThreadDetailById(input.threadId) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationGenerateContinuationSummaryError({ + message: "Failed to load thread", + cause, + }), + ), + ); + if (Option.isNone(threadOption)) { + return yield* new OrchestrationGenerateContinuationSummaryError({ + message: "Thread not found", + }); + } + const thread = threadOption.value; + + const projectOption = yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationGenerateContinuationSummaryError({ + message: "Failed to load project", + cause, + }), + ), + ); + const cwd = + thread.worktreePath ?? + (Option.isSome(projectOption) ? projectOption.value.workspaceRoot : null); + if (cwd === null) { + return yield* new OrchestrationGenerateContinuationSummaryError({ + message: "Project not found for thread", + }); + } + + const transcript = buildContinuationTranscript(thread.messages); + if (transcript.trim().length === 0) { + return yield* new OrchestrationGenerateContinuationSummaryError({ + message: "Thread has no conversation to summarize", + }); + } + + const { textGenerationModelSelection } = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new OrchestrationGenerateContinuationSummaryError({ + message: "Failed to load server settings", + cause, + }), + ), + ); + const generated = yield* textGeneration + .generateContinuationSummary({ + cwd, + sourceTitle: thread.title, + transcript, + modelSelection: textGenerationModelSelection, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationGenerateContinuationSummaryError({ + message: "Failed to generate continuation summary", + cause, + }), + ), + ); + const summary = generated.summary.trim(); + if (summary.length === 0) { + return yield* new OrchestrationGenerateContinuationSummaryError({ + message: "Generated summary was empty", + }); + } + + return { + sourceThreadId: thread.id, + sourceTitle: thread.title, + summary, + }; + }), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeThread, @@ -1698,22 +1902,27 @@ const makeWsRpcLayer = ( observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitWorkflow - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), - ), + projectionSnapshotQuery.getDefaultModelSelectionForCwd(input.cwd).pipe( + // A projection read failure must not break the git action; fall + // back to the server-wide source-control writer selection. + Effect.catch(() => Effect.succeedNone), + Effect.flatMap((projectModelSelection) => + gitWorkflow.runStackedAction(input, { + actionId: input.actionId, + projectModelSelection: Option.getOrNull(projectModelSelection), + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, }), ), + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), ), { "rpc.aggregate": "vcs" }, ), @@ -1725,6 +1934,12 @@ const makeWsRpcLayer = ( "rpc.aggregate": "git", }, ), + [WS_METHODS.gitMergePullRequest]: (input) => + observeRpcEffect( + WS_METHODS.gitMergePullRequest, + gitWorkflow.mergePullRequest(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b89b87c92898..4748f02e9943 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -20,7 +20,7 @@ import { isPreviewSupportedInRuntime, rememberPreviewUrl, } from "~/previewStateStore"; -import { useRightPanelStore } from "~/rightPanelStore"; +import { openBrowserPreviewInChat } from "~/components/preview/openBrowserPreviewInChat"; export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); @@ -48,7 +48,7 @@ export async function openUrlInPreview(input: { return mapAtomCommandResult(result, (snapshot) => { applyPreviewServerSnapshot(input.threadRef, snapshot); rememberPreviewUrl(input.threadRef, input.url); - useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + openBrowserPreviewInChat(input.threadRef, snapshot.tabId); }); } diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..042dc5c84966 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -3,7 +3,7 @@ const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { - readonly open?: "add-project" | "new-thread-in"; + readonly open?: "add-project" | "new-thread-in" | "file"; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 76336f1ef1f2..2e352e9d6884 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -5,6 +5,7 @@ import { deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, + resolveExactBranchWorktreeInput, resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, @@ -663,6 +664,67 @@ describe("resolveBranchSelectionTarget", () => { }); }); +describe("resolveExactBranchWorktreeInput", () => { + it("checks out a local branch directly without creating a child branch", () => { + expect( + resolveExactBranchWorktreeInput({ + activeProjectCwd: "/repo", + ref: { name: "feature/review", isRemote: false, worktreePath: null }, + }), + ).toEqual({ + kind: "create", + cwd: "/repo", + refName: "feature/review", + path: null, + }); + }); + + it("creates the corresponding local branch for a remote branch", () => { + expect( + resolveExactBranchWorktreeInput({ + activeProjectCwd: "/repo", + ref: { name: "origin/feature/review", isRemote: true, worktreePath: null }, + }), + ).toEqual({ + kind: "create", + cwd: "/repo", + refName: "origin/feature/review", + newRefName: "feature/review", + path: null, + }); + }); + + it("reuses a branch's existing secondary worktree", () => { + expect( + resolveExactBranchWorktreeInput({ + activeProjectCwd: "/repo", + ref: { + name: "feature/review", + isRemote: false, + worktreePath: "/worktrees/feature-review", + }, + }), + ).toEqual({ + kind: "reuse", + branch: "feature/review", + worktreePath: "/worktrees/feature-review", + }); + }); + + it("reuses the primary checkout as the local workspace for an already-checked-out branch", () => { + expect( + resolveExactBranchWorktreeInput({ + activeProjectCwd: "/repo", + ref: { name: "feature/review", isRemote: false, worktreePath: "/repo" }, + }), + ).toEqual({ + kind: "reuse", + branch: "feature/review", + worktreePath: null, + }); + }); +}); + describe("shouldIncludeBranchPickerItem", () => { it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d9737f17a323..ebce548b72a1 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,10 +1,9 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; +import { deriveLocalBranchNameFromRemoteRef, stripRemoteRefPrefix } from "@t3tools/shared/git"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; -export { - dedupeRemoteBranchesWithLocalMatches, - deriveLocalBranchNameFromRemoteRef, -} from "@t3tools/shared/git"; +export { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +export { deriveLocalBranchNameFromRemoteRef }; export interface EnvironmentOption { environmentId: EnvironmentId; @@ -235,6 +234,51 @@ export function resolveBranchSelectionTarget(input: { }; } +export function resolveExactBranchWorktreeInput(input: { + activeProjectCwd: string; + ref: Pick; +}): + | { kind: "reuse"; branch: string; worktreePath: string | null } + | { + kind: "create"; + cwd: string; + refName: string; + newRefName?: string; + path: null; + } { + const { activeProjectCwd, ref } = input; + // A branch already checked out somewhere can never get a second worktree — + // `git worktree add` aborts with "already checked out". Reuse the existing + // checkout instead: a secondary worktree as-is, the primary checkout as the + // local workspace (worktreePath null). + if (ref.worktreePath) { + return { + kind: "reuse", + branch: ref.name, + worktreePath: ref.worktreePath === activeProjectCwd ? null : ref.worktreePath, + }; + } + if (ref.isRemote) { + return { + kind: "create", + cwd: activeProjectCwd, + refName: ref.name, + // The ref's own remote is the only prefix worth stripping; falling back to + // the first segment covers refs listed without a parsed remote name. + newRefName: ref.remoteName + ? stripRemoteRefPrefix(ref.name, ref.remoteName) + : deriveLocalBranchNameFromRemoteRef(ref.name), + path: null, + }; + } + return { + kind: "create", + cwd: activeProjectCwd, + refName: ref.name, + path: null, + }; +} + export function shouldIncludeBranchPickerItem(input: { itemValue: string; normalizedQuery: string; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3a83f5c9a0ff..a6298826593a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,10 +9,15 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { useBranches } from "../state/queries"; +import { parsePullRequestReference } from "../pullRequestReference"; +import { vcsEnvironment } from "../state/vcs"; +import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, @@ -29,6 +34,7 @@ import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; +import { Input } from "./ui/input"; import { Menu, MenuGroup, @@ -62,6 +68,7 @@ interface MobileRunContextSelectorProps { envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; + cwd: string | null; availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; showEnvironmentIndicator: boolean; @@ -71,12 +78,15 @@ interface MobileRunContextSelectorProps { onEnvModeChange: (mode: EnvMode) => void; previousWorktreeLabel: string | null; onUsePreviousWorktree: () => void; + checkoutPullRequestLabel: string; + onCheckoutPullRequest: ((reference: string) => void) | undefined; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ envLocked, envModeLocked, environmentId, + cwd, availableEnvironments, showEnvironmentPicker, showEnvironmentIndicator, @@ -86,11 +96,17 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ onEnvModeChange, previousWorktreeLabel, onUsePreviousWorktree, + checkoutPullRequestLabel, + onCheckoutPullRequest, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], ); + const [checkoutReference, setCheckoutReference] = useState(""); + const branchQuery = useBranches({ environmentId, cwd, query: checkoutReference }); + const branchSuggestions = branchQuery.data?.refs.slice(0, 8) ?? []; + const pullRequestReference = parsePullRequestReference(checkoutReference.trim()); const WorkspaceIcon = effectiveEnvMode === "worktree" ? FolderGit2Icon @@ -141,6 +157,61 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ + {onCheckoutPullRequest ? ( +
+ setCheckoutReference(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key !== "Enter" || !checkoutReference.trim()) return; + event.preventDefault(); + onCheckoutPullRequest(checkoutReference.trim()); + setCheckoutReference(""); + }} + /> + {pullRequestReference || branchSuggestions.length > 0 ? ( +
+ {pullRequestReference ? ( + + ) : null} + {branchSuggestions.map((ref) => ( + + ))} +
+ ) : null} +
+ ) : null} {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> @@ -290,6 +361,30 @@ export const BranchToolbar = memo(function BranchToolbar({ }); }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); + // Surface "checkout from a PR" as a first-class workspace option (alongside + // local/worktree) whenever the composer allows it — the branch-status query + // shares its cache key with the branch selector, so this adds no extra fetch. + const branchCwd = activeWorktreePath ?? activeProject?.workspaceRoot ?? null; + const gitStatusQuery = useEnvironmentQuery( + branchCwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd: branchCwd }, + }), + ); + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(gitStatusQuery.data?.sourceControlProvider), + [gitStatusQuery.data?.sourceControlProvider], + ); + const checkoutPullRequestLabel = `Open branch or ${sourceControlPresentation.terminology.shortLabel}`; + const handleCheckoutPullRequest = useCallback( + (reference: string) => { + onCheckoutPullRequestRequest?.(reference); + }, + [onCheckoutPullRequestRequest], + ); + const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); @@ -310,6 +405,7 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envLocked} envModeLocked={envModeLocked} environmentId={environmentId} + cwd={activeProject.workspaceRoot} availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} showEnvironmentIndicator={showEnvironmentIndicator} @@ -319,6 +415,10 @@ export const BranchToolbar = memo(function BranchToolbar({ onEnvModeChange={onEnvModeChange} previousWorktreeLabel={previousWorktreeLabel} onUsePreviousWorktree={onUsePreviousWorktree} + checkoutPullRequestLabel={checkoutPullRequestLabel} + onCheckoutPullRequest={ + onCheckoutPullRequestRequest ? handleCheckoutPullRequest : undefined + } /> ) : (
@@ -335,11 +435,17 @@ export const BranchToolbar = memo(function BranchToolbar({ )}
)} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9b4cbf2b4a41..e4b7e36d59b4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -5,7 +5,15 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { + ChevronDownIcon, + GitBranchIcon, + GitBranchPlusIcon, + FolderGit2Icon, + RefreshCwIcon, + SearchIcon, +} from "lucide-react"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useCallback, useDeferredValue, @@ -31,7 +39,7 @@ import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; -import { cn } from "../lib/utils"; +import { cn, randomHex } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { @@ -39,6 +47,7 @@ import { resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, + resolveExactBranchWorktreeInput, resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, @@ -109,6 +118,9 @@ export function BranchToolbarBranchSelector({ const createRefMutation = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false, }); + const createWorktreeMutation = useAtomCommand(vcsEnvironment.createWorktree, { + reportFailure: false, + }); // --------------------------------------------------------------------------- // Thread / project state (pushed down from parent to colocate with mutation) // --------------------------------------------------------------------------- @@ -475,15 +487,51 @@ export function BranchToolbarBranchSelector({ }); }; - // Default the worktree base to the repo default branch (origin/HEAD), only - // falling back to the checked-out branch when no default is known. + const openExactBranchInWorktree = (ref: VcsRef) => { + if (!activeProjectCwd || isBranchActionPending) return; + setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); + + const target = resolveExactBranchWorktreeInput({ activeProjectCwd, ref }); + if (target.kind === "reuse") { + setThreadBranch(target.branch, target.worktreePath); + return; + } + + runBranchAction(async () => { + const result = await createWorktreeMutation({ + environmentId, + input: target, + }); + if (result._tag === "Success") { + setOptimisticBranch(result.value.worktree.refName); + setThreadBranch(result.value.worktree.refName, result.value.worktree.path); + return; + } + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open branch in a worktree", + description: toBranchActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + }); + }; + + // Default the worktree base to the project's configured default branch when + // set; otherwise the repo default branch (origin/HEAD), falling back to the + // checked-out branch when no default is known. A configured project default + // applies immediately without waiting for the ref list to load. const defaultBranchName = useMemo( () => refs.find((refName) => refName.isDefault)?.name ?? null, [refs], ); - const worktreeBaseBranchCandidate = isInitialBranchesLoadPending - ? null - : (defaultBranchName ?? currentGitBranch); + const projectDefaultWorktreeBranch = activeProject?.defaultWorktreeBranch ?? null; + const worktreeBaseBranchCandidate = + projectDefaultWorktreeBranch ?? + (isInitialBranchesLoadPending ? null : (defaultBranchName ?? currentGitBranch)); useEffect(() => { if ( @@ -617,6 +665,57 @@ export function BranchToolbarBranchSelector({ : ""; const openPrLink = useOpenPrLink(); + // Once a change request is merged its worktree is spent. "Continue" spins a + // fresh worktree off the branch the PR merged into and re-points THIS thread + // onto it (keeping the whole conversation), so follow-up work starts clean. + const canContinueMergedThread = + branchPr?.state === "merged" && hasServerThread && activeProjectCwd !== null; + const continueBaseBranch = + branchPr?.baseRef ?? projectDefaultWorktreeBranch ?? defaultBranchName ?? currentGitBranch; + const continueInNewWorktree = () => { + if ( + !canContinueMergedThread || + !activeProjectCwd || + !continueBaseBranch || + isBranchActionPending + ) { + return; + } + const newBranch = buildTemporaryWorktreeBranchName( + randomHex, + activeProject?.worktreeBranchPrefix ?? null, + ); + onComposerFocusRequest?.(); + runBranchAction(async () => { + const result = await createWorktreeMutation({ + environmentId, + input: { + cwd: activeProjectCwd, + refName: continueBaseBranch, + newRefName: newBranch, + baseRefName: continueBaseBranch, + path: null, + }, + }); + if (result._tag === "Success") { + // Re-point the thread; setThreadBranch stops the live session so the + // next turn re-roots into the new worktree. + setOptimisticBranch(result.value.worktree.refName); + setThreadBranch(result.value.worktree.refName, result.value.worktree.path); + return; + } + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to start a new worktree", + description: toBranchActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + }); + }; + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( @@ -687,9 +786,32 @@ export function BranchToolbarBranchSelector({ onClick={() => selectBranch(refName)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} > -
+
{itemValue} {badge && {badge}} + + { + event.preventDefault(); + event.stopPropagation(); + openExactBranchInWorktree(refName); + }} + /> + } + > + + + + {hasSecondaryWorktree + ? "Use this branch's existing worktree" + : "Open this exact branch in a worktree"} + +
); @@ -736,6 +858,31 @@ export function BranchToolbarBranchSelector({ {branchPrTooltip} ) : null} + {canContinueMergedThread ? ( + + + } + > + + Continue + + + {continueBaseBranch + ? `Start a fresh worktree off ${continueBaseBranch} and keep this chat` + : "Start a fresh worktree and keep this chat"} + + + ) : null} {/* Context menu lives on the wrapper: the disabled Button has pointer-events-none, so the trigger itself never sees right-clicks while refs are loading or a branch action is pending. */} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..e23c7847a1eb 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,5 +1,9 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useMemo, useState } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { useBranches } from "../state/queries"; +import { parsePullRequestReference } from "../pullRequestReference"; import { resolveCurrentWorkspaceLabel, @@ -16,27 +20,41 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; +import { Input } from "./ui/input"; export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; + environmentId: EnvironmentId; + cwd: string | null; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; previousWorktreeLabel?: string | null; onUsePreviousWorktree?: () => void; + checkoutPullRequestLabel?: string; + onCheckoutPullRequest?: (reference: string) => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ envLocked, + environmentId, + cwd, effectiveEnvMode, activeWorktreePath, onEnvModeChange, previousWorktreeLabel, onUsePreviousWorktree, + checkoutPullRequestLabel, + onCheckoutPullRequest, }: BranchToolbarEnvModeSelectorProps) { const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); + const showCheckoutPullRequest = Boolean(onCheckoutPullRequest); + const [checkoutReference, setCheckoutReference] = useState(""); + const branchQuery = useBranches({ environmentId, cwd, query: checkoutReference }); + const branchSuggestions = branchQuery.data?.refs.slice(0, 8) ?? []; + const pullRequestReference = parsePullRequestReference(checkoutReference.trim()); const envModeItems = useMemo( () => [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, @@ -95,6 +113,61 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe + {showCheckoutPullRequest ? ( +
+ setCheckoutReference(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key !== "Enter" || !checkoutReference.trim()) return; + event.preventDefault(); + onCheckoutPullRequest?.(checkoutReference.trim()); + setCheckoutReference(""); + }} + /> + {pullRequestReference || branchSuggestions.length > 0 ? ( +
+ {pullRequestReference ? ( + + ) : null} + {branchSuggestions.map((ref) => ( + + ))} +
+ ) : null} +
+ ) : null} Workspace diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d86fe39a77f3..e0f0785c2466 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,8 +54,9 @@ import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { DIFF_HIGHLIGHTER_THEME_NAMES, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; +import { useDiffThemeName } from "../hooks/useDiffThemeName"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; @@ -72,9 +73,10 @@ import { rewriteMarkdownFileUriHref, type MarkdownFileLinkMeta, } from "../markdown-links"; +import { openMarkdownFilePrimaryAction } from "../markdownFileActions"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; -import { useRightPanelStore } from "../rightPanelStore"; +import { useWorkspaceThreadRef } from "../lib/workspaceThreadRef"; import { useActiveEnvironmentId } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { assetEnvironment } from "../state/assets"; @@ -338,7 +340,7 @@ function getHighlighterPromise(language: string): Promise { if (cached) return cached; const promise = getSharedHighlighter({ - themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + themes: [...DIFF_HIGHLIGHTER_THEME_NAMES], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", }).catch((err) => { @@ -1106,13 +1108,18 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpen, targetPath]); + // Opening a file from a chat link lands in the shared per-worktree workspace + // panel, so resolve the thread to its worktree representative first. + const workspaceThreadRef = useWorkspaceThreadRef(threadRef); const handleOpenInFilePreview = useCallback(() => { - if (!threadRef || !workspaceRelativePath) { - handleOpenInEditor(); - return; - } - useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); - }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); + openMarkdownFilePrimaryAction({ + threadRef: workspaceThreadRef ?? undefined, + workspaceRelativePath, + targetPath, + line, + openInEditor: handleOpenInEditor, + }); + }, [handleOpenInEditor, line, targetPath, workspaceThreadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1248,10 +1255,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - if (onOpenInBrowser) { - handleOpenInBrowser(); - return; - } handleOpenInFilePreview(); }} onContextMenu={handleContextMenu} @@ -1311,13 +1314,16 @@ function ChatMarkdown({ reportFailure: false, }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); + // Preview surfaces opened from chat content belong to the shared per-worktree + // workspace, so target the worktree representative rather than this chat. + const workspaceThreadRef = useWorkspaceThreadRef(threadRef); const environmentId = useActiveEnvironmentId(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, serverConfig?.availableEditors ?? [], ); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const diffThemeName = useDiffThemeName(); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, @@ -1367,7 +1373,7 @@ function ChatMarkdown({ }, []); const openExternalLinkInPreview = useCallback( (url: string) => { - if (!threadRef) { + if (!workspaceThreadRef) { return Promise.resolve( AsyncResult.failure( Cause.fail( @@ -1378,13 +1384,13 @@ function ChatMarkdown({ ), ); } - return openUrlInPreview({ threadRef, url, openPreview }); + return openUrlInPreview({ threadRef: workspaceThreadRef, url, openPreview }); }, - [openPreview, threadRef], + [openPreview, workspaceThreadRef], ); const openMarkdownFileInPreview = useCallback( (path: string) => { - if (!threadRef || preparedConnection._tag === "None") { + if (!workspaceThreadRef || preparedConnection._tag === "None") { return Promise.resolve( AsyncResult.failure( Cause.fail( @@ -1396,14 +1402,14 @@ function ChatMarkdown({ ); } return openFileInPreview({ - threadRef, + threadRef: workspaceThreadRef, filePath: path, httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, }); }, - [createAssetUrl, openPreview, preparedConnection, threadRef], + [createAssetUrl, openPreview, preparedConnection, workspaceThreadRef], ); const markdownComponents = useMemo(() => { const fileLinkChip = ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1af..ef08b64472f2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -16,6 +16,7 @@ import { buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, + canCreateEmptyWorktreeThread, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, @@ -24,6 +25,7 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveTimelineComposerLayout, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, startNewThreadForProject, @@ -36,6 +38,48 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("resolveTimelineComposerLayout", () => { + it("reserves viewport space for the docked composer", () => { + expect(resolveTimelineComposerLayout({ isDraftHeroState: false, composerHeight: 144 })).toEqual( + { viewportPaddingBottom: 144, contentInsetEndAdjustment: 0 }, + ); + }); + + it("keeps the draft hero overlaid and accounts for it in scroll calculations", () => { + expect(resolveTimelineComposerLayout({ isDraftHeroState: true, composerHeight: 144 })).toEqual({ + viewportPaddingBottom: 0, + contentInsetEndAdjustment: 144, + }); + }); + + it("clamps an unmeasured negative height", () => { + expect(resolveTimelineComposerLayout({ isDraftHeroState: false, composerHeight: -1 })).toEqual({ + viewportPaddingBottom: 0, + contentInsetEndAdjustment: 0, + }); + }); +}); + +describe("canCreateEmptyWorktreeThread", () => { + it("allows an empty local draft in worktree mode", () => { + expect( + canCreateEmptyWorktreeThread({ + hasSendableContent: false, + isLocalDraftThread: true, + envMode: "worktree", + }), + ).toBe(true); + }); + + it.each([ + { hasSendableContent: true, isLocalDraftThread: true, envMode: "worktree" as const }, + { hasSendableContent: false, isLocalDraftThread: false, envMode: "worktree" as const }, + { hasSendableContent: false, isLocalDraftThread: true, envMode: "local" as const }, + ])("rejects non-empty, server, and local-mode submissions", (input) => { + expect(canCreateEmptyWorktreeThread(input)).toBe(false); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd45516..b4f36e8cfaa9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -28,6 +28,27 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function resolveTimelineComposerLayout(input: { + isDraftHeroState: boolean; + composerHeight: number; +}): { + viewportPaddingBottom: number; + contentInsetEndAdjustment: number; +} { + const composerHeight = Math.max(0, input.composerHeight); + return input.isDraftHeroState + ? { viewportPaddingBottom: 0, contentInsetEndAdjustment: composerHeight } + : { viewportPaddingBottom: composerHeight, contentInsetEndAdjustment: 0 }; +} + +export function canCreateEmptyWorktreeThread(input: { + hasSendableContent: boolean; + isLocalDraftThread: boolean; + envMode: DraftThreadEnvMode; +}): boolean { + return !input.hasSendableContent && input.isLocalDraftThread && input.envMode === "worktree"; +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, @@ -217,6 +238,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ export interface PullRequestDialogState { initialReference: string | null; + autoSubmit: boolean; key: number; } @@ -270,6 +292,8 @@ export function deriveComposerSendState(options: { * contexts do: a prompt of just element chips is still a valid send. */ elementContextCount?: number; + /** Optional document-attachment count; a document alone is a valid send. */ + documentCount?: number; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; @@ -281,6 +305,7 @@ export function deriveComposerSendState(options: { const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; const elementContextCount = options.elementContextCount ?? 0; + const documentCount = options.documentCount ?? 0; return { trimmedPrompt, sendableTerminalContexts, @@ -288,6 +313,7 @@ export function deriveComposerSendState(options: { hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0 || + documentCount > 0 || sendableTerminalContexts.length > 0 || elementContextCount > 0, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d532c8b1233b..3ade6265cdd7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -37,7 +37,6 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; @@ -87,7 +86,7 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { timelineContentOverflowsViewport, type TimelineScrollMode } from "./chat/timelineScroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -124,21 +123,29 @@ import { type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; +import { + selectWorktreeContentTabs, + useWorkspaceContentTabsStore, + type WorkspaceContentTabView, +} from "../workspaceContentTabsStore"; import { isPreviewSupportedInRuntime, setActivePreviewTab, useThreadPreviewState, } from "../previewStateStore"; +import { openUrlInPreview } from "../browser/openFileInPreview"; import { addBrowserSurface } from "./preview/addBrowserSurface"; +import { openBrowserPreviewInChat } from "./preview/openBrowserPreviewInChat"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; -import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { getConfiguredPreviewUrls, previewTabLabel } from "./preview/previewEmptyStateLogic"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; +import TasksDock from "./tasks/TasksDock"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -149,19 +156,22 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + ScanSearchIcon, TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import GitActionsControl from "./GitActionsControl"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { - buildProjectScript, + appendProjectScript, commandForProjectScript, - nextProjectScriptId, + isTaskTerminalId, projectScriptIdFromCommand, + replaceProjectScript, } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; @@ -169,6 +179,7 @@ import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { consumePendingDraftAutoSubmit, hasPendingDraftAutoSubmit } from "../draftAutoSubmit"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -176,7 +187,7 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -195,6 +206,7 @@ import { formatElementContextLabel, } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; +import { useWorkspaceThreadRef, useWorkspaceWorktreeKey } from "../lib/workspaceThreadRef"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -218,14 +230,17 @@ import { useThreadProposedPlans, useThreadRefs, useThreadShell, + useThreadShellsForProjectRefs, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { FileViewModeToggle } from "./FileViewModeToggle"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; +import { WorktreeThreadTabs } from "./chat/WorktreeThreadTabs"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -254,6 +269,7 @@ import { buildLocalDraftThread, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, + canCreateEmptyWorktreeThread, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, @@ -270,6 +286,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveTimelineComposerLayout, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -309,6 +326,8 @@ import { useAssetUrls } from "../assets/assetUrls"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const DOCUMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Open the attached file(s) from the paths noted below and respond using the conversation context.]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -655,7 +674,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const drawerTerminalSessions = useMemo( () => - knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), + knownTerminalSessions.filter( + (session) => + !panelTerminalIds.has(session.target.terminalId) && + // Task terminals are owned by the Run panel; keep them out of the drawer. + !isTaskTerminalId(session.target.terminalId), + ), [knownTerminalSessions, panelTerminalIds], ); const terminalLabelsById = useMemo(() => { @@ -727,6 +751,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); const [localFocusRequestId, setLocalFocusRequestId] = useState(0); const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + // See PersistentThreadTerminalPanel: hold off rooting a terminal until the + // thread's worktree is known so a new chat's terminal isn't stranded in the + // main repo root (the server never re-roots a running session). + const worktreeSettled = serverThread !== null || draftThread?.worktreePath != null; const effectiveWorktreePath = useMemo(() => { if (launchContext !== null) { return launchContext.worktreePath; @@ -736,13 +764,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const cwd = useMemo( () => launchContext?.cwd ?? - (project + (project && worktreeSettled ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : null), - [effectiveWorktreePath, launchContext?.cwd, project], + [effectiveWorktreePath, launchContext?.cwd, project, worktreeSettled], ); const runtimeEnv = useMemo( () => @@ -995,6 +1023,15 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane threadId: threadRef.threadId, }); const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + // A terminal must be rooted at the thread's worktree so `open .` (and scripts) + // act on the right folder. For a brand-new chat the worktree is still being + // created, so its path isn't known on the first render — root the terminal at + // the main repo root then and it stays stranded outside the worktree, because + // the server never re-roots a running session. Treat the worktree as "settled" + // only once the server thread has loaded (its worktree path is then + // authoritative) or a draft already carries one, and hold off opening until + // then instead of falling back to the repo root. + const worktreeSettled = serverThread !== null || draftThread?.worktreePath != null; const activeSummary = knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; @@ -1004,13 +1041,13 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane () => launchContext?.cwd ?? activeSummary?.cwd ?? - (project + (project && worktreeSettled ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath, }) : null), - [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + [activeSummary?.cwd, launchContext?.cwd, project, worktreeSettled, worktreePath], ); const runtimeEnv = useMemo( () => @@ -1050,7 +1087,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane const terminalCwd = launchContext?.cwd ?? summary?.cwd ?? - (project + (project && worktreeSettled ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, @@ -1074,6 +1111,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane project, surface.terminalIds, threadWorktreePath, + worktreeSettled, ]); if (!project || !cwd) return null; @@ -1154,6 +1192,7 @@ function ChatViewContent(props: ChatViewProps) { const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); + const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -1234,6 +1273,7 @@ function ChatViewContent(props: ChatViewProps) { ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const addComposerDraftDocuments = useComposerDraftStore((store) => store.addDocuments); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); @@ -1343,9 +1383,6 @@ function ChatViewContent(props: ChatViewProps) { return () => observer.disconnect(); }, [composerOverlayElement]); - const terminalUiState = useTerminalUiStateStore((state) => - selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), - ); const openTerminalThreadKeys = useTerminalUiStateStore( useShallow((state) => Object.entries(state.terminalUiStateByThreadKey).flatMap( @@ -1449,22 +1486,38 @@ function ChatViewContent(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const activeThreadRef = useMemo( + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), + [activeThread], + ); + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + // Chats sharing an on-disk worktree share one workspace — the same open + // files, diff, terminals, and preview. Route every workspace store and the + // terminal-session lookups through the worktree's representative ref so all + // siblings read and write the same panel state and see the same live + // terminals. Threads with no worktree resolve back to their own ref. + const workspaceThreadRef = useWorkspaceThreadRef(activeThreadRef); + const workspaceThreadKey = workspaceThreadRef ? scopedThreadKey(workspaceThreadRef) : null; + const workspaceThreadId = workspaceThreadRef?.threadId ?? null; + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, workspaceThreadRef), + ); const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, + environmentId: workspaceThreadRef?.environmentId ?? null, + threadId: workspaceThreadId, }); const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, + environmentId: workspaceThreadRef?.environmentId ?? null, + threadId: workspaceThreadId, }); const activeThreadKnownSessions = useMemo(() => { - if (activeThreadId === null) { + if (workspaceThreadId === null) { return []; } return activeThreadKnownSessionsRaw.filter( - (session) => session.target.threadId === activeThreadId, + (session) => session.target.threadId === workspaceThreadId, ); - }, [activeThreadId, activeThreadKnownSessionsRaw]); + }, [workspaceThreadId, activeThreadKnownSessionsRaw]); const activeServerOrderedTerminalIds = useMemo( () => activeThreadKnownSessions.map((session) => session.target.terminalId), [activeThreadKnownSessions], @@ -1483,34 +1536,21 @@ function ChatViewContent(props: ChatViewProps) { } return labels; }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], - ); - const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - const [timelineAnchor, setTimelineAnchor] = useState<{ - readonly threadKey: string | null; - readonly messageId: MessageId | null; - }>({ threadKey: activeThreadKey, messageId: null }); - if (timelineAnchor.threadKey !== activeThreadKey) { - setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); - } - const timelineAnchorMessageId = timelineAnchor.messageId; const activeRightPanelKind = useRightPanelStore((state) => - selectActiveRightPanel(state.byThreadKey, activeThreadRef), + selectActiveRightPanel(state.byThreadKey, workspaceThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; const rightPanelState = useRightPanelStore((state) => - selectThreadRightPanelState(state.byThreadKey, activeThreadRef), + selectThreadRightPanelState(state.byThreadKey, workspaceThreadRef), ); const activeRightPanelSurface = useRightPanelStore((state) => - selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), + selectActiveRightPanelSurface(state.byThreadKey, workspaceThreadRef), ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; - const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewState = useThreadPreviewState(workspaceThreadRef); const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => - selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), + selectThreadPreviewMiniPlayer(state.byThreadKey, workspaceThreadRef), ); const panelTerminalIds = useMemo( () => @@ -1529,27 +1569,20 @@ function ChatViewContent(props: ChatViewProps) { const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { - if (!activeThreadRef) return; - useRightPanelStore - .getState() - .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); - }, [activePreviewState.sessions, activeThreadRef]); - - useEffect(() => { - if (!activeThreadRef || !activePreviewMiniPlayer) return; + if (!workspaceThreadRef || !activePreviewMiniPlayer) return; const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); const sameTabOpenInPanel = previewPanelOpen && activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; if (!miniTabStillExists || sameTabOpenInPanel) { - usePreviewMiniPlayerStore.getState().close(activeThreadRef); + usePreviewMiniPlayerStore.getState().close(workspaceThreadRef); } }, [ activePreviewMiniPlayer, activePreviewState.sessions, activeRightPanelSurface, - activeThreadRef, + workspaceThreadRef, previewPanelOpen, ]); @@ -1603,9 +1636,34 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const activeProjectRefs = useMemo( + () => (activeProjectRef === null ? [] : [activeProjectRef]), + [activeProjectRef], + ); + const activeProjectThreadShells = useThreadShellsForProjectRefs(activeProjectRefs); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); + // Reuse the active thread's worktree for a fresh chat. Passing an explicit + // worktreePath keeps the create path from provisioning a new git worktree — + // multiple chats share one on-disk tree, matching the sidebar's + // "New thread on {branch}" affordance. + const newChatWorktreePath = activeThread?.worktreePath ?? null; + // File-diff "content tabs" live in the chat-column tab strip and are scoped to + // the worktree, so they stay visible while switching between its chats. Key + // them off the stable route ref (resolved via the shell/draft worktree) rather + // than `activeThread?.worktreePath`: `activeThread` swings between the server + // detail, shell, and draft sources whose worktree paths can transiently + // disagree, and any blink there would orphan the open tab and flicker it. + const contentTabsWorktreeKey = useWorkspaceWorktreeKey(routeThreadRef); + const contentTabsState = useWorkspaceContentTabsStore((state) => + selectWorktreeContentTabs(state.byWorktree, contentTabsWorktreeKey), + ); + const activeContentTab = + contentTabsState.tabs.find((tab) => tab.id === contentTabsState.activeTabId) ?? null; + // The browser preview is shown as a content tab in the chat column (beside the + // file/diff viewers), not in the right panel. + const previewTabActive = activeContentTab?.view === "preview"; const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -1643,9 +1701,9 @@ function ChatViewContent(props: ChatViewProps) { ); useEffect(() => { - if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; - useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); - }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); + if (!workspaceThreadRef || !activeEnvironmentBootstrapComplete) return; + useRightPanelStore.getState().reconcileFileSurfaces(workspaceThreadRef, activeProject !== null); + }, [activeEnvironmentBootstrapComplete, activeProject, workspaceThreadRef]); // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. @@ -1726,6 +1784,7 @@ function ChatViewContent(props: ChatViewProps) { } setPullRequestDialogState({ initialReference: reference ?? null, + autoSubmit: Boolean(reference?.trim()), key: Date.now(), }); }, @@ -1814,14 +1873,53 @@ function ChatViewContent(props: ChatViewProps) { ); const handlePreparedPullRequestThread = useCallback( - async (input: { branch: string; worktreePath: string | null }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", - }); + async (input: { + branch: string; + worktreePath: string | null; + reuseLocalCheckout?: boolean; + }) => { + const existing = activeProjectThreadShells.find( + (shell) => + activeProject !== null && + shell.environmentId === activeProject.environmentId && + shell.projectId === activeProject.id && + shell.archivedAt === null && + shell.worktreePath !== null && + (input.worktreePath !== null + ? shell.worktreePath === input.worktreePath + : shell.branch === input.branch), + ); + if (existing) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams({ + environmentId: existing.environmentId, + threadId: existing.id, + }), + }); + return true; + } + if (input.worktreePath !== null) { + await openOrReuseProjectDraftThread({ + branch: input.branch, + worktreePath: input.worktreePath, + envMode: "worktree", + }); + return true; + } + // The branch is the primary checkout — git can't give it a second + // worktree, so open a chat rooted at that checkout in local mode. + if (input.reuseLocalCheckout) { + await openOrReuseProjectDraftThread({ + branch: input.branch, + worktreePath: null, + envMode: "local", + }); + return true; + } + return false; }, - [openOrReuseProjectDraftThread], + [activeProject, activeProjectThreadShells, navigate, openOrReuseProjectDraftThread], ); useEffect(() => { @@ -2315,7 +2413,19 @@ function ChatViewContent(props: ChatViewProps) { const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; const isDraftHeroState = - isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + isLocalDraftThread && + timelineEntries.length === 0 && + !isWorking && + !draftHeroDockRequested && + // A file/diff/preview content tab fills the chat column as an overlay that + // reserves space for the composer via its measured height. The hero + // composer is `absolute inset-0`, so its height would collapse the file + // content to nothing — dock the composer instead so the tab stays visible. + !activeContentTab; + const timelineComposerLayout = resolveTimelineComposerLayout({ + isDraftHeroState, + composerHeight: composerOverlayHeight, + }); const [ attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, @@ -2379,6 +2489,26 @@ function ChatViewContent(props: ChatViewProps) { input: { cwd: gitStatusCwd }, }), ); + // The reference a review or new worktree chat targets must follow the + // worktree's *actual* checked-out branch — the one shown in the footer chip — + // not the thread's stored `branch`, which drifts after a checkout, PR + // checkout, or branch switch inside the worktree. Mirror the footer's + // resolution (live git ref, then stored branch) for worktree-backed chats so + // a review never runs against a stale reference; local chats keep the stored + // value. + const newChatWorktreeBranch = newChatWorktreePath + ? (gitStatusQuery.data?.refName ?? activeThread?.branch ?? null) + : (activeThread?.branch ?? null); + const handleNewChatInScope = useCallback(() => { + if (!activeProjectRef) return; + void handleNewThread(activeProjectRef, { + branch: newChatWorktreeBranch, + worktreePath: newChatWorktreePath, + envMode: newChatWorktreePath ? "worktree" : "local", + forceNew: true, + preservePreviousDraft: true, + }); + }, [activeProjectRef, newChatWorktreeBranch, newChatWorktreePath, handleNewThread]); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g. @@ -2426,10 +2556,43 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + // Keep the fixed Diff + Files tabs present. A brand-new thread opens the panel + // with both (Diff active); focusing any thread with a diff snaps back to the + // Diff tab, unless the user last explicitly picked Files (then Files stays). + // A closed panel stays closed on return — the fresh-open only defaults new + // threads. + const autoOpenedRightPanelKeysRef = useRef>(new Set()); + useEffect(() => { + if (!workspaceThreadRef || !activeProject || !activeThreadKey || shouldUsePlanSidebarSheet) + return; + const store = useRightPanelStore.getState(); + const state = selectThreadRightPanelState(store.byThreadKey, workspaceThreadRef); + // The repository diff is available for client-side drafts too. Adding it + // immediately makes Diff the true first-open default; after that, the + // persisted active surface remains whatever the user selected last. + const diffAvailable = isGitRepo; + const firstVisit = !autoOpenedRightPanelKeysRef.current.has(activeThreadKey); + if (firstVisit) autoOpenedRightPanelKeysRef.current.add(activeThreadKey); + if (firstVisit && !state.isOpen && state.surfaces.length === 0) { + store.ensureFixedSurfaces( + workspaceThreadRef, + { diff: diffAvailable, files: true }, + { open: true, activateDefault: true }, + ); + return; + } + // Backfill the fixed tabs on any thread whose panel is already present, and + // snap focus to the Diff tab whenever a diff is available — unless the user + // last explicitly selected the Files tab, which we preserve. + if (state.isOpen || state.surfaces.length > 0) { + store.ensureFixedSurfaces( + workspaceThreadRef, + { diff: diffAvailable, files: true }, + { focusDiffUnlessFiles: true }, + ); + } + }, [workspaceThreadRef, activeProject, activeThreadKey, isGitRepo, shouldUsePlanSidebarSheet]); const showComposerContextStrip = isGitRepo && activeProject !== null; - const initialDiffPanelGitScope = - gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; - const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -2463,10 +2626,10 @@ function ChatViewContent(props: ChatViewProps) { if (!diffOpen) { onDiffPanelOpen?.(); } - if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); + if (workspaceThreadRef) { + useRightPanelStore.getState().toggle(workspaceThreadRef, "diff"); } - }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); + }, [workspaceThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); const envLocked = Boolean( activeThread && @@ -2554,13 +2717,13 @@ function ChatViewContent(props: ChatViewProps) { ); const setTerminalOpen = useCallback( (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); + if (!workspaceThreadRef) return; + storeSetTerminalOpen(workspaceThreadRef, open); }, - [activeThreadRef, storeSetTerminalOpen], + [workspaceThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; if (nextOpen && terminalUiState.terminalIds.length === 0) { if (!activeThreadId || !activeProject) { @@ -2571,7 +2734,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); - storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); + storeEnsureTerminal(workspaceThreadRef, terminalId, { open: true }); void openTerminal({ environmentId, input: { @@ -2592,7 +2755,7 @@ function ChatViewContent(props: ChatViewProps) { activeKnownTerminalIds, activeProject, activeThreadId, - activeThreadRef, + workspaceThreadRef, activeThreadWorktreePath, environmentId, gitCwd, @@ -2605,7 +2768,7 @@ function ChatViewContent(props: ChatViewProps) { ]); const splitTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + if (!workspaceThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2614,9 +2777,9 @@ function ChatViewContent(props: ChatViewProps) { } const terminalId = nextTerminalId(activeKnownTerminalIds); if (direction === "vertical") { - storeSplitTerminalVertical(activeThreadRef, terminalId); + storeSplitTerminalVertical(workspaceThreadRef, terminalId); } else { - storeSplitTerminal(activeThreadRef, terminalId); + storeSplitTerminal(workspaceThreadRef, terminalId); } setTerminalFocusRequestId((value) => value + 1); void openTerminal({ @@ -2637,7 +2800,7 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeKnownTerminalIds, activeThreadId, - activeThreadRef, + workspaceThreadRef, openTerminal, activeThreadWorktreePath, environmentId, @@ -2648,7 +2811,7 @@ function ChatViewContent(props: ChatViewProps) { ], ); const createNewTerminal = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) { + if (!workspaceThreadRef || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2656,7 +2819,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const terminalId = nextTerminalId(activeKnownTerminalIds); - storeNewTerminal(activeThreadRef, terminalId); + storeNewTerminal(workspaceThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId, @@ -2675,7 +2838,7 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeKnownTerminalIds, activeThreadId, - activeThreadRef, + workspaceThreadRef, openTerminal, activeThreadWorktreePath, environmentId, @@ -2684,7 +2847,7 @@ function ChatViewContent(props: ChatViewProps) { ]); const closeTerminal = useCallback( (terminalId: string) => { - if (!activeThreadId || !activeThreadRef) return; + if (!activeThreadId || !workspaceThreadRef) return; const fallbackExitWrite = () => writeTerminal({ environmentId, @@ -2703,12 +2866,12 @@ function ChatViewContent(props: ChatViewProps) { await fallbackExitWrite(); } })(); - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(workspaceThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [ activeThreadId, - activeThreadRef, + workspaceThreadRef, closeTerminalMutation, environmentId, storeCloseTerminal, @@ -2747,7 +2910,7 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: targetWorktreePath, }); setTerminalOpen(true); - if (!activeThreadRef) { + if (!workspaceThreadRef) { return; } setTerminalFocusRequestId((value) => value + 1); @@ -2781,9 +2944,9 @@ function ChatViewContent(props: ChatViewProps) { }; if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); + storeNewTerminal(workspaceThreadRef, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); + storeSetActiveTerminal(workspaceThreadRef, targetTerminalId); } const openResult = await openTerminal({ environmentId, input: openTerminalInput }); @@ -2818,7 +2981,7 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeThread, activeThreadId, - activeThreadRef, + workspaceThreadRef, gitCwd, setTerminalOpen, setThreadError, @@ -2880,19 +3043,10 @@ function ChatViewContent(props: ChatViewProps) { if (!activeProject) { return AsyncResult.success(undefined); } - const nextId = nextProjectScriptId( - input.name, - activeProject.scripts.map((script) => script.id), + const { scripts: nextScripts, script: nextScript } = appendProjectScript( + activeProject.scripts, + input, ); - const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...activeProject.scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...activeProject.scripts, nextScript]; return persistProjectScripts({ projectId: activeProject.id, @@ -2900,7 +3054,7 @@ function ChatViewContent(props: ChatViewProps) { previousScripts: activeProject.scripts, nextScripts, keybinding: input.keybinding, - keybindingCommand: commandForProjectScript(nextId), + keybindingCommand: commandForProjectScript(nextScript.id), }); }, [activeProject, persistProjectScripts], @@ -2913,20 +3067,11 @@ function ChatViewContent(props: ChatViewProps) { if (!activeProject) { return AsyncResult.success(undefined); } - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); - if (!existingScript) { + const nextScripts = replaceProjectScript(activeProject.scripts, scriptId, input); + if (!nextScripts) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } - const updatedScript = buildProjectScript(existingScript.id, input); - const nextScripts = activeProject.scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, @@ -3020,77 +3165,187 @@ function ChatViewContent(props: ChatViewProps) { activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const togglePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (planSidebarOpen) { dismissPlanSidebarForCurrentTurn(); } else { planSidebarDismissedForTurnRef.current = null; } - useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); + useRightPanelStore.getState().toggle(workspaceThreadRef, "plan"); + }, [workspaceThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); const closePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().close(workspaceThreadRef); dismissPlanSidebarForCurrentTurn(); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); + }, [workspaceThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { - if (!activeThreadRef) return; - void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); - }, [activeThreadRef, openPreview]); - const addDiffSurface = useCallback(() => { - if (!activeThreadRef || !isServerThread || !isGitRepo) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); + if (!workspaceThreadRef) return; + void addBrowserSurface({ threadRef: workspaceThreadRef, openPreview }); + }, [workspaceThreadRef, openPreview]); + // Opens the project's configured localhost port as a new preview tab (from the + // chat header's "Preview" button). Each click opens its own tab. + const openConfiguredPreview = useCallback(() => { + const port = activeProject?.previewPort ?? null; + if (!workspaceThreadRef || port === null || !isPreviewSupportedInRuntime()) return; + void openUrlInPreview({ + threadRef: workspaceThreadRef, + url: `http://localhost:${port}`, + openPreview, + }); + }, [activeProject?.previewPort, workspaceThreadRef, openPreview]); + const startReviewInNewChat = useCallback(() => { + if (!activeProjectRef || !activeProject || !newChatWorktreePath) return; + const reviewModelSelection = + activeProject.reviewModelSelection ?? activeProject.defaultModelSelection; + void handleNewThread(activeProjectRef, { + branch: newChatWorktreeBranch, + worktreePath: newChatWorktreePath, + envMode: "worktree", + forceNew: true, + initialPrompt: primaryServerSettings.reviewPrompt, + ...(reviewModelSelection ? { modelSelection: reviewModelSelection } : {}), + autoSubmitInitialPrompt: true, + // A review opens another chat *beside* the current one, exactly like the + // tab strip's "New chat in this worktree". Without this the chat being + // viewed is dropped as the project's active draft, its route falls back + // to the index landing, and that landing resets this fresh review draft + // to the configured defaults — detaching it from the worktree. + preservePreviousDraft: true, + }); }, [ - activeThreadRef, - dismissPlanSidebarForCurrentTurn, - isGitRepo, - isServerThread, - onDiffPanelOpen, - planSidebarOpen, + activeProject, + activeProjectRef, + handleNewThread, + newChatWorktreeBranch, + newChatWorktreePath, + primaryServerSettings.reviewPrompt, ]); - const addFilesSurface = useCallback(() => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().open(activeThreadRef, "files"); - }, [activeProject, activeThreadRef]); + const startConflictResolutionInNewChat = useCallback(() => { + if (!activeProjectRef || !newChatWorktreePath || !activeThread) return; + void handleNewThread(activeProjectRef, { + branch: newChatWorktreeBranch, + worktreePath: newChatWorktreePath, + envMode: "worktree", + forceNew: true, + initialPrompt: primaryServerSettings.resolvePrompt, + modelSelection: activeThread.modelSelection, + autoSubmitInitialPrompt: true, + }); + }, [ + activeProjectRef, + activeThread, + handleNewThread, + newChatWorktreeBranch, + newChatWorktreePath, + primaryServerSettings.resolvePrompt, + ]); + // Opens a file's contents as its own tab in the chat-column strip + // (worktree-scoped), chosen from the Files explorer — the file-view sibling + // of the diff tabs opened from the Diff navigator. const openFileSurface = useCallback( (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + if (!contentTabsWorktreeKey) return; + useWorkspaceContentTabsStore.getState().openFile(contentTabsWorktreeKey, relativePath); + }, + [contentTabsWorktreeKey], + ); + // Opens a file's diff as its own tab in the chat-column strip (worktree-scoped), + // chosen from the Diff navigator tree. + const openFileDiffSurface = useCallback( + (filePath: string) => { + if (!contentTabsWorktreeKey) return; + useWorkspaceContentTabsStore.getState().openFileDiff(contentTabsWorktreeKey, filePath); }, - [activeProject, activeThreadRef], + [contentTabsWorktreeKey], + ); + const selectContentTab = useCallback( + (tabId: string) => { + if (!contentTabsWorktreeKey) return; + useWorkspaceContentTabsStore.getState().activateTab(contentTabsWorktreeKey, tabId); + // Preview content tabs are keyed by their preview session id, so focusing + // one must also make that session the active preview. This is a no-op for + // file/diff tabs, whose ids are file paths rather than session ids. + if (workspaceThreadRef) setActivePreviewTab(workspaceThreadRef, tabId); + }, + [contentTabsWorktreeKey, workspaceThreadRef], + ); + const closeContentTab = useCallback( + (tabId: string) => { + if (!contentTabsWorktreeKey) return; + const tab = contentTabsState.tabs.find((entry) => entry.id === tabId); + useWorkspaceContentTabsStore.getState().closeTab(contentTabsWorktreeKey, tabId); + // Closing a preview tab also tears down its underlying browser session. + if (tab?.view === "preview" && tab.previewTabId && workspaceThreadRef) { + void closePreviewSession({ + closePreview, + snapshot: activePreviewState.sessions[tab.previewTabId] ?? null, + tabId: tab.previewTabId, + threadRef: workspaceThreadRef, + }); + } + }, + [ + contentTabsWorktreeKey, + contentTabsState.tabs, + workspaceThreadRef, + closePreview, + activePreviewState.sessions, + ], + ); + const activateChatContent = useCallback(() => { + if (!contentTabsWorktreeKey) return; + useWorkspaceContentTabsStore.getState().activateChat(contentTabsWorktreeKey); + }, [contentTabsWorktreeKey]); + // Flip the single file viewer between the diff and the editable file contents. + const setContentTabView = useCallback( + (view: WorkspaceContentTabView) => { + if (!contentTabsWorktreeKey) return; + useWorkspaceContentTabsStore.getState().setTabView(contentTabsWorktreeKey, view); + }, + [contentTabsWorktreeKey], ); const togglePreviewPanel = useCallback(() => { - if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; + if (!workspaceThreadRef || !isPreviewSupportedInRuntime()) return; + // Preview is shown as a content tab in the chat column; toggle returns to the + // conversation. (The right-panel branch only fires for worktree-less threads + // that fall back to the panel.) + if (previewTabActive) { + activateChatContent(); + return; + } if (previewPanelOpen) { - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().close(workspaceThreadRef); return; } const activeTabId = activePreviewState.activeTabId; if (activeTabId) { - useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); + openBrowserPreviewInChat(workspaceThreadRef, activeTabId); } else { createBrowserSurface(); } - }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); + }, [ + activePreviewState.activeTabId, + workspaceThreadRef, + createBrowserSurface, + previewPanelOpen, + previewTabActive, + activateChatContent, + ]); const closePreviewPanel = useCallback(() => { - if (activeThreadRef) { + if (workspaceThreadRef) { setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().close(workspaceThreadRef); } - }, [activeThreadRef]); + }, [workspaceThreadRef]); const addTerminalSurface = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; + if (!workspaceThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); + useRightPanelStore.getState().openTerminal(workspaceThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ - environmentId: activeThreadRef.environmentId, + environmentId: workspaceThreadRef.environmentId, input: { threadId: activeThreadId, terminalId, @@ -3106,7 +3361,7 @@ function ChatViewContent(props: ChatViewProps) { activeKnownTerminalIds, activeProject, activeThreadId, - activeThreadRef, + workspaceThreadRef, activeThreadWorktreePath, gitCwd, openTerminal, @@ -3115,7 +3370,7 @@ function ChatViewContent(props: ChatViewProps) { const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { if ( - !activeThreadRef || + !workspaceThreadRef || !activeThreadId || !activeProject || activeRightPanelSurface?.kind !== "terminal" || @@ -3127,10 +3382,10 @@ function ChatViewContent(props: ChatViewProps) { const cwd = gitCwd ?? activeProject.workspaceRoot; useRightPanelStore .getState() - .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); + .splitTerminal(workspaceThreadRef, activeRightPanelSurface.id, terminalId, direction); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ - environmentId: activeThreadRef.environmentId, + environmentId: workspaceThreadRef.environmentId, input: { threadId: activeThreadId, terminalId, @@ -3148,7 +3403,7 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeRightPanelSurface, activeThreadId, - activeThreadRef, + workspaceThreadRef, activeThreadWorktreePath, gitCwd, openTerminal, @@ -3160,40 +3415,40 @@ function ChatViewContent(props: ChatViewProps) { }, [splitPanelTerminal]); const activatePanelTerminal = useCallback( (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + if (!workspaceThreadRef || activeRightPanelSurface?.kind !== "terminal") return; useRightPanelStore .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + .activateTerminal(workspaceThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef], + [activeRightPanelSurface, workspaceThreadRef], ); const closePanelTerminal = useCallback( (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + if (!workspaceThreadRef || activeRightPanelSurface?.kind !== "terminal") return; void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + environmentId: workspaceThreadRef.environmentId, + input: { threadId: workspaceThreadRef.threadId, terminalId, deleteHistory: true }, }); - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(workspaceThreadRef, terminalId); useRightPanelStore .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + .closeTerminal(workspaceThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], + [activeRightPanelSurface, workspaceThreadRef, closeTerminalMutation, storeCloseTerminal], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (surface.kind === "plan") { planSidebarDismissedForTurnRef.current = null; } else if (planSidebarOpen) { dismissPlanSidebarForCurrentTurn(); } - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + useRightPanelStore.getState().activateSurface(workspaceThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); + setActivePreviewTab(workspaceThreadRef, surface.resourceId); } if (surface.kind === "terminal") { setTerminalFocusRequestId((value) => value + 1); @@ -3202,10 +3457,16 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], + [ + workspaceThreadRef, + diffOpen, + dismissPlanSidebarForCurrentTurn, + onDiffPanelOpen, + planSidebarOpen, + ], ); const toggleRightPanel = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (rightPanelOpen) { if (planSidebarOpen) { closePlanSidebar(); @@ -3214,8 +3475,8 @@ function ChatViewContent(props: ChatViewProps) { } return; } - useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); + useRightPanelStore.getState().toggleVisibility(workspaceThreadRef); + }, [workspaceThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -3224,7 +3485,7 @@ function ChatViewContent(props: ChatViewProps) { }, [canMaximizeRightPanel, routeThreadKey]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; if (surfaces.some((surface) => surface.kind === "plan")) { dismissPlanSidebarForCurrentTurn(); } @@ -3235,22 +3496,22 @@ function ChatViewContent(props: ChatViewProps) { closePreview, snapshot: activePreviewState.sessions[surface.resourceId] ?? null, tabId: surface.resourceId, - threadRef: activeThreadRef, + threadRef: workspaceThreadRef, }); } if (surface.kind === "terminal") { for (const terminalId of surface.terminalIds) { - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(workspaceThreadRef, terminalId); void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + environmentId: workspaceThreadRef.environmentId, + input: { threadId: workspaceThreadRef.threadId, terminalId, deleteHistory: true }, }); } } } }, [ - activeThreadRef, + workspaceThreadRef, activePreviewState.sessions, closePreview, closeTerminalMutation, @@ -3259,34 +3520,34 @@ function ChatViewContent(props: ChatViewProps) { ], ); const syncActivePreviewSurface = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const nextActiveSurface = selectActiveRightPanelSurface( useRightPanelStore.getState().byThreadKey, - activeThreadRef, + workspaceThreadRef, ); if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { - setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); + setActivePreviewTab(workspaceThreadRef, nextActiveSurface.resourceId); } - }, [activeThreadRef]); + }, [workspaceThreadRef]); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; cleanupRightPanelSurfaces([surface]); - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + useRightPanelStore.getState().closeSurface(workspaceThreadRef, surface.id); syncActivePreviewSurface(); }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + [workspaceThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id); + useRightPanelStore.getState().closeOtherSurfaces(workspaceThreadRef, surface.id); syncActivePreviewSurface(); }, [ - activeThreadRef, + workspaceThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces, syncActivePreviewSurface, @@ -3294,26 +3555,26 @@ function ChatViewContent(props: ChatViewProps) { ); const closeRightPanelSurfacesToRight = useCallback( (surface: RightPanelSurface) => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id); + useRightPanelStore.getState().closeSurfacesToRight(workspaceThreadRef, surface.id); syncActivePreviewSurface(); }, [ - activeThreadRef, + workspaceThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces, syncActivePreviewSurface, ], ); const closeAllRightPanelSurfaces = useCallback(() => { - if (!activeThreadRef) return; + if (!workspaceThreadRef) return; cleanupRightPanelSurfaces(rightPanelState.surfaces); - useRightPanelStore.getState().closeAllSurfaces(activeThreadRef); - }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); + useRightPanelStore.getState().closeAllSurfaces(workspaceThreadRef); + }, [workspaceThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -3436,31 +3697,12 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); + const userScrollGenerationRef = useRef(0); const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { - anchorUserScrollGenerationRef.current += 1; + userScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, @@ -3469,52 +3711,17 @@ function ChatViewContent(props: ChatViewProps) { cancelTimelineLiveFollowForUserNavigationRef.current = cancelTimelineLiveFollowForUserNavigation; }, [cancelTimelineLiveFollowForUserNavigation]); - const getActiveTimelineTurnMetrics = useCallback( - (list?: LegendListRef | null) => { - const resolvedList = list ?? legendListRef.current; - const anchorIndex = activeTimelineAnchorIndexRef.current; - const state = resolvedList?.getState(); - if (!resolvedList || !state || anchorIndex === null) { - return null; - } - - return getAnchoredTurnMetrics({ - state, - anchorIndex, - composerOverlayHeight, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }, - [composerOverlayHeight], - ); const timelineRealContentOverflowsViewport = useCallback( (list?: LegendListRef | null) => { - const resolvedList = list ?? legendListRef.current; - const state = resolvedList?.getState(); - if (!resolvedList || !state || state.data.length === 0) { - return false; - } - - const lastRowIndex = state.data.length - 1; - const lastRowTop = state.positionAtIndex(lastRowIndex); - const lastRowHeight = state.sizeAtIndex(lastRowIndex); - if ( - typeof lastRowTop !== "number" || - typeof lastRowHeight !== "number" || - !Number.isFinite(lastRowTop) || - !Number.isFinite(lastRowHeight) - ) { - return false; - } - - const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); - const visibleScrollLength = Math.max( - 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, - ); - return realContentBottom > visibleScrollLength; + const state = (list ?? legendListRef.current)?.getState(); + return state + ? timelineContentOverflowsViewport({ + state, + composerOverlayHeight: timelineComposerLayout.contentInsetEndAdjustment, + }) + : false; }, - [composerOverlayHeight], + [timelineComposerLayout.contentInsetEndAdjustment], ); // Live-follow stays active after send/thread-open until an actual list scroll @@ -3522,9 +3729,7 @@ function ChatViewContent(props: ChatViewProps) { const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); void legendListRef.current?.scrollToEnd?.({ animated }); @@ -3561,103 +3766,8 @@ function ChatViewContent(props: ChatViewProps) { }; }, [activeThread?.id]); - const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = legendListRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } - return; - } - const scrollNode = list.getScrollableNode(); - let finished = false; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }); - }; - requestAnimationFrame(() => positionAnchor(12)); - }, []); - const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }, []); - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { + if (!isAtEnd && liveFollowUserScrollGenerationRef.current === userScrollGenerationRef.current) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); return; @@ -3666,7 +3776,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = isAtEnd; if (isAtEnd) { timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3680,23 +3790,14 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThread?.id) { return; } - if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { + if (liveFollowUserScrollGenerationRef.current !== userScrollGenerationRef.current) { return; } let secondFrame: number | null = null; const frame = requestAnimationFrame(() => { secondFrame = requestAnimationFrame(() => { - if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { - return; - } - if (pendingTimelineAnchorRef.current !== null) { - return; - } - if ( - positionedTimelineAnchorRef.current !== null && - settledTimelineAnchorRef.current !== positionedTimelineAnchorRef.current - ) { + if (liveFollowUserScrollGenerationRef.current !== userScrollGenerationRef.current) { return; } const list = legendListRef.current; @@ -3704,20 +3805,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - if (timelineScrollModeRef.current === "anchoring-new-turn") { - const metrics = getActiveTimelineTurnMetrics(list); - if (!metrics) { - return; - } - if (metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - - const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; - void list.scrollToOffset({ offset: nextOffset, animated: false }); - return; - } - if (timelineScrollModeRef.current !== "following-end") { return; } @@ -3735,32 +3822,23 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(secondFrame); } }; - }, [ - activeThread?.id, - timelineEntries, - getActiveTimelineTurnMetrics, - timelineRealContentOverflowsViewport, - ]); + }, [activeThread?.id, timelineEntries, timelineRealContentOverflowsViewport]); useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - useRightPanelStore.getState().open(activeThreadRef, "plan"); + if (workspaceThreadRef) { + useRightPanelStore.getState().open(workspaceThreadRef, "plan"); } } planSidebarDismissedForTurnRef.current = null; - // activeThreadRef resets transitively with the active thread. + // workspaceThreadRef resets transitively with the active thread. }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. @@ -3773,13 +3851,13 @@ function ChatViewContent(props: ChatViewProps) { if (latestTurnId && activePlan.turnId !== latestTurnId) return; const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; - if (activeThreadRef) { - useRightPanelStore.getState().open(activeThreadRef, "plan"); + if (workspaceThreadRef) { + useRightPanelStore.getState().open(workspaceThreadRef, "plan"); } }, [ activePlan, activeLatestTurn?.turnId, - activeThreadRef, + workspaceThreadRef, autoOpenPlanSidebar, planSidebarOpen, sidebarProposedPlan?.turnId, @@ -4499,9 +4577,10 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) return; + if (!sendCtx) return; const { images: composerImages, + documents: composerDocuments, terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, previewAnnotations: composerPreviewAnnotations, @@ -4521,12 +4600,99 @@ function ChatViewContent(props: ChatViewProps) { } = deriveComposerSendState({ prompt: promptForSend, imageCount: composerImages.length, + documentCount: composerDocuments.length, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length + composerReviewComments.length, }); + const shouldCreateEmptyWorktreeThread = canCreateEmptyWorktreeThread({ + hasSendableContent, + isLocalDraftThread, + envMode: sendEnvMode, + }); + if (shouldCreateEmptyWorktreeThread) { + if (!activeProject) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Choose a project first", + description: "This draft no longer points to an available project.", + }), + ); + return; + } + if (!activeThreadBranch) { + setThreadError(activeThread.id, "Select a base branch before creating the worktree."); + return; + } + + sendInFlightRef.current = true; + beginLocalDispatch({ preparingWorktree: !activeThread.worktreePath }); + setThreadError(activeThread.id, null); + + let worktreePath = activeThread.worktreePath; + let threadBranch = activeThreadBranch; + let failure: AtomCommandResult | null = null; + if (!worktreePath) { + const worktreeResult = await createWorktree({ + environmentId, + input: { + cwd: activeProject.workspaceRoot, + refName: activeThreadBranch, + newRefName: buildTemporaryWorktreeBranchName( + randomHex, + activeProject.worktreeBranchPrefix ?? settings.worktreeBranchPrefix, + ), + baseRefName: activeThreadBranch, + path: null, + }, + }); + if (worktreeResult._tag === "Failure") { + failure = worktreeResult; + } else { + worktreePath = worktreeResult.value.worktree.path; + threadBranch = worktreeResult.value.worktree.refName; + } + } + + if (failure === null && worktreePath) { + const createdAt = activeThread.createdAt; + const modelSelection = createModelSelection( + ctxSelectedModelSelection.instanceId, + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, + ctxSelectedModelSelection.options, + ); + const createResult = await createThread({ + environmentId, + input: { + threadId: activeThread.id, + projectId: activeProject.id, + title: "New thread", + modelSelection, + runtimeMode, + interactionMode, + branch: threadBranch, + worktreePath, + createdAt, + }, + }); + if (createResult._tag === "Failure") failure = createResult; + } + + if (failure !== null && !isAtomCommandInterrupted(failure)) { + const error = squashAtomCommandFailure(failure); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to create worktree thread.", + ); + } + sendInFlightRef.current = false; + resetLocalDispatch(); + return; + } + if (!sendCtx.providerAvailable) return; if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -4535,6 +4701,8 @@ function ChatViewContent(props: ChatViewProps) { promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); + activateChatContent(); + setMaximizedRightPanelThreadKey(null); await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, @@ -4543,6 +4711,7 @@ function ChatViewContent(props: ChatViewProps) { } const standaloneSlashCommand = composerImages.length === 0 && + composerDocuments.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && @@ -4599,6 +4768,8 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = true; + activateChatContent(); + setMaximizedRightPanelThreadKey(null); if (isDraftHeroState && activeThreadKey) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { @@ -4617,6 +4788,7 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; + const composerDocumentsSnapshot = [...composerDocuments]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; @@ -4635,44 +4807,57 @@ function ChatViewContent(props: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const attachmentOnlyBootstrapPrompt = + composerImagesSnapshot.length > 0 + ? IMAGE_ONLY_BOOTSTRAP_PROMPT + : DOCUMENT_ONLY_BOOTSTRAP_PROMPT; const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || attachmentOnlyBootstrapPrompt, }); - const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ + const turnAttachmentsPromise = Promise.all([ + ...composerImagesSnapshot.map(async (image) => ({ type: "image" as const, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl: await readFileAsDataUrl(image.file), })), - ); - const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - previewUrl: image.previewUrl, - })); - // Sending always returns to the live edge. The new row becomes the - // anchored end-space target so it lands near the top while the response - // streams into the reserved space below it. + ...composerDocumentsSnapshot.map(async (document) => ({ + type: "document" as const, + name: document.name, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + dataUrl: await readFileAsDataUrl(document.file), + })), + ]); + const optimisticAttachments = [ + ...composerImagesSnapshot.map((image) => ({ + type: "image" as const, + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + previewUrl: image.previewUrl, + })), + ...composerDocumentsSnapshot.map((document) => ({ + type: "document" as const, + id: document.id, + name: document.name, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + })), + ]; + // Sending always returns to the live edge and stays there, so the streaming + // response keeps hugging the composer instead of trailing empty space. isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); setOptimisticUserMessages((existing) => [ ...existing, { @@ -4715,6 +4900,8 @@ function ChatViewContent(props: ChatViewProps) { if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerDocumentsSnapshot.length > 0 && composerDocumentsSnapshot[0]) { + titleSeed = `File: ${composerDocumentsSnapshot[0].name}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { @@ -4790,7 +4977,10 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), + branch: buildTemporaryWorktreeBranchName( + randomHex, + activeProject.worktreeBranchPrefix ?? settings.worktreeBranchPrefix, + ), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, @@ -4828,6 +5018,8 @@ function ChatViewContent(props: ChatViewProps) { if ( promptRef.current.length === 0 && composerImagesRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.documents.length ?? + 0) === 0 && composerTerminalContextsRef.current.length === 0 && composerElementContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations @@ -4850,6 +5042,9 @@ function ChatViewContent(props: ChatViewProps) { composerElementContextsRef.current = composerElementContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); + if (composerDocumentsSnapshot.length > 0) { + addComposerDraftDocuments(composerDraftTarget, composerDocumentsSnapshot); + } setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); @@ -4877,6 +5072,25 @@ function ChatViewContent(props: ChatViewProps) { } }; + useEffect(() => { + if (!draftId || !hasPendingDraftAutoSubmit(draftId)) return; + if ( + !activeThread || + isSendBusy || + isConnecting || + threadDetailLoading || + activeEnvironmentUnavailable || + sendInFlightRef.current || + !composerRef.current?.getSendContext().providerAvailable || + promptRef.current.trim().length === 0 + ) { + return; + } + if (consumePendingDraftAutoSubmit(draftId)) { + void onSend(); + } + }); + const onInterrupt = async () => { if (!activeThread) return; const result = await interruptThreadTurn({ @@ -5104,18 +5318,12 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - // Position this sent row once LegendList has measured the anchored tail. + // Follow the live edge from the sent row onward. isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); setOptimisticUserMessages((existing) => [ ...existing, @@ -5185,8 +5393,8 @@ function ChatViewContent(props: ChatViewProps) { // step-tracking activities that the sidebar will display. if (nextInteractionMode === "default" && autoOpenPlanSidebar) { planSidebarDismissedForTurnRef.current = null; - if (activeThreadRef) { - useRightPanelStore.getState().open(activeThreadRef, "plan"); + if (workspaceThreadRef) { + useRightPanelStore.getState().open(workspaceThreadRef, "plan"); } } sendInFlightRef.current = false; @@ -5531,12 +5739,20 @@ function ChatViewContent(props: ChatViewProps) { }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { - if (!isServerThread || !activeThreadRef) return; + if (!isServerThread || !workspaceThreadRef || !activeThreadRef) return; + // A turn belongs to this conversation, so its selection is per chat; the + // diff surface itself is shared across the worktree. useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); - useRightPanelStore.getState().open(activeThreadRef, "diff"); + // Choosing a specific file opens its diff as its own tab in the chat + // column; the bare "open diff" action lands on the surface navigator. + if (filePath && contentTabsWorktreeKey) { + useWorkspaceContentTabsStore.getState().openFileDiff(contentTabsWorktreeKey, filePath); + } else { + useRightPanelStore.getState().open(workspaceThreadRef, "diff"); + } onDiffPanelOpen?.(); }, - [activeThreadRef, isServerThread, onDiffPanelOpen], + [workspaceThreadRef, activeThreadRef, isServerThread, onDiffPanelOpen, contentTabsWorktreeKey], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. @@ -5580,12 +5796,48 @@ function ChatViewContent(props: ChatViewProps) { {panelToggleControls}
); - const rightPanelContent = activeThreadRef ? ( + const rightPanelTabActions = activeProject ? ( +
+ + + } + > + + Review + + + Start a new chat with the configured review prompt + + +
+ ) : null; + const rightPanelHeaderActions = activeProject ? ( +
+ +
+ ) : null; + const rightPanelContent = workspaceThreadRef ? ( activeRightPanelSurface?.kind === "preview" ? ( ) : activeRightPanelSurface?.kind === "terminal" ? ( ) : activeRightPanelSurface?.kind === "plan" ? ( @@ -5624,7 +5883,7 @@ function ChatViewContent(props: ChatViewProps) { activeProposedPlan={sidebarProposedPlan} label={planSidebarLabel} environmentId={environmentId} - threadRef={activeThreadRef} + threadRef={workspaceThreadRef} markdownCwd={gitCwd ?? undefined} workspaceRoot={activeWorkspaceRoot} timestampFormat={timestampFormat} @@ -5639,7 +5898,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeProject.environmentId} cwd={activeWorkspaceRoot} projectName={activeProject.title} - threadRef={activeThreadRef} + threadRef={workspaceThreadRef} composerDraftTarget={composerDraftTarget} keybindings={keybindings} availableEditors={availableEditors} @@ -5655,6 +5914,28 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const tasksDock = + workspaceThreadRef && activeProject && activeWorkspaceRoot ? ( + + ) : null; + return (
{rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} @@ -5684,8 +5965,6 @@ function ChatViewContent(props: ChatViewProps) { {!rightPanelOpen ? panelLayoutControls : null} + { + if (tab.view !== "preview") { + return { + id: tab.id, + title: tab.filePath.slice(tab.filePath.lastIndexOf("/") + 1), + view: tab.view, + }; + } + const session = tab.previewTabId + ? activePreviewState.sessions[tab.previewTabId] + : undefined; + const url = session && session.navStatus._tag !== "Idle" ? session.navStatus.url : ""; + return { id: tab.id, title: previewTabLabel(url), view: tab.view }; + })} + activeContentTabId={contentTabsState.activeTabId} + onSelectContentTab={selectContentTab} + onCloseContentTab={closeContentTab} + onActivateChat={activateChatContent} + /> + setThreadError(activeThread.id, null)} @@ -5722,7 +6031,10 @@ function ChatViewContent(props: ChatViewProps) { />
{/* Messages Wrapper */} -
+
{/* Messages — LegendList handles virtualization and scrolling internally */} + {/* File content tab: fills the messages area while keeping the + composer docked below (still connected to the current chat). + A tab shows either the file's diff or its contents. */} + {activeContentTab && activeThreadRef ? ( +
+ + {activeContentTab.view === "preview" ? ( + + ) : activeContentTab.view === "file" && activeProject && activeWorkspaceRoot ? ( + + } + /> + ) : ( + + } + /> + )} + +
+ ) : null} + {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
- {activeThreadRef && activePreviewMiniPlayer ? ( + {workspaceThreadRef && activePreviewMiniPlayer ? ( @@ -6009,6 +6378,8 @@ function ChatViewContent(props: ChatViewProps) { threadId={activeThread.id} cwd={activeProject?.workspaceRoot ?? null} initialReference={pullRequestDialogState.initialReference} + autoSubmitInitialReference={pullRequestDialogState.autoSubmit} + headless={pullRequestDialogState.autoSubmit} onOpenChange={(open) => { if (!open) { closePullRequestDialog(); @@ -6046,6 +6417,8 @@ function ChatViewContent(props: ChatViewProps) { {rightPanelContent} @@ -6073,6 +6440,8 @@ function ChatViewContent(props: ChatViewProps) { {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 04de1784715e..867bb8611077 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildArchivedThreadActionItems, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, @@ -195,6 +196,67 @@ describe("buildThreadActionItems", () => { }); }); +describe("buildArchivedThreadActionItems", () => { + it("builds environment-scoped restore results for archived threads", async () => { + const archivedThread = makeThread({ + id: ThreadId.make("thread-archived"), + title: "Restore search indexing", + branch: "fix/archive-search", + archivedAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }); + const runThread = vi.fn(async (_thread: Thread) => undefined); + + const items = buildArchivedThreadActionItems({ + threads: [archivedThread, makeThread({ id: ThreadId.make("thread-active") })], + projectTitleByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + runThread, + }); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + value: `archived-thread:${LOCAL_ENVIRONMENT_ID}:thread-archived`, + searchTerms: ["Restore search indexing", "T3 Code", "fix/archive-search", "archived"], + description: "T3 Code · #fix/archive-search · Archived", + }); + + await items[0]?.run(); + expect(runThread).toHaveBeenCalledWith(archivedThread); + }); + + it("makes archived threads discoverable through the root thread search group", () => { + const archivedItems = buildArchivedThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-archived"), + title: "Recover websocket notes", + archivedAt: "2026-03-20T00:00:00.000Z", + }), + ], + projectTitleByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "websocket", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: archivedItems, + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("threads-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + `archived-thread:${LOCAL_ENVIRONMENT_ID}:thread-archived`, + ]); + }); +}); + describe("buildBrowseGroups", () => { it("waits for asynchronous browse navigation actions", async () => { let finishNavigation: (() => void) | undefined; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 058322744bb4..07bcc95364af 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -164,6 +164,39 @@ export function buildThreadActionItems(input: { + threads: ReadonlyArray; + projectTitleByKey: ReadonlyMap; + sortOrder: SidebarThreadSortOrder; + icon: ReactNode; + runThread: (thread: TThread) => Promise; +}): CommandPaletteActionItem[] { + return sortThreads( + input.threads.filter((thread) => thread.archivedAt !== null), + input.sortOrder, + ).map((thread) => { + const projectTitle = input.projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`); + const description = [projectTitle, thread.branch ? `#${thread.branch}` : null, "Archived"] + .filter((part): part is string => part !== undefined && part !== null) + .join(" · "); + + return { + kind: "action", + value: `archived-thread:${thread.environmentId}:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? "", "archived"], + title: thread.title, + description, + timestamp: formatRelativeTimeLabel(thread.archivedAt ?? thread.updatedAt ?? thread.createdAt), + icon: input.icon, + run: async () => { + await input.runThread(thread); + }, + }; + }); +} + function rankSearchFieldMatch(field: string, normalizedQuery: string): number { const normalizedField = normalizeSearchText(field); if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b4e874017d41..ac7a705573aa 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -17,6 +17,7 @@ import { type EnvironmentId, type FilesystemBrowseResult, type ProjectId, + type ProjectEntry, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, @@ -28,8 +29,10 @@ import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, + ArchiveRestoreIcon, CornerLeftUpIcon, FolderIcon, + FileIcon, FolderPlusIcon, LinkIcon, MessageSquareIcon, @@ -53,6 +56,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; @@ -64,6 +68,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; +import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -78,6 +83,9 @@ import { } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { useWorkspaceThreadRef } from "../lib/workspaceThreadRef"; +import { useWorkspaceContentTabsStore, worktreeContentTabsKey } from "../workspaceContentTabsStore"; +import { useRightPanelStore } from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -90,6 +98,7 @@ import { } from "../wslPaths"; import { ADDON_ICON_CLASS, + buildArchivedThreadActionItems, buildBrowseGroups, buildProjectActionItems, buildRootGroups, @@ -341,7 +350,7 @@ function errorMessage(error: unknown): string { } interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; + readonly kind: "add-project" | "new-thread-in" | "file"; } interface CommandPaletteUiState { @@ -354,6 +363,7 @@ type CommandPaletteUiAction = | { readonly _tag: "Toggle" } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } + | { readonly _tag: "OpenFile" } | { readonly _tag: "ClearOpenIntent" }; function reduceCommandPaletteUiState( @@ -372,6 +382,8 @@ function reduceCommandPaletteUiState( return { open: true, openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": return { open: true, openIntent: { kind: "new-thread-in" } }; + case "OpenFile": + return { open: true, openIntent: { kind: "file" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } @@ -386,6 +398,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const openFile = useCallback(() => dispatch({ _tag: "OpenFile" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); @@ -394,9 +407,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { select: (params) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const workspaceThreadRef = useWorkspaceThreadRef(routeThreadRef); const terminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + workspaceThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, workspaceThreadRef) + .terminalOpen : false, ); @@ -409,16 +424,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { terminalOpen, }, }); - if (command !== "commandPalette.toggle") { - return; - } + if (command !== "commandPalette.toggle" && command !== "file.open") return; event.preventDefault(); event.stopPropagation(); - toggleOpen(); + if (command === "file.open") openFile(); + else toggleOpen(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, terminalOpen, toggleOpen]); + }, [keybindings, openFile, terminalOpen, toggleOpen]); useEffect( () => @@ -427,11 +441,13 @@ export function CommandPalette({ children }: { children: ReactNode }) { openNewThreadIn(); } else if (detail.open === "add-project") { openAddProject(); + } else if (detail.open === "file") { + openFile(); } else { setOpen(true); } }), - [openAddProject, openNewThreadIn, setOpen], + [openAddProject, openFile, openNewThreadIn, setOpen], ); return ( @@ -502,6 +518,13 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + const { unarchiveThread } = useThreadActions(); + const archivedEnvironmentIds = useMemo( + () => [...new Set(projects.map((project) => project.environmentId))], + [projects], + ); + const { snapshots: archivedSnapshots, refresh: refreshArchivedThreads } = + useArchivedThreadSnapshots(archivedEnvironmentIds); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); @@ -722,6 +745,53 @@ function OpenCommandPaletteDialog(props: { const currentProjectCwd = currentProjectId ? (projectCwdById.get(currentProjectId) ?? null) : null; + const activeWorkspaceThreadRef = useWorkspaceThreadRef( + activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : activeDraftThread + ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) + : null, + ); + const activeWorktreePath = activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null; + const projectEntriesQuery = useEnvironmentQuery( + currentProjectEnvironmentId && currentProjectCwd + ? projectEnvironment.listEntries({ + environmentId: currentProjectEnvironmentId, + input: { cwd: activeWorktreePath ?? currentProjectCwd }, + }) + : null, + ); + const searchableProjectFiles = useMemo( + () => + (projectEntriesQuery.data?.entries ?? []).filter( + (entry): entry is ProjectEntry & { readonly kind: "file" } => entry.kind === "file", + ), + [projectEntriesQuery.data?.entries], + ); + const quickOpenFileItems = useMemo(() => { + if (!currentProjectEnvironmentId || !activeWorkspaceThreadRef) return []; + const worktreeKey = worktreeContentTabsKey(currentProjectEnvironmentId, activeWorktreePath); + return searchableProjectFiles.map((entry) => ({ + kind: "action", + value: `file:${entry.path}`, + title: entry.path.split("/").at(-1) ?? entry.path, + description: entry.path, + searchTerms: [entry.path], + icon: , + run: async () => { + if (worktreeKey) { + useWorkspaceContentTabsStore.getState().openFile(worktreeKey, entry.path); + } else { + useRightPanelStore.getState().openFile(activeWorkspaceThreadRef, entry.path); + } + }, + })); + }, [ + activeWorkspaceThreadRef, + activeWorktreePath, + currentProjectEnvironmentId, + searchableProjectFiles, + ]); const currentProjectCwdForBrowse = browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId ? currentProjectCwd @@ -915,6 +985,151 @@ function OpenCommandPaletteDialog(props: { }), [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], ); + const archivedWorktreeItems = useMemo(() => { + const activeWorktreeKeys = new Set( + threads.flatMap((thread) => + thread.archivedAt === null && thread.worktreePath !== null + ? [`${thread.environmentId}:${thread.projectId}:${thread.worktreePath}`] + : [], + ), + ); + const groups = new Map< + string, + { + environmentId: EnvironmentId; + projectId: ProjectId; + projectTitle: string; + workspaceRoot: string; + worktreePath: string; + threads: Array<(typeof archivedSnapshots)[number]["snapshot"]["threads"][number]>; + } + >(); + + for (const { environmentId, snapshot } of archivedSnapshots) { + const archivedProjectById = new Map( + snapshot.projects.map((project) => [project.id, project]), + ); + for (const thread of snapshot.threads) { + if (thread.worktreePath === null) continue; + const key = `${environmentId}:${thread.projectId}:${thread.worktreePath}`; + if (activeWorktreeKeys.has(key)) continue; + const project = archivedProjectById.get(thread.projectId); + if (!project) continue; + const existing = groups.get(key); + if (existing) { + existing.threads.push(thread); + continue; + } + groups.set(key, { + environmentId, + projectId: thread.projectId, + projectTitle: project.title, + workspaceRoot: project.workspaceRoot, + worktreePath: thread.worktreePath, + threads: [thread], + }); + } + } + + return [...groups.entries()].map(([key, group]): CommandPaletteActionItem => { + const representative = group.threads.toSorted((left, right) => + right.updatedAt.localeCompare(left.updatedAt), + )[0]!; + const branch = representative.branch; + const worktreeName = group.worktreePath.split(/[\\/]/).filter(Boolean).at(-1) ?? "Worktree"; + return { + kind: "action", + value: `archived-worktree:${key}`, + searchTerms: [ + branch ?? "", + worktreeName, + group.worktreePath, + group.projectTitle, + group.workspaceRoot, + ...group.threads.map((thread) => thread.title), + ], + title: branch ?? worktreeName, + description: `${group.projectTitle} · Archived worktree`, + icon: , + run: async () => { + for (const thread of group.threads) { + const result = await unarchiveThread(scopeThreadRef(group.environmentId, thread.id)); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to restore worktree", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + refreshArchivedThreads(); + return; + } + } + refreshArchivedThreads(); + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(group.environmentId, representative.id)), + }); + }, + }; + }); + }, [archivedSnapshots, navigate, refreshArchivedThreads, threads, unarchiveThread]); + const archivedThreadItems = useMemo(() => { + const projectTitleByKey = new Map( + archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.projects.map( + (project) => [`${environmentId}:${project.id}`, project.title] as const, + ), + ), + ); + const archivedThreads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ); + + return buildArchivedThreadActionItems({ + threads: archivedThreads, + projectTitleByKey, + sortOrder: clientSettings.sidebarThreadSortOrder, + icon: , + runThread: async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const result = await unarchiveThread(threadRef); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unarchive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + refreshArchivedThreads(); + return; + } + refreshArchivedThreads(); + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + }); + }, [ + archivedSnapshots, + clientSettings.sidebarThreadSortOrder, + navigate, + refreshArchivedThreads, + unarchiveThread, + ]); + const searchableThreadItems = useMemo( + () => [...allThreadItems, ...archivedThreadItems, ...archivedWorktreeItems], + [allThreadItems, archivedThreadItems, archivedWorktreeItems], + ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); const pushPaletteView = useCallback( @@ -1231,8 +1446,52 @@ function OpenCommandPaletteDialog(props: { pushPaletteView, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "file" || projectEntriesQuery.isPending) return; + clearOpenIntent(); + browseNavigation.invalidate(); + setViewStack([]); + setQuery(""); + pushPaletteView({ + addonIcon: , + groups: [ + { + value: "files", + label: "Files", + items: quickOpenFileItems, + }, + ], + }); + }, [ + browseNavigation, + clearOpenIntent, + openIntent, + projectEntriesQuery.isPending, + pushPaletteView, + quickOpenFileItems, + ]); + const actionItems: Array = []; + if (currentProjectId && activeWorkspaceThreadRef) { + actionItems.push({ + kind: "submenu", + value: "action:open-file", + searchTerms: ["open file", "quick open", "search files"], + title: "Open file", + icon: , + addonIcon: , + shortcutCommand: "file.open", + groups: [ + { + value: "files", + label: "Files", + items: quickOpenFileItems, + }, + ], + }); + } + if (projects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? @@ -1345,7 +1604,7 @@ function OpenCommandPaletteDialog(props: { query: deferredQuery, isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, - threadSearchItems: allThreadItems, + threadSearchItems: searchableThreadItems, }); const handleAddProjectForEnvironment = useCallback( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d10cb39f0e36..38e3ca49b5aa 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -5,48 +5,55 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; +import type { ReviewDiffPreviewSourceKind, ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, Columns2Icon, + CopyIcon, PilcrowIcon, Rows3Icon, SearchIcon, - TextWrapIcon, + Undo2Icon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { type DraftId } from "../composerDraftStore"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; +import { + type GitScope, + selectThreadDiffPanelSelection, + useDiffPanelStore, +} from "../diffPanelStore"; +import { selectViewedSignatures, useDiffViewedStore } from "../diffViewedStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, getDiffCollapseIconClassName, getDiffLineStat, getRenderablePatch, - resolveDiffThemeName, resolveFileDiffPath, } from "../lib/diffRendering"; -import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; +import { buildDiffViewedSignature } from "../lib/diffViewedSignature"; +import { useDiffThemeName } from "../hooks/useDiffThemeName"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; +import { useWorkspaceThreadRef } from "../lib/workspaceThreadRef"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; +import { DiffNavigatorFileList, type DiffNavigatorFile } from "./chat/ChangedFilesTree"; +import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; +import { Checkbox } from "./ui/checkbox"; import { Combobox, ComboboxEmpty, @@ -76,12 +83,32 @@ type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; -interface CollapsedDiffFilesState { +// The dropdown's three git scopes. "Working tree" is the combined view (every +// change since the fork point, committed *and* uncommitted); "Uncommitted +// changes" is the local dirty diff; "Branch changes" is the committed range. +const GIT_SCOPE_LABELS: Record = { + all: "Working tree", + unstaged: "Uncommitted changes", + branch: "Branch changes", +}; +const GIT_SCOPE_SOURCE_KIND: Record = { + all: "working-tree-all", + unstaged: "working-tree", + branch: "branch-range", +}; + +/** + * Tracks which files the user has explicitly expanded, keyed by file path so + * expansion survives edits to other files. Files default to collapsed, so an + * empty set (a fresh scope, or returning after navigating away) shows every + * file collapsed. + */ +interface ExpandedDiffFilesState { readonly scopeKey: string | null; - readonly fileKeys: ReadonlySet; + readonly filePaths: ReadonlySet; } -const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); +const EMPTY_EXPANDED_DIFF_FILE_PATHS: ReadonlySet = new Set(); const DIFF_PANEL_UNSAFE_CSS = ` [data-diffs-header], @@ -94,6 +121,8 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-light: var(--foreground) !important; + --diffs-dark: var(--foreground) !important; --diffs-token-light-bg: transparent; --diffs-token-dark-bg: transparent; @@ -184,7 +213,39 @@ const DIFF_PANEL_UNSAFE_CSS = ` interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; - initialGitScope: "branch" | "unstaged"; + /** + * The chat this panel belongs to. Passed explicitly because a brand-new chat + * is a draft with no thread route params yet; when omitted the panel falls + * back to the route params (server chats). + */ + threadRef?: ScopedThreadRef | null; + /** + * `navigator` (default) renders only the changed-file tree; clicking a file + * opens its diff in a dedicated tab via `onOpenFileDiff`. `file` renders a + * single file's diff — the per-file tab opened from the navigator. + */ + variant?: "navigator" | "file"; + /** The file whose diff to show when `variant === "file"`. */ + fileDiffPath?: string; + /** + * The file whose diff is currently open in a dedicated tab — highlighted in + * the `navigator` variant's file list so the active row stands out. + */ + navigatorActiveFilePath?: string | null; + /** Invoked when a file is chosen in the navigator tree. */ + onOpenFileDiff?: (filePath: string) => void; + /** + * Optional control rendered at the trailing edge of the `file` variant + * toolbar — used to host the shared diff/edit toggle for the file viewer. + */ + fileViewToggle?: ReactNode; + /** + * Optional revert handler for the `file` variant. When provided, the toolbar + * shows an undo button that discards the focused file's changes. Omitted + * until a per-file discard capability exists, so the button stays hidden + * rather than rendering a dead control. + */ + onRevertFile?: (filePath: string) => void; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; @@ -192,57 +253,115 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline", composerDraftTarget, - initialGitScope: initialGitScopeProp, + threadRef: threadRefProp, + variant = "navigator", + fileDiffPath, + navigatorActiveFilePath, + onOpenFileDiff, + fileViewToggle, + onRevertFile, }: DiffPanelProps) { const { resolvedTheme } = useTheme(); + const diffThemeName = useDiffThemeName(); const settings = useClientSettings(); - const [initialGitScope] = useState(initialGitScopeProp); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [diffRenderMode, setDiffRenderMode] = useState("split"); + // Line wrapping follows the saved client setting; the file toolbar no longer + // exposes a per-view toggle for it. + const [wordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); - const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ + const [copiedFilePath, setCopiedFilePath] = useState(false); + const copiedResetTimerRef = useRef | null>(null); + useEffect( + () => () => { + if (copiedResetTimerRef.current) clearTimeout(copiedResetTimerRef.current); + }, + [], + ); + const [expandedDiffFiles, setExpandedDiffFiles] = useState(() => ({ scopeKey: null, - fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, + filePaths: EMPTY_EXPANDED_DIFF_FILE_PATHS, })); - const codeViewRef = useRef(null); - + // The `file` variant focuses a single file's diff; the navigator never shows + // an inline diff (clicking a file opens a dedicated per-file tab instead). + const focusedFilePath = variant === "file" ? (fileDiffPath ?? null) : null; + // Whether the navigator tree shows folders expanded (defaults to open so the + // full folder structure is visible, matching the changed-files list). const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); - const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useThread(routeThreadRef); - const activeProjectId = activeThread?.projectId ?? null; + // A brand-new chat is a client-side draft with no thread route params, so the + // host passes its pre-allocated ref explicitly; server chats fall back to the + // route params. + const currentThreadRef = threadRefProp ?? routeThreadRef; + const activeThreadId = currentThreadRef?.threadId ?? null; + // The diff view is part of the shared per-worktree workspace: chats in one + // worktree key their diff selection and reviewed-file state off the worktree's + // representative thread. Turn summaries still come from the current chat + // (turns are per-chat), but the worktree's working-tree/branch diff, its git + // status, and the diff store keying resolve through the representative — so a + // not-yet-sent draft shows the shared diff immediately instead of only after + // its first message promotes it to a server thread. + const workspaceThreadRef = useWorkspaceThreadRef(currentThreadRef); + const activeThread = useThread(currentThreadRef); + // The worktree's data source. For a draft the current chat has no server + // thread yet, so this resolves to the representative sibling that owns the + // worktree; for an ordinary chat it's the same worktree either way. + const workspaceThread = useThread(workspaceThreadRef) ?? activeThread; + // A worktree can hold only a not-yet-sent draft (no server thread), so + // `workspaceThread` is null even though the draft already targets a worktree + // on disk. Read that draft's worktree/environment/project so the shared diff, + // git status, and branch preview load on open instead of only after the first + // message promotes the draft to a server thread. Drafts with no worktree keep + // their empty per-chat panel, matching the pre-draft behavior. + const draftThreadsByThreadKey = useComposerDraftStore((state) => state.draftThreadsByThreadKey); + const workspaceDraft = useMemo(() => { + if (workspaceThread || !workspaceThreadRef) return null; + const draft = Object.values(draftThreadsByThreadKey).find( + (candidate) => + candidate.environmentId === workspaceThreadRef.environmentId && + candidate.threadId === workspaceThreadRef.threadId, + ); + return draft?.worktreePath != null ? draft : null; + }, [draftThreadsByThreadKey, workspaceThread, workspaceThreadRef]); + // The resolved worktree data source: a real thread when one exists, otherwise + // the worktree-targeting draft. Everything below keys off these instead of + // `workspaceThread` so a draft-only worktree behaves like a real one. + const workspaceEnvironmentId = + workspaceThread?.environmentId ?? workspaceDraft?.environmentId ?? null; + const hasWorkspaceSource = workspaceThread != null || workspaceDraft != null; + const activeProjectId = workspaceThread?.projectId ?? workspaceDraft?.projectId ?? null; const activeProject = useProject( - activeThread && activeProjectId + workspaceEnvironmentId && activeProjectId ? { - environmentId: activeThread.environmentId, + environmentId: workspaceEnvironmentId, projectId: activeProjectId, } : null, ); - const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; - const serverConfig = useAtomValue( - serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), - ); + const activeCwd = + workspaceThread?.worktreePath ?? workspaceDraft?.worktreePath ?? activeProject?.workspaceRoot; + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(workspaceEnvironmentId)); const openInPreferredEditor = useOpenInPreferredEditor( - activeThread?.environmentId ?? null, + workspaceEnvironmentId, serverConfig?.availableEditors ?? [], ); const gitStatusQuery = useEnvironmentQuery( - activeThread !== null && activeThread !== undefined && activeCwd != null + workspaceEnvironmentId != null && activeCwd != null ? vcsEnvironment.status({ - environmentId: activeThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: activeCwd }, }) : null, ); const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection( - state.byThreadKey, - routeThreadRef, - initialGitScope === "unstaged", + state, + // Turn selection is per chat; the working-tree/branch view is shared + // across the worktree via the representative thread. + currentThreadRef, + workspaceThreadRef, ), ); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; @@ -264,19 +383,26 @@ export default function DiffPanel({ ); useEffect(() => { - if (!routeThreadRef || diffSelection.kind !== "turn") return; + if (!currentThreadRef || diffSelection.kind !== "turn") return; useDiffPanelStore.getState().reconcileTurnSelection( - routeThreadRef, + currentThreadRef, orderedTurnDiffSummaries.map((summary) => summary.turnId), ); - }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); + }, [currentThreadRef, diffSelection, orderedTurnDiffSummaries]); const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; - const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; + const selectedGitScope: GitScope = + diffSelection.kind === "unstaged" + ? "unstaged" + : diffSelection.kind === "branch" + ? "branch" + : "all"; const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; - const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; - const selectedFileRevealRequestId = - diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; + const openPullRequestBaseRef = + gitStatusQuery.data?.pr?.state === "open" ? gitStatusQuery.data.pr.baseRef : null; + // Automatic review scopes follow the open change request's actual target. + // This matters for stacked branches and PRs retargeted away from the repo default. + const effectiveBaseRef = selectedBaseRef ?? openPullRequestBaseRef; const selectedTurn = selectedTurnId === null ? undefined @@ -288,25 +414,33 @@ export default function DiffPanel({ const latestTurn = orderedTurnDiffSummaries[0]; const selectedScopeLabel = selectedTurnId === null - ? selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes" + ? GIT_SCOPE_LABELS[selectedGitScope] : selectedTurn?.turnId === latestTurn?.turnId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope; - const collapseScopeKey = routeThreadRef - ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` + const collapseScopeKey = workspaceThreadRef + ? `${workspaceThreadRef.environmentId}:${workspaceThreadRef.threadId}:${reviewSectionId}` + : null; + const viewedScopeId = + selectedTurn !== undefined + ? reviewSectionId + : selectedGitScope === "unstaged" + ? "unstaged" + : "branch-and-working-tree"; + const viewedScopeKey = workspaceThreadRef + ? `${workspaceThreadRef.environmentId}:${workspaceThreadRef.threadId}:${viewedScopeId}` : null; - const collapsedDiffFileKeys = - collapsedDiffFiles.scopeKey === collapseScopeKey - ? collapsedDiffFiles.fileKeys - : EMPTY_COLLAPSED_DIFF_FILE_KEYS; + const expandedDiffFilePaths = + expandedDiffFiles.scopeKey === collapseScopeKey + ? expandedDiffFiles.filePaths + : EMPTY_EXPANDED_DIFF_FILE_PATHS; + const viewedSignatures = useDiffViewedStore((state) => + selectViewedSignatures(state.viewedByScope, viewedScopeKey), + ); const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes"; + : GIT_SCOPE_LABELS[selectedGitScope]; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -329,13 +463,19 @@ export default function DiffPanel({ { enabled: isGitRepo && selectedTurn !== undefined }, ); const primaryBranchDiffPreview = useEnvironmentQuery( - selectedTurnId === null && activeThread && activeCwd + selectedTurnId === null && workspaceEnvironmentId && activeCwd ? reviewEnvironment.diffPreview({ - environmentId: activeThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: activeCwd, - ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ...(effectiveBaseRef ? { baseRef: effectiveBaseRef } : {}), + ...(openPullRequestBaseRef && selectedBaseRef === null + ? { preferRemoteBaseRef: true } + : {}), ignoreWhitespace: diffIgnoreWhitespace, + ...(focusedFilePath !== null || expandedDiffFilePaths.size > 0 + ? { fullContext: true } + : {}), }, }) : null, @@ -346,13 +486,19 @@ export default function DiffPanel({ serverConfig?.cwd !== undefined && serverConfig.cwd !== activeCwd; const fallbackBranchDiffPreview = useEnvironmentQuery( - shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig + shouldRetryBranchDiffAtEnvironmentCwd && workspaceEnvironmentId && serverConfig ? reviewEnvironment.diffPreview({ - environmentId: activeThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: serverConfig.cwd, - ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ...(effectiveBaseRef ? { baseRef: effectiveBaseRef } : {}), + ...(openPullRequestBaseRef && selectedBaseRef === null + ? { preferRemoteBaseRef: true } + : {}), ignoreWhitespace: diffIgnoreWhitespace, + ...(focusedFilePath !== null || expandedDiffFilePaths.size > 0 + ? { fullContext: true } + : {}), }, }) : null, @@ -360,16 +506,62 @@ export default function DiffPanel({ const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd ? fallbackBranchDiffPreview : primaryBranchDiffPreview; + const latestCompletedTurnMarker = latestTurn + ? `${latestTurn.turnId}:${latestTurn.completedAt}` + : null; + const refreshBranchDiffPreview = branchDiffPreview.refresh; + const refreshGitStatus = gitStatusQuery.refresh; + const workingTreeDiffMarker = gitStatusQuery.data + ? JSON.stringify({ + refName: gitStatusQuery.data.refName, + aheadCount: gitStatusQuery.data.aheadCount, + files: gitStatusQuery.data.workingTree.files.map((file) => [ + file.path, + file.insertions, + file.deletions, + ]), + }) + : null; + const workingTreeDiffMarkerRef = useRef(null); + useEffect(() => { + if (selectedTurnId !== null || workingTreeDiffMarker === null) return; + if (workingTreeDiffMarkerRef.current === null) { + workingTreeDiffMarkerRef.current = workingTreeDiffMarker; + return; + } + if (workingTreeDiffMarkerRef.current === workingTreeDiffMarker) return; + workingTreeDiffMarkerRef.current = workingTreeDiffMarker; + // VCS status streams exact numstat updates while files are edited. Refresh + // the patch snapshot as those stats change so navigator line counters and + // viewed signatures do not wait for the current turn to finish. + const refreshTimer = setTimeout(refreshBranchDiffPreview, 100); + return () => clearTimeout(refreshTimer); + }, [refreshBranchDiffPreview, selectedTurnId, workingTreeDiffMarker]); + const refreshedTurnMarkerRef = useRef(null); + useEffect(() => { + if ( + latestCompletedTurnMarker === null || + refreshedTurnMarkerRef.current === latestCompletedTurnMarker + ) { + return; + } + refreshedTurnMarkerRef.current = latestCompletedTurnMarker; + // The query atom is shared and cached by cwd/base. A completed turn changes + // the filesystem without changing that key, so explicitly replace the stale + // branch/working-tree snapshot with one that includes the turn's local work. + refreshBranchDiffPreview(); + refreshGitStatus(); + }, [latestCompletedTurnMarker, refreshBranchDiffPreview, refreshGitStatus]); const selectedGitSource = branchDiffPreview.data?.sources.find( - (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + (source) => source.kind === GIT_SCOPE_SOURCE_KIND[selectedGitScope], ); const localBranchRefs = useEnvironmentQuery( selectedTurnId === null && selectedGitScope === "branch" && - activeThread && + workspaceEnvironmentId && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ - environmentId: activeThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: branchDiffPreview.data.cwd, includeMatchingRemoteRefs: true, @@ -383,10 +575,10 @@ export default function DiffPanel({ const remoteBranchRefs = useEnvironmentQuery( selectedTurnId === null && selectedGitScope === "branch" && - activeThread && + workspaceEnvironmentId && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ - environmentId: activeThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: branchDiffPreview.data.cwd, includeMatchingRemoteRefs: true, @@ -425,8 +617,10 @@ export default function DiffPanel({ () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, { compactPartialHunkOffsets: selectedTurnId === null, + expandableFullContext: + selectedTurnId === null && (focusedFilePath !== null || expandedDiffFilePaths.size > 0), }), - [resolvedTheme, selectedPatch, selectedTurnId], + [expandedDiffFilePaths.size, focusedFilePath, resolvedTheme, selectedPatch, selectedTurnId], ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { @@ -439,34 +633,107 @@ export default function DiffPanel({ }), ); }, [renderablePatch]); + const sharedGitViewedSignatures = useMemo(() => { + if (selectedTurn !== undefined || selectedGitScope === "unstaged") return null; + const signaturesByKind = new Map>(); + for (const kind of ["branch-range", "working-tree-all"] as const) { + const source = branchDiffPreview.data?.sources.find((candidate) => candidate.kind === kind); + const patch = source ? getRenderablePatch(source.diff, `diff-viewed:${kind}`) : null; + signaturesByKind.set( + kind, + new Map( + patch?.kind === "files" + ? patch.files.map((file) => [resolveFileDiffPath(file), buildDiffViewedSignature(file)]) + : [], + ), + ); + } + const branchSignatures = signaturesByKind.get("branch-range")!; + const workingTreeSignatures = signaturesByKind.get("working-tree-all")!; + const paths = new Set([...branchSignatures.keys(), ...workingTreeSignatures.keys()]); + return new Map( + [...paths].map((path) => [ + path, + `${branchSignatures.get(path) ?? ""}:${workingTreeSignatures.get(path) ?? ""}`, + ]), + ); + }, [branchDiffPreview.data?.sources, selectedGitScope, selectedTurn]); const codeViewFiles = useMemo( () => renderableFiles.map((fileDiff) => { const fileKey = buildFileDiffRenderKey(fileDiff); + const filePath = resolveFileDiffPath(fileDiff); + const signature = buildDiffViewedSignature(fileDiff); + const viewedSignature = sharedGitViewedSignatures?.get(filePath) ?? signature; return { fileDiff, - filePath: resolveFileDiffPath(fileDiff), + filePath, fileKey, - collapsed: collapsedDiffFileKeys.has(fileKey), + signature, + // Viewed only counts while the file's content is unchanged since it + // was marked; an edit clears the mark ("the check is gone"). + viewed: viewedSignatures[filePath] === viewedSignature, + viewedSignature, + // Files default to collapsed; only explicit expansion opens them. + collapsed: !expandedDiffFilePaths.has(filePath), }; }), - [collapsedDiffFileKeys, renderableFiles], + [expandedDiffFilePaths, renderableFiles, sharedGitViewedSignatures, viewedSignatures], ); - const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); - const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); - const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]); - + const viewedFileKeySet = useMemo( + () => new Set(codeViewFiles.filter((file) => file.viewed).map((file) => file.fileKey)), + [codeViewFiles], + ); + const signatureByFilePath = useMemo( + () => new Map(codeViewFiles.map((file) => [file.filePath, file.viewedSignature])), + [codeViewFiles], + ); + const currentViewedSignatures = sharedGitViewedSignatures ?? signatureByFilePath; useEffect(() => { - if (!selectedFilePath) return; - const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath); - if (!file) return; - codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" }); - }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]); + if (!viewedScopeKey || !hasResolvedPatch) return; + useDiffViewedStore + .getState() + .reconcileViewedSignatures(viewedScopeKey, currentViewedSignatures); + }, [currentViewedSignatures, hasResolvedPatch, viewedScopeKey]); + const focusedFile = useMemo( + () => + focusedFilePath + ? (codeViewFiles.find((file) => file.filePath === focusedFilePath) ?? null) + : null, + [codeViewFiles, focusedFilePath], + ); + // The focused file is always expanded; otherwise show the whole change set. + const displayedCodeViewFiles = useMemo( + () => (focusedFile ? [{ ...focusedFile, collapsed: false }] : codeViewFiles), + [codeViewFiles, focusedFile], + ); + // The navigator renders a flat list of the changed files (no folder nesting). + // Per-file stats come from each file's own hunks so the list matches the diff + // exactly; `viewed` drives the dim + move-to-bottom treatment. + const navigatorFiles = useMemo(() => { + const conflictedPaths = new Set( + selectedTurnId === null + ? (gitStatusQuery.data?.workingTree.files + .filter((file) => file.conflicted === true) + .map((file) => file.path) ?? []) + : [], + ); + return codeViewFiles.map(({ fileDiff, filePath, viewed }) => { + const stat = getDiffLineStat([fileDiff]); + return { + path: filePath, + additions: stat.additions, + deletions: stat.deletions, + viewed, + conflicted: conflictedPaths.has(filePath), + }; + }); + }, [codeViewFiles, gitStatusQuery.data?.workingTree.files, selectedTurnId]); const openDiffFile = useCallback( (filePath: string) => { openDiffFilePrimaryAction({ - threadRef: routeThreadRef, + threadRef: workspaceThreadRef, filePath, activeCwd, openInEditor: (targetPath) => { @@ -475,10 +742,10 @@ export default function DiffPanel({ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { console.warn("Failed to open diff file in editor.", { operation: "open-diff-file", - ...(routeThreadRef + ...(currentThreadRef ? { - environmentId: routeThreadRef.environmentId, - threadId: routeThreadRef.threadId, + environmentId: currentThreadRef.environmentId, + threadId: currentThreadRef.threadId, } : {}), ...safeErrorLogAttributes(squashAtomCommandFailure(result)), @@ -488,334 +755,464 @@ export default function DiffPanel({ }, }); }, - [activeCwd, openInPreferredEditor, routeThreadRef], + [activeCwd, openInPreferredEditor, currentThreadRef, workspaceThreadRef], ); - const toggleDiffFileCollapsed = useCallback( - (fileKey: string) => { - setCollapsedDiffFiles((current) => { - const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); - if (next.has(fileKey)) { - next.delete(fileKey); + const copyFocusedFilePath = useCallback((filePath: string) => { + void navigator.clipboard + .writeText(filePath) + .then(() => { + setCopiedFilePath(true); + if (copiedResetTimerRef.current) clearTimeout(copiedResetTimerRef.current); + copiedResetTimerRef.current = setTimeout(() => setCopiedFilePath(false), 1500); + }) + .catch(() => {}); + }, []); + const setDiffFileExpanded = useCallback( + (filePath: string, expanded: boolean) => { + setExpandedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.filePaths : []); + if (expanded) { + next.add(filePath); } else { - next.add(fileKey); + next.delete(filePath); } - return { scopeKey: collapseScopeKey, fileKeys: next }; + return { scopeKey: collapseScopeKey, filePaths: next }; }); }, [collapseScopeKey], ); - const toggleDiffFileCollapse = useCallback(() => { - setCollapsedDiffFiles((current) => { - const currentKeys = - current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; + const toggleDiffFileCollapsed = useCallback( + (filePath: string) => { + setExpandedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.filePaths : []); + if (next.has(filePath)) { + next.delete(filePath); + } else { + next.add(filePath); + } + return { scopeKey: collapseScopeKey, filePaths: next }; + }); + }, + [collapseScopeKey], + ); - return { - scopeKey: collapseScopeKey, - fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys), - }; - }); - }, [collapseScopeKey, diffFileKeys]); + const setFileViewed = useCallback( + (filePath: string, viewed: boolean) => { + if (!viewedScopeKey) return; + const signature = signatureByFilePath.get(filePath); + if (signature === undefined) return; + useDiffViewedStore.getState().setFileViewed(viewedScopeKey, filePath, signature, viewed); + // Collapse a file when it is marked viewed, and expand it when unmarked. + setDiffFileExpanded(filePath, !viewed); + // After marking a file viewed, jump to the next still-unviewed file so a + // review flows file-to-file without hunting the list. Wraps around, and + // stops (stays put) once everything is viewed. + if (viewed && onOpenFileDiff) { + const order = codeViewFiles.map((file) => file.filePath); + const currentIndex = order.indexOf(filePath); + if (currentIndex >= 0) { + const isViewed = (candidate: string) => { + if (candidate === filePath) return true; + const candidateSignature = signatureByFilePath.get(candidate); + return ( + candidateSignature !== undefined && viewedSignatures[candidate] === candidateSignature + ); + }; + const rotated = [...order.slice(currentIndex + 1), ...order.slice(0, currentIndex)]; + const nextFilePath = rotated.find((candidate) => !isViewed(candidate)); + if (nextFilePath) onOpenFileDiff(nextFilePath); + } + } + }, + [ + codeViewFiles, + onOpenFileDiff, + setDiffFileExpanded, + signatureByFilePath, + viewedScopeKey, + viewedSignatures, + ], + ); const selectTurn = (turnId: TurnId) => { - if (!routeThreadRef) return; - useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); + // Turns belong to this conversation, so the selection keys off the chat's + // own ref rather than the shared worktree representative. + if (!currentThreadRef) return; + useDiffPanelStore.getState().selectTurn(currentThreadRef, turnId); }; - const selectGitScope = (scope: "branch" | "unstaged") => { - if (!routeThreadRef) return; - useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); + const selectGitScope = (scope: GitScope) => { + if (!workspaceThreadRef || !currentThreadRef) return; + useDiffPanelStore.getState().selectGitScope(workspaceThreadRef, currentThreadRef, scope); }; const selectBranchBaseRef = (baseRef: string | null) => { - if (!routeThreadRef) return; - useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); + if (!workspaceThreadRef || !currentThreadRef) return; + useDiffPanelStore.getState().selectBranchBaseRef(workspaceThreadRef, currentThreadRef, baseRef); }; + const focusedFileDirName = focusedFile + ? focusedFile.filePath.slice(0, focusedFile.filePath.lastIndexOf("/") + 1) + : ""; + const focusedFileBaseName = focusedFile + ? focusedFile.filePath.slice(focusedFile.filePath.lastIndexOf("/") + 1) + : ""; + const filePathPill = focusedFile ? ( + + ) : null; + const headerRow = ( <>
- - - {selectedScopeLabel} - - - - selectGitScope("unstaged")} - > - Working tree - - selectGitScope("branch")} - > - Branch changes - - { - if (latestTurn) selectTurn(latestTurn.turnId); - }} - > - Latest turn - - - Turn - - {orderedTurnDiffSummaries.map((summary) => { - const turnCount = - summary.checkpointTurnCount ?? - inferredCheckpointTurnCountByTurnId[summary.turnId] ?? - "?"; - return ( - selectTurn(summary.turnId)} - > - Turn {turnCount} - - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} - - - ); - })} - - - - - {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( -
- {selectedGitSource.headRef ?? "HEAD"} - - { - if (!open) setBaseRefQuery(""); - }} - onValueChange={(value) => { - if (!value) return; - selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value); - }} - > - + + - {selectedGitSource.baseRef} - - - -
-
-
-
-
-
- No matching refs. - - {selectedScopeLabel} + + + + selectGitScope("all")} + > + Working tree + + selectGitScope("unstaged")} + > + Uncommitted changes + + selectGitScope("branch")} + > + Branch changes + + { + if (latestTurn) selectTurn(latestTurn.turnId); + }} + > + Latest turn + + + Turn + + {orderedTurnDiffSummaries.map((summary) => { + const turnCount = + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId] ?? + "?"; + return ( + selectTurn(summary.turnId)} + > + Turn {turnCount} + + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + + + ); + })} + + + + + {selectedTurnId === null && + selectedGitScope === "branch" && + selectedGitSource?.baseRef && ( +
+ + {selectedGitSource.headRef ?? "HEAD"} + + + { + if (!open) setBaseRefQuery(""); + }} + onValueChange={(value) => { + if (!value) return; + selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value); + }} > - Automatic - - {baseRefChoices.map((choice) => { - const item = valueForBaseRefChoice(choice); - const hasBoth = choice.local !== null && choice.remote !== null; - const useRemote = choice.remote?.name === item; - return ( - -
- {choice.label} - {hasBoth ? ( -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > - { - const nextRef = checked - ? choice.remote?.name - : choice.local?.name; - if (nextRef) selectBranchBaseRef(nextRef); - }} - /> -
- ) : choice.remote ? ( - - - ) : null} + + {selectedGitSource.baseRef} + + + +
+
+
+
+
+
+
+ No matching refs. + + + Automatic + + {baseRefChoices.map((choice) => { + const item = valueForBaseRefChoice(choice); + const hasBoth = choice.local !== null && choice.remote !== null; + const useRemote = choice.remote?.name === item; + return ( + +
+ {choice.label} + {hasBoth ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + { + const nextRef = checked + ? choice.remote?.name + : choice.local?.name; + if (nextRef) selectBranchBaseRef(nextRef); + }} + /> +
+ ) : choice.remote ? ( + + + ) : null} +
+
+ ); + })} +
+ +
+
+ )} + )}
-
- {codeViewFiles.length > 0 && ( - - )} - {codeViewFiles.length > 0 && ( + {variant === "file" ? ( +
+ {focusedFile ? ( + + + setFileViewed(focusedFile.filePath, checked)} + /> + Viewed + + } + /> + + {focusedFile.viewed + ? "Marked viewed — unchecks automatically if the file changes" + : "Mark as viewed"} + + + ) : null} + {focusedFile && onRevertFile ? ( + + onRevertFile(focusedFile.filePath)} + > + + + } + /> + Revert file changes + + ) : null} + { + const next = value[0]; + if (next === "stacked" || next === "split") { + setDiffRenderMode(next); + } + }} + > + + + + + } + /> + Unified view + + + + + + } + /> + Split view + + { + setDiffIgnoreWhitespace(Boolean(pressed)); + }} /> } > - {allDiffFilesCollapsed ? ( - - ) : ( - - )} + - {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} + {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} - )} - { - const next = value[0]; - if (next === "stacked" || next === "split") { - setDiffRenderMode(next); - } - }} - > - - - - - - - - - { - setWordWrap(Boolean(pressed)); - }} - /> - } - > - - - - {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} - - - - + copyFocusedFilePath(focusedFile.filePath)} + > + {copiedFilePath ? ( + + ) : ( + + )} + } - variant="outline" - size="xs" - pressed={diffIgnoreWhitespace} - onPressedChange={(pressed) => { - setDiffIgnoreWhitespace(Boolean(pressed)); - }} /> - } - > - - - - {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} - - -
+ {copiedFilePath ? "Copied" : "Copy file path"} + + ) : null} + {fileViewToggle ?? null} +
+ ) : null} ); return ( - {!activeThread ? ( + {!hasWorkspaceSource ? (
Select a thread to inspect turn diffs.
@@ -828,125 +1225,177 @@ export default function DiffPanel({ No completed turns yet.
) : ( - <> -
- {isSelectedPatchTruncated && ( -

- This diff was truncated because it exceeded the preview limit. The changes shown are - incomplete. -

- )} - {selectedPatchError && !renderablePatch && ( -
-

{selectedPatchError}

+
+ {isSelectedPatchTruncated && ( +

+ This diff was truncated because it exceeded the preview limit. The changes shown are + incomplete. +

+ )} + {selectedPatchError && !renderablePatch && ( +
+

{selectedPatchError}

+
+ )} + {!renderablePatch ? ( + isLoadingSelectedPatch ? ( + + ) : ( +
+

+ {hasNoNetChanges + ? "No net changes in this selection." + : "No patch available for this selection."} +

- )} - {!renderablePatch ? ( - isLoadingSelectedPatch ? ( - - ) : ( -
-

- {hasNoNetChanges - ? "No net changes in this selection." - : "No patch available for this selection."} -

-
- ) - ) : renderablePatch.kind === "files" ? ( -
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - const title = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-title"), - ); - const filePath = title?.textContent?.trim(); - if (filePath) openDiffFile(filePath); - }} - > - { - const filePath = resolveFileDiffPath(fileDiff); - return ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - - )} - - - {collapsed ? "Expand diff" : "Collapse diff"} - - - ); - }} - options={{ - diffStyle: diffRenderMode === "split" ? "split" : "unified", - lineDiffType: "none", - overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), - themeType: resolvedTheme as DiffThemeType, - unsafeCSS: DIFF_PANEL_UNSAFE_CSS, - stickyHeaders: true, - itemMetrics: { diffHeaderHeight: 33 }, - layout: { paddingTop: 0, paddingBottom: 8, gap: 8 }, - }} + ) + ) : variant === "navigator" ? ( + navigatorFiles.length > 0 ? ( +
+ onOpenFileDiff?.(filePath)} + onToggleViewed={setFileViewed} />
) : ( -
-
-

{renderablePatch.reason}

-
-                    {renderablePatch.text}
-                  
+
+

+ {renderablePatch.kind === "raw" + ? renderablePatch.reason + : "No changes in this selection."} +

+
+ ) + ) : renderablePatch.kind === "files" ? ( + focusedFile ? ( + <> +
+ {/* CodeView measures wrapped rows after rendering. Disable native scroll + anchoring so it does not compete with Pierre's viewport correction. */} + { + const filePath = resolveFileDiffPath(fileDiff); + const viewed = viewedFileKeySet.has(fileKey); + return ( + <> + + { + event.stopPropagation(); + toggleDiffFileCollapsed(filePath); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + + event.stopPropagation()} + onCheckedChange={(checked) => setFileViewed(filePath, checked)} + /> + } + /> + + {viewed ? "Mark as not viewed" : "Mark as viewed"} + + + + ); + }} + options={{ + diffStyle: diffRenderMode === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: wordWrap ? "wrap" : "scroll", + theme: diffThemeName, + themeType: resolvedTheme as DiffThemeType, + unsafeCSS: DIFF_PANEL_UNSAFE_CSS, + stickyHeaders: true, + hunkSeparators: "line-info", + // Keep Pierre's virtual window fine-grained even for wrapped rows. + // Larger chunks leave the rendered content stationary until scrolling + // crosses a chunk boundary, then visibly jump to catch up. + itemMetrics: { + diffHeaderHeight: 33, + hunkLineCount: 1, + lineHeight: 20, + }, + layout: { paddingTop: 0, paddingBottom: 8, gap: 8 }, + }} + />
+ + ) : ( +
+

This file has no changes in the current selection.

+
+ ) + ) : ( +
+
+

{renderablePatch.reason}

+
+                  {renderablePatch.text}
+                
- )} -
- +
+ )} +
)} ); diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6bcb2..3c368365ebf0 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -2,12 +2,12 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; -import { useTheme } from "../hooks/useTheme"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { useDiffThemeName } from "../hooks/useDiffThemeName"; +import { type DiffThemeName } from "../lib/diffRendering"; export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), - themeName: Schema.Literals(["pierre-light", "pierre-dark"]), + themeName: Schema.String, cause: Schema.Defect(), }) { override get message(): string { @@ -46,8 +46,7 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const diffThemeName = useDiffThemeName(); const workerPoolSize = useMemo(() => { const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); diff --git a/apps/web/src/components/FileViewModeToggle.tsx b/apps/web/src/components/FileViewModeToggle.tsx new file mode 100644 index 000000000000..32e0b74c8d36 --- /dev/null +++ b/apps/web/src/components/FileViewModeToggle.tsx @@ -0,0 +1,58 @@ +import type { WorkspaceContentTabView } from "../workspaceContentTabsStore"; +import { Toggle, ToggleGroup } from "./ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Segmented toggle for the single file viewer: "Diff" shows the file's diff, + * "Edit" shows its editable contents. Rendered inside each viewer's own header + * (DiffPanel / FilePreviewPanel) so the control stays in one place regardless + * of which view is active. + */ +export function FileViewModeToggle(props: { + view: WorkspaceContentTabView; + onChange: (view: WorkspaceContentTabView) => void; +}) { + const { view, onChange } = props; + return ( + { + const next = value[0]; + if (next === "diff") onChange("diff"); + else if (next === "edit") onChange("file"); + }} + > + + + Diff + + } + /> + Show the file's diff + + + + Edit + + } + /> + Edit the file contents + + + ); +} diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..d1b2821de379 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -88,8 +88,43 @@ describe("when: ref is clean and has an open PR", () => { icon: "pr", kind: "open_pr", }, + { + id: "merge", + label: "Merge PR", + disabled: false, + icon: "merge", + kind: "merge_pr", + }, ]); }); + + it("offers conflict resolution instead of attempting to merge a conflicting PR", () => { + const items = buildMenuItems( + status({ + pr: { + number: 12, + title: "Conflicting PR", + url: "https://example.com/pr/12", + baseRef: "main", + headRef: "feature/test", + state: "open", + mergeability: "conflicting", + }, + }), + false, + ); + + assert.deepEqual( + items.find((item) => item.id === "merge"), + { + id: "merge", + label: "Resolve conflicts", + disabled: false, + icon: "resolve", + kind: "resolve_conflicts", + }, + ); + }); }); describe("when: actions are busy", () => { @@ -209,6 +244,13 @@ describe("when: ref is clean, ahead, and has an open PR", () => { icon: "pr", kind: "open_pr", }, + { + id: "merge", + label: "Merge PR", + disabled: false, + icon: "merge", + kind: "merge_pr", + }, ]); }); }); @@ -346,7 +388,7 @@ describe("when: ref is behind upstream", () => { assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); }); - it("buildMenuItems disables push and create PR", () => { + it("buildMenuItems disables push and create PR but offers resolve conflicts", () => { const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); assert.deepEqual(items, [ { @@ -373,8 +415,20 @@ describe("when: ref is behind upstream", () => { kind: "open_dialog", dialogAction: "create_pr", }, + { + id: "resolve", + label: "Resolve conflicts", + disabled: false, + icon: "resolve", + kind: "resolve_conflicts", + }, ]); }); + + it("buildMenuItems omits resolve conflicts without an upstream", () => { + const items = buildMenuItems(status({ behindCount: 1, pr: null, hasUpstream: false }), false); + assert.isUndefined(items.find((item) => item.id === "resolve")); + }); }); describe("when: ref has diverged from upstream", () => { @@ -579,6 +633,13 @@ describe("when: working tree has local changes and ref is behind upstream", () = kind: "open_dialog", dialogAction: "create_pr", }, + { + id: "resolve", + label: "Resolve conflicts", + disabled: false, + icon: "resolve", + kind: "resolve_conflicts", + }, ]); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..3e7033113af4 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -10,16 +10,16 @@ import { type ChangeRequestTerminology, } from "../sourceControlPresentation"; -export type GitActionIconName = "commit" | "push" | "pr"; +export type GitActionIconName = "commit" | "push" | "pr" | "merge" | "resolve"; export type GitDialogAction = "commit" | "push" | "create_pr"; export interface GitActionMenuItem { - id: "commit" | "push" | "pr"; + id: "commit" | "push" | "pr" | "merge" | "resolve"; label: string; disabled: boolean; icon: GitActionIconName; - kind: "open_dialog" | "open_pr"; + kind: "open_dialog" | "open_pr" | "merge_pr" | "resolve_conflicts"; dialogAction?: GitDialogAction; } @@ -102,6 +102,7 @@ export function buildMenuItems( const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; + const hasConflictingPr = hasOpenPr && gitStatus.pr?.mergeability === "conflicting"; const isBehind = gitStatus.behindCount > 0; const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; @@ -121,6 +122,7 @@ export function buildMenuItems( !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; + const canMergePr = !isBusy && hasOpenPr; const commitItem: GitActionMenuItem = { id: "commit", @@ -135,32 +137,64 @@ export function buildMenuItems( return [commitItem]; } + const pushItem: GitActionMenuItem = { + id: "push", + label: "Push", + disabled: !canPush, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }; + + // Offer an AI-assisted "Resolve conflicts" action whenever the branch has + // diverged from (or fallen behind) its upstream, i.e. when merging origin + // back in may produce conflicts. The action itself is never gated on + // `isBusy` because it only hands a prompt to the agent. + const resolveItem: GitActionMenuItem | null = + hasBranch && gitStatus.hasUpstream && isBehind + ? { + id: "resolve", + label: "Resolve conflicts", + disabled: false, + icon: "resolve", + kind: "resolve_conflicts", + } + : null; + + if (hasOpenPr) { + return [ + commitItem, + pushItem, + { + id: "pr", + label: `View ${terminology.shortLabel}`, + disabled: !canOpenPr, + icon: "pr", + kind: "open_pr", + }, + { + id: "merge", + label: hasConflictingPr ? "Resolve conflicts" : `Merge ${terminology.shortLabel}`, + disabled: !canMergePr, + icon: hasConflictingPr ? "resolve" : "merge", + kind: hasConflictingPr ? "resolve_conflicts" : "merge_pr", + }, + ...(resolveItem ? [resolveItem] : []), + ]; + } + return [ commitItem, + pushItem, { - id: "push", - label: "Push", - disabled: !canPush, - icon: "push", + id: "pr", + label: `Create ${terminology.shortLabel}`, + disabled: !canCreatePr, + icon: "pr", kind: "open_dialog", - dialogAction: "push", + dialogAction: "create_pr", }, - hasOpenPr - ? { - id: "pr", - label: `View ${terminology.shortLabel}`, - disabled: !canOpenPr, - icon: "pr", - kind: "open_pr", - } - : { - id: "pr", - label: `Create ${terminology.shortLabel}`, - disabled: !canCreatePr, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, + ...(resolveItem ? [resolveItem] : []), ]; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 4f2bd19952d2..d62f17e152e5 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -26,9 +26,14 @@ import { ExternalLinkIcon, GitBranchPlusIcon, GitCommitIcon, + GitMergeIcon, InfoIcon, LockIcon, GlobeIcon, + MessageSquareWarningIcon, + TriangleAlertIcon, + WandSparklesIcon, + XIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; @@ -71,6 +76,7 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useOpenInPreferredEditor } from "~/editorPreferences"; import { + useGitMergePullRequestAction, useGitStackedAction, useSourceControlActionRunning, useSourceControlPublishRepositoryAction, @@ -79,7 +85,7 @@ import { } from "~/lib/sourceControlActions"; import { useThread } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; -import { serverEnvironment } from "~/state/server"; +import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -87,6 +93,7 @@ import { vcsEnvironment } from "~/state/vcs"; import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { markDraftForAutoSubmit } from "~/draftAutoSubmit"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { openPullRequestLink } from "~/lib/openPullRequestLink"; @@ -94,7 +101,10 @@ import { openPullRequestLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; + defaultBaseBranch?: string | null; draftId?: DraftId; + previewUrl?: string | null; + onSolveConflicts?: () => void; } interface PendingDefaultBranchAction { @@ -153,7 +163,12 @@ function requestVcsStatusRefresh( } void refresh({ environmentId, input: { cwd } }); } -const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; +const RUNNING_SOURCE_CONTROL_ACTIONS = [ + "runStackedAction", + "pull", + "publishRepository", + "mergePullRequest", +] as const; const PUBLISH_PROVIDER_OPTIONS = [ { @@ -305,6 +320,13 @@ function getMenuActionDisabledReason({ return "Push is currently unavailable."; } + if (item.id === "merge") { + if (!hasOpenPr) { + return `No open ${terminology.singular} to merge.`; + } + return `Merge ${terminology.singular} is currently unavailable.`; + } + if (hasOpenPr) { return `View ${terminology.singular} is currently unavailable.`; } @@ -339,6 +361,8 @@ function GitActionItemIcon({ }) { if (icon === "commit") return ; if (icon === "push") return ; + if (icon === "merge") return ; + if (icon === "resolve") return ; return ; } @@ -970,7 +994,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { export default function GitActionsControl({ gitCwd, activeThreadRef, + defaultBaseBranch = null, draftId, + previewUrl = null, + onSolveConflicts, }: GitActionsControlProps) { const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -997,6 +1024,8 @@ export default function GitActionsControl({ waitForShell: activeDraftThread !== null, }); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const setComposerPrompt = useComposerDraftStore((store) => store.setPrompt); + const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); @@ -1109,6 +1138,7 @@ export default function GitActionsControl({ const initAction = useVcsInitAction(sourceControlScope); const runImmediateGitAction = useGitStackedAction(sourceControlScope); const pullAction = useVcsPullAction(sourceControlScope); + const mergePrAction = useGitMergePullRequestAction(sourceControlScope); const isGitActionRunning = useSourceControlActionRunning( sourceControlScope, RUNNING_SOURCE_CONTROL_ACTIONS, @@ -1145,6 +1175,16 @@ export default function GitActionsControl({ return gitStatusForActions?.isDefaultRef ?? false; }, [gitStatusForActions?.isDefaultRef]); + const hasPrConflicts = + gitStatusForActions?.pr?.state === "open" && + !gitStatusForActions.hasWorkingTreeChanges && + gitStatusForActions.pr.mergeability === "conflicting"; + const unresolvedReviewThreadCount = + gitStatusForActions?.pr?.state === "open" + ? (gitStatusForActions.pr.unresolvedReviewThreadCount ?? 0) + : 0; + const unresolvedReviewCommentsLabel = `${unresolvedReviewThreadCount} unresolved review ${unresolvedReviewThreadCount === 1 ? "comment" : "comments"} on this ${changeRequestTerminology.singular}.`; + const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], @@ -1244,6 +1284,48 @@ export default function GitActionsControl({ }); }, [gitStatusForActions, threadToastData]); + const mergeExistingPr = useCallback(async () => { + const pr = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr : null; + if (!pr) { + toastManager.add({ + type: "error", + title: `No open ${changeRequestTerminology.singular} found.`, + data: threadToastData, + }); + return; + } + const toastId = toastManager.add({ + type: "loading", + title: `Merging ${changeRequestTerminology.singular}...`, + timeout: 0, + data: threadToastData, + }); + const result = await mergePrAction.run({ reference: String(pr.number) }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + const error = squashAtomCommandFailure(result); + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: `Failed to merge ${changeRequestTerminology.singular}`, + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + return; + } + toastManager.update(toastId, { + type: "success", + title: `Merged ${changeRequestTerminology.singular}`, + description: `${pr.title} (#${pr.number})`, + data: threadToastData, + }); + }, [changeRequestTerminology, gitStatusForActions, mergePrAction, threadToastData]); + runGitActionWithToast = useEffectEvent( async ({ action, @@ -1391,6 +1473,7 @@ export default function GitActionsControl({ const result = await runImmediateGitAction.run({ actionId, action, + ...(defaultBaseBranch ? { baseBranch: defaultBaseBranch } : {}), ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), @@ -1587,12 +1670,39 @@ export default function GitActionsControl({ } }; + const triggerResolveConflicts = () => { + const target = draftId ?? activeThreadRef; + if (!target) { + toastManager.add({ + type: "error", + title: "Open a chat before resolving conflicts.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }); + return; + } + setComposerPrompt(target, primaryServerSettings.resolvePrompt); + // Auto-submit is only wired for draft-backed composers (ChatView watches the + // draftId). When we only have a thread ref, fill the composer and let the + // user send it. + if (draftId) { + markDraftForAutoSubmit(draftId); + } + }; + const openDialogForMenuItem = (item: GitActionMenuItem) => { if (item.disabled) return; + if (item.kind === "resolve_conflicts") { + triggerResolveConflicts(); + return; + } if (item.kind === "open_pr") { void openExistingPr(); return; } + if (item.kind === "merge_pr") { + void mergeExistingPr(); + return; + } if (item.dialogAction === "push") { void runGitActionWithToast({ action: "push" }); return; @@ -1684,136 +1794,97 @@ export default function GitActionsControl({ {initAction.isPending ? "Initializing..." : "Initialize Git"} + ) : runImmediateGitAction.isPending ? ( + ) : ( - - {quickActionDisabledReason ? ( - - - } - > - - - {quickAction.label} - - - - {quickActionDisabledReason} - - - ) : ( +
+ {gitStatusForActions?.pr?.state === "open" ? ( + + ) : null} + {gitStatusForActions?.pr?.state === "open" && previewUrl ? ( - )} - - { - if (open) { - requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + ) : null} + {gitStatusForActions?.pr?.state === "open" && + gitStatusForActions.hasWorkingTreeChanges ? ( + Uncommitted changes + ) : null} + {unresolvedReviewThreadCount > 0 ? ( + + + + {unresolvedReviewThreadCount} + + } + /> + + {unresolvedReviewCommentsLabel} + + + ) : null} + - + {hasPrConflicts ? ( + + ) : null} + {gitStatusForActions?.pr?.state === "open" + ? gitStatusForActions.hasWorkingTreeChanges + ? "Commit and push" + : hasPrConflicts + ? "Resolve conflicts" + : gitStatusForActions.pr.checks === "failing" + ? `Merge · ${gitStatusForActions.pr.failedCheckCount ?? 1} ${gitStatusForActions.pr.failedCheckCount === 1 ? "check" : "checks"} failing` + : "Merge" + : "Create PR"} + +
)} void; + kind?: "chat" | "review"; + disabled?: boolean; + idPrefix: string; +}) { + const { environmentId, projectId, onChange, kind = "chat", disabled, idPrefix } = props; + const projectRef = useMemo( + () => scopeProjectRef(environmentId, projectId), + [environmentId, projectId], + ); + // Read the selection live so the control reflects the persisted value even + // when the enclosing dialog renders from a captured project snapshot. + const project = useProject(projectRef); + const value = + kind === "review" + ? (project?.reviewModelSelection ?? null) + : (project?.defaultModelSelection ?? null); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const config = serverConfigs.get(environmentId) ?? null; + + const entries = useMemo(() => { + const providers = config?.providers ?? []; + const settings = config?.settings; + const derived = deriveProviderInstanceEntries(providers); + const withSettings = settings ? applyProviderInstanceSettings(derived, settings) : derived; + return sortProviderInstanceEntries(withSettings).filter(isProviderInstancePickerVisible); + }, [config?.providers, config?.settings]); + + const providers = config?.providers ?? []; + + const selectedInstanceId = value?.instanceId ?? null; + const selectedEntry: ProviderInstanceEntry | undefined = selectedInstanceId + ? entries.find((entry) => entry.instanceId === selectedInstanceId) + : undefined; + + // A pinned instance that has since been removed/disabled still shows so the + // user can see (and clear) the stale pin instead of it silently vanishing. + const showStalePin = selectedInstanceId !== null && selectedEntry === undefined; + + const handleAccountChange = (next: string | null) => { + if (next === null || next === USE_DEFAULT_VALUE) { + onChange(null); + return; + } + const instanceId = next as ProviderInstanceId; + // Keep the model when re-selecting the same instance; otherwise reset to + // that instance's default so we never persist a cross-account model pair. + const model = + value?.instanceId === instanceId + ? value.model + : getDefaultProviderInstanceModel(providers, instanceId); + if (!model) { + // Instance reports no models yet (probe pending); pinning it now would + // produce an invalid selection, so leave the project on its default. + onChange(null); + return; + } + onChange({ instanceId, model }); + }; + + const handleModelChange = (slug: string | null) => { + if (!selectedInstanceId || slug === null) return; + onChange({ instanceId: selectedInstanceId, model: slug }); + }; + + const models = selectedEntry?.models ?? []; + const accountSelectValue = selectedInstanceId ?? USE_DEFAULT_VALUE; + const defaultAccountLabel = kind === "review" ? "Use chat model" : "Use default account"; + const fieldLabel = kind === "review" ? "Review" : "Chat"; + + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/ProjectDefaultWorktreeBranchField.tsx b/apps/web/src/components/ProjectDefaultWorktreeBranchField.tsx new file mode 100644 index 000000000000..2224c67eb58a --- /dev/null +++ b/apps/web/src/components/ProjectDefaultWorktreeBranchField.tsx @@ -0,0 +1,104 @@ +import { useMemo } from "react"; + +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +import { useProject } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { vcsEnvironment } from "../state/vcs"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; + +// Sentinel for "no pinned branch": new worktrees fall back to the repository +// default branch (origin/HEAD), then the current checkout. Kept distinct from +// any real branch name. +const USE_REPO_DEFAULT_VALUE = "__t3_repo_default__"; + +/** + * Per-project "default worktree branch" control. Picks which branch new + * worktrees for a project are created from. The selection is written to the + * project's `defaultWorktreeBranch`; the composer's branch toolbar seeds the + * worktree base from this value before falling back to the repository default. + * + * Branches are read per-environment from the project's own repository. + */ +export function ProjectDefaultWorktreeBranchField(props: { + environmentId: EnvironmentId; + projectId: ProjectId; + onChange: (branch: string | null) => void; + idPrefix: string; +}) { + const { environmentId, projectId, onChange, idPrefix } = props; + const projectRef = useMemo( + () => scopeProjectRef(environmentId, projectId), + [environmentId, projectId], + ); + // Read live so the control reflects the persisted value even when the + // enclosing panel renders from a captured project snapshot. + const project = useProject(projectRef); + const value = project?.defaultWorktreeBranch ?? null; + const cwd = project?.workspaceRoot ?? null; + + const refsQuery = useEnvironmentQuery( + cwd !== null + ? vcsEnvironment.listRefs({ + environmentId, + input: { cwd, refKind: "all", limit: 200 }, + }) + : null, + ); + + // Dedupe by branch name (a branch can appear as both a local and remote ref), + // keeping the repo default first, then alphabetical. + const branchNames = useMemo(() => { + const refs = refsQuery.data?.refs ?? []; + const names = new Set(); + for (const ref of refs) names.add(ref.name); + // Always include the persisted value so a stale/renamed branch still shows + // (and can be cleared) instead of silently vanishing. + if (value !== null) names.add(value); + const defaultName = refs.find((ref) => ref.isDefault)?.name ?? null; + return Array.from(names).sort((a, b) => { + if (a === defaultName) return -1; + if (b === defaultName) return 1; + return a.localeCompare(b); + }); + }, [refsQuery.data?.refs, value]); + + const selectValue = value ?? USE_REPO_DEFAULT_VALUE; + + return ( + + ); +} diff --git a/apps/web/src/components/ProjectGitHubAccountField.tsx b/apps/web/src/components/ProjectGitHubAccountField.tsx new file mode 100644 index 000000000000..14cb8c3ede3f Binary files /dev/null and b/apps/web/src/components/ProjectGitHubAccountField.tsx differ diff --git a/apps/web/src/components/ProjectScriptDialog.tsx b/apps/web/src/components/ProjectScriptDialog.tsx new file mode 100644 index 000000000000..0e7459672d7e --- /dev/null +++ b/apps/web/src/components/ProjectScriptDialog.tsx @@ -0,0 +1,378 @@ +import type { + ProjectScript, + ProjectScriptIcon, + ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import React, { type FormEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react"; + +import { + keybindingValueForCommand, + decodeProjectScriptKeybindingRule, +} from "~/lib/projectScriptKeybindings"; +import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; +import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; +import { SCRIPT_ICONS, ScriptIcon } from "./projectScriptIcons"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { Switch } from "./ui/switch"; +import { Textarea } from "./ui/textarea"; + +export interface NewProjectScriptInput { + name: string; + command: string; + icon: ProjectScriptIcon; + runOnWorktreeCreate: boolean; + keybinding: string | null; + /** Optional URL to open in the in-app preview when this script runs. */ + previewUrl: string | null; + /** When true, automatically open the preview panel pointed at `previewUrl`. */ + autoOpenPreview: boolean; +} + +export type ProjectScriptActionResult = AtomCommandResult; + +/** Seed values for the add form, e.g. when retrying a failed t3.json import. */ +export interface ProjectScriptDraft { + name: string; + command: string; + icon: ProjectScriptIcon; + runOnWorktreeCreate: boolean; + previewUrl: string; + autoOpenPreview: boolean; +} + +interface ProjectScriptDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The script being edited, or null to add a new one. */ + editingScript: ProjectScript | null; + /** Ids already in use, so a new script gets a unique id. */ + existingScriptIds: ReadonlyArray; + keybindings: ResolvedKeybindingsConfig; + /** Optional prefill for add mode (import retry). Ignored when editing. */ + initialDraft?: ProjectScriptDraft | null; + /** Optional error to surface immediately (import retry). */ + initialError?: string | null; + onAddScript: (input: NewProjectScriptInput) => Promise; + onUpdateScript: ( + scriptId: string, + input: NewProjectScriptInput, + ) => Promise; + onDeleteScript: (scriptId: string) => Promise; +} + +export function ProjectScriptDialog({ + open, + onOpenChange, + editingScript, + existingScriptIds, + keybindings, + initialDraft = null, + initialError = null, + onAddScript, + onUpdateScript, + onDeleteScript, +}: ProjectScriptDialogProps) { + const formId = React.useId(); + const [name, setName] = useState(""); + const [command, setCommand] = useState(""); + const [icon, setIcon] = useState("play"); + const [iconPickerOpen, setIconPickerOpen] = useState(false); + const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); + const [keybinding, setKeybinding] = useState(""); + const [previewUrl, setPreviewUrl] = useState(""); + const [autoOpenPreview, setAutoOpenPreview] = useState(false); + const [validationError, setValidationError] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + + const editingScriptId = editingScript?.id ?? null; + const isEditing = editingScriptId !== null; + + // Re-seed the form whenever the dialog opens (or its subject changes). + useEffect(() => { + if (!open) return; + setIconPickerOpen(false); + setDeleteConfirmOpen(false); + setValidationError(initialError ?? null); + if (editingScript) { + setName(editingScript.name); + setCommand(editingScript.command); + setIcon(editingScript.icon); + setRunOnWorktreeCreate(editingScript.runOnWorktreeCreate); + setKeybinding( + keybindingValueForCommand(keybindings, commandForProjectScript(editingScript.id)) ?? "", + ); + setPreviewUrl(editingScript.previewUrl ?? ""); + setAutoOpenPreview(editingScript.autoOpenPreview ?? false); + return; + } + setName(initialDraft?.name ?? ""); + setCommand(initialDraft?.command ?? ""); + setIcon(initialDraft?.icon ?? "play"); + setRunOnWorktreeCreate(initialDraft?.runOnWorktreeCreate ?? false); + setKeybinding(""); + setPreviewUrl(initialDraft?.previewUrl ?? ""); + setAutoOpenPreview(initialDraft?.autoOpenPreview ?? false); + // initialDraft/initialError are only consumed on open; excluded from deps on purpose. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, editingScriptId]); + + const captureKeybinding = (event: KeyboardEvent) => { + if (event.key === "Tab") return; + event.preventDefault(); + if (event.key === "Backspace" || event.key === "Delete") { + setKeybinding(""); + return; + } + const next = keybindingFromKeyboardEvent(event, navigator.platform); + if (!next) return; + setKeybinding(next); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const trimmedName = name.trim(); + const trimmedCommand = command.trim(); + if (trimmedName.length === 0) { + setValidationError("Name is required."); + return; + } + if (trimmedCommand.length === 0) { + setValidationError("Command is required."); + return; + } + + setValidationError(null); + let payload: NewProjectScriptInput; + try { + const scriptIdForValidation = + editingScriptId ?? nextProjectScriptId(trimmedName, existingScriptIds); + const keybindingRule = decodeProjectScriptKeybindingRule({ + keybinding, + command: commandForProjectScript(scriptIdForValidation), + }); + const trimmedPreviewUrl = previewUrl.trim(); + payload = { + name: trimmedName, + command: trimmedCommand, + icon, + runOnWorktreeCreate, + keybinding: keybindingRule?.key ?? null, + previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, + autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, + } satisfies NewProjectScriptInput; + } catch (error) { + setValidationError(error instanceof Error ? error.message : "Failed to save action."); + return; + } + + const result = editingScriptId + ? await onUpdateScript(editingScriptId, payload) + : await onAddScript(payload); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setValidationError(error instanceof Error ? error.message : "Failed to save action."); + } + return; + } + onOpenChange(false); + setIconPickerOpen(false); + }; + + const confirmDelete = useCallback(() => { + if (!editingScriptId) return; + setDeleteConfirmOpen(false); + onOpenChange(false); + void onDeleteScript(editingScriptId); + }, [editingScriptId, onDeleteScript, onOpenChange]); + + return ( + <> + { + onOpenChange(next); + if (!next) setIconPickerOpen(false); + }} + open={open} + > + + + {isEditing ? "Edit Action" : "Add Action"} + + Actions are project-scoped commands you can run from the top bar, the Run panel, or + keybindings. + + + +
+
+ +
+ + + } + > + + + +
+ {SCRIPT_ICONS.map((entry) => { + const isSelected = entry.id === icon; + return ( + + ); + })} +
+
+
+ setName(event.target.value)} + /> +
+
+
+ + +

+ Press a shortcut. Use Backspace to clear. +

+
+
+ +