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' } } }