fix(desktop): restore diff reference cards #1004

This commit is contained in:
程序员阿江(Relakkes) 2026-07-14 01:41:54 +08:00
parent 465fbafb3d
commit 59a7bad2f9
4 changed files with 224 additions and 5 deletions

View File

@ -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,
})),

View File

@ -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',
'<section id="hero" class="pt-32 pb-20 px-6">',
' <h1>{{ name }}</h1>',
'</section>',
'```',
].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: '<section id="hero" class="pt-32 pb-20 px-6">\n <h1>{{ name }}</h1>\n</section>',
}],
},
])
})
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',
'<section id="hero">',
'```',
].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: '<section id="hero">',
}],
},
])
})
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

View File

@ -1155,6 +1155,8 @@ export const useChatStore = create<ChatStore>((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]

View File

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