Wrap AI generation controls with tooltips and clean i18n dependencies (#5087)

- Wrap the AI generation button in a tooltip so users can see the
  disabled reason or the action description on hover.
- Add unit tests verifying tooltip triggers and aria-label safety.
- Simplify memo dependencies in settings metadata and worktree palette
  by using 'useTranslation()' to handle language-change rerenders
  directly without needing 'i18n.language'.
This commit is contained in:
Jinjing 2026-06-10 01:19:34 -07:00 committed by GitHub
parent 256e0ef81a
commit 0ad5add6f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 180 additions and 57 deletions

View File

@ -247,7 +247,9 @@ function getSettingsTargetFromSectionId(sectionId: string): {
}
export default function WorktreeJumpPalette(): React.JSX.Element | null {
const { i18n } = useTranslation()
// Why: subscribe this palette to language changes; translated memo contents
// recompute on the rerender without using i18n.language as a fake dependency.
useTranslation()
const visible = useAppStore((s) => s.activeModal === 'worktree-palette')
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
@ -581,10 +583,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
() => buildCmdJSettingsResults(settingsSections),
[settingsSections]
)
const actionResults = useMemo(
() => buildCmdJActionResults(getCmdJQuickActions()),
[i18n.language]
)
const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), [])
const prefetchCreateWorkspaceBaseForComposer = useCallback((initialRepoId?: string): void => {
const state = useAppStore.getState()
@ -782,7 +781,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
appendPaletteListEntries(entries, visibleOpenTabItems)
}
return entries
}, [hasQuery, paletteSections, showCreateAction, worktreeItems.length, i18n.language])
}, [hasQuery, paletteSections, showCreateAction, worktreeItems.length])
const selectionItemIds = useMemo(
() => getWorktreePaletteSelectionItemIds(listEntries),

View File

@ -0,0 +1,116 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { PullRequestComposer } from './SourceControl'
import { resolveDropdownItems } from './source-control-dropdown-items'
import { resolvePrimaryAction } from './source-control-primary-action'
type RenderPullRequestComposerOptions = {
generating?: boolean
generateDisabled?: boolean
generateDisabledReason?: string
}
function renderPullRequestComposer({
generating = false,
generateDisabled = false,
generateDisabledReason
}: RenderPullRequestComposerOptions = {}): string {
const sourceControlInputs = {
stagedCount: 1,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: true,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }
}
const primaryAction = resolvePrimaryAction(sourceControlInputs)
return renderToStaticMarkup(
<TooltipProvider>
<PullRequestComposer
provider="github"
branch="branch-login-issue"
base="master"
setBase={vi.fn()}
title=""
setTitle={vi.fn()}
body=""
setBody={vi.fn()}
draft={false}
setDraft={vi.fn()}
baseQuery=""
setBaseQuery={vi.fn()}
baseResults={[]}
setBaseResults={vi.fn()}
baseSearchError={null}
aiGenerationEnabled={true}
generating={generating}
generateDisabled={generateDisabled}
generateDisabledReason={generateDisabledReason}
generateError={null}
createError={null}
isCreating={false}
primaryAction={primaryAction}
dropdownItems={resolveDropdownItems(sourceControlInputs)}
onGenerate={vi.fn()}
onCancelGenerate={vi.fn()}
onPrimaryAction={vi.fn()}
onDropdownAction={vi.fn()}
/>
</TooltipProvider>
)
}
function elementByLabel(markup: string, tagName: string, label: string): string {
const element = [...markup.matchAll(new RegExp(`<${tagName}\\b[\\s\\S]*?</${tagName}>`, 'g'))]
.map((match) => match[0])
.find((entry) => entry.includes(`aria-label="${label}"`))
if (!element) {
throw new Error(`${tagName} not found: ${label}`)
}
return element
}
describe('PullRequestComposer generate tooltip', () => {
it('renders hosted review labels without leaking interpolation placeholders', () => {
const markup = renderPullRequestComposer()
expect(markup).toContain('aria-label="Generate pull request details with AI"')
expect(markup).not.toContain('{{value0}}')
expect(markup).not.toContain('title="Generate {{value0}} details with AI"')
})
it('keeps enabled generation controls as direct tooltip triggers', () => {
const markup = renderPullRequestComposer()
const button = elementByLabel(markup, 'button', 'Generate pull request details with AI')
expect(button).toContain('data-slot="tooltip-trigger"')
})
it('wraps only disabled generation controls so the disabled reason can show on hover', () => {
const markup = renderPullRequestComposer({
generateDisabled: true,
generateDisabledReason: 'Stage changes before generating.'
})
const wrapper = elementByLabel(markup, 'span', 'Generate pull request details with AI')
const button = elementByLabel(markup, 'button', 'Generate pull request details with AI')
expect(wrapper).toContain('data-slot="tooltip-trigger"')
expect(button).toContain('disabled=""')
expect(button).toContain('data-slot="button"')
})
it('keeps the active stop control focusable as the tooltip trigger', () => {
const markup = renderPullRequestComposer({ generating: true, generateDisabled: true })
const button = elementByLabel(markup, 'button', 'Stop generating pull request details')
expect(button).toContain('data-slot="tooltip-trigger"')
expect(button).not.toContain('disabled=""')
})
})

