diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 906a345d5..9ea18fdbb 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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), diff --git a/src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx b/src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx new file mode 100644 index 000000000..6cc3d36d8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx @@ -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( + + + + ) +} + +function elementByLabel(markup: string, tagName: string, label: string): string { + const element = [...markup.matchAll(new RegExp(`<${tagName}\\b[\\s\\S]*?`, '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=""') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 7fb84e20a..752aaeda1 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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 ? ( + + ) : ( + + ) return (
@@ -4796,54 +4838,18 @@ function PullRequestComposer({
{aiGenerationEnabled ? ( - generating ? ( - - ) : ( - - ) + + {!generating && generateDisabled ? ( + + {generateButton} + + ) : ( + {generateButton} + )} + + {generateTooltipLabel} + + ) : null} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index eed03a10b..33830ba84 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -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] ) }