From 11f78d1177827b7e46cfa36e83117113bc57d71d Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Sat, 26 Sep 2026 15:39:30 -0400 Subject: [PATCH 1/5] feat(web): show model context and Settings for generation failures Name the attempted model and provider instance on commit/PR generation errors. Route Settings to the model picker used by the acting environment and checkout. Cover writer selection failures, RPC serialization, and legacy error decoding. Part of #12653. Co-authored-by: Codex Co-authored-by: Claude AI-Tool: OpenAI Codex AI-Harness: Codex harness (integration/version not exposed) AI-Host: T3 Code AI-Model: gpt-6-astra AI-Reasoning: medium AI-Contribution: Implementation, test execution, browser verification, review response, and draft preparation AI-Tool: Claude Code AI-Harness: Claude Code CLI 2.1.283 invoked by Codex harness AI-Host: T3 Code via PowerShell AI-Model: claude-fable-5-1 AI-Reasoning: high AI-Contribution: Independent code and draft review and final drafting --- apps/server/src/git/GitManager.test.ts | 115 +++++++++++++++++- apps/server/src/git/GitManager.ts | 67 +++++++--- apps/web/src/components/GitActionsControl.tsx | 55 ++++++++- packages/contracts/src/git.test.ts | 40 ++++++ packages/contracts/src/git.ts | 12 +- 5 files changed, 268 insertions(+), 21 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6a1a8c1510fa..7b1502a711c6 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -30,6 +30,7 @@ import { ProviderDriverKind, ProviderInstanceId, TextGenerationError, + ServerProvider, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; @@ -52,6 +53,7 @@ import * as GitManager from "./GitManager.ts"; const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema); +const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); interface FakeGhScenario { prListSequence?: string[]; @@ -635,6 +637,7 @@ function makeManager(input?: { sourceControlProvider?: SourceControlProvider["Service"]; textGeneration?: Partial; serverSettings?: Parameters[0]; + providers?: ReadonlyArray; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; gitConfigReads?: string[]; }) { @@ -692,7 +695,7 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(TextGeneration.TextGeneration, textGeneration), Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), + getProviders: Effect.succeed(input?.providers ?? []), }), Layer.succeed( ProjectSetupScriptRunner.ProjectSetupScriptRunner, @@ -719,6 +722,116 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { + for (const writer of ["unset", "available", "unavailable", "disabled"] as const) { + it.effect( + `commit generation failures identify the attempted model with an ${writer} writer`, + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "changed\n"); + const instanceId = ProviderInstanceId.make("test_writer"); + const writerSelection = { instanceId, model: "writer-model" }; + const cause = new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Unsupported model", + }); + const { manager } = yield* makeManager({ + serverSettings: { + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + config: {}, + enabled: writer !== "disabled", + }, + }, + sourceControlWriterModelSelection: writer === "unset" ? null : writerSelection, + }, + providers: + writer === "available" || writer === "disabled" + ? [ + decodeServerProvider({ + instanceId, + driver: "codex", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-26T00:00:00.000Z", + models: [], + }), + ] + : [], + textGeneration: { generateCommitMessage: () => Effect.fail(cause) }, + }); + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit" }).pipe( + Effect.flip, + ); + const selection = + writer === "available" + ? writerSelection + : DEFAULT_SERVER_SETTINGS.textGenerationModelSelection; + expect(error).toMatchObject({ + _tag: "TextGenerationError", + operation: "generateCommitMessage", + detail: "fake text generation failed", + cause, + modelSelection: { instanceId: selection.instanceId, model: selection.model }, + modelSetting: + writer === "available" + ? "sourceControlWriterModelSelection" + : "textGenerationModelSelection", + }); + expect(error.message).toContain(selection.model); + expect(error.message).toContain(selection.instanceId); + expect((yield* runGit(repoDir, ["log", "-1", "--pretty=%s"])).stdout.trim()).toBe( + "Initial commit", + ); + }), + ); + } + + it.effect("PR generation failures carry model context without creating a PR", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature-generation-error"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-generation-error"]); + yield* runGit(repoDir, ["config", "branch.feature-generation-error.gh-merge-base", "main"]); + const { manager, ghCalls } = yield* makeManager({ + textGeneration: { + generatePrContent: () => + Effect.fail( + new TextGenerationError({ + operation: "generatePrContent", + detail: "Model unavailable", + }), + ), + }, + ghScenario: { prListSequence: ["[]"] }, + }); + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" }).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "TextGenerationError", + operation: "generatePrContent", + modelSelection: { + instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }, + modelSetting: "textGenerationModelSelection", + }); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); + }), + ); + it.effect("status includes draft PR metadata when branch already has a draft PR", () => 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 f57afeb2a419..9a2a41d5612b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -35,6 +35,7 @@ import { type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, + TextGenerationError, } from "@t3tools/contracts"; import { hasProjectSettingsOverrides, @@ -95,9 +96,26 @@ export type GitBranchPullRequest = NonNullable & { interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; + readonly modelSetting: NonNullable; readonly style: SourceControlWritingStyleSettings; } +function withTextGenerationContext( + error: TextGenerationError, + settings: SourceControlTextGenerationSettings, +): TextGenerationError { + return new TextGenerationError({ + operation: error.operation, + detail: error.detail, + ...(error.cause !== undefined ? { cause: error.cause } : {}), + modelSelection: { + instanceId: settings.modelSelection.instanceId, + model: settings.modelSelection.model, + }, + modelSetting: settings.modelSetting, + }); +} + export class GitManager extends Context.Service< GitManager, { @@ -1877,7 +1895,10 @@ export const make = Effect.gen(function* () { ...(policy ? { policy } : {}), modelSelection: input.settings.modelSelection, }) - .pipe(Effect.map((result) => sanitizeCommitMessage(result))); + .pipe( + Effect.mapError((error) => withTextGenerationContext(error, input.settings)), + Effect.map((result) => sanitizeCommitMessage(result)), + ); return { subject: generated.subject, @@ -2057,17 +2078,19 @@ export const make = Effect.gen(function* () { ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) : undefined; - const generated = yield* textGeneration.generatePrContent({ - cwd, - baseBranch, - headBranch: headContext.headBranch, - commitSummary: limitContext(rangeContext.commitSummary, 20_000), - diffSummary: limitContext(rangeContext.diffSummary, 20_000), - diffPatch: limitContext(rangeContext.diffPatch, 60_000), - ...(changeRequestTemplate ? { changeRequestTemplate } : {}), - ...(policy ? { policy } : {}), - modelSelection: settings.modelSelection, - }); + const generated = yield* textGeneration + .generatePrContent({ + cwd, + baseBranch, + headBranch: headContext.headBranch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(changeRequestTemplate ? { changeRequestTemplate } : {}), + ...(policy ? { policy } : {}), + modelSelection: settings.modelSelection, + }) + .pipe(Effect.mapError((error) => withTextGenerationContext(error, settings))); const bodyFile = path.join( tempDir, @@ -2708,16 +2731,26 @@ export const make = Effect.gen(function* () { settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, + modelSetting: "textGenerationModelSelection" as const, style: settings.sourceControlWritingStyle, }) : providerRegistry.getProviders.pipe( - Effect.map((providers) => ({ - modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( + Effect.map((providers) => { + const modelSelection = ServerSettings.resolveSourceControlWriterModelSelection( settings, providers, - ), - style: settings.sourceControlWritingStyle, - })), + ); + return { + modelSelection, + // The resolver returns the chosen settings object unchanged, preserving + // which picker supplied it even when both pickers name the same model. + modelSetting: + modelSelection === settings.sourceControlWriterModelSelection + ? ("sourceControlWriterModelSelection" as const) + : ("textGenerationModelSelection" as const), + style: settings.sourceControlWritingStyle, + }; + }), ), ), Effect.mapError( diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c26f1e69b1d..14e398d3ae90 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { type ScopedThreadRef } from "@t3tools/contracts"; +import { TextGenerationError, type ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -17,6 +17,7 @@ import type { } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback, @@ -102,7 +103,10 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { useThreadShell } from "~/state/entities"; +import { readProjects, useThreadShell } from "~/state/entities"; +import { getClientSettings } from "~/hooks/useSettings"; +import { derivePhysicalProjectKey, selectProjectGroupingSettings } from "~/logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -116,6 +120,8 @@ import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useOpenLink } from "~/browser/useOpenLink"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; +const isTextGenerationError = Schema.is(TextGenerationError); + interface GitActionsControlProps { presentation?: "toolbar" | "menu"; gitCwd: string | null; @@ -949,6 +955,7 @@ export default function GitActionsControl({ draftId, onOpenPullRequest, }: GitActionsControlProps) { + const navigate = useNavigate(); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, "thread branch metadata update", @@ -1387,12 +1394,56 @@ export default function GitActionsControl({ } const error = squashAtomCommandFailure(result); + const modelSetting = isTextGenerationError(error) ? error.modelSetting : undefined; toastManager.update( resolvedProgressToastId, stackedThreadToast({ type: "error", title: "Action failed", description: error instanceof Error ? error.message : "An error occurred.", + ...(isTextGenerationError(error) + ? { + actionProps: { + children: "Settings", + onClick: () => { + const projectId = + activeServerThread?.projectId ?? activeDraftThread?.projectId; + const projects = readProjects(); + const settingsProject = projects.find( + (project) => + project.environmentId === activeEnvironmentId && + (projectId ? project.id === projectId : project.workspaceRoot === gitCwd), + ); + const checkout = settingsProject + ? derivePhysicalProjectKey(settingsProject) + : undefined; + const project = checkout + ? buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: selectProjectGroupingSettings(getClientSettings()), + primaryEnvironmentId: activeEnvironmentId, + }).get(checkout) + : undefined; + toastManager.close(resolvedProgressToastId); + void navigate({ + to: + modelSetting === "sourceControlWriterModelSelection" + ? "/settings/source-control" + : "/settings/general", + hash: + modelSetting === "sourceControlWriterModelSelection" + ? "source-control-writer-model" + : "text-generation-model", + search: { + machine: activeEnvironmentId ?? undefined, + project, + checkout, + }, + }); + }, + }, + } + : {}), ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), }), ); diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..513786c7dc1d 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -8,7 +8,47 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + TextGenerationError, + GitManagerServiceError, } from "./git.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const decodeTextGenerationError = Schema.decodeUnknownSync(TextGenerationError); +const gitManagerErrorJson = Schema.fromJsonString(GitManagerServiceError); +const encodeGitManagerError = Schema.encodeSync(gitManagerErrorJson); +const decodeGitManagerError = Schema.decodeUnknownSync(gitManagerErrorJson); + +describe("TextGenerationError", () => { + it("preserves model context and diagnostic detail through the RPC error schema", () => { + const error = new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Model is not supported by this backend", + modelSelection: { instanceId: ProviderInstanceId.make("claude_work"), model: "custom-model" }, + modelSetting: "sourceControlWriterModelSelection", + }); + const decoded = decodeGitManagerError(encodeGitManagerError(error)); + expect(decoded).toBeInstanceOf(TextGenerationError); + expect(decoded).toMatchObject({ + modelSelection: error.modelSelection, + modelSetting: error.modelSetting, + }); + expect(decoded.message).toBe( + "Text generation failed in generateCommitMessage using custom-model (claude_work): Model is not supported by this backend", + ); + }); + + it("still decodes errors from servers without model context", () => { + const decoded = decodeTextGenerationError({ + _tag: "TextGenerationError", + operation: "generatePrContent", + detail: "Provider unavailable", + }); + expect(decoded.message).toBe( + "Text generation failed in generatePrContent: Provider unavailable", + ); + expect(decoded.modelSelection).toBeUndefined(); + }); +}); const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index c5a5825e193e..d53e5e3e825c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { ProviderInstanceId } from "./providerInstance.ts"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; @@ -360,11 +361,20 @@ export class TextGenerationError extends Schema.TaggedError { operation: Schema.String, detail: Schema.String, + modelSelection: Schema.optional( + Schema.Struct({ instanceId: ProviderInstanceId, model: TrimmedNonEmptyString }), + ), + modelSetting: Schema.optional( + Schema.Literals(["textGenerationModelSelection", "sourceControlWriterModelSelection"]), + ), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Text generation failed in ${this.operation}: ${this.detail}`; + const model = this.modelSelection + ? ` using ${this.modelSelection.model} (${this.modelSelection.instanceId})` + : ""; + return `Text generation failed in ${this.operation}${model}: ${this.detail}`; } } From b6dc056eed484fa28c75f18764c0f7144d704ca3 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Sat, 26 Sep 2026 19:49:23 -0400 Subject: [PATCH 2/5] docs: describe text generation error context Document the existing behavior without changing executable code. Co-authored-by: Codex Co-authored-by: Claude AI-Tool: OpenAI Codex AI-Harness: Codex harness (integration/version not exposed) AI-Host: T3 Code AI-Model: gpt-6-astra AI-Reasoning: medium AI-Contribution: JSDoc drafting, source-equivalence checks, targeted lint, diff checks, and PR update preparation AI-Tool: Claude Code AI-Harness: Claude Code CLI 2.1.283 invoked by Codex harness AI-Host: T3 Code via PowerShell AI-Model: claude-fable-5-1 AI-Reasoning: high AI-Contribution: Independent review, docstring wording improvements, and follow-up drafting --- apps/server/src/git/GitManager.ts | 1 + apps/web/src/components/GitActionsControl.tsx | 1 + packages/contracts/src/git.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 9a2a41d5612b..2ba114578942 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -100,6 +100,7 @@ interface SourceControlTextGenerationSettings { readonly style: SourceControlWritingStyleSettings; } +/** Adds the attempted model and picker to a generation failure, preserving its diagnostic and cause. */ function withTextGenerationContext( error: TextGenerationError, settings: SourceControlTextGenerationSettings, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 14e398d3ae90..8d9514bedca5 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -948,6 +948,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ); } +/** Runs Git actions for the active checkout and routes generation failures to its model settings. */ export default function GitActionsControl({ presentation = "toolbar", gitCwd, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d53e5e3e825c..202e63230bc0 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -370,6 +370,7 @@ export class TextGenerationError extends Schema.TaggedError cause: Schema.optional(Schema.Defect()), }, ) { + /** Includes the attempted model when supplied, retaining the legacy message for older errors. */ override get message(): string { const model = this.modelSelection ? ` using ${this.modelSelection.model} (${this.modelSelection.instanceId})` From b1cfd80c72e7d888902c7509d37a7cd8aed47140 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Mon, 28 Sep 2026 22:32:39 -0400 Subject: [PATCH 3/5] fix(web): retain unavailable checkout in generation settings link Co-authored-by: Codex Co-authored-by: Claude AI-Tool: OpenAI Codex AI-Harness: Codex harness (integration/version not exposed) AI-Host: T3 Code AI-Model: gpt-6-astra AI-Reasoning: medium AI-Contribution: Implementation, regression tests, UI verification, docstrings, and publishing preparation AI-Tool: Claude Code AI-Harness: Claude Code CLI 2.1.283 invoked by Codex harness AI-Host: T3 Code via PowerShell AI-Model: claude-fable-5-1 AI-Reasoning: high AI-Contribution: Independent implementation and docstring review, follow-up drafting --- apps/server/src/git/GitManager.test.ts | 1 + apps/server/src/git/GitManager.ts | 4 + .../GitActionsControl.logic.test.ts | 75 ++++++++++++++++++- .../src/components/GitActionsControl.logic.ts | 35 +++++++++ apps/web/src/components/GitActionsControl.tsx | 38 +++------- 5 files changed, 126 insertions(+), 27 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 7b1502a711c6..21039fba467f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -632,6 +632,7 @@ function preparePullRequestThread( return manager.preparePullRequestThread(input); } +/** Builds a scoped Git manager with configurable provider responses and captured CLI calls. */ function makeManager(input?: { ghScenario?: FakeGhScenario; sourceControlProvider?: SourceControlProvider["Service"]; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2ba114578942..5b908290cf81 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -712,6 +712,7 @@ function toPullRequestHeadRemoteInfo(pr: { }; } +/** Assembles Git status and PR caches plus ordered commit, push, and PR actions over the VCS driver. */ export const make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -1857,6 +1858,7 @@ export const make = Effect.gen(function* () { ); }); + /** Uses a custom commit message or generates commit and optional branch text with the selected writer. */ const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( function* (input: { cwd: string; @@ -2023,6 +2025,7 @@ export const make = Effect.gen(function* () { }; }); + /** Reuses an existing PR or generates its title and body with the selected writer before creation. */ const runPrStep = Effect.fn("runPrStep")(function* ( settings: SourceControlTextGenerationSettings, cwd: string, @@ -2662,6 +2665,7 @@ export const make = Effect.gen(function* () { }; }); + /** Runs branch, commit, push, and PR steps in order, reporting progress and generation model context. */ const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = yield* createProgressEmitter(input, options); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..5ee6c0d85fb2 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,4 +1,4 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { buildGitActionProgressStages, @@ -8,9 +8,82 @@ import { resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, + resolveGitActionSettingsScope, resolveThreadBranchUpdate, resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; +import { resolveSettingsScope } from "./settings/settingsScope"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import type { Project } from "../types"; +import type { ProjectGroupingSettings } from "../logicalProject"; + +describe("generation failure settings scope", () => { + const environmentId = EnvironmentId.make("acting-environment"); + const project: Project = { + id: ProjectId.make("acting-project"), + environmentId, + title: "Test", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const groupingSettings: ProjectGroupingSettings = { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: {}, + }; + const environments = [{ environmentId, label: "Acting environment" }]; + + /** Resolves the generated link through the same group and scope builders used by Settings. */ + function resolveDestination(projects: Project[], projectId: ProjectId | undefined = project.id) { + const search = resolveGitActionSettingsScope({ + environmentId, + projectId, + gitCwd: "/repo", + projects, + groupingSettings, + }); + const groups = buildSidebarProjectSnapshots({ + projects, + settings: groupingSettings, + primaryEnvironmentId: environmentId, + resolveEnvironmentLabel: () => "Acting environment", + }); + return { search, scope: resolveSettingsScope(search, groups, environments) }; + } + + it("keeps the acting checkout for an existing project", () => { + assert.equal(resolveDestination([project]).scope.kind, "checkout"); + }); + + it("rejects a removed project instead of falling back to environment defaults", () => { + const { search, scope } = resolveDestination([]); + assert.equal(search.checkout, "acting-environment:/repo"); + assert.equal(search.machine, environmentId); + assert.equal(scope.kind, "unavailable"); + assert.deepEqual(scope.environmentIds, []); + }); + + it("does not substitute a different project record at the same path", () => { + assert.equal( + resolveDestination([{ ...project, id: ProjectId.make("replacement") }]).scope.kind, + "unavailable", + ); + }); + + it("does not substitute a project from another environment", () => { + assert.equal( + resolveDestination([{ ...project, environmentId: EnvironmentId.make("other") }]).scope.kind, + "unavailable", + ); + }); + + it("keeps a previously resolved target unavailable if it disappears before Settings loads", () => { + const { search } = resolveDestination([project]); + assert.equal(resolveSettingsScope(search, [], environments).kind, "unavailable"); + }); +}); function status(overrides: Partial = {}): VcsStatusResult { return { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..deec0d3d76ea 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,4 +1,6 @@ import type { + EnvironmentId, + ProjectId, GitRunStackedActionResult, GitStackedAction, VcsStatusResult, @@ -9,6 +11,39 @@ import { getChangeRequestTerminology, type ChangeRequestTerminology, } from "../sourceControlPresentation"; +import { + derivePhysicalProjectKey, + derivePhysicalProjectKeyFromPath, + type ProjectGroupingSettings, +} from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; +import type { Project } from "../types"; + +/** Keeps a missing checkout identifiable so Settings rejects it instead of editing environment defaults. */ +export function resolveGitActionSettingsScope(input: { + environmentId: EnvironmentId; + projectId: ProjectId | undefined; + gitCwd: string; + projects: readonly Project[]; + groupingSettings: ProjectGroupingSettings; +}) { + const settingsProject = input.projects.find( + (project) => + project.environmentId === input.environmentId && + (input.projectId ? project.id === input.projectId : project.workspaceRoot === input.gitCwd), + ); + const checkout = settingsProject + ? derivePhysicalProjectKey(settingsProject) + : derivePhysicalProjectKeyFromPath(input.environmentId, input.gitCwd); + const project = settingsProject + ? buildPhysicalToLogicalProjectKeyMap({ + projects: input.projects, + settings: input.groupingSettings, + primaryEnvironmentId: input.environmentId, + }).get(checkout) + : undefined; + return { machine: input.environmentId, project, checkout }; +} export type GitActionIconName = "commit" | "push" | "pr"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 8d9514bedca5..7df8a4b68ba9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -63,6 +63,7 @@ import { resolveLiveThreadBranchUpdate, resolveThreadBranchMetadataPatch, resolveQuickAction, + resolveGitActionSettingsScope, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; @@ -105,8 +106,7 @@ import { } from "~/lib/sourceControlActions"; import { readProjects, useThreadShell } from "~/state/entities"; import { getClientSettings } from "~/hooks/useSettings"; -import { derivePhysicalProjectKey, selectProjectGroupingSettings } from "~/logicalProject"; -import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; +import { selectProjectGroupingSettings } from "~/logicalProject"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -420,6 +420,7 @@ interface PublishRepositoryDialogProps { readonly gitCwd: string; } +/** Guides repository publication through provider selection, credentials, and repository details. */ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const openLink = useOpenLink(props.threadRef); const navigate = useNavigate(); @@ -1402,29 +1403,18 @@ export default function GitActionsControl({ type: "error", title: "Action failed", description: error instanceof Error ? error.message : "An error occurred.", - ...(isTextGenerationError(error) + ...(isTextGenerationError(error) && activeEnvironmentId !== null && gitCwd !== null ? { actionProps: { children: "Settings", onClick: () => { - const projectId = - activeServerThread?.projectId ?? activeDraftThread?.projectId; - const projects = readProjects(); - const settingsProject = projects.find( - (project) => - project.environmentId === activeEnvironmentId && - (projectId ? project.id === projectId : project.workspaceRoot === gitCwd), - ); - const checkout = settingsProject - ? derivePhysicalProjectKey(settingsProject) - : undefined; - const project = checkout - ? buildPhysicalToLogicalProjectKeyMap({ - projects, - settings: selectProjectGroupingSettings(getClientSettings()), - primaryEnvironmentId: activeEnvironmentId, - }).get(checkout) - : undefined; + const search = resolveGitActionSettingsScope({ + environmentId: activeEnvironmentId, + projectId: activeServerThread?.projectId ?? activeDraftThread?.projectId, + gitCwd, + projects: readProjects(), + groupingSettings: selectProjectGroupingSettings(getClientSettings()), + }); toastManager.close(resolvedProgressToastId); void navigate({ to: @@ -1435,11 +1425,7 @@ export default function GitActionsControl({ modelSetting === "sourceControlWriterModelSelection" ? "source-control-writer-model" : "text-generation-model", - search: { - machine: activeEnvironmentId ?? undefined, - project, - checkout, - }, + search, }); }, }, From 7728ea1c445c914cc7a4bb38f93acfdb48e35ce0 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Mon, 28 Sep 2026 22:42:12 -0400 Subject: [PATCH 4/5] docs: document adjacent Git review helpers Co-authored-by: Codex Co-authored-by: Claude AI-Tool: OpenAI Codex AI-Harness: Codex harness (integration/version not exposed) AI-Host: T3 Code AI-Model: gpt-6-astra AI-Reasoning: medium AI-Contribution: Docstring drafting, source-equivalence checks, formatting, targeted lint, and publishing preparation AI-Tool: Claude Code AI-Harness: Claude Code CLI 2.1.283 invoked by Codex harness AI-Host: T3 Code via PowerShell AI-Model: claude-fable-5-1 AI-Reasoning: high AI-Contribution: Independent implementation and docstring review, follow-up drafting --- apps/server/src/git/GitManager.test.ts | 1 + apps/server/src/git/GitManager.ts | 1 + apps/web/src/components/GitActionsControl.logic.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 21039fba467f..f657adc0c386 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -625,6 +625,7 @@ function resolvePullRequest( return manager.resolvePullRequest(input); } +/** Invokes PR thread preparation through the configured test manager. */ function preparePullRequestThread( manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 5b908290cf81..686b3278e176 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -696,6 +696,7 @@ function shouldPreferSshRemote(url: string | null): boolean { return isSshRemoteUrl(url); } +/** Copies available fork and head-repository details without introducing undefined fields. */ function toPullRequestHeadRemoteInfo(pr: { isCrossRepository?: boolean | undefined; headRepositoryNameWithOwner?: string | null | undefined; diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 5ee6c0d85fb2..f7d880a18453 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -85,6 +85,7 @@ describe("generation failure settings scope", () => { }); }); +/** Builds a clean feature-branch status with overrides for each Git action scenario. */ function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, From a31d2828fb238525216c4ddd3ccc5d2085a546d8 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Thu, 1 Oct 2026 04:12:59 -0400 Subject: [PATCH 5/5] docs: remove doc comments on code this change does not touch Keep only the comments that explain the model context, the optional fields for older servers, and the settings-scope guard. Code is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/git/GitManager.test.ts | 2 -- apps/server/src/git/GitManager.ts | 11 +++-------- .../src/components/GitActionsControl.logic.test.ts | 1 - apps/web/src/components/GitActionsControl.tsx | 2 -- packages/contracts/src/git.ts | 2 +- 5 files changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index f657adc0c386..7b1502a711c6 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -625,7 +625,6 @@ function resolvePullRequest( return manager.resolvePullRequest(input); } -/** Invokes PR thread preparation through the configured test manager. */ function preparePullRequestThread( manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, @@ -633,7 +632,6 @@ function preparePullRequestThread( return manager.preparePullRequestThread(input); } -/** Builds a scoped Git manager with configurable provider responses and captured CLI calls. */ function makeManager(input?: { ghScenario?: FakeGhScenario; sourceControlProvider?: SourceControlProvider["Service"]; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 686b3278e176..c669b633c6f4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -100,7 +100,7 @@ interface SourceControlTextGenerationSettings { readonly style: SourceControlWritingStyleSettings; } -/** Adds the attempted model and picker to a generation failure, preserving its diagnostic and cause. */ +/** Adds the attempted model and the setting that chose it to a generation failure. */ function withTextGenerationContext( error: TextGenerationError, settings: SourceControlTextGenerationSettings, @@ -696,7 +696,6 @@ function shouldPreferSshRemote(url: string | null): boolean { return isSshRemoteUrl(url); } -/** Copies available fork and head-repository details without introducing undefined fields. */ function toPullRequestHeadRemoteInfo(pr: { isCrossRepository?: boolean | undefined; headRepositoryNameWithOwner?: string | null | undefined; @@ -713,7 +712,6 @@ function toPullRequestHeadRemoteInfo(pr: { }; } -/** Assembles Git status and PR caches plus ordered commit, push, and PR actions over the VCS driver. */ export const make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -1859,7 +1857,6 @@ export const make = Effect.gen(function* () { ); }); - /** Uses a custom commit message or generates commit and optional branch text with the selected writer. */ const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( function* (input: { cwd: string; @@ -2026,7 +2023,6 @@ export const make = Effect.gen(function* () { }; }); - /** Reuses an existing PR or generates its title and body with the selected writer before creation. */ const runPrStep = Effect.fn("runPrStep")(function* ( settings: SourceControlTextGenerationSettings, cwd: string, @@ -2666,7 +2662,6 @@ export const make = Effect.gen(function* () { }; }); - /** Runs branch, commit, push, and PR steps in order, reporting progress and generation model context. */ const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = yield* createProgressEmitter(input, options); @@ -2748,8 +2743,8 @@ export const make = Effect.gen(function* () { ); return { modelSelection, - // The resolver returns the chosen settings object unchanged, preserving - // which picker supplied it even when both pickers name the same model. + // The resolver returns the chosen settings object itself, so identity tells + // which setting supplied it even when both name the same model. modelSetting: modelSelection === settings.sourceControlWriterModelSelection ? ("sourceControlWriterModelSelection" as const) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f7d880a18453..5ee6c0d85fb2 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -85,7 +85,6 @@ describe("generation failure settings scope", () => { }); }); -/** Builds a clean feature-branch status with overrides for each Git action scenario. */ function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7df8a4b68ba9..eb50775f4eb7 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -420,7 +420,6 @@ interface PublishRepositoryDialogProps { readonly gitCwd: string; } -/** Guides repository publication through provider selection, credentials, and repository details. */ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const openLink = useOpenLink(props.threadRef); const navigate = useNavigate(); @@ -949,7 +948,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ); } -/** Runs Git actions for the active checkout and routes generation failures to its model settings. */ export default function GitActionsControl({ presentation = "toolbar", gitCwd, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 202e63230bc0..63b73e2bb2bb 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -370,7 +370,7 @@ export class TextGenerationError extends Schema.TaggedError cause: Schema.optional(Schema.Defect()), }, ) { - /** Includes the attempted model when supplied, retaining the legacy message for older errors. */ + /** Names the attempted model when known; older servers send errors without it. */ override get message(): string { const model = this.modelSelection ? ` using ${this.modelSelection.model} (${this.modelSelection.instanceId})`