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 <help@stably.ai> * fix: address review findings * fix: address CI failures --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d625079c4d
commit
ca05dbf652
|
|
@ -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({
|
|||
<h1 className="min-w-0 flex-1 truncate text-base font-medium text-foreground">
|
||||
{check.name}
|
||||
</h1>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canFixWithAI && (
|
||||
<SourceControlFixSplitButton
|
||||
label={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
|
||||
'Fix with AI'
|
||||
)}
|
||||
actionId="fixChecks"
|
||||
dialogTitle={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
|
||||
'Fix with AI'
|
||||
)}
|
||||
dialogDescription={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.c8f1a2d4e7',
|
||||
'Choose the agent and edit the full command input before launch.'
|
||||
)}
|
||||
launchSource="task_page"
|
||||
contextUnavailableLabel={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.b3e7f9a1c2',
|
||||
'Check fix context unavailable'
|
||||
)}
|
||||
primaryTitle={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.d5a8c2f1b9',
|
||||
'Start the default AI agent to fix this check'
|
||||
)}
|
||||
primaryAriaLabel={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
|
||||
'Fix with AI'
|
||||
)}
|
||||
chevronTitle={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.e2b4d7c8a1',
|
||||
'Choose an agent for this check'
|
||||
)}
|
||||
chevronAriaLabel={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.f1c9e3a6d4',
|
||||
'Choose agent to fix check'
|
||||
)}
|
||||
worktreeId={worktreeId}
|
||||
groupId={worktreeId}
|
||||
connectionId={connectionId}
|
||||
repoId={repoId}
|
||||
launchPlatform={launchPlatform}
|
||||
prompt={fixPrompt}
|
||||
isLaunching={loading || isFixing}
|
||||
disabledReason={disabledReason}
|
||||
variant="default"
|
||||
size="sm"
|
||||
iconClassName="size-3.5"
|
||||
primaryClassName="rounded-r-none font-medium"
|
||||
chevronClassName="rounded-l-none border-l border-primary-foreground/20 px-2"
|
||||
savedAgentId={savedAgentId}
|
||||
savedCommandInputTemplate={savedCommandInputTemplate}
|
||||
savedAgentArgs={savedAgentArgs}
|
||||
onSaveAgentDefault={saveLaunchActionDefault}
|
||||
onOpenSettings={openSourceControlAiSettings}
|
||||
onFixWithDefaultAgent={fixWithAI}
|
||||
onPromptDelivered={() =>
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819',
|
||||
'Started an AI agent for this check.'
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -637,6 +637,7 @@ export function EditorContent({
|
|||
loading={checkRunDetails.loading}
|
||||
error={checkRunDetails.error}
|
||||
openUrl={openUrl}
|
||||
worktreeId={activeFile.worktreeId}
|
||||
onRefresh={() => {
|
||||
void reloadOpenCheckRunDetailsTab(activeFile.id)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<string, Worktree[]>,
|
||||
repos: [fixtures.repo] as Repo[],
|
||||
settings: {},
|
||||
prCache: {
|
||||
[fixtures.prCacheKey]: { data: fixtures.pr, fetchedAt: 1 }
|
||||
} as Record<string, { data: PRInfo; fetchedAt: number }>,
|
||||
hostedReviewCache: {} as Record<string, { data: unknown }>
|
||||
}))
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<boolean> {
|
||||
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<typeof readSourceControlLaunchRecipeAgentId>
|
||||
savedCommandInputTemplate: string | null
|
||||
savedAgentArgs: string | null
|
||||
saveLaunchActionDefault: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
) => Promise<void>
|
||||
openSourceControlAiSettings: () => void
|
||||
fixWithAI: () => Promise<boolean>
|
||||
} {
|
||||
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<void> => {
|
||||
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<boolean> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -114,6 +114,8 @@ export function FolderWorkspacePrChecksRow({
|
|||
checksLoading={row.isRefreshing}
|
||||
checkDetailsContextKey={row.refreshIdentity}
|
||||
onLoadCheckDetails={onLoadCheckDetails}
|
||||
worktreeId={row.worktree.id}
|
||||
detailsStickySurface="card"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => 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<PRCheckRunDetails | null>
|
||||
}> = {}
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ChecksList
|
||||
checks={[failingCheck]}
|
||||
checksLoading={false}
|
||||
checkDetailsContextKey="repo:42"
|
||||
worktreeId={props.worktreeId}
|
||||
detailsStickySurface={props.detailsStickySurface ?? 'sidebar'}
|
||||
onLoadCheckDetails={
|
||||
props.onLoadCheckDetails ??
|
||||
(async () => {
|
||||
await Promise.resolve()
|
||||
return checkDetails
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<HTMLButtonElement>) => void
|
||||
label: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 min-w-[7.25rem] shrink-0 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={onClick}
|
||||
>
|
||||
<PanelRight className="size-3" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>): void => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-1 ml-[26px] mr-3 min-w-0 border-l border-border pl-3">
|
||||
{worktreeId && (
|
||||
// Why: inline check details can be long; pinning the affordance keeps it
|
||||
// visible while scrolling through annotations and job output.
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 -ml-3 flex min-w-0 items-center gap-2 border-b border-border/60 py-1 pl-3 backdrop-blur-sm',
|
||||
getCheckDetailsStickySurfaceClass(detailsStickySurface)
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-foreground">
|
||||
{check.name}
|
||||
</span>
|
||||
<ViewFullCheckDetailsButton label={fullDetailsLabel} onClick={handleOpenFullDetails} />
|
||||
</div>
|
||||
)}
|
||||
{state?.loading ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
|
|
@ -521,25 +582,6 @@ function CheckRunDetails({
|
|||
'Loading check details…'
|
||||
)}
|
||||
</div>
|
||||
{activeWorktree && (
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-2.5 py-1.5">
|
||||
|
|
@ -744,26 +786,6 @@ function CheckRunDetails({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-start pt-1">
|
||||
{activeWorktree && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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<PRCheckRunDetails | null>
|
||||
/** 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<Set<string>>(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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<typeof Button>['variant']
|
||||
size: React.ComponentProps<typeof Button>['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<void>
|
||||
onOpenSettings?: () => void
|
||||
onFixWithDefaultAgent: (promptOverride?: string) => Promise<boolean> | 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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<div className="flex shrink-0 items-stretch">
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('rounded-r-none', primaryClassName)}
|
||||
disabled={isLaunching || !canLaunch}
|
||||
title={disabledReason ?? primaryTitle}
|
||||
aria-label={primaryAriaLabel}
|
||||
onClick={() => void onFixWithDefaultAgent()}
|
||||
>
|
||||
{isLaunching ? (
|
||||
<RefreshCw className={cn(iconClassName, 'animate-spin')} />
|
||||
) : (
|
||||
<Sparkle className={iconClassName} />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('rounded-l-none border-l', dividerClass, chevronClassName)}
|
||||
disabled={isLaunching || !canLaunch}
|
||||
title={chevronTitle}
|
||||
aria-label={chevronAriaLabel}
|
||||
>
|
||||
<ChevronDown className={iconClassName} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent align="end" className="min-w-[210px] p-1">
|
||||
{worktreeId && groupId && prompt && !disabledReason ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setComposerOpen(true)}
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
|
||||
>
|
||||
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.f0a2dc9e46',
|
||||
'Customize launch...'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>{contextUnavailableLabel}</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{worktreeId && groupId && prompt ? (
|
||||
<SourceControlAgentActionDialog
|
||||
open={composerOpen}
|
||||
onOpenChange={setComposerOpen}
|
||||
actionId={actionId}
|
||||
title={dialogTitle}
|
||||
description={dialogDescription}
|
||||
baseCommandInput={prompt}
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
connectionId={connectionId}
|
||||
repoId={repoId}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchPlatform={launchPlatform}
|
||||
launchSource={launchSource}
|
||||
savedAgentId={savedAgentId}
|
||||
savedCommandInputTemplate={savedCommandInputTemplate}
|
||||
savedAgentArgs={savedAgentArgs}
|
||||
onSaveAgentDefault={onSaveAgentDefault}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onLaunched={onPromptDelivered}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}</>
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue