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',
+ '',
+ '```',
+ ].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: '',
+ }],
+ },
+ ])
+ })
+
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
}