diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
index 30e44d375..b3907d683 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
@@ -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({
{hasResumableContent ? (
<>
-
}
- label={translate(
- 'auto.components.right.sidebar.AiVaultSessionDetails.firstPrompt',
- 'First prompt'
- )}
- >
-
-
+
}
label={translate(
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx
index c272ea700..7e67ba06e 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx
+++ b/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx
@@ -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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
)
+
+ 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(
-
+
)
@@ -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(
)
+ render(
)
expect(screen.getByText('Loading first prompt…')).toBeTruthy()
await vi.advanceTimersByTimeAsync(15_000)
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx b/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx
index 181a8dc0a..cd8a1cb12 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx
+++ b/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx
@@ -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 (
-
-
-
-
- {translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You')}
-
- {loading || copying ? (
-
- ) : null}
-
-
+
+
+
+
+
+ {promptSectionLabel(displaySource)}
- {showEmpty ? (
-
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionDetails.noFirstPromptAvailable',
- 'No first prompt available'
- )}
-
- ) : (
-
- {displayText ||
- translate(
- 'auto.components.right.sidebar.AiVaultSessionDetails.loadingFirstPrompt',
- 'Loading first prompt…'
+
+
+
+
+ {translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You')}
+
+ {loading || copying ? (
+
+ ) : null}
+
+
+
+ {showEmpty ? (
+
+ {translate(
+ 'auto.components.right.sidebar.AiVaultSessionDetails.noFirstPromptAvailable',
+ 'No first prompt available'
)}
-
- )}
-
+
+ ) : (
+
+ {displayText ||
+ translate(
+ 'auto.components.right.sidebar.AiVaultSessionDetails.loadingFirstPrompt',
+ 'Loading first prompt…'
+ )}
+
+ )}
+
+
)
}
+
+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'
+ )
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts
index 2bd85ce29..3818e7f84 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts
@@ -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'
+ })
})
})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts
index 9d7f0eea6..888a2dc6d 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts
@@ -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'
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 3cb7acdff..ac2de3671 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index d13b704ba..cef83e8bd 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 764f515a5..5610ec1d7 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -11201,9 +11201,14 @@
"emptyConversationDetail": "このセッションには保存された会話がなく、再開できません。",
"queuedMessages": "キュー内のメッセージ {{value0}} 件",
"subagentTranscripts": "サブエージェントの履歴 {{value0}} 件",
+ "prompt": "プロンプト",
"firstPrompt": "最初のプロンプト",
+ "recentPrompt": "最近のプロンプト",
"firstPromptCopied": "最初のプロンプトをコピーしました",
+ "recentPromptCopied": "最近のプロンプトをコピーしました",
"copyFirstPrompt": "最初のプロンプトをコピー",
+ "copyRecentPrompt": "最近のプロンプトをコピー",
+ "copyPrompt": "プロンプトをコピー",
"copied": "コピーしました",
"copy": "コピー",
"noFirstPromptAvailable": "最初のプロンプトはありません",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 2b007dfdb..31c001734 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -11201,9 +11201,14 @@
"emptyConversationDetail": "이 세션에는 저장된 대화가 없어 재개할 수 없습니다.",
"queuedMessages": "대기 중인 메시지 {{value0}}개",
"subagentTranscripts": "서브에이전트 대화 기록 {{value0}}개",
+ "prompt": "프롬프트",
"firstPrompt": "첫 프롬프트",
+ "recentPrompt": "최근 프롬프트",
"firstPromptCopied": "첫 프롬프트를 복사했습니다",
+ "recentPromptCopied": "최근 프롬프트를 복사했습니다",
"copyFirstPrompt": "첫 프롬프트 복사",
+ "copyRecentPrompt": "최근 프롬프트 복사",
+ "copyPrompt": "프롬프트 복사",
"copied": "복사됨",
"copy": "복사",
"noFirstPromptAvailable": "첫 프롬프트를 사용할 수 없습니다",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index cbee13ada..647fea043 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -11213,9 +11213,14 @@
"emptyConversationDetail": "此会话没有已保存的对话,无法恢复。",
"queuedMessages": "{{value0}} 条排队消息",
"subagentTranscripts": "{{value0}} 个子智能体记录",
+ "prompt": "提示",
"firstPrompt": "首次提示",
+ "recentPrompt": "最近提示",
"firstPromptCopied": "已复制首次提示",
+ "recentPromptCopied": "已复制最近提示",
"copyFirstPrompt": "复制首次提示",
+ "copyRecentPrompt": "复制最近提示",
+ "copyPrompt": "复制提示",
"copied": "已复制",
"copy": "复制",
"noFirstPromptAvailable": "没有可用的首次提示",
diff --git a/src/shared/ai-vault-session-display.ts b/src/shared/ai-vault-session-display.ts
index b45f43d2f..b105f7703 100644
--- a/src/shared/ai-vault-session-display.ts
+++ b/src/shared/ai-vault-session-display.ts
@@ -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' }
}
}