From ca05dbf6527287fa37ef0951943f5905b6f74293 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:31:10 -0700 Subject: [PATCH] Fix PR check full-details worktree routing (#5529) * Fix view full details button to use correct worktree and sticky header - Pin a sticky bar with check name and "View full logs" button inside expanded check details so the affordance stays visible while scrolling through annotations and job output - Accept an explicit `worktreeId` prop so folder-workspace PR checks use the child worktree ID rather than the (non-existent) active worktree - Show "View full logs" when log tail is available, "View full details" otherwise Co-authored-by: Orca * fix: address review findings * fix: address CI failures --------- Co-authored-by: Orca --- .../editor/CheckRunDetailsPanel.tsx | 117 ++++++++- .../src/components/editor/EditorContent.tsx | 1 + .../editor/check-run-details-fix-context.ts | 153 ++++++++++++ .../check-run-details-fix-with-ai.test.ts | 203 +++++++++++++++ .../editor/check-run-details-fix-with-ai.ts | 232 ++++++++++++++++++ .../FolderWorkspacePrChecksRow.tsx | 2 + .../checks-list-expanded-details.test.tsx | 213 ++++++++++++++++ .../right-sidebar/checks-panel-content.tsx | 130 ++++++---- .../source-control-fix-split-button.tsx | 171 +++++++++++++ .../tab-bar/tab-title-tooltip.test.tsx | 1 + src/renderer/src/i18n/locales/en.json | 26 +- src/renderer/src/i18n/locales/es.json | 28 ++- src/renderer/src/i18n/locales/ja.json | 28 ++- src/renderer/src/i18n/locales/ko.json | 28 ++- src/renderer/src/i18n/locales/zh.json | 28 ++- 15 files changed, 1292 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/components/editor/check-run-details-fix-context.ts create mode 100644 src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts create mode 100644 src/renderer/src/components/editor/check-run-details-fix-with-ai.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx index db8dbcade..754837a67 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -1,10 +1,13 @@ import React from 'react' import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' import { CheckJobLogTail } from '@/components/right-sidebar/check-job-log-tail' +import { SourceControlFixSplitButton } from '@/components/right-sidebar/source-control-fix-split-button' import { translate } from '@/i18n/i18n' +import { useCheckRunDetailsFixWithAI } from './check-run-details-fix-with-ai' function formatCheckTimestamp(value: string | null | undefined): string | null { if (!value) { @@ -52,6 +55,7 @@ export function CheckRunDetailsPanel({ loading, error, openUrl, + worktreeId, onRefresh }: { check: PRCheckDetail @@ -59,8 +63,28 @@ export function CheckRunDetailsPanel({ loading: boolean error: string | null openUrl: string | null | undefined + worktreeId: string | null onRefresh?: () => void }): React.JSX.Element { + const { + canFixWithAI, + disabledReason, + isFixing, + fixPrompt, + repoId, + connectionId, + launchPlatform, + savedAgentId, + savedCommandInputTemplate, + savedAgentArgs, + saveLaunchActionDefault, + openSourceControlAiSettings, + fixWithAI + } = useCheckRunDetailsFixWithAI({ + worktreeId, + check, + details + }) const startedAt = formatCheckTimestamp(details?.startedAt) const completedAt = formatCheckTimestamp(details?.completedAt) const detailsStatusCheck: PRCheckDetail = { @@ -85,19 +109,86 @@ export function CheckRunDetailsPanel({

{check.name}

- {onRefresh && ( - - )} +
+ {canFixWithAI && ( + + toast.success( + translate( + 'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819', + 'Started an AI agent for this check.' + ) + ) + } + /> + )} + {onRefresh && ( + + )} +
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 48d2c812d..a322f72e4 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -637,6 +637,7 @@ export function EditorContent({ loading={checkRunDetails.loading} error={checkRunDetails.error} openUrl={openUrl} + worktreeId={activeFile.worktreeId} onRefresh={() => { void reloadOpenCheckRunDetailsTab(activeFile.id) }} diff --git a/src/renderer/src/components/editor/check-run-details-fix-context.ts b/src/renderer/src/components/editor/check-run-details-fix-context.ts new file mode 100644 index 000000000..7f0881109 --- /dev/null +++ b/src/renderer/src/components/editor/check-run-details-fix-context.ts @@ -0,0 +1,153 @@ +import { + buildFixBrokenChecksPrompt, + getBrokenChecks, + getCheckDetailsPromptKey +} from '@/components/pr-checks-fix-prompt' +import { gitHubPRToChecksPanelReview } from '@/components/right-sidebar/checks-panel-review' +import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { useAppStore } from '@/store' +import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PRCheckDetail, PRCheckRunDetails, Repo } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function resolveCheckRunDetailsFixCheck( + check: PRCheckDetail, + details: PRCheckRunDetails | null +): PRCheckDetail { + if (!details) { + return check + } + return { + ...check, + status: (details.status as PRCheckDetail['status'] | undefined) ?? check.status, + conclusion: (details.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion + } +} + +export function isCheckRunDetailsFixCandidate( + check: PRCheckDetail, + details: PRCheckRunDetails | null = null +): boolean { + return getBrokenChecks([resolveCheckRunDetailsFixCheck(check, details)]).length > 0 +} + +export function resolveHostedReviewForCheckRunDetailsFix( + worktreeId: string +): HostedReviewInfo | null { + const store = useAppStore.getState() + const worktree = findWorktreeById(store.worktreesByRepo, worktreeId) + if (!worktree) { + return null + } + const repo = store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null + if (!repo) { + return null + } + const identity = getWorktreeGitIdentityDisplay(worktree) + const branch = identity?.kind === 'branch' ? identity.branchName : null + if (!branch) { + return null + } + const settings = store.settings + const prCacheKey = getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId + ) + const hostedReviewCacheKey = getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId + ) + const pr = prCacheKey ? (store.prCache[prCacheKey]?.data ?? null) : null + const hostedReview = hostedReviewCacheKey + ? (store.hostedReviewCache[hostedReviewCacheKey]?.data ?? null) + : null + const gitLabHostedReview = hostedReview?.provider === 'gitlab' ? hostedReview : null + const linkedGitLabMR = worktree.linkedGitLabMR ?? null + if (gitLabHostedReview) { + return gitLabHostedReview + } + if (linkedGitLabMR !== null) { + return null + } + return pr ? gitHubPRToChecksPanelReview(pr) : null +} + +export function buildCheckRunDetailsFixBasePrompt(args: { + worktreeId: string + check: PRCheckDetail + details: PRCheckRunDetails | null +}): string | null { + const review = resolveHostedReviewForCheckRunDetailsFix(args.worktreeId) + if (!review) { + return null + } + const resolvedCheck = resolveCheckRunDetailsFixCheck(args.check, args.details) + if (!isCheckRunDetailsFixCandidate(resolvedCheck)) { + return null + } + const checkRunDetailsByCheckKey = args.details + ? { [getCheckDetailsPromptKey(resolvedCheck, 0)]: args.details } + : undefined + return buildFixBrokenChecksPrompt({ + reviewKind: review.provider === 'gitlab' ? 'MR' : 'PR', + reviewNumber: review.number, + reviewTitle: review.title, + reviewUrl: review.url, + checks: [resolvedCheck], + checkRunDetailsByCheckKey + }) +} + +export function getCheckRunDetailsFixDisabledReason(worktreeId: string | null): string | undefined { + if (!worktreeId) { + return translate( + 'auto.components.editor.check.run.details.fix.with.ai.1a8c4e2b90', + 'Select a workspace before launching an AI action.' + ) + } + const store = useAppStore.getState() + const worktree = findWorktreeById(store.worktreesByRepo, worktreeId) + if (!worktree) { + return translate( + 'auto.components.editor.check.run.details.fix.with.ai.1a8c4e2b90', + 'Select a workspace before launching an AI action.' + ) + } + const repo = store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null + if (!repo) { + return translate( + 'auto.components.editor.check.run.details.fix.with.ai.4f2d9a8c17', + 'Select a repository before launching an AI action.' + ) + } + if (!resolveHostedReviewForCheckRunDetailsFix(worktreeId)) { + return translate( + 'auto.components.editor.check.run.details.fix.with.ai.7c3e1b5d42', + 'Open a PR or MR before launching an AI fix.' + ) + } + return undefined +} + +export function resolveCheckRunDetailsFixRepo(worktreeId: string | null): Repo | null { + if (!worktreeId) { + return null + } + const store = useAppStore.getState() + const worktree = findWorktreeById(store.worktreesByRepo, worktreeId) + if (!worktree) { + return null + } + return store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null +} diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts new file mode 100644 index 000000000..10531e222 --- /dev/null +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + PRCheckDetail, + PRCheckRunDetails, + PRInfo, + Repo, + Worktree +} from '../../../../shared/types' +import { + getCheckRunDetailsFixDisabledReason, + isCheckRunDetailsFixCandidate, + resolveCheckRunDetailsFixCheck, + resolveHostedReviewForCheckRunDetailsFix, + startCheckRunDetailsFixWithAI +} from './check-run-details-fix-with-ai' + +const startFixChecksAgent = vi.fn() + +const fixtures = vi.hoisted(() => { + const repo: Repo = { + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: '#2563eb', + addedAt: 1, + connectionId: null, + executionHostId: null + } + const worktree: Worktree = { + id: 'repo-1::/tmp/repo/feature', + repoId: 'repo-1', + path: '/tmp/repo/feature', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: 42, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + const pr: PRInfo = { + number: 42, + title: 'Fix CI', + state: 'open', + url: 'https://github.com/acme/widgets/pull/42', + checksStatus: 'failure', + updatedAt: '2026-06-16T00:00:00Z', + mergeable: 'MERGEABLE' + } + const failingCheck: PRCheckDetail = { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: null, + checkRunId: 42 + } + const checkDetails: PRCheckRunDetails = { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: null, + detailsUrl: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + annotations: [], + jobs: [ + { + id: 1, + name: 'test', + status: 'completed', + conclusion: 'failure', + startedAt: null, + completedAt: null, + url: null, + steps: [], + logTail: 'assertion failed' + } + ] + } + const prCacheKey = 'repo-1::feature' + return { repo, worktree, pr, failingCheck, checkDetails, prCacheKey } +}) + +const storeState = vi.hoisted(() => ({ + worktreesByRepo: { 'repo-1': [fixtures.worktree] } as Record, + repos: [fixtures.repo] as Repo[], + settings: {}, + prCache: { + [fixtures.prCacheKey]: { data: fixtures.pr, fetchedAt: 1 } + } as Record, + hostedReviewCache: {} as Record +})) + +vi.mock('@/lib/worktree-git-identity-display', () => ({ + getWorktreeGitIdentityDisplay: () => ({ kind: 'branch', branchName: 'feature' }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { + getState: () => storeState + } + ) +})) + +vi.mock('@/lib/fix-checks-agent-launch', () => ({ + startFixChecksAgent: (...args: unknown[]) => startFixChecksAgent(...args) +})) + +vi.mock('sonner', () => ({ + toast: { + message: vi.fn(), + success: vi.fn() + } +})) + +beforeEach(() => { + startFixChecksAgent.mockReset() + startFixChecksAgent.mockResolvedValue(true) + storeState.worktreesByRepo = { 'repo-1': [fixtures.worktree] } + storeState.repos = [fixtures.repo] + storeState.prCache = { + [fixtures.prCacheKey]: { data: fixtures.pr, fetchedAt: 1 } + } + storeState.hostedReviewCache = {} +}) + +describe('check-run-details-fix-with-ai', () => { + it('detects failing checks as fix candidates', () => { + expect(isCheckRunDetailsFixCandidate(fixtures.failingCheck)).toBe(true) + expect( + isCheckRunDetailsFixCandidate({ + ...fixtures.failingCheck, + conclusion: 'success' + }) + ).toBe(false) + }) + + it('prefers loaded details conclusion over the list-level check', () => { + const listFailure = fixtures.failingCheck + const passingDetails: PRCheckRunDetails = { + ...fixtures.checkDetails, + conclusion: 'success', + status: 'completed' + } + expect(isCheckRunDetailsFixCandidate(listFailure, passingDetails)).toBe(false) + expect( + resolveCheckRunDetailsFixCheck( + { ...fixtures.failingCheck, conclusion: 'success' }, + fixtures.checkDetails + ).conclusion + ).toBe('failure') + expect( + isCheckRunDetailsFixCandidate( + { ...fixtures.failingCheck, conclusion: 'success' }, + fixtures.checkDetails + ) + ).toBe(true) + }) + + it('resolves hosted review metadata from the worktree PR cache', () => { + expect(resolveHostedReviewForCheckRunDetailsFix(fixtures.worktree.id)).toMatchObject({ + number: 42, + title: 'Fix CI', + url: 'https://github.com/acme/widgets/pull/42' + }) + }) + + it('requires a hosted review before launching an AI fix', () => { + storeState.prCache = {} + expect(getCheckRunDetailsFixDisabledReason(fixtures.worktree.id)).toContain('PR or MR') + }) + + it('starts a single-check AI fix prompt for the owning worktree', async () => { + await startCheckRunDetailsFixWithAI({ + worktreeId: fixtures.worktree.id, + check: fixtures.failingCheck, + details: fixtures.checkDetails + }) + + expect(startFixChecksAgent).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'repo-1', + worktreeId: fixtures.worktree.id, + groupId: fixtures.worktree.id, + launchSource: 'task_page', + basePrompt: expect.stringContaining('"name": "verify"') + }) + ) + expect(startFixChecksAgent.mock.calls[0]?.[0]?.basePrompt).toContain('assertion failed') + }) +}) diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts new file mode 100644 index 000000000..80059758c --- /dev/null +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts @@ -0,0 +1,232 @@ +import { toast } from 'sonner' +import { useCallback, useMemo, useState } from 'react' +import { + buildCheckRunDetailsFixBasePrompt, + getCheckRunDetailsFixDisabledReason, + isCheckRunDetailsFixCandidate, + resolveCheckRunDetailsFixCheck, + resolveHostedReviewForCheckRunDetailsFix, + resolveCheckRunDetailsFixRepo +} from './check-run-details-fix-context' +import { openSourceControlAiSettingsTarget } from '@/components/right-sidebar/source-control-ai-settings-navigation' +import { getConnectionId } from '@/lib/connection-context' +import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' +import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection' +import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { useAppStore } from '@/store' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai' +import { + saveSourceControlActionRecipe, + type SourceControlAiWriteTarget +} from '../../../../shared/source-control-ai-recipe-save' +import type { + SourceControlActionRecipe, + SourceControlLaunchActionId +} from '../../../../shared/source-control-ai-actions' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export { + buildCheckRunDetailsFixBasePrompt, + getCheckRunDetailsFixDisabledReason, + isCheckRunDetailsFixCandidate, + resolveCheckRunDetailsFixCheck, + resolveHostedReviewForCheckRunDetailsFix +} from './check-run-details-fix-context' + +export async function startCheckRunDetailsFixWithAI(args: { + worktreeId: string + check: PRCheckDetail + details: PRCheckRunDetails | null +}): Promise { + const disabledReason = getCheckRunDetailsFixDisabledReason(args.worktreeId) + if (disabledReason) { + toast.message(disabledReason) + return false + } + const resolvedCheck = resolveCheckRunDetailsFixCheck(args.check, args.details) + if (!isCheckRunDetailsFixCandidate(resolvedCheck)) { + toast.message( + translate( + 'auto.components.editor.check.run.details.fix.with.ai.9b2f6d4a81', + 'This check is not failing.' + ) + ) + return false + } + const review = resolveHostedReviewForCheckRunDetailsFix(args.worktreeId) + if (!review) { + toast.message( + translate( + 'auto.components.editor.check.run.details.fix.with.ai.7c3e1b5d42', + 'Open a PR or MR before launching an AI fix.' + ) + ) + return false + } + const repoId = resolveCheckRunDetailsFixRepo(args.worktreeId)?.id + if (!repoId) { + return false + } + const basePrompt = + buildCheckRunDetailsFixBasePrompt({ + worktreeId: args.worktreeId, + check: args.check, + details: args.details + }) ?? '' + if (!basePrompt) { + return false + } + const started = await startFixChecksAgent({ + repoId, + basePrompt, + worktreeId: args.worktreeId, + groupId: args.worktreeId, + launchSource: 'task_page' + }) + if (started) { + toast.success( + translate( + 'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819', + 'Started an AI agent for this check.' + ) + ) + } + return started +} + +export function useCheckRunDetailsFixWithAI(args: { + worktreeId: string | null + check: PRCheckDetail + details: PRCheckRunDetails | null +}): { + canFixWithAI: boolean + disabledReason: string | undefined + isFixing: boolean + fixPrompt: string | null + repoId: string | null + connectionId: string | null | undefined + launchPlatform: NodeJS.Platform | undefined + savedAgentId: ReturnType + savedCommandInputTemplate: string | null + savedAgentArgs: string | null + saveLaunchActionDefault: ( + target: SourceControlAiWriteTarget, + actionId: SourceControlLaunchActionId, + recipe: SourceControlActionRecipe + ) => Promise + openSourceControlAiSettings: () => void + fixWithAI: () => Promise +} { + const [isFixing, setIsFixing] = useState(false) + const settings = useAppStore((state) => state.settings) + const updateSettings = useAppStore((state) => state.updateSettings) + const updateRepo = useAppStore((state) => state.updateRepo) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const repo = useMemo(() => resolveCheckRunDetailsFixRepo(args.worktreeId), [args.worktreeId]) + const worktree = useMemo(() => { + if (!args.worktreeId) { + return null + } + return findWorktreeById(useAppStore.getState().worktreesByRepo, args.worktreeId) + }, [args.worktreeId]) + const canFixWithAI = isCheckRunDetailsFixCandidate(args.check, args.details) + const disabledReason = getCheckRunDetailsFixDisabledReason(args.worktreeId) + const fixPrompt = useMemo(() => { + if (!args.worktreeId || !canFixWithAI) { + return null + } + return buildCheckRunDetailsFixBasePrompt({ + worktreeId: args.worktreeId, + check: args.check, + details: args.details + }) + }, [args.check, args.details, args.worktreeId, canFixWithAI]) + const connectionId = args.worktreeId + ? (getConnectionId(args.worktreeId) ?? repo?.connectionId ?? null) + : null + const launchPlatform = resolveSourceControlLaunchPlatform({ + connectionId, + worktreePath: worktree?.path ?? null + }) + const fixChecksRecipe = useMemo( + () => + resolveSourceControlActionRecipe({ + settings, + repo, + actionId: 'fixChecks' + }), + [repo, settings] + ) + const saveLaunchActionDefault = useCallback( + async ( + target: SourceControlAiWriteTarget, + actionId: SourceControlLaunchActionId, + recipe: SourceControlActionRecipe + ): Promise => { + const state = useAppStore.getState() + const latestSettings = state.settings + if (!latestSettings) { + throw new Error('Settings are not loaded.') + } + const latestRepo = + target.type === 'repo' + ? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null) + : null + const result = saveSourceControlActionRecipe({ + target, + settings: latestSettings, + repo: latestRepo, + actionId, + recipe + }) + if ('sourceControlAi' in result) { + await updateSettings({ sourceControlAi: result.sourceControlAi }) + return + } + await updateRepo(result.target.repoId, result.update) + }, + [updateRepo, updateSettings] + ) + const openSourceControlAiSettings = useCallback((): void => { + openSourceControlAiSettingsTarget({ + activeRepo: repo, + openSettingsTarget, + openSettingsPage + }) + }, [openSettingsPage, openSettingsTarget, repo]) + + const fixWithAI = useCallback(async (): Promise => { + if (!args.worktreeId || isFixing || disabledReason) { + return false + } + setIsFixing(true) + try { + return await startCheckRunDetailsFixWithAI({ + worktreeId: args.worktreeId, + check: args.check, + details: args.details + }) + } finally { + setIsFixing(false) + } + }, [args.check, args.details, args.worktreeId, disabledReason, isFixing]) + + return { + canFixWithAI, + disabledReason, + isFixing, + fixPrompt, + repoId: repo?.id ?? null, + connectionId, + launchPlatform, + savedAgentId: readSourceControlLaunchRecipeAgentId(fixChecksRecipe), + savedCommandInputTemplate: fixChecksRecipe.commandInputTemplate ?? null, + savedAgentArgs: fixChecksRecipe.agentArgs ?? null, + saveLaunchActionDefault, + openSourceControlAiSettings, + fixWithAI + } +} diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx index bc9966b7e..13796a63b 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx @@ -114,6 +114,8 @@ export function FolderWorkspacePrChecksRow({ checksLoading={row.isRefreshing} checkDetailsContextKey={row.refreshIdentity} onLoadCheckDetails={onLoadCheckDetails} + worktreeId={row.worktree.id} + detailsStickySurface="card" />
) : null} diff --git a/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx b/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx new file mode 100644 index 000000000..e7ee2002d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' +import { ChecksList } from './checks-panel-content' + +const openCheckRunDetails = vi.fn() +const patchOpenCheckRunDetails = vi.fn() +const activeWorktreeState = vi.hoisted(() => ({ + current: null as { id: string } | null +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + openCheckRunDetails, + patchOpenCheckRunDetails + }) +})) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => activeWorktreeState.current +})) + +let container: HTMLDivElement +let root: Root + +const failingCheck: PRCheckDetail = { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: null, + checkRunId: 42, + workflowRunId: 7 +} + +const checkDetails: PRCheckRunDetails = { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: null, + detailsUrl: null, + startedAt: '2026-06-16T12:00:00Z', + completedAt: '2026-06-16T12:05:00Z', + title: 'Verify failed', + summary: null, + text: null, + annotations: [], + jobs: [ + { + id: 1, + name: 'test', + status: 'completed', + conclusion: 'failure', + startedAt: null, + completedAt: null, + url: null, + steps: [], + logTail: 'Error: assertion failed' + } + ] +} + +beforeEach(() => { + activeWorktreeState.current = null + openCheckRunDetails.mockReset() + patchOpenCheckRunDetails.mockReset() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +function renderChecksList( + props: Partial<{ + worktreeId: string + detailsStickySurface: 'sidebar' | 'card' + onLoadCheckDetails: (check: PRCheckDetail) => Promise + }> = {} +): void { + act(() => { + root.render( + + { + await Promise.resolve() + return checkDetails + }) + } + /> + + ) + }) +} + +describe('ChecksList expanded check details', () => { + it('pins a contextual full-details action with the correct sticky surface', async () => { + renderChecksList({ worktreeId: 'wt-child-1', detailsStickySurface: 'card' }) + + await act(async () => { + await Promise.resolve() + }) + + const stickyBar = container.querySelector('.sticky.top-0') + expect(stickyBar).not.toBeNull() + expect(stickyBar?.className).toContain('bg-card/95') + expect(stickyBar?.textContent).toContain('verify') + expect(stickyBar?.textContent).toContain('View full logs') + expect(container.innerHTML).toContain('lucide-panel-right') + expect(container.innerHTML).toContain('data-variant="outline"') + }) + + it('uses the sidebar sticky surface by default in the hosted checks panel', async () => { + activeWorktreeState.current = { id: 'wt-active-1' } + renderChecksList() + + await act(async () => { + await Promise.resolve() + }) + + const stickyBar = container.querySelector('.sticky.top-0') + expect(stickyBar?.className).toContain('bg-sidebar/95') + }) + + it('falls back to the active worktree when no worktree override is provided', async () => { + activeWorktreeState.current = { id: 'wt-active-1' } + renderChecksList() + + await act(async () => { + await Promise.resolve() + }) + + const button = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes('View full logs') + ) + expect(button).toBeDefined() + + act(() => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(openCheckRunDetails).toHaveBeenCalledWith( + 'wt-active-1', + 'repo:42', + failingCheck, + expect.objectContaining({ + details: checkDetails, + loading: false, + error: null + }) + ) + }) + + it('opens full details on the provided worktree instead of the active worktree', async () => { + activeWorktreeState.current = { id: 'wt-active-1' } + renderChecksList({ worktreeId: 'wt-attached-9' }) + + await act(async () => { + await Promise.resolve() + }) + + const button = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes('View full logs') + ) + expect(button).toBeDefined() + + act(() => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(openCheckRunDetails).toHaveBeenCalledWith( + 'wt-attached-9', + 'repo:42', + failingCheck, + expect.objectContaining({ + details: checkDetails, + loading: false, + error: null + }) + ) + }) + + it('keeps the generic label while inline details are still loading', async () => { + renderChecksList({ + worktreeId: 'wt-child-1', + onLoadCheckDetails: () => new Promise(() => {}) + }) + + await act(async () => { + await Promise.resolve() + }) + + const stickyBar = container.querySelector('.sticky.top-0') + expect(stickyBar?.textContent).toContain('View full details') + expect(stickyBar?.textContent).not.toContain('View full logs') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 1e0391a9c..6c8627bb5 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -15,6 +15,7 @@ import { Plus, ChevronDown, ChevronRight, + PanelRight, SendHorizontal, Sparkles, RefreshCw, @@ -469,16 +470,46 @@ export function getFailedChecksForDetails(checks: PRCheckDetail[]): PRCheckDetai return checks.filter(isFailedCheck) } +type CheckDetailsStickySurface = 'sidebar' | 'card' + +function getCheckDetailsStickySurfaceClass(surface: CheckDetailsStickySurface): string { + return surface === 'card' ? 'bg-card/95' : 'bg-sidebar/95' +} + +function ViewFullCheckDetailsButton({ + onClick, + label +}: { + onClick: (event: React.MouseEvent) => void + label: string +}): React.JSX.Element { + return ( + + ) +} + function CheckRunDetails({ check, state, - checkDetailsContextKey + checkDetailsContextKey, + worktreeId, + detailsStickySurface = 'sidebar' }: { check: PRCheckDetail state: CheckDetailsLoadState | undefined checkDetailsContextKey: string + worktreeId: string | null + detailsStickySurface?: CheckDetailsStickySurface }): React.JSX.Element { - const activeWorktree = useActiveWorktree() const openCheckRunDetails = useAppStore((s) => s.openCheckRunDetails) const details = state?.details const startedAt = formatCheckTimestamp(details?.startedAt) @@ -499,19 +530,49 @@ function CheckRunDetails({ const hasJobs = jobs.length > 0 const hasLogTail = jobs.some((job) => Boolean(job.logTail)) + // Why: wait until inline details finish loading before switching to the logs label + // so the sticky button does not resize mid-fetch. + const fullDetailsLabel = + !state?.loading && hasLogTail + ? translate('auto.components.right.sidebar.checks.panel.content.b8c4e2a1f7', 'View full logs') + : translate( + 'auto.components.right.sidebar.checks.panel.content.e4e3af15ee', + 'View full details' + ) + const openFullDetailsTab = (): void => { - if (!activeWorktree) { + if (!worktreeId) { return } - openCheckRunDetails(activeWorktree.id, checkDetailsContextKey, check, { + openCheckRunDetails(worktreeId, checkDetailsContextKey, check, { details: state?.details ?? null, loading: state?.loading ?? false, error: state?.error ?? null }) } + const handleOpenFullDetails = (event: React.MouseEvent): void => { + event.stopPropagation() + openFullDetailsTab() + } + return (
+ {worktreeId && ( + // Why: inline check details can be long; pinning the affordance keeps it + // visible while scrolling through annotations and job output. +
+ + {check.name} + + +
+ )} {state?.loading ? (
@@ -521,25 +582,6 @@ function CheckRunDetails({ 'Loading check details…' )}
- {activeWorktree && ( -
- -
- )}
) : (
@@ -744,26 +786,6 @@ function CheckRunDetails({ )}
)} - -
- {activeWorktree && ( - - )} -
)} @@ -777,14 +799,20 @@ export function ChecksList({ checks, checksLoading, checkDetailsContextKey, - onLoadCheckDetails + onLoadCheckDetails, + worktreeId: worktreeIdOverride, + detailsStickySurface = 'sidebar' }: { checks: PRCheckDetail[] checksLoading: boolean checkDetailsContextKey: string onLoadCheckDetails?: (check: PRCheckDetail) => Promise + /** Why: folder-workspace PR checks render rows for attached worktrees, not the active one. */ + worktreeId?: string + detailsStickySurface?: CheckDetailsStickySurface }): React.JSX.Element { const activeWorktree = useActiveWorktree() + const resolvedWorktreeId = worktreeIdOverride ?? activeWorktree?.id ?? null const patchOpenCheckRunDetails = useAppStore((s) => s.patchOpenCheckRunDetails) const [checksExpanded, setChecksExpanded] = useState(true) const [expandedCheckKeys, setExpandedCheckKeys] = useState>(new Set()) @@ -952,7 +980,7 @@ export function ChecksList({ }, [checksExpanded, detailsByCheckKey, expandedCheckKeys, requestCheckDetails, rows]) useEffect(() => { - if (!activeWorktree) { + if (!resolvedWorktreeId) { return } for (const row of rows) { @@ -960,13 +988,19 @@ export function ChecksList({ if (!detailsState) { continue } - patchOpenCheckRunDetails(activeWorktree.id, checkDetailsContextKey, row.check, { + patchOpenCheckRunDetails(resolvedWorktreeId, checkDetailsContextKey, row.check, { details: detailsState.details ?? null, loading: detailsState.loading ?? false, error: detailsState.error ?? null }) } - }, [activeWorktree, checkDetailsContextKey, detailsByCheckKey, patchOpenCheckRunDetails, rows]) + }, [ + checkDetailsContextKey, + detailsByCheckKey, + patchOpenCheckRunDetails, + resolvedWorktreeId, + rows + ]) const toggleCheckExpanded = useCallback( (row: { check: PRCheckDetail; key: string }) => { @@ -1124,6 +1158,8 @@ export function ChecksList({ check={check} state={detailsByCheckKey[row.key]} checkDetailsContextKey={checkDetailsContextKey} + worktreeId={resolvedWorktreeId} + detailsStickySurface={detailsStickySurface} /> )} diff --git a/src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx b/src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx new file mode 100644 index 000000000..26fed7960 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx @@ -0,0 +1,171 @@ +import React, { useState } from 'react' +import { ChevronDown, RefreshCw, SlidersHorizontal, Sparkle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import type { + SourceControlActionRecipe, + SourceControlLaunchActionId +} from '../../../../shared/source-control-ai-actions' +import type { TuiAgent } from '../../../../shared/types' +import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' +import { translate } from '@/i18n/i18n' +import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog' + +export type SourceControlFixSplitButtonProps = { + label: string + actionId: SourceControlLaunchActionId + dialogTitle: string + dialogDescription: string + launchSource: LaunchSource + contextUnavailableLabel: string + primaryTitle: string + primaryAriaLabel: string + chevronTitle: string + chevronAriaLabel: string + worktreeId: string | null + groupId: string | null + connectionId?: string | null + repoId?: string | null + launchPlatform?: NodeJS.Platform + prompt: string | null + isLaunching: boolean + disabledReason?: string + 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 +} + +export function SourceControlFixSplitButton({ + label, + actionId, + dialogTitle, + dialogDescription, + launchSource, + contextUnavailableLabel, + primaryTitle, + primaryAriaLabel, + chevronTitle, + chevronAriaLabel, + worktreeId, + groupId, + connectionId, + repoId, + launchPlatform, + prompt, + isLaunching, + disabledReason, + variant, + size, + iconClassName, + primaryClassName, + chevronClassName, + savedAgentId, + savedCommandInputTemplate, + savedAgentArgs, + onSaveAgentDefault, + onOpenSettings, + onFixWithDefaultAgent, + onPromptDelivered +}: SourceControlFixSplitButtonProps): React.JSX.Element { + const [composerOpen, setComposerOpen] = useState(false) + const canLaunch = Boolean(worktreeId && groupId && prompt && !disabledReason) + const dividerClass = variant === 'default' ? 'border-primary-foreground/20' : 'border-border' + + return ( + <> + +
+ + + + +
+ + {worktreeId && groupId && prompt && !disabledReason ? ( + 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...' + )} + + ) : ( + {contextUnavailableLabel} + )} + +
+ {worktreeId && groupId && prompt ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index ed5176c27..de2cb6ca3 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -61,6 +61,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuContent: () => null, DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}, DropdownMenuSeparator: () => null, + DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => <>{children}, DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} })) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 28a46f758..7ae5aa002 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8471,6 +8471,7 @@ "fd46a70f1a": "Started", "a54ae21c6f": "Status:", "e4e3af15ee": "View full details", + "b8c4e2a1f7": "View full logs", "2524d1fb83": "Log tail available in full details.", "a2fb3f4408": "Showing first 100 jobs", "df137989b3": "Showing first 20 annotations", @@ -10438,7 +10439,30 @@ "49731703ea": "Jobs", "ee07b33924": "unknown", "07eccfa397": "No details are available for this check.", - "a916648574": "Open details" + "a916648574": "Open details", + "834cb3f23d": "Fix with AI", + "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", + "b3e7f9a1c2": "Check fix context unavailable", + "d5a8c2f1b9": "Start the default AI agent to fix this check", + "e2b4d7c8a1": "Choose an agent for this check", + "f1c9e3a6d4": "Choose agent to fix check" + }, + "check": { + "run": { + "details": { + "fix": { + "with": { + "ai": { + "1a8c4e2b90": "Select a workspace before launching an AI action.", + "4f2d9a8c17": "Select a repository before launching an AI action.", + "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", + "9b2f6d4a81": "This check is not failing.", + "2ef90c9819": "Started an AI agent for this check." + } + } + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 86dab5929..0acfc6a1c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8505,7 +8505,8 @@ "d91f2a6c39": "Enviar {{value0}} comentarios en cola", "a6de3e5a20": "Borrar comentarios en cola", "49ea0937e4": "Agregar comentario a la lista de resolución", - "9fecebb29d": "Agregar" + "9fecebb29d": "Agregar", + "b8c4e2a1f7": "View full logs" }, "empty": { "state": { @@ -10438,7 +10439,30 @@ "49731703ea": "Jobs", "ee07b33924": "unknown", "07eccfa397": "No details are available for this check.", - "a916648574": "Open details" + "a916648574": "Open details", + "834cb3f23d": "Fix with AI", + "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", + "b3e7f9a1c2": "Check fix context unavailable", + "d5a8c2f1b9": "Start the default AI agent to fix this check", + "e2b4d7c8a1": "Choose an agent for this check", + "f1c9e3a6d4": "Choose agent to fix check" + }, + "check": { + "run": { + "details": { + "fix": { + "with": { + "ai": { + "1a8c4e2b90": "Select a workspace before launching an AI action.", + "4f2d9a8c17": "Select a repository before launching an AI action.", + "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", + "9b2f6d4a81": "This check is not failing.", + "2ef90c9819": "Started an AI agent for this check." + } + } + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e5995b78..0d4c246ac 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8505,7 +8505,8 @@ "d91f2a6c39": "キュー内の {{value0}} 件のコメントを送信", "a6de3e5a20": "キュー内のコメントをクリア", "49ea0937e4": "コメントを解決リストに追加", - "9fecebb29d": "追加" + "9fecebb29d": "追加", + "b8c4e2a1f7": "View full logs" }, "empty": { "state": { @@ -10438,7 +10439,30 @@ "49731703ea": "Jobs", "ee07b33924": "unknown", "07eccfa397": "No details are available for this check.", - "a916648574": "Open details" + "a916648574": "Open details", + "834cb3f23d": "Fix with AI", + "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", + "b3e7f9a1c2": "Check fix context unavailable", + "d5a8c2f1b9": "Start the default AI agent to fix this check", + "e2b4d7c8a1": "Choose an agent for this check", + "f1c9e3a6d4": "Choose agent to fix check" + }, + "check": { + "run": { + "details": { + "fix": { + "with": { + "ai": { + "1a8c4e2b90": "Select a workspace before launching an AI action.", + "4f2d9a8c17": "Select a repository before launching an AI action.", + "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", + "9b2f6d4a81": "This check is not failing.", + "2ef90c9819": "Started an AI agent for this check." + } + } + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fc08403d0..a43873824 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8505,7 +8505,8 @@ "d91f2a6c39": "대기 중인 댓글 {{value0}}개 보내기", "a6de3e5a20": "대기 중인 댓글 지우기", "49ea0937e4": "댓글을 해결 목록에 추가", - "9fecebb29d": "추가" + "9fecebb29d": "추가", + "b8c4e2a1f7": "View full logs" }, "empty": { "state": { @@ -10438,7 +10439,30 @@ "49731703ea": "Jobs", "ee07b33924": "unknown", "07eccfa397": "No details are available for this check.", - "a916648574": "Open details" + "a916648574": "Open details", + "834cb3f23d": "Fix with AI", + "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", + "b3e7f9a1c2": "Check fix context unavailable", + "d5a8c2f1b9": "Start the default AI agent to fix this check", + "e2b4d7c8a1": "Choose an agent for this check", + "f1c9e3a6d4": "Choose agent to fix check" + }, + "check": { + "run": { + "details": { + "fix": { + "with": { + "ai": { + "1a8c4e2b90": "Select a workspace before launching an AI action.", + "4f2d9a8c17": "Select a repository before launching an AI action.", + "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", + "9b2f6d4a81": "This check is not failing.", + "2ef90c9819": "Started an AI agent for this check." + } + } + } + } + } } }, "diff": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9aeb0c421..194233d18 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8505,7 +8505,8 @@ "d91f2a6c39": "发送 {{value0}} 条已排队评论", "a6de3e5a20": "清除已排队评论", "49ea0937e4": "将评论添加到解决列表", - "9fecebb29d": "添加" + "9fecebb29d": "添加", + "b8c4e2a1f7": "View full logs" }, "empty": { "state": { @@ -10438,7 +10439,30 @@ "49731703ea": "Jobs", "ee07b33924": "unknown", "07eccfa397": "No details are available for this check.", - "a916648574": "Open details" + "a916648574": "Open details", + "834cb3f23d": "Fix with AI", + "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", + "b3e7f9a1c2": "Check fix context unavailable", + "d5a8c2f1b9": "Start the default AI agent to fix this check", + "e2b4d7c8a1": "Choose an agent for this check", + "f1c9e3a6d4": "Choose agent to fix check" + }, + "check": { + "run": { + "details": { + "fix": { + "with": { + "ai": { + "1a8c4e2b90": "Select a workspace before launching an AI action.", + "4f2d9a8c17": "Select a repository before launching an AI action.", + "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", + "9b2f6d4a81": "This check is not failing.", + "2ef90c9819": "Started an AI agent for this check." + } + } + } + } + } } }, "diff": {