View File

@ -4718,7 +4718,7 @@ type PullRequestComposerProps = {
onDropdownAction: (kind: DropdownActionKind) => void
}
function PullRequestComposer({
export function PullRequestComposer({
provider,
branch,
base,
@ -4780,6 +4780,48 @@ function PullRequestComposer({
// the user can't race the request — the hook otherwise rejects the result
// with "Fields changed while generating" and silently drops the draft.
const fieldsLocked = generating
const generateDetailsLabel = translate(
'auto.components.right.sidebar.SourceControl.02d8c04339',
'Generate {{value0}} details with AI',
{ value0: copy.reviewLabel }
)
const stopGeneratingDetailsLabel = translate(
'auto.components.right.sidebar.SourceControl.b355e740b2',
'Stop generating {{value0}} details',
{ value0: copy.reviewLabel }
)
const generateTooltipLabel = generating
? stopGeneratingDetailsLabel
: (generateDisabledReason ?? generateDetailsLabel)
const generateButton = generating ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onCancelGenerate()}
className="text-[11px] text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={stopGeneratingDetailsLabel}
>
<RefreshCw className="size-3 animate-spin" />
<span>
{translate('auto.components.right.sidebar.SourceControl.e868cec4e1', 'Generating…')}
</span>
<Square className="size-2.5 fill-current" />
</Button>
) : (
<Button
type="button"
variant="outline"
size="xs"
disabled={generateDisabled}
onClick={() => onGenerate()}
className="text-[11px] disabled:hover:bg-background"
aria-label={generateDetailsLabel}
>
<Sparkles className="size-3" />
{translate('auto.components.right.sidebar.SourceControl.aee92f8684', 'Generate')}
</Button>
)
return (
<div className="px-3 pb-2">
@ -4796,54 +4838,18 @@ function PullRequestComposer({
</span>
</div>
{aiGenerationEnabled ? (
generating ? (
<button
type="button"
onClick={() => onCancelGenerate()}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border border-border bg-background px-2 text-[11px] text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title={translate(
'auto.components.right.sidebar.SourceControl.527e130b6f',
'Stop generating'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.b355e740b2',
'Stop generating {{value0}} details',
{ value0: copy.reviewLabel }
)}
>
<RefreshCw className="size-3 animate-spin" />
<span>
{translate(
'auto.components.right.sidebar.SourceControl.e868cec4e1',
'Generating…'
)}
</span>
<Square className="size-2.5 fill-current" />
</button>
) : (
<button
type="button"
disabled={generateDisabled}
onClick={() => onGenerate()}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border border-border bg-background px-2 text-[11px] font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-background"
title={
generateDisabledReason ??
translate(
'auto.components.right.sidebar.SourceControl.02d8c04339',
'Generate {{value0}} details with AI',
{ value0: copy.reviewLabel }
)
}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.02d8c04339',
'Generate {{value0}} details with AI',
{ value0: copy.reviewLabel }
)}
>
<Sparkles className="size-3" />
{translate('auto.components.right.sidebar.SourceControl.aee92f8684', 'Generate')}
</button>
)
<Tooltip>
{!generating && generateDisabled ? (
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 cursor-not-allowed">{generateButton}</span>
</TooltipTrigger>
) : (
<TooltipTrigger asChild>{generateButton}</TooltipTrigger>
)}
<TooltipContent side="left" sideOffset={6}>
{generateTooltipLabel}
</TooltipContent>
</Tooltip>
) : null}
</div>

View File

@ -491,7 +491,9 @@ export function buildSettingsNavigationMetadata({
}
export function useSettingsNavigationMetadata(): SettingsNavSection[] {
const { i18n } = useTranslation()
// Why: subscribe metadata consumers to language changes; translated memo
// contents refresh on rerender without depending on i18n.language directly.
useTranslation()
const repos = useAppStore((state) => state.repos)
const activeRuntimeEnvironmentId = useAppStore(
(state) => state.settings?.activeRuntimeEnvironmentId
@ -518,6 +520,6 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
isWebClient,
repos
}),
[i18n.language, isMac, isWindows, isWindowsTerminalHost, isWebClient, repos]
[isMac, isWindows, isWindowsTerminalHost, isWebClient, repos]
)
}