From edfae877ce0b4107066a571ae518459b9bd1cd0d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:04:43 -0700 Subject: [PATCH] feat(source-control): add push failure AI recovery Co-authored-by: dalveytech-vincent --- ...source-control-push-failure-ai-recovery.md | 368 +++++++++ src/main/git/remote.test.ts | 29 + .../CommitArea.chevron-spinner.test.tsx | 3 +- .../CommitArea.generate.test.tsx | 3 +- .../CommitArea.primary-icons.test.tsx | 3 +- .../right-sidebar/CommitArea.test.tsx | 33 +- ...rceControl.commit-failure-recovery.test.ts | 4 +- ...ourceControl.push-failure-recovery.test.ts | 6 +- ...SourceControl.remote-action-errors.test.ts | 36 +- .../right-sidebar/SourceControl.tsx | 765 ++++-------------- .../source-control-action-error.ts | 34 + ...e-control-ai-commit-failure-launch.test.ts | 117 ++- ...source-control-ai-commit-failure-launch.ts | 136 +--- .../source-control-ai-controller-types.ts | 4 +- .../source-control-ai-push-failure-launch.ts | 134 +-- .../source-control-ai-recovery-launch.ts | 178 ++++ .../source-control-manual-review-url.ts | 4 +- .../source-control-push-recovery.test.ts | 128 +++ .../source-control-push-recovery.ts | 85 ++ .../source-control-recovery-notice.tsx | 348 ++++++++ .../right-sidebar/use-source-control-ai.ts | 11 +- .../use-source-control-recovery-ai.ts | 64 +- .../settings/CommitMessageAiPane.test.tsx | 1 + .../RepositorySourceControlAiSection.test.ts | 1 + src/renderer/src/i18n/locales/en.json | 16 + src/renderer/src/i18n/locales/es.json | 16 + src/renderer/src/i18n/locales/ja.json | 16 + src/renderer/src/i18n/locales/ko.json | 16 + src/renderer/src/i18n/locales/zh.json | 16 + .../lib/source-control-remote-error.test.ts | 60 ++ .../src/lib/source-control-remote-error.ts | 28 +- src/renderer/src/store/slices/editor.test.ts | 47 ++ src/renderer/src/store/slices/editor.ts | 19 +- src/shared/git-remote-error.test.ts | 37 + src/shared/git-remote-error.ts | 8 + src/shared/source-control-ai-actions.test.ts | 26 + ...ce-control-commit-failure-agent-command.ts | 18 +- ...urce-control-push-failure-agent-command.ts | 18 +- .../source-control-push-failure.test.ts | 57 ++ src/shared/source-control-push-failure.ts | 31 +- ...rce-control-recovery-agent-command.test.ts | 53 ++ .../source-control-recovery-agent-command.ts | 25 + 42 files changed, 2007 insertions(+), 995 deletions(-) create mode 100644 docs/source-control-push-failure-ai-recovery.md create mode 100644 src/renderer/src/components/right-sidebar/source-control-action-error.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-ai-recovery-launch.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-push-recovery.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-recovery-notice.tsx create mode 100644 src/shared/source-control-recovery-agent-command.test.ts create mode 100644 src/shared/source-control-recovery-agent-command.ts diff --git a/docs/source-control-push-failure-ai-recovery.md b/docs/source-control-push-failure-ai-recovery.md new file mode 100644 index 00000000000..136b7e0031a --- /dev/null +++ b/docs/source-control-push-failure-ai-recovery.md @@ -0,0 +1,368 @@ +# Source Control Push Failure AI Recovery + +## Problem + +Push, force-push, publish, and sync push-stage failures flow through the shared +remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94` +and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716` +and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or +lint hook is not an auth, protected-branch, or transport problem, so generic +remote guidance sends the user in the wrong direction. + +PR #7787 proves the behavior is useful, but the prototype needs tightening before +it is maintainable: + +- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and + `src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat + push-failure predicates and feed live branch/file state into a prompt that says + "at failure time". +- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63` + imports push detection again instead of consuming one derived recovery model. +- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24` + and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24` + are copied launch flows with action-specific copy. +- `src/shared/source-control-commit-failure-agent-command.ts:6` and + `src/shared/source-control-push-failure-agent-command.ts:6` are identical + command-template builders. +- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21` + already contains a reusable split button, while `SourceControl.tsx:6415` + carries a second inline version. +- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats + any `isSync` error as push-like for hook detection. `syncBranch` calls the + formatter for fetch, upstream-status, pull, and push failures, so the renderer + cannot prove a sync error came from the push stage without an explicit marker. +- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2` + imports prompt helpers through the large React module instead of shared prompt + modules. + +## Goal + +Add first-class recovery for pre-push hook failures without growing a parallel +push-only subsystem: + +1. Toasts for push, force-push, publish, and sync push-stage failures say + "blocked" for detected pre-push or lint-hook output. +2. Source Control shows a concise Push blocked panel with Details and AI Fix + only for a failure snapshot that is both push-like and hook-like. +3. AI Fix launches the configured `fixPushFailure` Source Control action with a + safe, provider-neutral prompt. +4. Commit-failure recovery and push-failure recovery share launch, + command-template, split-button, and dense error-panel primitives. + +## Non-goals + +- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side + protected-branch failures from this entry point. +- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a + PR, or assume GitHub-specific terminology. +- Do not add app-wide persistence for this transient failure panel. +- Do not redesign the Source Control panel or the Source Control AI settings + model. +- Do not fold unrelated launch surfaces such as checks recovery into this change + unless a tiny shared helper is already needed for commit/push recovery. + +## Design + +1. Keep push-hook detection and prompt building in shared code. + - `src/shared/source-control-push-failure.ts` owns normalization, bounded + scanning, summary text, Details eligibility, prompt truncation, prompt + rules, and changed-file list bounding. + - Detection must stay conservative: explicit `pre-push`/`prepush`, + `hook declined to push`, hook-runner output in push context, or lint output + in push context. Auth, non-fast-forward, protected branch, pre-receive, + submodule, and generic transport failures stay on the existing remote-error + path. + - Keep the output scan bounded at 64 KiB and the prompt failure-output + section bounded. Also cap prompt file lines to a small constant with an + omitted-count line; do not rely on the git-status cap, which is far too high + for prompt context. + +2. Make remote-error formatting sync-stage aware. + - Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for + sync, for example `isSyncPushStage`. + - Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or + `isSyncPushStage`; do not use bare `isSync` as evidence of a push failure. + - In `syncBranch`, pass the push-stage flag only inside the two inner + `pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull + catches still pass `isSync` for Sync-shaped generic copy but must not render + blocked hook copy. + - Carry the same push-stage fact back to the renderer, either by tagging the + thrown `Error` with a narrow exported marker or by wrapping it in a typed + error that preserves the original message/cause. `SourceControl` must set + `syncPushStage` only from that marker, not from `kind === 'sync'` plus + hook-like stderr. + - Keep submodule push messages before hook detection so submodule guidance + remains more specific than AI recovery. + +3. Replace one-off command builders with one shared recovery command builder. + - Add a shared builder that accepts `{ actionId, promptOverride, + commandInputTemplate, basePrompt }` and renders the existing Source Control + AI command template for launch actions. + - Keep compatibility exports for commit and push helpers if nearby tests or + call sites still use those names, but make them wrappers over the generic + builder. + - Move prompt-builder tests to import from shared modules, not + `SourceControl.tsx`. + +4. Capture and derive push recovery state once. + - Extend `SourceControlActionError` with the raw error, a push-stage marker + for sync failures, the branch name at failure time, a bounded status-entry + snapshot at failure time, and a per-worktree sequence token. + - `runRemoteAction` clears the worktree's previous error and increments its + sequence before starting. A catch may write an error only if its sequence + still owns that worktree, so a slow failure cannot overwrite a newer retry + or success. + - Add a focused renderer helper that accepts the captured + `SourceControlActionError` and the current branch name, then returns either + `null` or a model with raw/sanitized detail text, summary, details flag, + kind label, and AI prompt. + - The helper returns `null` unless the operation is `push`, `force_push`, + `publish`, or `sync` with the sync push-stage marker and + `isPushHookFailure(rawError)` passes. + - If the current branch is known and differs from the captured branch, hide or + clear the model instead of launching an agent with wrong branch context. + - Pass that single model to `useSourceControlAi` and `CommitArea`; neither + prop construction nor `CommitArea` should repeat the push-hook predicate. + +5. Use one generic recovery launcher for commit and push failures. + - Replace the separate commit and push launch files with + `source-control-ai-recovery-launch.ts`, or keep the old files as one-line + wrappers if import stability is cheaper. + - The generic launcher owns connection resolution, SSH/local agent discovery, + saved-agent validation, agent-args validation, agent selection, terminal + launch, focus, and success/failure toasts. + - Resolve connection without collapsing local and unresolved states: read + `const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it + is a string or `null`, and only fall back to `sourceRepoConnectionId` when + the worktree lookup returns `undefined`. If the fallback is also + `undefined`, show the workspace-connection error instead of launching + locally. `null` means proven local; a string means SSH/remote. + - Validate saved CLI args before agent detection or terminal creation. On + Windows, keep using PowerShell-safe planning through + `planAgentCliArgsSuffix(..., 'powershell')`. + - Action-specific data is limited to action id, base prompt, empty-prompt + copy, unavailable-agent copy, and success copy. + +6. Keep the recovery hook thin. + - `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the + already-derived push recovery prompt/model, keeps independent loading + flags, and calls the generic launcher. + - It should not import push-hook detection, resolve agents, or duplicate + launch plumbing. + +7. Extract and reuse the recovery UI. + - Reuse or replace the existing + `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx` + rather than adding another split-button component. + - Move the dense recovery notice and Details dialog into focused renderer + modules named for source-control recovery. Avoid `helpers`, `utils`, and new + max-lines suppressions. + - Reuse the same notice/dialog component for commit and push recovery with + action-specific labels, summary, details, prompt, saved recipe, and launch + callback. + - Keep normal remote errors filtered out when a push recovery model is + rendered, so the user does not see both Push blocked and generic remote + error copy for the same failure. + +8. Retain `fixPushFailure` in the existing Source Control AI action model. + - The prototype already adds `fixPushFailure` to + `src/shared/source-control-ai-actions.ts`; keep it as a launch action like + `fixCommitFailure`. + - Verify default settings, normalizers, global settings rows, repository + override rows, labels, descriptions, and variable chips all include Push + failure fixes. + - Do not create a push-specific settings store or migration. Existing + `normalizeSourceControlAiSettings` hydrates missing action defaults. + +## Data Flow + +- User clicks Push, Force Push, Publish Branch, or Sync. +- `runRemoteAction` clears that worktree's previous remote error, records a new + sequence token, and starts the editor-store remote action. +- Git push fails and throws. +- `pushBranch` or sync's inner push-stage catch calls + `resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull, + and upstream-status failures do not pass the push-stage flag. +- Sync's inner push-stage catch marks the rethrown error as push-stage so + `runRemoteAction` can preserve `syncPushStage` in the captured snapshot. +- The formatter classifies hook output before auth/transport fallbacks only for + push-like options and shows blocked toast copy. +- `SourceControl` catches the same error, and if the sequence still owns the + worktree, stores `{ kind, rawError, message, syncPushStage, branchName, + entriesSnapshot }`. +- The push recovery helper derives one `pushRecovery` model from that snapshot. +- `CommitArea` renders normal remote errors or the Push blocked recovery notice. +- AI Fix builds the `fixPushFailure` command input and launches the selected + agent in the owning local or SSH runtime. + +## Edge Cases + +- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and + transport failures must not show the Push blocked panel. +- Submodule push errors keep their existing specialized messages before + push-hook detection. +- Sync failures from fetch, upstream-status, or pull stages must not show Push + blocked, even if stderr contains words like "lint" or "hook". +- Create PR intent remote failures use the same `runRemoteAction` plumbing; if + CommitArea is not visible, blocked toast copy is still required but no hidden + panel work is needed. +- A newer remote operation for the same worktree must clear stale recovery state + immediately and prevent an older in-flight failure from writing after it. +- Switching worktrees or branches must not show another worktree or branch's + push failure. +- External edits or another Orca window may make the failure stale. Do not add + cross-window persistence for this feature; rely on local retry/remount/branch + mismatch clearing and list this as residual risk. +- Source Control AI hidden for a repo still shows the blocked notice and Details; + it hides AI Fix. +- SSH worktrees must detect and launch agents on the owning connection, not a + local fallback. +- Windows launch planning must continue to use PowerShell-safe agent args. +- Very large hook output and very large changed-file sets must be bounded before + prompt generation. +- ANSI/control output must not leak into summaries or Details comparison logic. + Details may show sanitized text while preserving enough line breaks for + debugging. +- Empty custom command templates must stay empty so the launcher rejects them + with a clear settings error. +- Prompt text must treat file paths, branch names, and hook output as data, not + instructions. + +## Test Plan + +- Shared unit tests: + - `src/shared/source-control-push-failure.test.ts` covers detection positives, + auth/protected/pre-receive/non-fast-forward negatives, ANSI/control + stripping, bounded scanning, details comparison, prompt output truncation, + prompt file-list capping, and provider-neutral prompt rules. + - Shared command-template tests cover the generic recovery builder plus + commit/push wrappers, including empty templates and prompt overrides. + - `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure` + label, default template, variables, normalization, and action-list inclusion. +- Remote formatter and store tests: + - `src/renderer/src/lib/source-control-remote-error.test.ts` covers push, + force-push, publish, and sync push-stage blocked copy plus sync non-push + stage negatives, auth/non-fast-forward/protected/pre-receive/submodule + regressions. + - `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast + path and both sync push-stage and sync non-push-stage toasts. +- Renderer model and UI tests: + - A focused push recovery derivation test covers operation kind, sync + push-stage marker, branch mismatch, stale sequence ownership, snapshot file + entries, summary, details flag, prompt contents, and ordinary remote errors. + - `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix + visible/hidden, ordinary remote errors, no duplicate remote error when a push + model exists, and unchanged commit-failure recovery rendering. + - Settings tests cover global and repository Source Control AI action rows + showing Push failure fixes. +- Launcher tests: + - Generic recovery launcher tests cover commit and push action ids, invalid CLI + args before detection, empty template rejection, unavailable saved agent, + local versus SSH agent detection, successful launch/focus, and success copy. +- Checks: + - Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and + `pnpm check:max-lines-ratchet`. + +## UI Quality Bar + +The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome, +quiet, dense, token-based, and consistent with adjacent commit-failure recovery. +Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide +icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action +recipe row patterns. The Push blocked notice should keep the summary readable at +narrow right-sidebar widths, avoid nested cards, avoid new color values, show +stable split-button geometry while launching, and keep Details as progressive +disclosure for long hook output. Dialogs and dropdowns must remain usable in +light/dark mode and under SSH latency. + +## Review Screenshots + +1. Source Control after a simulated pre-push hook failure: Push blocked notice + with summary, AI Fix, and Details. +2. Push failure Details dialog showing hook output and Fix with AI. +3. AI Fix customize-launch dialog for `fixPushFailure`. +4. Ordinary push auth/protected/remote failure state: regular remote error, no + Push blocked notice. +5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push + blocked notice. +6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI + Fix. +7. Source Control AI settings showing the Push failure fixes launch recipe. +8. Adjacent commit-failure recovery notice still rendering correctly after the + shared UI extraction. + +## Rollout + +1. Tighten shared push-failure classifier/prompt bounds and add the generic + recovery command builder. +2. Make remote formatter/store sync-stage aware and update toast tests. +3. Add the `SourceControlActionError` snapshot/sequence data and one push + recovery derivation helper. +4. Replace duplicated commit/push launchers with the generic recovery launcher + and thin hook wiring. +5. Extract/reuse source-control recovery UI components, including the existing + split-button module. +6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels. +7. Update targeted tests and run typecheck, lint, and max-lines ratchet. +8. Validate rendered Electron states with screenshots before opening the PR. + +## Lightweight Eng Review + +- Scope: Reduced from PR #7787's push-specific parallel path to a shared + recovery layer. Keep the feature limited to local pre-push/lint-hook detection, + blocked copy, an inline recovery notice, Details, and AI launch. No persistence + or provider-specific recovery. +- Architecture/data flow: Shared code owns classification, prompt text, and + command-template rendering; the editor store owns stage-aware toast formatting; + `SourceControl` owns transient worktree-scoped failure snapshots and sequence + invalidation; the generic launcher owns local/SSH agent discovery and terminal + launch. +- Failure modes covered: + - Sync stage ambiguity: only the inner push stage may classify as hook-blocked. + - Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay + out of push recovery. + - Branch or worktree switches suppress stale recovery models. + - Per-worktree sequence tokens prevent older in-flight failures from + overwriting newer retries or successes. + - Empty custom templates, invalid CLI args, unavailable saved agents, missing + worktree context, SSH host detection, and Windows shell planning fail before + terminal launch. + - Large or hostile hook output and file lists are bounded and treated as data + in prompts. +- Test coverage required: + - Shared classifier/prompt/command/action-model tests in `src/shared`. + - Remote formatter and editor-store tests for push, force-push, publish, sync + push-stage, and sync non-push-stage paths. + - Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix, + hidden AI actions, stale branch/sequence, ordinary remote errors, and + existing commit failure recovery. + - Generic launcher tests for local/SSH, invalid args, saved-agent unavailable, + empty template, and success launch. + - Settings tests for global and repo Push failure fixes rows. + - Electron validation for all required screenshots; no separate E2E is + required if renderer tests cover the model and Electron validates the real UI. +- Performance/blast radius: No polling, watchers, migrations, or startup work. + Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt + output, and a bounded file snapshot only when a remote action fails. Largest + blast radius is the shared recovery UI and launcher used by existing commit + failures, so commit recovery needs tests and a screenshot. +- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density: + token-based error state, stable split-button geometry, progressive Details + disclosure, no nested cards, concise copy, light/dark compatibility, and + visible disabled/loading state under SSH latency. +- Required review screenshots: + 1. Push blocked notice after pre-push hook failure. + 2. Push failure Details dialog with Fix with AI. + 3. `fixPushFailure` customize-launch dialog. + 4. Ordinary push auth/protected/remote failure with no Push blocked panel. + 5. Sync non-push-stage failure with no Push blocked panel. + 6. Source Control AI hidden: Push blocked without AI Fix. + 7. Source Control AI settings showing Push failure fixes. + 8. Existing commit-failure recovery notice after shared UI extraction. +- Residual risks: Classifier false positives remain possible for unusual local + hook-runner output that mentions push context; keep tests biased toward + preserving auth, protected-branch, and non-fast-forward behavior. Failure + panels are renderer-local, so another Orca window or an external terminal can + fix the problem while the old panel remains visible until local retry, remount, + or branch/worktree change. Electron validation may need a mocked or temporary + repo scenario to trigger a real pre-push hook without mutating user data. diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 4e7b27639f0..166db56079d 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -270,6 +270,35 @@ describe('git remote operations', () => { await expect(gitPush('/repo', false)).rejects.toThrow('fatal: something obscure happened') }) + it('preserves redacted pre-push hook output from failed pushes', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('no branch')) + .mockRejectedValueOnce( + new Error( + [ + 'Command failed: git push https://x-access-token:ghp_secret@github.com/acme/repo.git HEAD', + 'husky - pre-push hook failed', + 'eslint found 2 errors', + "error: failed to push some refs to 'https://ghp_tailSecret@github.com/acme/repo.git'" + ].join('\n') + ) + ) + + let caught: Error | undefined + try { + await gitPush('/repo', false) + } catch (error) { + caught = error as Error + } + + expect(caught).toBeInstanceOf(Error) + expect(caught?.message).toContain('husky - pre-push hook failed') + expect(caught?.message).toContain('eslint found 2 errors') + expect(caught?.message).not.toContain('x-access-token') + expect(caught?.message).not.toContain('ghp_secret') + expect(caught?.message).not.toContain('ghp_tailSecret') + }) + it('strips embedded credentials from push error messages', async () => { gitExecFileAsyncMock .mockRejectedValueOnce(new Error('no branch')) diff --git a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx index dc9e46bae1a..25c898721f0 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx @@ -33,8 +33,7 @@ function baseProps(overrides: Partial = {}) { commitMessage: 'feat: add commit area', commitError: null as string | null, commitFailureRecoveryPrompt: null as string | null, - pushFailureRawError: null as string | null, - pushFailureRecoveryPrompt: null as string | null, + pushRecovery: null, remoteActionError: null as string | null, isCommitting: inputs.isCommitting, isFixingCommitFailureWithAI: false, diff --git a/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx index 5bed59d3ecb..137c4c3116e 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx @@ -33,8 +33,7 @@ function baseProps(overrides: Partial = {}) { commitMessage: 'feat: add commit area', commitError: null as string | null, commitFailureRecoveryPrompt: null as string | null, - pushFailureRawError: null as string | null, - pushFailureRecoveryPrompt: null as string | null, + pushRecovery: null, remoteActionError: null as string | null, isCommitting: inputs.isCommitting, isFixingCommitFailureWithAI: false, diff --git a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx index 053bb5c6526..4c8d9de1e0e 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx @@ -33,8 +33,7 @@ function baseProps(overrides: Partial = {}) { commitMessage: 'feat: add commit area', commitError: null as string | null, commitFailureRecoveryPrompt: null as string | null, - pushFailureRawError: null as string | null, - pushFailureRecoveryPrompt: null as string | null, + pushRecovery: null, remoteActionError: null as string | null, isCommitting: inputs.isCommitting, isFixingCommitFailureWithAI: false, diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index e0a87f7a3fa..b360795e662 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -7,6 +7,7 @@ import { } from './source-control-primary-action' import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items' import { TooltipProvider } from '@/components/ui/tooltip' +import { deriveSourceControlPushRecovery } from './source-control-push-recovery' function buildInputs(overrides: Partial = {}): PrimaryActionInputs { return { @@ -31,8 +32,7 @@ function baseProps(overrides: Partial = {}) { commitMessage: 'feat: add commit area', commitError: null as string | null, commitFailureRecoveryPrompt: null as string | null, - pushFailureRawError: null as string | null, - pushFailureRecoveryPrompt: null as string | null, + pushRecovery: null as ReturnType, remoteActionError: null as string | null, isCommitting: inputs.isCommitting, isFixingCommitFailureWithAI: false, @@ -59,6 +59,29 @@ function baseProps(overrides: Partial = {}) { } } +function buildPushRecovery( + rawError: string +): NonNullable> { + const recovery = deriveSourceControlPushRecovery({ + actionError: { + kind: 'push', + message: 'Push blocked', + rawError, + branchName: 'main', + worktreePath: '/repo', + entriesSnapshot: [], + entriesSnapshotTotalCount: 0, + sequence: 1 + }, + currentBranchName: 'main', + currentSequence: 1 + }) + if (!recovery) { + throw new Error('push recovery was not derived') + } + return recovery +} + function renderCommitArea(props: Parameters[0]): string { return renderToStaticMarkup( @@ -251,8 +274,7 @@ describe('CommitArea', () => { "error: failed to push some refs to 'origin'\nhusky - pre-push hook exited with code 1\neslint found 2 errors" const markup = renderCommitArea({ ...baseProps(), - pushFailureRawError: raw, - pushFailureRecoveryPrompt: 'Fix this push failure.' + pushRecovery: buildPushRecovery(raw) }) expect(markup).toContain('id="commit-area-push-error"') @@ -266,8 +288,7 @@ describe('CommitArea', () => { it('hides push failure AI actions when Source Control AI actions are hidden', () => { const markup = renderCommitArea({ ...baseProps(), - pushFailureRawError: 'husky - pre-push hook failed', - pushFailureRecoveryPrompt: 'Fix this push failure.', + pushRecovery: buildPushRecovery('husky - pre-push hook failed'), sourceControlAiActionsVisible: false }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts index 7bafffbbb30..02aeaa1911a 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { appendCommitFailureCustomInstruction, - buildCommitFailureAgentCommandInput, buildFixCommitFailurePrompt -} from './SourceControl' +} from '../../../../shared/source-control-commit-failure' +import { buildCommitFailureAgentCommandInput } from '../../../../shared/source-control-commit-failure-agent-command' describe('SourceControl commit failure recovery prompt', () => { it('builds a provider-neutral AI prompt for fixing a failed commit hook', () => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts index c28ad67c670..2ec8633d61c 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { appendPushFailureCustomInstruction, - buildFixPushFailurePrompt, - buildPushFailureAgentCommandInput -} from './SourceControl' + buildFixPushFailurePrompt +} from '../../../../shared/source-control-push-failure' +import { buildPushFailureAgentCommandInput } from '../../../../shared/source-control-push-failure-agent-command' describe('SourceControl push failure recovery prompt', () => { it('leaves blank launch templates blank so the launcher can reject them', () => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts index 71f709de658..b04fd1bea15 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts @@ -4,7 +4,11 @@ import { clearRemoteActionErrorsForCompletedConflictOperations } from './SourceC describe('SourceControl remote action error reconciliation', () => { it('clears a rebase failure after git status observes the rebase completed', () => { const errors = { - 'wt-1': { kind: 'rebase' as const, message: 'Rebase failed. Could not apply abc123' } + 'wt-1': { + kind: 'rebase' as const, + message: 'Rebase failed. Could not apply abc123', + rawError: 'Rebase failed. Could not apply abc123' + } } expect( @@ -18,7 +22,11 @@ describe('SourceControl remote action error reconciliation', () => { it('keeps a rebase failure while the rebase is still in progress', () => { const errors = { - 'wt-1': { kind: 'rebase' as const, message: 'Rebase failed. Could not apply abc123' } + 'wt-1': { + kind: 'rebase' as const, + message: 'Rebase failed. Could not apply abc123', + rawError: 'Rebase failed. Could not apply abc123' + } } expect( @@ -32,7 +40,11 @@ describe('SourceControl remote action error reconciliation', () => { it('keeps immediate rebase failures that never entered a rebase operation', () => { const errors = { - 'wt-1': { kind: 'rebase' as const, message: 'Rebase blocked - commit first.' } + 'wt-1': { + kind: 'rebase' as const, + message: 'Rebase blocked - commit first.', + rawError: 'Rebase blocked - commit first.' + } } expect( @@ -46,9 +58,21 @@ describe('SourceControl remote action error reconciliation', () => { it('clears pull and sync conflict errors after their merge operation completes', () => { const errors = { - 'wt-pull': { kind: 'pull' as const, message: 'Pull stopped with merge conflicts.' }, - 'wt-sync': { kind: 'sync' as const, message: 'Sync stopped with merge conflicts.' }, - 'wt-fetch': { kind: 'fetch' as const, message: 'Fetch failed. network timeout' } + 'wt-pull': { + kind: 'pull' as const, + message: 'Pull stopped with merge conflicts.', + rawError: 'Pull stopped with merge conflicts.' + }, + 'wt-sync': { + kind: 'sync' as const, + message: 'Sync stopped with merge conflicts.', + rawError: 'Sync stopped with merge conflicts.' + }, + 'wt-fetch': { + kind: 'fetch' as const, + message: 'Fetch failed. network timeout', + rawError: 'Fetch failed. network timeout' + } } expect( diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 8df58daed3c..dcd08ea3556 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -11,9 +11,7 @@ import { Loader2, RefreshCw, Settings2, - Sparkle, Sparkles, - SlidersHorizontal, Square, Undo2, Check, @@ -32,7 +30,10 @@ import { type LucideIcon } from 'lucide-react' import { useAppStore } from '@/store' -import { resolveRemoteOperationErrorMessage } from '@/lib/source-control-remote-error' +import { + isSyncPushStageError, + resolveRemoteOperationErrorMessage +} from '@/lib/source-control-remote-error' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' @@ -131,7 +132,6 @@ import { toast } from 'sonner' import { SourceControlEntryContextMenu } from './source-control-entry-context-menu' import { Dialog, - DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -219,18 +219,7 @@ import { loadSessionCommitDrafts, saveSessionCommitDrafts } from '@/lib/source-control-commit-draft-session' -import { - getCommitFailureDialogWorktreeKey, - shouldShowCommitFailureDialog, - syncCommitFailureDialogState, - type CommitFailureDialogState -} from './commit-failure-dialog-state' import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary' -import { - hasExpandedPushFailureDetails, - isPushHookFailure, - summarizePushFailure -} from './push-failure-summary' import { isSourceControlSplitOpenModifier, shouldOpenSourceControlRowAsPreview, @@ -308,6 +297,17 @@ import { type PullRequestGenerationContext, type PullRequestGenerationFields } from '@/store/slices/pull-request-generation' +import { + captureSourceControlRecoveryEntrySnapshot, + type SourceControlActionError, + type SourceControlRecoveryStatusEntry +} from './source-control-action-error' +import { + deriveSourceControlPushRecovery, + getSourceControlRecoveryFailureKindLabel, + type SourceControlPushRecovery +} from './source-control-push-recovery' +import { SourceControlRecoveryNotice } from './source-control-recovery-notice' export { appendCommitFailureCustomInstruction, @@ -325,12 +325,6 @@ export { } from './source-control-text-generation-defaults' type AbortConflictOperation = Extract -type AbortActionErrorKind = 'abort_merge' | 'abort_rebase' -export type SourceControlActionError = { - kind: RemoteOpKind | AbortActionErrorKind - message: string - rawError?: string -} type SourceControlOperationTarget = RuntimeGitContext & { worktreeId: string pushTarget?: GitPushTarget @@ -734,6 +728,7 @@ function resolveRemoteActionError(kind: RemoteOpKind, error: unknown): string { isPush: kind === 'push', isForcePush: kind === 'force_push', isSync: kind === 'sync', + isSyncPushStage: kind === 'sync' && isSyncPushStageError(error), isFetch: kind === 'fetch', isFastForward: kind === 'fast_forward', isRebase: kind === 'rebase' @@ -1043,6 +1038,7 @@ function SourceControlInner(): React.JSX.Element { const [remoteActionErrors, setRemoteActionErrors] = useState< Record >({}) + const remoteActionErrorSequenceByWorktreeRef = useRef>({}) const previousConflictOperationsRef = useRef>({}) // Why: keep commit-in-flight state per-worktree. A single boolean would be // cleared when the user switched worktrees, letting them double-click Commit @@ -1143,6 +1139,9 @@ function SourceControlInner(): React.JSX.Element { const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) const commitError = commitErrors[activeWorktreeId ?? ''] ?? null const remoteActionError = remoteActionErrors[activeWorktreeId ?? ''] ?? null + const activeRemoteActionSequence = activeWorktreeId + ? (remoteActionErrorSequenceByWorktreeRef.current[activeWorktreeId] ?? null) + : null const [gitHistoryByWorktree, setGitHistoryByWorktree] = useState< Record >({}) @@ -1896,6 +1895,15 @@ function SourceControlInner(): React.JSX.Element { })), [unresolvedConflicts] ) + const pushRecovery = useMemo( + () => + deriveSourceControlPushRecovery({ + actionError: remoteActionError, + currentBranchName: branchName || null, + currentSequence: activeRemoteActionSequence + }), + [activeRemoteActionSequence, branchName, remoteActionError] + ) const { sourceControlAiDiscoveryHostKey, sourceControlAiActionsVisible, @@ -1913,7 +1921,6 @@ function SourceControlInner(): React.JSX.Element { isLaunchingPushFailureAgent, resolveConflictsPrompt, commitFailureRecoveryPrompt, - pushFailureRecoveryPrompt, getLaunchActionRecipe, saveLaunchActionDefault, handleResolveConflictsWithAI, @@ -1935,17 +1942,7 @@ function SourceControlInner(): React.JSX.Element { worktreePath, commitMessage, commitError, - pushFailureRawError: - remoteActionError && - isPushHookFailure(remoteActionError.rawError ?? remoteActionError.message) && - (remoteActionError.kind === 'push' || - remoteActionError.kind === 'publish' || - remoteActionError.kind === 'force_push' || - remoteActionError.kind === 'sync') - ? (remoteActionError.rawError ?? remoteActionError.message) - : null, - pushFailureEntries: [...grouped.staged, ...grouped.unstaged, ...grouped.untracked], - branchName: branchName || null, + pushRecoveryPrompt: pushRecovery?.prompt ?? null, updateSettings, updateRepo, openSettingsTarget, @@ -2001,6 +1998,11 @@ function SourceControlInner(): React.JSX.Element { delete commitInFlightRef.current[key] } } + for (const key of Object.keys(remoteActionErrorSequenceByWorktreeRef.current)) { + if (!worktreeMap.has(key)) { + delete remoteActionErrorSequenceByWorktreeRef.current[key] + } + } for (const key of Object.keys(generateInFlightRef.current)) { if (!worktreeMap.has(key)) { delete generateInFlightRef.current[key] @@ -2470,6 +2472,19 @@ function SourceControlInner(): React.JSX.Element { if (!target) { return false } + const sequence = (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] ?? 0) + 1 + remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] = sequence + const targetIsActiveWorktree = target.worktreeId === activeWorktreeId + const recoveryEntrySnapshot = captureSourceControlRecoveryEntrySnapshot( + targetIsActiveWorktree + ? ([ + ...grouped.staged, + ...grouped.unstaged, + ...grouped.untracked + ] satisfies SourceControlRecoveryStatusEntry[]) + : [] + ) + const failureBranchName = targetIsActiveWorktree ? branchName || null : null setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null })) try { if (kind === 'publish') { @@ -2568,19 +2583,30 @@ function SourceControlInner(): React.JSX.Element { runtimeTargetSettings: target.settings } ) - setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] === sequence) { + setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + } return true } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep // one consistent toast path and avoid duplicate notifications in the UI. // Keep the latest failure inline too: dropdown-only actions like Fetch can // otherwise look like nothing happened once the menu closes. + if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] !== sequence) { + return false + } setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: { kind, message: resolveRemoteActionError(kind, error), - rawError: error instanceof Error ? error.message : String(error) + rawError: error instanceof Error ? error.message : String(error), + syncPushStage: kind === 'sync' ? isSyncPushStageError(error) : false, + branchName: failureBranchName, + worktreePath: target.worktreePath, + entriesSnapshot: recoveryEntrySnapshot.entries, + entriesSnapshotTotalCount: recoveryEntrySnapshot.totalCount, + sequence } })) return false @@ -2598,9 +2624,13 @@ function SourceControlInner(): React.JSX.Element { activeRepoSettings, activeWorktree?.pushTarget, activeWorktreeId, + branchName, fetchBranch, fastForwardBranch, effectiveBaseRef, + grouped.staged, + grouped.unstaged, + grouped.untracked, pullBranch, pushBranch, rebaseFromBase, @@ -2663,7 +2693,11 @@ function SourceControlInner(): React.JSX.Element { ) setRemoteActionErrors((prev) => ({ ...prev, - [activeWorktreeId]: { kind: isRebase ? 'abort_rebase' : 'abort_merge', message } + [activeWorktreeId]: { + kind: isRebase ? 'abort_rebase' : 'abort_merge', + message, + rawError: message + } })) } finally { setAbortOperationInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) @@ -5796,27 +5830,8 @@ function SourceControlInner(): React.JSX.Element { commitMessage={commitMessage} commitError={commitError} commitFailureRecoveryPrompt={commitFailureRecoveryPrompt} - pushFailureRawError={ - remoteActionError && - isPushHookFailure(remoteActionError.rawError ?? remoteActionError.message) && - (remoteActionError.kind === 'push' || - remoteActionError.kind === 'publish' || - remoteActionError.kind === 'force_push' || - remoteActionError.kind === 'sync') - ? (remoteActionError.rawError ?? remoteActionError.message) - : null - } - pushFailureRecoveryPrompt={pushFailureRecoveryPrompt} - remoteActionError={ - remoteActionError && - isPushHookFailure(remoteActionError.rawError ?? remoteActionError.message) && - (remoteActionError.kind === 'push' || - remoteActionError.kind === 'publish' || - remoteActionError.kind === 'force_push' || - remoteActionError.kind === 'sync') - ? null - : (remoteActionError?.message ?? null) - } + pushRecovery={pushRecovery} + remoteActionError={pushRecovery ? null : (remoteActionError?.message ?? null)} createPrIntentNotice={createPrIntentNotice} isCommitting={isCommitting} isFixingCommitFailureWithAI={isLaunchingCommitFailureAgent} @@ -6412,216 +6427,6 @@ function SourceControlInner(): React.JSX.Element { const SourceControl = React.memo(SourceControlInner) export default SourceControl -type SourceControlRecoveryFixSplitButtonProps = { - recoveryKind: 'commit' | 'push' - label: string - worktreeId: string | null - groupId: string | null - connectionId?: string | null - repoId?: string | null - launchPlatform?: NodeJS.Platform - prompt: string | null - isLaunching: boolean - variant: React.ComponentProps['variant'] - size: React.ComponentProps['size'] - iconClassName: string - primaryClassName?: string - chevronClassName?: string - savedAgentId?: TuiAgent | null - savedCommandInputTemplate?: string | null - savedAgentArgs?: string | null - onSaveAgentDefault?: ( - target: SourceControlAiWriteTarget, - actionId: SourceControlLaunchActionId, - recipe: SourceControlActionRecipe - ) => void | Promise - onOpenSettings?: () => void - onFixWithDefaultAgent: (promptOverride?: string) => Promise | boolean - onPromptDelivered: () => void -} - -function SourceControlRecoveryFixSplitButton({ - recoveryKind, - label, - worktreeId, - groupId, - connectionId, - repoId, - launchPlatform, - prompt, - isLaunching, - variant, - size, - iconClassName, - primaryClassName, - chevronClassName, - savedAgentId, - savedCommandInputTemplate, - savedAgentArgs, - onSaveAgentDefault, - onOpenSettings, - onFixWithDefaultAgent, - onPromptDelivered -}: SourceControlRecoveryFixSplitButtonProps): React.JSX.Element { - const [composerOpen, setComposerOpen] = useState(false) - const canLaunch = Boolean(worktreeId && groupId && prompt) - const dividerClass = variant === 'default' ? 'border-primary-foreground/20' : 'border-border' - const actionId: SourceControlLaunchActionId = - recoveryKind === 'push' ? 'fixPushFailure' : 'fixCommitFailure' - const copy = - recoveryKind === 'push' - ? { - defaultAgentTitle: translate( - 'auto.components.right.sidebar.SourceControl.pushRecovery.4b37ae99b0', - 'Start the default AI agent to fix this push failure' - ), - fixAriaLabel: translate( - 'auto.components.right.sidebar.SourceControl.pushRecovery.30b8d4f181', - 'Fix push failure with AI' - ), - chooseAgentTitle: translate( - 'auto.components.right.sidebar.SourceControl.pushRecovery.dd43c47089', - 'Choose an agent for this push failure' - ), - chooseAgentAriaLabel: translate( - 'auto.components.right.sidebar.SourceControl.pushRecovery.ec7bfced55', - 'Choose agent to fix push failure' - ), - contextUnavailable: translate( - 'auto.components.right.sidebar.SourceControl.pushRecovery.9e5ccd00aa', - 'Push failure context unavailable' - ), - dialogTitle: translate( - 'auto.components.right.sidebar.SourceControl.pushRecovery.054ead86b1', - 'Fix Push Failure With AI' - ), - dialogDescription: translate( - 'auto.components.right.sidebar.SourceControl.pushRecovery.15b7f210d7', - 'Choose the agent and edit the full command input before launch.' - ) - } - : { - defaultAgentTitle: translate( - 'auto.components.right.sidebar.SourceControl.4b37ae99b0', - 'Start the default AI agent to fix this commit failure' - ), - fixAriaLabel: translate( - 'auto.components.right.sidebar.SourceControl.30b8d4f181', - 'Fix commit failure with AI' - ), - chooseAgentTitle: translate( - 'auto.components.right.sidebar.SourceControl.dd43c47089', - 'Choose an agent for this commit failure' - ), - chooseAgentAriaLabel: translate( - 'auto.components.right.sidebar.SourceControl.ec7bfced55', - 'Choose agent to fix commit failure' - ), - contextUnavailable: translate( - 'auto.components.right.sidebar.SourceControl.9e5ccd00aa', - 'Commit failure context unavailable' - ), - dialogTitle: translate( - 'auto.components.right.sidebar.SourceControl.054ead86b1', - 'Fix Commit Failure With AI' - ), - dialogDescription: translate( - 'auto.components.right.sidebar.SourceControl.15b7f210d7', - 'Choose the agent and edit the full command input before launch.' - ) - } - - return ( - <> - -
- - - - -
- - {worktreeId && groupId && prompt ? ( - setComposerOpen(true)} - className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - > - - {translate( - 'auto.components.right.sidebar.SourceControl.f0a2dc9e46', - 'Customize launch...' - )} - - ) : ( - {copy.contextUnavailable} - )} - -
- {worktreeId && groupId && prompt ? ( - - ) : null} - - ) -} - -function getRecoveryFailureKindLabel(summary: string): string | null { - if (/\blint\b/i.test(summary)) { - return 'Lint' - } - - if (/\bhook\b|\bpre-commit\b|\bpre-push\b/i.test(summary)) { - return 'Hook' - } - - return null -} - type CommitAreaProps = { worktreeId: string | null groupId: string | null @@ -6631,8 +6436,7 @@ type CommitAreaProps = { commitMessage: string commitError: string | null commitFailureRecoveryPrompt: string | null - pushFailureRawError: string | null - pushFailureRecoveryPrompt: string | null + pushRecovery: SourceControlPushRecovery | null remoteActionError: string | null createPrIntentNotice?: CreatePrIntentNotice | null isCommitting: boolean @@ -6679,8 +6483,7 @@ export function CommitArea({ commitMessage, commitError, commitFailureRecoveryPrompt, - pushFailureRawError, - pushFailureRecoveryPrompt, + pushRecovery, remoteActionError, createPrIntentNotice, isCommitting, @@ -6749,23 +6552,9 @@ export function CommitArea({ [commitError] ) const commitFailureKindLabel = useMemo( - () => (commitFailureSummary ? getRecoveryFailureKindLabel(commitFailureSummary) : null), - [commitFailureSummary] - ) - const pushFailureSummary = useMemo( - () => (pushFailureRawError ? summarizePushFailure(pushFailureRawError) : null), - [pushFailureRawError] - ) - const pushFailureKindLabel = useMemo( - () => (pushFailureSummary ? getRecoveryFailureKindLabel(pushFailureSummary) : null), - [pushFailureSummary] - ) - const hasPushFailureDetails = useMemo( () => - pushFailureRawError && pushFailureSummary - ? hasExpandedPushFailureDetails(pushFailureRawError, pushFailureSummary) - : false, - [pushFailureRawError, pushFailureSummary] + commitFailureSummary ? getSourceControlRecoveryFailureKindLabel(commitFailureSummary) : null, + [commitFailureSummary] ) const hasCommitFailureDetails = useMemo( () => @@ -6774,79 +6563,6 @@ export function CommitArea({ : false, [commitError, commitFailureSummary] ) - // Why: the details dialog is scoped to the worktree, not the exact stderr - // text, so a retried commit can refresh an open dialog with newer output. - const commitFailureWorktreeKey = getCommitFailureDialogWorktreeKey(worktreeId) - const [commitFailureDialogState, setCommitFailureDialogState] = - useState({ - worktreeKey: commitFailureWorktreeKey, - open: false - }) - const isCommitFailureDialogOpen = shouldShowCommitFailureDialog( - commitFailureDialogState, - commitFailureWorktreeKey, - hasCommitFailureDetails - ) - const setCommitFailureDialogOpen = useCallback( - (open: boolean) => { - setCommitFailureDialogState({ worktreeKey: commitFailureWorktreeKey, open }) - }, - [commitFailureWorktreeKey] - ) - const handleFixCommitFailureWithAI = useCallback( - async (promptOverride?: string): Promise => { - const launched = await onFixCommitFailureWithAI(promptOverride) - if (launched) { - setCommitFailureDialogOpen(false) - } - return launched - }, - [onFixCommitFailureWithAI, setCommitFailureDialogOpen] - ) - const handleCommitFailureAgentPromptDelivered = useCallback(() => { - setCommitFailureDialogOpen(false) - }, [setCommitFailureDialogOpen]) - const pushFailureWorktreeKey = getCommitFailureDialogWorktreeKey(worktreeId) - const [pushFailureDialogState, setPushFailureDialogState] = useState({ - worktreeKey: pushFailureWorktreeKey, - open: false - }) - const isPushFailureDialogOpen = shouldShowCommitFailureDialog( - pushFailureDialogState, - pushFailureWorktreeKey, - hasPushFailureDetails - ) - const setPushFailureDialogOpen = useCallback( - (open: boolean) => { - setPushFailureDialogState({ worktreeKey: pushFailureWorktreeKey, open }) - }, - [pushFailureWorktreeKey] - ) - const handleFixPushFailureWithAI = useCallback( - async (promptOverride?: string): Promise => { - const launched = await onFixPushFailureWithAI(promptOverride) - if (launched) { - setPushFailureDialogOpen(false) - } - return launched - }, - [onFixPushFailureWithAI, setPushFailureDialogOpen] - ) - const handlePushFailureAgentPromptDelivered = useCallback(() => { - setPushFailureDialogOpen(false) - }, [setPushFailureDialogOpen]) - - useEffect(() => { - setCommitFailureDialogState((current) => - syncCommitFailureDialogState(current, commitFailureWorktreeKey, hasCommitFailureDetails) - ) - }, [commitFailureWorktreeKey, hasCommitFailureDetails]) - useEffect(() => { - setPushFailureDialogState((current) => - syncCommitFailureDialogState(current, pushFailureWorktreeKey, hasPushFailureDetails) - ) - }, [pushFailureWorktreeKey, hasPushFailureDetails]) - // Why: most primary-kind labels are anchored by a directional icon so // the affirmative Commit (✓) reads distinctly from the remote-state // labels sharing this slot — Push (↑), Sync (↕), Publish (☁︎↑). Pull is @@ -6867,7 +6583,7 @@ export function CommitArea({ }) const describedBy = [ commitError ? 'commit-area-error' : null, - pushFailureRawError ? 'commit-area-push-error' : null, + pushRecovery ? 'commit-area-push-error' : null, remoteActionError ? 'commit-area-remote-error' : null, createPrIntentNotice ? 'commit-area-create-pr-intent' : null, generateError ? 'commit-area-generate-error' : null @@ -7127,277 +6843,66 @@ export function CommitArea({ - {commitError && ( - // Why: role="alert" + aria-live="polite" lets screen readers announce - // commit failures; the id ties the message to the textarea via - // aria-describedby so assistive tech associates the two. -