From 59a7bad2f99bbfbe34143e85ff8a02ea6d236deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 14 Jul 2026 01:41:54 +0800 Subject: [PATCH] fix(desktop): restore diff reference cards #1004 --- desktop/src/components/chat/ChatInput.tsx | 2 + desktop/src/stores/chatStore.test.ts | 82 +++++++++++++ desktop/src/stores/chatStore.ts | 141 +++++++++++++++++++++- desktop/src/types/chat.ts | 4 + 4 files changed, 224 insertions(+), 5 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index fd5f9bdc..39c86bc5 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -693,6 +693,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro isDirectory: reference.isDirectory, lineStart: reference.lineStart, lineEnd: reference.lineEnd, + diffSide: reference.diffSide, + hunkId: reference.hunkId, note: reference.note, quote: reference.quote, })), diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index b2517fc1..c3ce248a 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1565,6 +1565,46 @@ describe('chatStore history mapping', () => { ]) }) + it('restores persisted workspace diff comments without exposing the model prompt', () => { + const modelPrompt = [ + '@"/repo/homepage/src/App.vue" Referenced workspace context:', + '@"homepage/src/App.vue:new:L94-L105":', + 'Comment: 这块儿我们不能再修改一下', + '```vue', + '
', + '

{{ name }}

', + '
', + '```', + ].join('\n') + const mapped = mapHistoryMessagesToUiMessages([ + { + id: 'workspace-comment-user-1', + type: 'user', + timestamp: '2026-07-14T00:00:00.000Z', + content: [{ type: 'text', text: modelPrompt }], + } as MessageEntry, + ]) + + expect(mapped).toMatchObject([ + { + id: 'workspace-comment-user-1', + type: 'user_text', + content: '', + modelContent: modelPrompt, + attachments: [{ + type: 'file', + name: 'App.vue', + path: 'homepage/src/App.vue', + lineStart: 94, + lineEnd: 105, + diffSide: 'new', + note: '这块儿我们不能再修改一下', + quote: '
\n

{{ name }}

\n
', + }], + }, + ]) + }) + it('keeps workspace reference chips visible while sending CLI attachment paths', () => { useChatStore.setState({ sessions: { @@ -1610,6 +1650,8 @@ describe('chatStore history mapping', () => { path: 'src/App.tsx', lineStart: 4, lineEnd: 4, + diffSide: 'new', + hunkId: 'hunk-1', note: 'tighten this', quote: 'const value = 1', }], @@ -1627,6 +1669,8 @@ describe('chatStore history mapping', () => { path: 'src/App.tsx', lineStart: 4, lineEnd: 4, + diffSide: 'new', + hunkId: 'hunk-1', note: 'tighten this', quote: 'const value = 1', }], @@ -5096,6 +5140,44 @@ describe('chatStore history mapping', () => { ]) }) + it('restores workspace diff comment styling from a replayed model prompt', () => { + const modelPrompt = [ + '@"/repo/src/App.vue" Referenced workspace context:', + '@"src/App.vue:new:L94-L105":', + 'Comment: 调整这里', + '```vue', + '
', + '```', + ].join('\n') + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ chatState: 'thinking' }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'user_message_replay', + content: modelPrompt, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + type: 'user_text', + content: '', + modelContent: modelPrompt, + attachments: [{ + type: 'file', + path: 'src/App.vue', + diffSide: 'new', + lineStart: 94, + lineEnd: 105, + note: '调整这里', + quote: '
', + }], + }, + ]) + }) + it('does not leak an image-bearing prompt when the replay appends [Image source] metadata (Windows path)', () => { // The optimistic message (e.g. a visual-selection annotation card) stores the // prompt body in modelContent with a hidden display. The CLI replay carries diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index b3060f7e..35f90701 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1155,6 +1155,8 @@ export const useChatStore = create((set, get) => ({ mimeType: a.mimeType, lineStart: a.lineStart, lineEnd: a.lineEnd, + diffSide: a.diffSide, + hunkId: a.hunkId, note: a.note, quote: a.quote, })) @@ -3552,6 +3554,133 @@ function extractLeadingFileReferences(text: string): { } } +type WorkspaceReferenceHistoryDisplay = { + content: string + attachments: UIAttachment[] +} + +function parseWorkspaceReferenceLocation(location: string): { + path: string + lineStart?: number + lineEnd?: number + diffSide?: 'old' | 'new' +} { + const match = location.match(/^(.*?)(?::(old|new))?:L(\d+)(?:-L(\d+))?$/) + if (!match?.[1] || !match[3]) return { path: location } + + const lineStart = Number(match[3]) + const lineEnd = Number(match[4] ?? match[3]) + return { + path: match[1], + lineStart, + lineEnd, + ...(match[2] === 'old' || match[2] === 'new' ? { diffSide: match[2] } : {}), + } +} + +function parseWorkspaceReferenceHistoryPrompt(text: string): WorkspaceReferenceHistoryDisplay | null { + const lines = text.replace(/\r\n?/g, '\n').split('\n') + if (lines[0]?.trim() !== 'Referenced workspace context:') return null + + const attachments: UIAttachment[] = [] + let index = 1 + const isLocationHeader = (line: string) => /^@".+":$/.test(line.trim()) + + while (index < lines.length) { + const header = lines[index]?.trim() ?? '' + const locationMatch = header.match(/^@"(.+)":$/) + if (!locationMatch?.[1]) break + + const location = parseWorkspaceReferenceLocation(locationMatch[1]) + index += 1 + + let note: string | undefined + if (lines[index]?.trimStart().startsWith('Comment:')) { + const noteLines = [lines[index]!.trimStart().slice('Comment:'.length).trimStart()] + index += 1 + while ( + index < lines.length && + lines[index]!.trim() !== '' && + !/^`{3,}/.test(lines[index]!.trim()) && + !isLocationHeader(lines[index]!) + ) { + noteLines.push(lines[index]!) + index += 1 + } + note = noteLines.join('\n').trim() || undefined + } + + let quote: string | undefined + const fenceMatch = lines[index]?.trim().match(/^(`{3,})[^`]*$/) + if (fenceMatch?.[1]) { + const fence = fenceMatch[1] + index += 1 + const quoteLines: string[] = [] + while (index < lines.length && lines[index]?.trim() !== fence) { + quoteLines.push(lines[index]!) + index += 1 + } + if (index >= lines.length) return null + index += 1 + quote = quoteLines.join('\n').trim() || undefined + } + + attachments.push({ + type: 'file', + name: getReferenceName(location.path), + path: location.path, + ...(location.lineStart ? { lineStart: location.lineStart } : {}), + ...(location.lineEnd ? { lineEnd: location.lineEnd } : {}), + ...(location.diffSide ? { diffSide: location.diffSide } : {}), + ...(note ? { note } : {}), + ...(quote ? { quote } : {}), + }) + } + + if (attachments.length === 0) return null + while (lines[index]?.trim() === '') index += 1 + return { + content: lines.slice(index).join('\n').trim(), + attachments, + } +} + +function pathsReferToSameFile(left: string | undefined, right: string | undefined): boolean { + if (!left || !right) return false + const normalizedLeft = left.replace(/\\/g, '/').replace(/^\.\//, '') + const normalizedRight = right.replace(/\\/g, '/').replace(/^\.\//, '') + return ( + normalizedLeft === normalizedRight || + normalizedLeft.endsWith(`/${normalizedRight}`) || + normalizedRight.endsWith(`/${normalizedLeft}`) + ) +} + +function extractRestoredUserDisplay(text: string): { + content: string + attachments?: UIAttachment[] + modelContent?: string +} { + const leading = extractLeadingFileReferences(text) + const workspace = parseWorkspaceReferenceHistoryPrompt(leading.content) + if (!workspace) return leading + + const unmatchedLeading = [...(leading.attachments ?? [])] + for (const attachment of workspace.attachments) { + const matchingIndex = unmatchedLeading.findIndex((candidate) => + pathsReferToSameFile(candidate.path, attachment.path), + ) + if (matchingIndex >= 0) unmatchedLeading.splice(matchingIndex, 1) + } + + const attachments = [...unmatchedLeading, ...workspace.attachments] + return { + content: workspace.content, + attachments: attachments.length > 0 ? attachments : undefined, + modelContent: text, + } +} + export function appendReplayedUserMessage( messages: UIMessage[], content: string, @@ -3562,9 +3691,9 @@ export function appendReplayedUserMessage( // mapping) so the dedupe below can match the already-rendered message instead // of appending the raw prompt — paths and all — as a duplicate bubble. const sanitized = stripGeneratedImageMetadataLines(content) || content.trim() - const parsed = extractLeadingFileReferences(sanitized) - const displayContent = parsed.content.trim() || sanitized - if (!displayContent) return messages + const parsed = extractRestoredUserDisplay(sanitized) + const displayContent = parsed.content.trim() + if (!displayContent && !parsed.attachments?.length) return messages const modelContent = parsed.modelContent ?? sanitized const currentTurnUserIndex = findCurrentTurnUserMessageIndex(messages, modelContent) @@ -3629,6 +3758,8 @@ function mapQueuedDisplayAttachments(attachments?: AttachmentRef[]): UIAttachmen isDirectory: attachment.isDirectory, lineStart: attachment.lineStart, lineEnd: attachment.lineEnd, + diffSide: attachment.diffSide, + hunkId: attachment.hunkId, note: attachment.note, quote: attachment.quote, })) @@ -3865,7 +3996,7 @@ export function mapHistoryMessagesToUiMessages( }) continue } - const parsed = extractLeadingFileReferences(msg.content) + const parsed = extractRestoredUserDisplay(msg.content) uiMessages.push({ id: msg.id || nextId(), type: 'user_text', @@ -3943,7 +4074,7 @@ export function mapHistoryMessagesToUiMessages( if (visualSelectionDisplay) { applyVisualSelectionHistoryDisplay(attachments, visualSelectionDisplay) } - const parsed = extractLeadingFileReferences(visibleText) + const parsed = extractRestoredUserDisplay(visibleText) const userContent = visualSelectionDisplay ? '' : parsed.content const modelContent = visualSelectionDisplay || modelText !== visibleText ? modelText : parsed.modelContent const allAttachments = [...(parsed.attachments ?? []), ...attachments] diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 94bb21ee..9e7f46e9 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -38,6 +38,8 @@ export type AttachmentRef = { isDirectory?: boolean lineStart?: number lineEnd?: number + diffSide?: 'old' | 'new' + hunkId?: string note?: string quote?: string } @@ -69,6 +71,8 @@ export type UIAttachment = { isDirectory?: boolean lineStart?: number lineEnd?: number + diffSide?: 'old' | 'new' + hunkId?: string note?: string quote?: string }