diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 9a8d1d93..580b3b1e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -2,11 +2,20 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + CopilotClient, + type CopilotClientOptions, + type PermissionRequest, + type PermissionRequestResult, + type SessionEvent, +} from "@github/copilot-sdk"; +import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { resolveBundledCopilotCliPath } from "../../provider/Layers/copilotCliPath.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, @@ -20,6 +29,9 @@ import { const CODEX_MODEL = "gpt-5.3-codex"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; +const COPILOT_MODEL = DEFAULT_MODEL_BY_PROVIDER.copilot; +const COPILOT_TIMEOUT_MS = 180_000; +const COPILOT_TIMEOUT_CANCELLATION_GRACE_MS = 5_000; function toCodexOutputJsonSchema(schema: Schema.Top): unknown { const document = Schema.toJsonSchemaDocument(schema); @@ -68,6 +80,44 @@ function normalizeCodexError( }); } +function normalizeCopilotError( + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + lower.includes("copilot") && + (lower.includes("enoent") || + lower.includes("not found") || + lower.includes("missing") || + lower.includes("spawn")) + ) { + return new TextGenerationError({ + operation, + detail: "GitHub Copilot CLI is required but not available.", + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} + function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); @@ -95,6 +145,107 @@ function sanitizePrTitle(raw: string): string { return "Update project changes"; } +function extractJsonCandidate(text: string): string { + const trimmed = text.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const fencedMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + if (fencedMatch?.[1]) { + return fencedMatch[1].trim(); + } + + const firstBrace = trimmed.indexOf("{"); + const lastBrace = trimmed.lastIndexOf("}"); + if (firstBrace >= 0 && lastBrace > firstBrace) { + return trimmed.slice(firstBrace, lastBrace + 1); + } + + return trimmed; +} + +function denyCopilotPermissionRequest(_request: PermissionRequest): PermissionRequestResult { + return { kind: "denied-by-rules" }; +} + +function withPromiseTimeout( + operation: () => Promise, + timeoutMs: number, + cancelOnTimeout: () => Promise | void, + onTimeout: () => Error, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + void Promise.race([ + Promise.resolve(cancelOnTimeout()).catch(() => undefined), + new Promise((resolveCancellation) => { + setTimeout(resolveCancellation, COPILOT_TIMEOUT_CANCELLATION_GRACE_MS); + }), + ]).finally(() => { + reject(onTimeout()); + }); + }, timeoutMs); + + void operation().then( + (value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve(value); + }, + (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +function extractLastCopilotAssistantText(events: ReadonlyArray): string { + let latestCompleted = ""; + const deltaByMessageId = new Map(); + let latestDeltaMessageId: string | null = null; + + for (const event of events) { + if (event.type === "assistant.message") { + const content = event.data.content.trim(); + if (content.length > 0) { + latestCompleted = content; + } + continue; + } + + if (event.type === "assistant.message_delta") { + const next = `${deltaByMessageId.get(event.data.messageId) ?? ""}${event.data.deltaContent}`; + deltaByMessageId.set(event.data.messageId, next); + latestDeltaMessageId = event.data.messageId; + } + } + + if (latestCompleted.length > 0) { + return latestCompleted; + } + + if (latestDeltaMessageId) { + return (deltaByMessageId.get(latestDeltaMessageId) ?? "").trim(); + } + + return ""; +} + const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -180,6 +331,39 @@ const makeCodexTextGeneration = Effect.gen(function* () { return { imagePaths }; }); + const decodeStructuredText = ({ + operation, + text, + outputSchemaJson, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + text: string; + outputSchemaJson: S; + }): Effect.Effect => + Effect.gen(function* () { + const jsonText = extractJsonCandidate(text); + const parsed = yield* Effect.try({ + try: () => JSON.parse(jsonText) as unknown, + catch: (cause) => + new TextGenerationError({ + operation, + detail: "Provider returned invalid JSON output.", + cause, + }), + }); + + return yield* Schema.decodeUnknownEffect(outputSchemaJson)(parsed).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Provider returned invalid structured output.", + cause, + }), + ), + ); + }); + const runCodexJson = ({ operation, cwd, @@ -312,6 +496,92 @@ const makeCodexTextGeneration = Effect.gen(function* () { }).pipe(Effect.ensuring(cleanup)); }); + const runCopilotJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + cwd: string; + prompt: string; + outputSchemaJson: S; + }): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const cliPath = resolveBundledCopilotCliPath(); + const clientOptions: CopilotClientOptions = { + ...(cliPath ? { cliPath } : {}), + cwd, + logLevel: "error", + }; + const client = new CopilotClient(clientOptions); + let session: Awaited> | undefined; + + try { + return await withPromiseTimeout( + async () => { + await client.start(); + session = await client.createSession({ + workingDirectory: cwd, + model: COPILOT_MODEL, + streaming: true, + availableTools: [], + onPermissionRequest: denyCopilotPermissionRequest, + }); + + const response = await (async () => { + try { + return await session.sendAndWait( + { + prompt, + mode: "immediate", + }, + COPILOT_TIMEOUT_MS, + ); + } catch (error) { + const history = await session.getMessages().catch(() => [] as SessionEvent[]); + const assistantText = extractLastCopilotAssistantText(history); + if (assistantText.length > 0) { + return { + data: { + content: assistantText, + }, + }; + } + throw error; + } + })(); + const history = await session.getMessages().catch(() => [] as SessionEvent[]); + const assistantText = + response?.data.content?.trim() || extractLastCopilotAssistantText(history); + if (assistantText.length === 0) { + throw new Error("GitHub Copilot did not return an assistant response."); + } + return assistantText; + }, + COPILOT_TIMEOUT_MS, + () => + Promise.race([ + Promise.allSettled([ + session ? session.abort().catch(() => undefined) : Promise.resolve(undefined), + client.forceStop().catch(() => undefined), + ]).then(() => undefined), + new Promise((resolveCancellation) => { + setTimeout(resolveCancellation, COPILOT_TIMEOUT_CANCELLATION_GRACE_MS); + }), + ]).then(() => undefined), + () => new Error("GitHub Copilot request timed out."), + ); + } finally { + await session?.destroy().catch(() => undefined); + await client.stop().catch(() => []); + } + }, + catch: (cause) => + normalizeCopilotError(operation, cause, "GitHub Copilot text generation failed"), + }).pipe(Effect.flatMap((text) => decodeStructuredText({ operation, text, outputSchemaJson }))); + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { const wantsBranch = input.includeBranch === true; @@ -348,12 +618,22 @@ const makeCodexTextGeneration = Effect.gen(function* () { body: Schema.String, }); - return runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson, - }).pipe( + const generateJson = + input.provider === "copilot" + ? runCopilotJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson, + }) + : runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson, + }); + + return generateJson.pipe( Effect.map( (generated) => ({ @@ -390,15 +670,27 @@ const makeCodexTextGeneration = Effect.gen(function* () { limitSection(input.diffPatch, 40_000), ].join("\n"); - return runCodexJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: Schema.Struct({ - title: Schema.String, - body: Schema.String, - }), - }).pipe( + const outputSchemaJson = Schema.Struct({ + title: Schema.String, + body: Schema.String, + }); + + const generateJson = + input.provider === "copilot" + ? runCopilotJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson, + }) + : runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson, + }); + + return generateJson.pipe( Effect.map( (generated) => ({ @@ -441,16 +733,33 @@ const makeCodexTextGeneration = Effect.gen(function* () { } const prompt = promptSections.join("\n"); - const generated = yield* runCodexJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: Schema.Struct({ - branch: Schema.String, - }), - imagePaths, + const outputSchemaJson = Schema.Struct({ + branch: Schema.String, }); + if (input.provider === "copilot" && imagePaths.length > 0) { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Copilot branch generation does not support image attachments yet.", + }); + } + + const generated = + input.provider === "copilot" + ? yield* runCopilotJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson, + }) + : yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson, + imagePaths, + }); + return { branch: sanitizeBranchFragment(generated.branch), } satisfies BranchNameGenerationResult; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 8c72941c..e266a6c4 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -43,6 +43,7 @@ interface FakeGhScenario { interface FakeGitTextGeneration { generateCommitMessage: (input: { cwd: string; + provider?: "codex" | "copilot"; branch: string | null; stagedSummary: string; stagedPatch: string; @@ -53,6 +54,7 @@ interface FakeGitTextGeneration { >; generatePrContent: (input: { cwd: string; + provider?: "codex" | "copilot"; baseBranch: string; headBranch: string; commitSummary: string; @@ -61,6 +63,7 @@ interface FakeGitTextGeneration { }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; generateBranchName: (input: { cwd: string; + provider?: "codex" | "copilot"; message: string; }) => Effect.Effect<{ branch: string }, TextGenerationError>; } @@ -449,6 +452,7 @@ function runStackedAction( input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr"; + provider?: "codex" | "copilot"; commitMessage?: string; featureBranch?: boolean; filePaths?: readonly string[]; @@ -843,6 +847,60 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("passes the selected provider to generated commit and PR content", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/copilot-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "copilot flow\n"); + + const seenProviders: Array<"codex" | "copilot" | undefined> = []; + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 91, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/91", + baseRefName: "main", + headRefName: "feature/copilot-pr", + }, + ]), + ], + }, + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + seenProviders.push(input.provider); + return { subject: "Use GitHub Copilot for git copy", body: "" }; + }), + generatePrContent: (input) => + Effect.sync(() => { + seenProviders.push(input.provider); + return { + title: "Use GitHub Copilot for git copy", + body: "## Summary\n- Route git copy through Copilot\n\n## Testing\n- Not run", + }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + provider: "copilot", + }); + + expect(result.commit.status).toBe("created"); + expect(result.pr.status).toBe("created"); + expect(seenProviders).toEqual(["copilot", "copilot"]); + }), + ); + it.effect("featureBranch uses custom commit message and derives branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 83577951..a885f4aa 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -634,6 +634,7 @@ export const makeGitManager = Effect.gen(function* () { const resolveCommitAndBranchSuggestion = (input: { cwd: string; + provider?: "codex" | "copilot"; branch: string | null; commitMessage?: string; /** When true, also produce a semantic feature branch name. */ @@ -661,6 +662,7 @@ export const makeGitManager = Effect.gen(function* () { const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, + ...(input.provider ? { provider: input.provider } : {}), branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), @@ -678,6 +680,7 @@ export const makeGitManager = Effect.gen(function* () { const runCommitStep = ( cwd: string, + provider: "codex" | "copilot" | undefined, branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, @@ -688,6 +691,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedSuggestion ?? (yield* resolveCommitAndBranchSuggestion({ cwd, + ...(provider ? { provider } : {}), branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), @@ -704,7 +708,11 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null) => + const runPrStep = ( + cwd: string, + provider: "codex" | "copilot" | undefined, + fallbackBranch: string | null, + ) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -743,6 +751,7 @@ export const makeGitManager = Effect.gen(function* () { const generated = yield* textGeneration.generatePrContent({ cwd, + ...(provider ? { provider } : {}), baseBranch, headBranch: headContext.headBranch, commitSummary: limitContext(rangeContext.commitSummary, 20_000), @@ -969,6 +978,7 @@ export const makeGitManager = Effect.gen(function* () { const runFeatureBranchStep = ( cwd: string, + provider: "codex" | "copilot" | undefined, branch: string | null, commitMessage?: string, filePaths?: readonly string[], @@ -976,6 +986,7 @@ export const makeGitManager = Effect.gen(function* () { Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, + ...(provider ? { provider } : {}), branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), @@ -1025,6 +1036,7 @@ export const makeGitManager = Effect.gen(function* () { if (input.featureBranch) { const result = yield* runFeatureBranchStep( input.cwd, + input.provider, initialStatus.branch, input.commitMessage, input.filePaths, @@ -1040,6 +1052,7 @@ export const makeGitManager = Effect.gen(function* () { const commit = yield* runCommitStep( input.cwd, + input.provider, currentBranch, commitMessageForStep, preResolvedCommitSuggestion, @@ -1051,7 +1064,7 @@ export const makeGitManager = Effect.gen(function* () { : { status: "skipped_not_requested" as const }; const pr = wantsPr - ? yield* runPrStep(input.cwd, currentBranch) + ? yield* runPrStep(input.cwd, input.provider, currentBranch) : { status: "skipped_not_requested" as const }; return { diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe..2b98e7c9 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,12 +8,13 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ChatAttachment } from "@t3tools/contracts"; +import type { ChatAttachment, ProviderKind } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; export interface CommitMessageGenerationInput { cwd: string; + provider?: ProviderKind; branch: string | null; stagedSummary: string; stagedPatch: string; @@ -30,6 +31,7 @@ export interface CommitMessageGenerationResult { export interface PrContentGenerationInput { cwd: string; + provider?: ProviderKind; baseBranch: string; headBranch: string; commitSummary: string; @@ -44,6 +46,7 @@ export interface PrContentGenerationResult { export interface BranchNameGenerationInput { cwd: string; + provider?: ProviderKind; message: string; attachments?: ReadonlyArray | undefined; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2a9a2760..7ac89c7c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4278,6 +4278,7 @@ export default function ChatView({ threadId }: ChatViewProps) { availableEditors={availableEditors} diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} + activeProvider={activeProvider} diffOpen={diffOpen} onRunProjectScript={(script) => { void runProjectScript(script); @@ -5022,6 +5023,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; gitCwd: string | null; + activeProvider: ProviderKind; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; @@ -5042,6 +5044,7 @@ const ChatHeader = memo(function ChatHeader({ availableEditors, diffToggleShortcutLabel, gitCwd, + activeProvider, diffOpen, onRunProjectScript, onAddProjectScript, @@ -5089,7 +5092,13 @@ const ChatHeader = memo(function ChatHeader({ openInCwd={openInCwd} /> )} - {activeProjectName && } + {activeProjectName && ( + + )} void; @@ -153,7 +155,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadId, + activeProvider, +}: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -258,6 +264,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const runGitActionWithToast = useCallback( async ({ action, + provider = activeProvider, commitMessage, forcePushOnlyProgress = false, onConfirmed, @@ -269,6 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions filePaths, }: { action: GitStackedAction; + provider?: ProviderKind; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; @@ -297,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, branchName: actionBranch, includesCommit, + provider, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), @@ -348,6 +357,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, + provider, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), @@ -442,6 +452,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [ + activeProvider, isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, @@ -452,11 +463,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = + const { action, provider, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, + provider, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), @@ -468,6 +480,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutNewBranchAndRunAction = useCallback( (actionParams: { action: GitStackedAction; + provider?: ProviderKind; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; @@ -484,11 +497,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = + const { action, provider, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); checkoutNewBranchAndRunAction({ action, + provider, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911b..114e4861 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,5 +1,6 @@ import { type EditorId, + type ProviderKind, type ProjectScript, type ResolvedKeybindingsConfig, type ThreadId, @@ -26,6 +27,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; gitCwd: string | null; + activeProvider: ProviderKind; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; @@ -46,6 +48,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, diffToggleShortcutLabel, gitCwd, + activeProvider, diffOpen, onRunProjectScript, onAddProjectScript, @@ -93,7 +96,13 @@ export const ChatHeader = memo(function ChatHeader({ openInCwd={openInCwd} /> )} - {activeProjectName && } + {activeProjectName && ( + + )} { describe("git mutation options", () => { const queryClient = new QueryClient(); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("attaches cwd-scoped mutation key for runStackedAction", () => { const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient }); expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); }); + it("forwards provider to runStackedAction RPC", async () => { + const runStackedAction = vi.fn(async () => ({ + action: "commit", + branch: { status: "skipped_not_requested" }, + commit: { status: "created", subject: "Use Copilot" }, + push: { status: "skipped_not_requested" }, + pr: { status: "skipped_not_requested" }, + })); + + vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ + git: { + runStackedAction, + }, + } as never); + + const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient }); + if (!options.mutationFn) { + throw new Error("Expected mutationFn to be defined."); + } + + await options.mutationFn( + { + action: "commit", + provider: "copilot", + }, + {} as never, + ); + + expect(runStackedAction).toHaveBeenCalledWith({ + cwd: "/repo/a", + action: "commit", + provider: "copilot", + }); + }); + it("attaches cwd-scoped mutation key for pull", () => { const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 9b5fe773..ad599370 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import type { GitStackedAction } from "@t3tools/contracts"; +import type { GitStackedAction, ProviderKind } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -117,11 +117,13 @@ export function gitRunStackedActionMutationOptions(input: { mutationKey: gitMutationKeys.runStackedAction(input.cwd), mutationFn: async ({ action, + provider, commitMessage, featureBranch, filePaths, }: { action: GitStackedAction; + provider?: ProviderKind; commitMessage?: string; featureBranch?: boolean; filePaths?: string[]; @@ -131,6 +133,7 @@ export function gitRunStackedActionMutationOptions(input: { return api.git.runStackedAction({ cwd: input.cwd, action, + ...(provider ? { provider } : {}), ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 081b4d0d..d5fe8142 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -59,6 +60,7 @@ export type GitPullInput = typeof GitPullInput.Type; export const GitRunStackedActionInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, action: GitStackedAction, + provider: Schema.optional(ProviderKind), commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), filePaths: Schema.optional(