fix(chat): restore a message's attached context on revert and fork
Review comments, quotes, terminal selections and annotations were consumed at send and never put back, so reverting pulled the message into the composer without the context it was sent with. Claude-Session: https://claude.ai/code/session_01TwLFeTfBnWdvbg9XyezZQx
This commit is contained in:
@@ -333,3 +333,78 @@ export function readContextPart(part: ContextCarrierPart): ContextPartPayload |
|
||||
export function hasContextParts(parts: ContextCarrierPart[]): boolean {
|
||||
return parts.some((part) => readContextPart(part) !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer draft a context payload came from, so reverting or forking a
|
||||
* message can put its attached context back on the chips instead of dropping
|
||||
* it. Linked issues/PRs have no draft form — they are owned by their own
|
||||
* pickers — so they map to null.
|
||||
*/
|
||||
export function draftFromContextPayload(
|
||||
payload: ContextPartPayload,
|
||||
): Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'> | null {
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const draft: Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'> = {
|
||||
source: payload.source,
|
||||
fileLabel: payload.fileLabel,
|
||||
startLine: payload.startLine,
|
||||
endLine: payload.endLine,
|
||||
code: payload.code,
|
||||
language: payload.language,
|
||||
text: payload.text,
|
||||
};
|
||||
if (payload.side) draft.side = payload.side;
|
||||
return draft;
|
||||
}
|
||||
case 'terminal':
|
||||
return {
|
||||
source: 'terminal',
|
||||
fileLabel: payload.terminalLabel,
|
||||
startLine: payload.startLine,
|
||||
endLine: payload.endLine,
|
||||
code: payload.output,
|
||||
language: '',
|
||||
text: '',
|
||||
terminalId: payload.terminalId,
|
||||
};
|
||||
case 'browser-annotation':
|
||||
return {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: payload.pageUrl,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
code: payload.prompt,
|
||||
language: '',
|
||||
text: payload.text,
|
||||
};
|
||||
case 'pr-comment':
|
||||
return { source: 'pr-comment', fileLabel: payload.label, startLine: 0, endLine: 0, code: payload.body, language: '', text: payload.text };
|
||||
case 'pr-check':
|
||||
return { source: 'pr-check', fileLabel: payload.label, startLine: 0, endLine: 0, code: payload.output, language: '', text: payload.text };
|
||||
case 'file-quote':
|
||||
return {
|
||||
source: 'file-quote',
|
||||
fileLabel: payload.fileLabel,
|
||||
startLine: payload.startLine ?? 0,
|
||||
endLine: payload.endLine ?? 0,
|
||||
code: payload.quote,
|
||||
language: '',
|
||||
text: payload.text,
|
||||
};
|
||||
case 'chat-quote':
|
||||
return {
|
||||
source: 'chat-quote',
|
||||
fileLabel: payload.messageId ?? '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
code: payload.quote,
|
||||
language: '',
|
||||
text: payload.text,
|
||||
};
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { registerSessionDirectory } from "./sync-refs"
|
||||
import { useGlobalSessionStatusStore } from "./global-session-status"
|
||||
import { recordSendFailure } from "./send-failure-log"
|
||||
import { isSyntheticPart } from "@/lib/messages/synthetic"
|
||||
import { draftFromContextPayload, readContextPart, type ContextCarrierPart } from "@/lib/messages/contextParts"
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from "@/stores/useInlineCommentDraftStore"
|
||||
import { materializeSessionSnapshots } from "./materialization"
|
||||
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { sessionEvents } from "@/lib/sessionEvents"
|
||||
@@ -598,6 +600,32 @@ function restoreFilePartsToInput(fileParts: Array<Record<string, unknown>>): voi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a message's attached context (review comments, quotes, terminal
|
||||
* selections, annotations) back on the composer chips.
|
||||
*
|
||||
* Context rides out as synthetic parts carrying structured metadata, so a
|
||||
* reverted or forked message can be rebuilt into the drafts it came from.
|
||||
* Without this the context is simply gone: the message is pulled back into the
|
||||
* composer with its text and files, but the comments attached to it are not.
|
||||
*
|
||||
* The target's existing drafts are replaced, matching how text and file
|
||||
* attachments are restored — the composer ends up as the message was sent.
|
||||
*/
|
||||
function restoreContextPartsToInput(
|
||||
parts: readonly ContextCarrierPart[],
|
||||
target: InlineCommentDraftTarget,
|
||||
): void {
|
||||
const store = useInlineCommentDraftStore.getState()
|
||||
store.clearDrafts(target)
|
||||
for (const part of parts) {
|
||||
const payload = readContextPart(part)
|
||||
if (!payload) continue
|
||||
const draft = draftFromContextPayload(payload)
|
||||
if (draft) store.addDraft(target, draft)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-confirmed directory that owns a session, from the session record
|
||||
* (`directory`, then `project.worktree`). Mirrors the authoritative source in
|
||||
@@ -1985,6 +2013,7 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
const targetMsg = messages.find((m) => m.id === messageId)
|
||||
let messageText = ""
|
||||
let submittedFileParts: Array<Record<string, unknown>> = []
|
||||
let submittedContextParts: readonly ContextCarrierPart[] = []
|
||||
if (targetMsg && targetMsg.role === "user") {
|
||||
const parts = state.part[messageId] ?? []
|
||||
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
|
||||
@@ -1996,6 +2025,9 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
// Exclude synthetic file parts (server-generated file content that should
|
||||
// not be restored to the composer).
|
||||
submittedFileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>>
|
||||
// Attached context (review comments, quotes, terminal selections) rides in
|
||||
// synthetic text parts and belongs back on the composer chips.
|
||||
submittedContextParts = parts
|
||||
}
|
||||
|
||||
// Optimistically set only the revert marker. Keep messages and parts in the
|
||||
@@ -2023,6 +2055,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
const prevInputAttachments = [...useInputStore.getState().attachedFiles]
|
||||
const prevInputText = useInputStore.getState().pendingInputText
|
||||
const prevInputMode = useInputStore.getState().pendingInputMode
|
||||
const draftTarget: InlineCommentDraftTarget | null = directory
|
||||
? { directory, sessionKey: sessionId }
|
||||
: null
|
||||
const prevDrafts = draftTarget ? useInlineCommentDraftStore.getState().getDrafts(draftTarget) : []
|
||||
|
||||
// Restore reverted message text and file attachments to input
|
||||
if (messageText) {
|
||||
@@ -2036,6 +2072,7 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
// Clear existing attachments first — previous revert's attachments
|
||||
// must not carry over, even when the current message has no files.
|
||||
restoreFilePartsToInput(submittedFileParts)
|
||||
if (draftTarget) restoreContextPartsToInput(submittedContextParts, draftTarget)
|
||||
|
||||
// Call SDK and merge authoritative result into store
|
||||
try {
|
||||
@@ -2070,6 +2107,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
pendingInputMode: prevInputMode,
|
||||
attachedFiles: prevInputAttachments,
|
||||
})
|
||||
if (draftTarget) {
|
||||
useInlineCommentDraftStore.getState().clearDrafts(draftTarget)
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(draftTarget, prevDrafts)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -2192,6 +2233,11 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
|
||||
}
|
||||
// Clear existing attachments and restore file parts from the forked message.
|
||||
restoreFilePartsToInput(fileParts)
|
||||
// The forked session is a fresh draft target, so the attached context of the
|
||||
// forked message follows the text into its composer.
|
||||
if (directory) {
|
||||
restoreContextPartsToInput(parts, { directory, sessionKey: forkedSession.id })
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user