Use scrollable hover cards for source control variable previews (#5029)

Replace the variable chip Tooltips with HoverCards to allow scrolling
through long prompt previews (such as the base prompt template).

* Pass action-specific base prompt previews (commit, PR, branch name)
  into the text generation dialogs to populate the variable chips.
* Style the hover cards to support vertical scrolling with a sleek
  scrollbar and update theme colors to match design guidelines.
* Add unit tests for the variable chips and dialog form behavior.
This commit is contained in:
Jinjing
2026-06-09 13:38:16 -07:00
committed by GitHub
parent c043939bf8
commit 0ca2cb49ab
5 changed files with 173 additions and 21 deletions
@@ -1,11 +1,43 @@
import { describe, expect, it } from 'vitest'
import React, { type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { buildCommitMessageGenerationParams } from './SourceControlTextGenerationDialog'
import { getDefaultSourceControlTextGenerationSaveTargetKey } from './SourceControlTextGenerationDialogForm'
import {
getDefaultSourceControlTextGenerationSaveTargetKey,
SourceControlTextGenerationDialogForm
} from './SourceControlTextGenerationDialogForm'
import {
applyCommitMessageGenerationDefaults,
applySourceControlTextGenerationDefaults
} from './SourceControlTextGenerationDefaults'
vi.mock('../source-control/SourceControlActionVariableChips', () => ({
SourceControlActionVariableChips: ({
variablePreviews
}: {
variablePreviews?: Partial<Record<string, string>>
}) =>
React.createElement('div', {
'data-variable-previews': JSON.stringify(variablePreviews ?? {})
})
}))
vi.mock('@/components/ui/dialog', () => ({
DialogFooter: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children)
}))
vi.mock('@/components/ui/select', () => ({
Select: ({ children }: { children?: ReactNode }) => React.createElement('div', null, children),
SelectContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
SelectItem: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
SelectTrigger: ({ children }: { children?: ReactNode }) =>
React.createElement('button', null, children),
SelectValue: () => React.createElement('span')
}))
describe('buildCommitMessageGenerationParams', () => {
it('defaults saved text-generation recipes to the global target when repo and global are available', () => {
expect(
@@ -24,6 +56,29 @@ describe('buildCommitMessageGenerationParams', () => {
).toBe('global')
})
it('passes the base prompt preview to variable chips in text generation dialogs', () => {
const markup = renderToStaticMarkup(
React.createElement(SourceControlTextGenerationDialogForm, {
actionId: 'commitMessage',
generateLabel: 'Generate',
settings: null,
repo: null,
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: '{basePrompt}'
},
basePromptPreview: 'You are generating a single git commit message.',
saveTargets: [],
onGenerate: () => {},
onOpenChange: () => {},
onSaveDefaults: () => {}
})
)
expect(markup).toContain('You are generating a single git commit message.')
})
it('preserves the resolved model and thinking level for the selected agent', () => {
expect(
buildCommitMessageGenerationParams({
@@ -14,6 +14,9 @@ import {
import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo } from '../../../../shared/types'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work'
import { buildCommitMessagePrompt } from '../../../../shared/commit-message-generation'
import { buildPullRequestFieldsPrompt } from '../../../../shared/pull-request-generation'
import {
SourceControlTextGenerationDialogForm,
type SourceControlTextGenerationSaveTarget
@@ -42,6 +45,40 @@ type SourceControlTextGenerationDialogProps = SourceControlTextGenerationBaseDia
generateLabel: string
}
function buildBasePromptPreview(actionId: SourceControlTextActionId): string {
switch (actionId) {
case 'commitMessage':
return buildCommitMessagePrompt(
{
branch: 'feature/example',
stagedSummary: 'M src/example.ts',
stagedPatch: 'diff --git a/src/example.ts b/src/example.ts\n+addSourceControlAiPreview()'
},
''
)
case 'pullRequest':
return buildPullRequestFieldsPrompt(
{
branch: 'feature/example',
base: 'main',
branchChangedByPreparation: false,
currentTitle: 'Draft title',
currentBody: 'Draft description',
currentDraft: false,
commitSummary: 'a1b2c3d Add Source Control AI prompt previews',
changeSummary: 'src/example.ts | 12 ++++++++++--',
patch: 'diff --git a/src/example.ts b/src/example.ts\n+addSourceControlAiPreview()'
},
''
)
case 'branchName':
return buildBranchNamePrompt({
firstPrompt: 'Add source-control AI prompt previews',
assistantMessage: 'I will update the generation dialog variable chip preview.'
})
}
}
export function SourceControlTextGenerationDialog({
actionId,
title,
@@ -126,6 +163,7 @@ export function SourceControlTextGenerationDialog({
settings={settings}
repo={repo ?? null}
baseParams={baseParams}
basePromptPreview={buildBasePromptPreview(actionId)}
saveTargets={saveTargets}
onGenerate={onGenerate}
onOpenChange={onOpenChange}
@@ -45,6 +45,7 @@ type SourceControlTextGenerationDialogFormProps = {
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
baseParams: ResolvedSourceControlAiGenerationParams | null
basePromptPreview?: string
saveTargets: SourceControlTextGenerationSaveTarget[]
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
onOpenChange: (open: boolean) => void
@@ -78,6 +79,7 @@ export function SourceControlTextGenerationDialogForm({
settings,
repo,
baseParams,
basePromptPreview,
saveTargets,
onGenerate,
onOpenChange,
@@ -285,6 +287,7 @@ export function SourceControlTextGenerationDialogForm({
/>
<SourceControlActionVariableChips
actionId={actionId}
variablePreviews={basePromptPreview ? { basePrompt: basePromptPreview } : undefined}
onInsert={(variable) => {
const separator =
commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' '
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { SourceControlActionVariableChips } from './SourceControlActionVariableChips'
vi.mock('../ui/hover-card', () => ({
HoverCard: ({ children }: { children: ReactNode }) => (
<div data-slot="hover-card">{children}</div>
),
HoverCardContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-slot="hover-card-content" className={className}>
{children}
</div>
),
HoverCardTrigger: ({ children }: { children: ReactNode }) => (
<div data-slot="hover-card-trigger">{children}</div>
)
}))
describe('SourceControlActionVariableChips', () => {
it('renders variable details in a scrollable hover card', () => {
const markup = renderToStaticMarkup(
<SourceControlActionVariableChips
actionId="commitMessage"
variablePreviews={{ basePrompt: 'Generate a commit message.\n\nInclude staged changes.' }}
onInsert={() => {}}
/>
)
expect(markup).toContain('data-slot="hover-card-content"')
expect(markup).toContain('scrollbar-sleek')
expect(markup).toContain('overflow-y-auto')
expect(markup).toContain('Generate a commit message.')
})
})
@@ -6,7 +6,7 @@ import {
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
import { translate } from '@/i18n/i18n'
type SourceControlActionVariableChipsProps = {
@@ -28,7 +28,7 @@ function hasVariablePreview(
)
}
function SourceControlVariableTooltip({
function SourceControlVariableDetails({
variable,
preview
}: {
@@ -38,17 +38,25 @@ function SourceControlVariableTooltip({
if (preview !== undefined) {
if (variable === 'basePrompt') {
return (
<pre className="scrollbar-sleek max-h-72 max-w-[min(32rem,calc(100vw-2rem))] overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
{preview || translate("auto.components.source.control.SourceControlActionVariableChips.4bf6d88039", "(empty)")}
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
{preview ||
translate(
'auto.components.source.control.SourceControlActionVariableChips.4bf6d88039',
'(empty)'
)}
</pre>
)
}
return (
<div className="space-y-1.5">
<div className="font-mono text-[11px] text-background/70">{`{${variable}}`}</div>
<pre className="scrollbar-sleek max-h-72 max-w-[min(32rem,calc(100vw-2rem))] overflow-auto rounded-sm bg-background/10 p-2 whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
{preview || translate("auto.components.source.control.SourceControlActionVariableChips.4bf6d88039", "(empty)")}
<div className="font-mono text-[11px] text-muted-foreground">{`{${variable}}`}</div>
<pre className="rounded-sm bg-background/60 p-2 whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
{preview ||
translate(
'auto.components.source.control.SourceControlActionVariableChips.4bf6d88039',
'(empty)'
)}
</pre>
</div>
)
@@ -59,12 +67,16 @@ function SourceControlVariableTooltip({
<div className="max-w-80 space-y-2 text-left leading-relaxed">
<div className="space-y-0.5">
<div className="font-mono text-[11px]">{`{${variable}}`}</div>
<div className="text-background/80">{info.description}</div>
<div className="text-muted-foreground">{info.description}</div>
</div>
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-wide text-background/60">
{translate("auto.components.source.control.SourceControlActionVariableChips.6b921a0ac2", "Example")}</div>
<pre className="scrollbar-sleek max-h-40 overflow-auto rounded-sm bg-background/10 p-2 whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{translate(
'auto.components.source.control.SourceControlActionVariableChips.6b921a0ac2',
'Example'
)}
</div>
<pre className="rounded-sm bg-background/60 p-2 whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
{info.example}
</pre>
</div>
@@ -82,14 +94,18 @@ export function SourceControlActionVariableChips({
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Braces className="size-3" />
{translate("auto.components.source.control.SourceControlActionVariableChips.1b77798d5f", "Variables")}</span>
{translate(
'auto.components.source.control.SourceControlActionVariableChips.1b77798d5f',
'Variables'
)}
</span>
{SOURCE_CONTROL_ACTION_VARIABLES[actionId].map((variable) => {
const preview = hasVariablePreview(variablePreviews, variable)
? variablePreviews?.[variable]
: undefined
return (
<Tooltip key={variable}>
<TooltipTrigger asChild>
<HoverCard key={variable} openDelay={150} closeDelay={120}>
<HoverCardTrigger asChild>
<span className="inline-flex">
<Button
type="button"
@@ -102,11 +118,16 @@ export function SourceControlActionVariableChips({
{`{${variable}}`}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="px-2 py-2 text-left">
<SourceControlVariableTooltip variable={variable} preview={preview} />
</TooltipContent>
</Tooltip>
</HoverCardTrigger>
<HoverCardContent
side="top"
sideOffset={6}
collisionPadding={12}
className="scrollbar-sleek max-h-[min(18rem,calc(100vh-2rem))] w-[min(32rem,calc(100vw-2rem))] overflow-y-auto p-2 text-left text-xs"
>
<SourceControlVariableDetails variable={variable} preview={preview} />
</HoverCardContent>
</HoverCard>
)
})}
</div>