fix(ai-vault): label preview prompt honestly (#12178)

This commit is contained in:
Brennan Benson 2026-08-02 21:16:49 -07:00 committed by GitHub
parent f3e087ec06
commit c9a37f58d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 229 additions and 116 deletions

View File

@ -1,12 +1,5 @@
import type React from 'react'
import {
FileJson,
FolderGit2,
MessageSquare,
MessageSquarePlus,
Play,
TextCursorInput
} from 'lucide-react'
import { FileJson, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@ -17,7 +10,7 @@ import {
} from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { FirstPromptCard } from './ai-vault-first-prompt-card'
import { sessionDetailConversationTurns, sessionFirstPrompt } from './ai-vault-session-display'
import { sessionDetailConversationTurns, sessionPromptPreview } from './ai-vault-session-display'
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice'
import {
@ -59,7 +52,7 @@ export function SessionInlineDetails({
const showResumeInNewTab =
hasResumableContent &&
(!resumeActions.worktree.worktreeId || Boolean(resumeActions.newTab.worktreeId))
const firstPromptPreview = sessionFirstPrompt(session)
const promptPreview = sessionPromptPreview(session)
const detailTurns = sessionDetailConversationTurns(session, 3)
const worktreeDisplay = worktreeInfo
@ -158,19 +151,7 @@ export function SessionInlineDetails({
<div className="space-y-3 p-3">
{hasResumableContent ? (
<>
<SessionReceiptSection
icon={<TextCursorInput className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.firstPrompt',
'First prompt'
)}
>
<FirstPromptCard
key={session.id}
session={session}
previewText={firstPromptPreview ?? ''}
/>
</SessionReceiptSection>
<FirstPromptCard key={session.id} session={session} preview={promptPreview} />
<SessionReceiptSection
icon={<MessageSquare className="size-3" />}
label={translate(

View File

@ -3,6 +3,7 @@ import { StrictMode } from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FirstPromptCard } from './ai-vault-first-prompt-card'
import { sessionPromptPreview } from './ai-vault-session-display'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
const session = {
@ -27,6 +28,61 @@ afterEach(() => {
})
describe('FirstPromptCard', () => {
it('labels stored firstUserPrompt text as the first prompt', () => {
const remoteSession = {
...session,
executionHostId: 'ssh:dev-box',
firstUserPrompt: 'The authoritative opening ask'
} as AiVaultSession
stubApi(vi.fn().mockResolvedValue({ prompt: null }))
render(
<FirstPromptCard session={remoteSession} preview={sessionPromptPreview(remoteSession)} />
)
expect(screen.getByText('First prompt')).toBeTruthy()
expect(screen.getByText('The authoritative opening ask')).toBeTruthy()
})
it('labels a remote preview fallback as a recent prompt when no full read is available', () => {
const getFirstUserPrompt = vi.fn().mockResolvedValue({ prompt: null })
const remoteSession = {
...session,
executionHostId: 'ssh:dev-box',
previewMessagesTruncated: true,
previewMessages: [
{ role: 'user', text: 'A recent ask from the sliding window', timestamp: null }
]
} as AiVaultSession
stubApi(getFirstUserPrompt)
render(
<FirstPromptCard session={remoteSession} preview={sessionPromptPreview(remoteSession)} />
)
expect(screen.getByText('Recent prompt')).toBeTruthy()
expect(screen.getByText('A recent ask from the sliding window')).toBeTruthy()
expect(getFirstUserPrompt).not.toHaveBeenCalled()
})
it('relabels a local preview fallback after the full first prompt loads', async () => {
const localSession = {
...session,
previewMessagesTruncated: true,
previewMessages: [
{ role: 'user', text: 'A recent ask from the sliding window', timestamp: null }
]
} as AiVaultSession
stubApi(vi.fn().mockResolvedValue({ prompt: 'The authoritative opening ask' }))
render(<FirstPromptCard session={localSession} preview={sessionPromptPreview(localSession)} />)
expect(screen.getByText('Recent prompt')).toBeTruthy()
expect(screen.getByText('A recent ask from the sliding window')).toBeTruthy()
await waitFor(() => expect(screen.getByText('First prompt')).toBeTruthy())
expect(screen.getByText('The authoritative opening ask')).toBeTruthy()
})
it('resolves loading under StrictMode double-invoke instead of stranding the card', async () => {
// StrictMode mounts, cleans up, then re-mounts. The cleanup marks the first
// request stale, so only a fresh second request can clear `loading`.
@ -34,7 +90,7 @@ describe('FirstPromptCard', () => {
render(
<StrictMode>
<FirstPromptCard session={session} previewText="" />
<FirstPromptCard session={session} preview={null} />
</StrictMode>
)
@ -47,7 +103,7 @@ describe('FirstPromptCard', () => {
// A main process that never answers must not pin the card in `loading`.
stubApi(vi.fn().mockReturnValue(new Promise(() => {})))
render(<FirstPromptCard session={session} previewText="" />)
render(<FirstPromptCard session={session} preview={null} />)
expect(screen.getByText('Loading first prompt…')).toBeTruthy()
await vi.advanceTimersByTimeAsync(15_000)

View File

@ -1,11 +1,12 @@
import type React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, LoaderCircle } from 'lucide-react'
import { Check, Copy, LoaderCircle, TextCursorInput } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { AiVaultSessionPromptPreview } from './ai-vault-session-display'
// Why: the main-process re-parse has no deadline of its own. Without this the
// card can sit in `loading` forever on a huge or stalled transcript.
@ -23,15 +24,11 @@ function canLoadFullFirstPrompt(
export function FirstPromptCard({
session,
previewText
preview
}: {
session: AiVaultSession
/**
* Short preview from the list scan; replaced by the full on-demand body when
* available. Empty unless the scan proved it really is the opening ask, so the
* fallback below can never show a recent turn (remote rows never re-parse).
*/
previewText: string
/** Short list-scan text, replaced by the full opening ask when available. */
preview: AiVaultSessionPromptPreview | null
}): React.JSX.Element {
// Loading starts true when an on-demand re-parse is possible so the mount effect
// does not need a sync setState (react-doctor: no-adjust-state-on-prop-change).
@ -128,7 +125,9 @@ export function FirstPromptCard({
}
}, [loadFullPrompt])
const previewText = preview?.text ?? ''
const displayText = (fullText ?? previewText).trim()
const displaySource = fullText ? 'first-user-prompt' : preview?.source
const showEmpty = !loading && !displayText
const copyFirstPrompt = (): void => {
@ -143,12 +142,7 @@ export function FirstPromptCard({
}
return window.api.ui.writeClipboardText(copyText).then(() => {
setCopied(true)
toast.success(
translate(
'auto.components.right.sidebar.AiVaultSessionDetails.firstPromptCopied',
'First prompt copied'
)
)
toast.success(promptCopiedLabel(loaded ? 'first-user-prompt' : preview?.source))
window.setTimeout(() => {
setCopied(false)
}, 1400)
@ -163,54 +157,103 @@ export function FirstPromptCard({
}
return (
<div className="rounded-md border border-border/70 bg-foreground/[0.04] px-2.5 py-2">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span>
{translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You')}
</span>
{loading || copying ? (
<LoaderCircle className="size-3 shrink-0 animate-spin text-muted-foreground/70" />
) : null}
</div>
<Button
type="button"
variant="ghost"
size="xs"
draggable={false}
disabled={copying || (!displayText && !loading)}
onClick={(event) => {
event.stopPropagation()
copyFirstPrompt()
}}
className="h-6 shrink-0 gap-1 px-1.5 text-[10px] text-muted-foreground"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyFirstPrompt',
'Copy first prompt'
)}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied
? translate('auto.components.right.sidebar.AiVaultSessionDetails.copied', 'Copied')
: translate('auto.components.right.sidebar.AiVaultSessionDetails.copy', 'Copy')}
</Button>
<section className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span className="text-muted-foreground/80">
<TextCursorInput className="size-3" />
</span>
<span>{promptSectionLabel(displaySource)}</span>
</div>
{showEmpty ? (
<p className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noFirstPromptAvailable',
'No first prompt available'
)}
</p>
) : (
<p className="scrollbar-sleek max-h-48 select-text overflow-y-auto whitespace-pre-wrap text-[12px] leading-[1.35] text-foreground/90 [overflow-wrap:anywhere]">
{displayText ||
translate(
'auto.components.right.sidebar.AiVaultSessionDetails.loadingFirstPrompt',
'Loading first prompt…'
<div className="rounded-md border border-border/70 bg-foreground/[0.04] px-2.5 py-2">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span>
{translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You')}
</span>
{loading || copying ? (
<LoaderCircle className="size-3 shrink-0 animate-spin text-muted-foreground/70" />
) : null}
</div>
<Button
type="button"
variant="ghost"
size="xs"
draggable={false}
disabled={copying || (!displayText && !loading)}
onClick={(event) => {
event.stopPropagation()
copyFirstPrompt()
}}
className="h-6 shrink-0 gap-1 px-1.5 text-[10px] text-muted-foreground"
aria-label={copyPromptLabel(displaySource)}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied
? translate('auto.components.right.sidebar.AiVaultSessionDetails.copied', 'Copied')
: translate('auto.components.right.sidebar.AiVaultSessionDetails.copy', 'Copy')}
</Button>
</div>
{showEmpty ? (
<p className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noFirstPromptAvailable',
'No first prompt available'
)}
</p>
)}
</div>
</p>
) : (
<p className="scrollbar-sleek max-h-48 select-text overflow-y-auto whitespace-pre-wrap text-[12px] leading-[1.35] text-foreground/90 [overflow-wrap:anywhere]">
{displayText ||
translate(
'auto.components.right.sidebar.AiVaultSessionDetails.loadingFirstPrompt',
'Loading first prompt…'
)}
</p>
)}
</div>
</section>
)
}
function promptSectionLabel(source: AiVaultSessionPromptPreview['source'] | undefined): string {
if (source === 'first-user-prompt') {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.firstPrompt',
'First prompt'
)
}
if (source === 'preview-window') {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.recentPrompt',
'Recent prompt'
)
}
return translate('auto.components.right.sidebar.AiVaultSessionDetails.prompt', 'Prompt')
}
function copyPromptLabel(source: AiVaultSessionPromptPreview['source'] | undefined): string {
if (source === 'first-user-prompt') {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyFirstPrompt',
'Copy first prompt'
)
}
if (source === 'preview-window') {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyRecentPrompt',
'Copy recent prompt'
)
}
return translate('auto.components.right.sidebar.AiVaultSessionDetails.copyPrompt', 'Copy prompt')
}
function promptCopiedLabel(source: AiVaultSessionPromptPreview['source'] | undefined): string {
return source === 'first-user-prompt'
? translate(
'auto.components.right.sidebar.AiVaultSessionDetails.firstPromptCopied',
'First prompt copied'
)
: translate(
'auto.components.right.sidebar.AiVaultSessionDetails.recentPromptCopied',
'Recent prompt copied'
)
}

View File

@ -4,8 +4,8 @@ import {
latestSessionConversationTurn,
recentSessionConversationTurns,
sessionDetailConversationTurns,
sessionFirstPrompt,
sessionModelLabel,
sessionPromptPreview,
sessionPreviewSearchText
} from './ai-vault-session-display'
@ -103,7 +103,7 @@ describe('ai vault session display', () => {
it('prefers the stored firstUserPrompt over sliding preview turns', () => {
expect(
sessionFirstPrompt({
sessionPromptPreview({
...baseSession,
firstUserPrompt: 'Original long first prompt that scrolled out of preview',
previewMessages: [
@ -111,23 +111,28 @@ describe('ai vault session display', () => {
{ role: 'assistant', text: 'Later reply', timestamp: null }
]
})
).toBe('Original long first prompt that scrolled out of preview')
).toEqual({
text: 'Original long first prompt that scrolled out of preview',
source: 'first-user-prompt'
})
})
it('falls back to the earliest user preview turn when firstUserPrompt is absent', () => {
expect(sessionFirstPrompt(baseSession)).toBe('Please fix the flaky golden tests')
it('marks list-scan fallback text as a preview-window prompt', () => {
expect(sessionPromptPreview(baseSession)).toEqual({
text: 'Please fix the flaky golden tests',
source: 'preview-window'
})
expect(
sessionFirstPrompt({
sessionPromptPreview({
...baseSession,
previewMessages: [{ role: 'assistant', text: 'Only agent text', timestamp: null }]
})
).toBeNull()
})
// Remote/SSH rows never re-parse on demand, so a truncated window would
// otherwise show a RECENT ask permanently labelled as the first prompt.
it('refuses the preview fallback once the sliding window has truncated', () => {
it('keeps a truncated sliding-window fallback available as a recent prompt', () => {
expect(
sessionFirstPrompt({
sessionPromptPreview({
...baseSession,
previewMessagesTruncated: true,
previewMessages: [
@ -135,12 +140,15 @@ describe('ai vault session display', () => {
{ role: 'assistant', text: 'Later reply', timestamp: null }
]
})
).toBeNull()
).toEqual({
text: 'Later user turn still in the preview window',
source: 'preview-window'
})
})
it('still prefers a stored firstUserPrompt when the window has truncated', () => {
expect(
sessionFirstPrompt({
sessionPromptPreview({
...baseSession,
previewMessagesTruncated: true,
firstUserPrompt: 'Original long first prompt that scrolled out of preview',
@ -148,6 +156,9 @@ describe('ai vault session display', () => {
{ role: 'user', text: 'Later user turn still in the preview window', timestamp: null }
]
})
).toBe('Original long first prompt that scrolled out of preview')
).toEqual({
text: 'Original long first prompt that scrolled out of preview',
source: 'first-user-prompt'
})
})
})

View File

@ -1,11 +1,14 @@
// Why: the pure preview/search-text core now lives in /shared so mobile can
// reuse it (Metro can't import renderer). Re-export for renderer import parity.
export type { AiVaultSessionDisplayTurn } from '../../../../shared/ai-vault-session-display'
export type {
AiVaultSessionDisplayTurn,
AiVaultSessionPromptPreview
} from '../../../../shared/ai-vault-session-display'
export {
latestSessionConversationTurn,
recentSessionConversationTurns,
sessionDetailConversationTurns,
sessionFirstPrompt,
sessionModelLabel,
sessionPromptPreview,
sessionPreviewSearchText
} from '../../../../shared/ai-vault-session-display'

View File

@ -11337,9 +11337,14 @@
"jumpToOriginalPane": "Jump to Original Pane",
"worktree": "Worktree",
"jumpToWorktree": "Jump to Worktree",
"prompt": "Prompt",
"firstPrompt": "First prompt",
"recentPrompt": "Recent prompt",
"firstPromptCopied": "First prompt copied",
"recentPromptCopied": "Recent prompt copied",
"copyFirstPrompt": "Copy first prompt",
"copyRecentPrompt": "Copy recent prompt",
"copyPrompt": "Copy prompt",
"copied": "Copied",
"copy": "Copy",
"noFirstPromptAvailable": "No first prompt available",

View File

@ -11201,9 +11201,14 @@
"emptyConversationDetail": "Esta sesión no tiene conversación guardada y no se puede reanudar.",
"queuedMessages": "{{value0}} mensaje(s) en cola",
"subagentTranscripts": "{{value0}} transcripción(es) de subagente",
"prompt": "Prompt",
"firstPrompt": "Primer prompt",
"recentPrompt": "Prompt reciente",
"firstPromptCopied": "Primer prompt copiado",
"recentPromptCopied": "Prompt reciente copiado",
"copyFirstPrompt": "Copiar primer prompt",
"copyRecentPrompt": "Copiar prompt reciente",
"copyPrompt": "Copiar prompt",
"copied": "Copiado",
"copy": "Copiar",
"noFirstPromptAvailable": "No hay primer prompt disponible",

View File

@ -11201,9 +11201,14 @@
"emptyConversationDetail": "このセッションには保存された会話がなく、再開できません。",
"queuedMessages": "キュー内のメッセージ {{value0}} 件",
"subagentTranscripts": "サブエージェントの履歴 {{value0}} 件",
"prompt": "プロンプト",
"firstPrompt": "最初のプロンプト",
"recentPrompt": "最近のプロンプト",
"firstPromptCopied": "最初のプロンプトをコピーしました",
"recentPromptCopied": "最近のプロンプトをコピーしました",
"copyFirstPrompt": "最初のプロンプトをコピー",
"copyRecentPrompt": "最近のプロンプトをコピー",
"copyPrompt": "プロンプトをコピー",
"copied": "コピーしました",
"copy": "コピー",
"noFirstPromptAvailable": "最初のプロンプトはありません",

View File

@ -11201,9 +11201,14 @@
"emptyConversationDetail": "이 세션에는 저장된 대화가 없어 재개할 수 없습니다.",
"queuedMessages": "대기 중인 메시지 {{value0}}개",
"subagentTranscripts": "서브에이전트 대화 기록 {{value0}}개",
"prompt": "프롬프트",
"firstPrompt": "첫 프롬프트",
"recentPrompt": "최근 프롬프트",
"firstPromptCopied": "첫 프롬프트를 복사했습니다",
"recentPromptCopied": "최근 프롬프트를 복사했습니다",
"copyFirstPrompt": "첫 프롬프트 복사",
"copyRecentPrompt": "최근 프롬프트 복사",
"copyPrompt": "프롬프트 복사",
"copied": "복사됨",
"copy": "복사",
"noFirstPromptAvailable": "첫 프롬프트를 사용할 수 없습니다",

View File

@ -11213,9 +11213,14 @@
"emptyConversationDetail": "此会话没有已保存的对话,无法恢复。",
"queuedMessages": "{{value0}} 条排队消息",
"subagentTranscripts": "{{value0}} 个子智能体记录",
"prompt": "提示",
"firstPrompt": "首次提示",
"recentPrompt": "最近提示",
"firstPromptCopied": "已复制首次提示",
"recentPromptCopied": "已复制最近提示",
"copyFirstPrompt": "复制首次提示",
"copyRecentPrompt": "复制最近提示",
"copyPrompt": "复制提示",
"copied": "已复制",
"copy": "复制",
"noFirstPromptAvailable": "没有可用的首次提示",

View File

@ -12,6 +12,11 @@ export type AiVaultSessionDisplayTurn = {
timestamp: string | null
}
export type AiVaultSessionPromptPreview = {
text: string
source: 'first-user-prompt' | 'preview-window'
}
export function latestSessionConversationTurn(
session: AiVaultSession
): AiVaultSessionDisplayTurn | null {
@ -44,22 +49,11 @@ export function sessionDetailConversationTurns(
return dedupeAdjacentConversationTurns(turns).slice(-limit)
}
/**
* Placeholder text for the first-prompt row while the full body loads on demand.
* List scans no longer store firstUserPrompt (payload/perf); this is preview-only.
*/
export function sessionFirstPrompt(session: AiVaultSession): string | null {
// Prefer a stored full body when present (on-demand re-parse / tests).
/** Prompt text with enough provenance for the renderer to avoid overclaiming. */
export function sessionPromptPreview(session: AiVaultSession): AiVaultSessionPromptPreview | null {
const stored = session.firstUserPrompt?.trim()
if (stored) {
return stored
}
// Why: `previewMessages` is a newest-N sliding window. Once it has truncated,
// its earliest user turn is a RECENT ask, not the opening one — returning it
// would show (and copy) the wrong message under a "first prompt" label.
if (session.previewMessagesTruncated) {
return null
return { text: stored, source: 'first-user-prompt' }
}
for (const message of session.previewMessages) {
@ -68,7 +62,7 @@ export function sessionFirstPrompt(session: AiVaultSession): string | null {
}
const text = message.text.trim()
if (text) {
return text
return { text, source: 'preview-window' }
}
}