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:
parent
256e0ef81a
commit
0ad5add6f0
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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=""')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue