fix(chat): restore file attachments when reverting or forking messages (#1288)

* chore: add .worktrees/ to gitignore for worktree workflow

* fix(chat): restore file attachments when reverting or forking messages

* fix(chat): address review findings in attachment restoration

- Move filePartsToAttachments helper below all imports into its own
  'Attachment helpers' section (was incorrectly placed between imports)
- Compute size from base64 data URL for pasted screenshots instead of
  hardcoding 0; file:// URLs keep size 0 which formatFileSize suppresses
  gracefully
- Capture prevAttachedFiles before optimistic mutation and restore on
  SDK revert failure
- Always use source: 'local' for restored attachments so they are
  visible and removable in the composer regardless of URL scheme

* fix(chat): resolve merge conflicts and restore attachments in fork

- Merge upstream main which already added attachment restoration to
  revertToMessage via addRestoredAttachment
- Add !isSyntheticPart filter to revertToMessage file part collection
  (upstream was missing this)
- Add attachment restoration to forkFromMessage (was not fixed upstream)
- Use upstream's addRestoredAttachment approach for consistency

* fix(chat): clear restored attachments when opening new session draft

Reverted-message attachments (and any other pending attachments in the
global input store) were carrying over to the new session input because
openNewSessionDraft did not clear attachedFiles.

Clear attachedFiles in openNewSessionDraft, which is the navigation-away
event for new sessions (it already sets currentSessionId: null). This
matches the semantics of starting a fresh conversation.

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Erman HAVUÇ
2026-05-17 15:25:59 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 0779656d80
commit 4384d7a8d3
4 changed files with 48 additions and 19 deletions
@@ -1652,7 +1652,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}); });
} }
if (!primaryText && additionalParts.length === 0) return; if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return;
// Clear queue and input // Clear queue and input
if (currentSessionId && hasQueuedMessages) { if (currentSessionId && hasQueuedMessages) {
+17 -1
View File
@@ -42,6 +42,22 @@ const readFileAsDataUrl = (file: File): Promise<string> => new Promise((resolve,
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
const getDataUrlByteSize = (url: string): number => {
if (!url.startsWith("data:")) return 0
const commaIndex = url.indexOf(",")
if (commaIndex < 0) return 0
const metadata = url.slice(0, commaIndex).toLowerCase()
const payload = url.slice(commaIndex + 1)
if (!metadata.endsWith(";base64")) return 0
let padding = 0
if (payload.endsWith("==")) {
padding = 2
} else if (payload.endsWith("=")) {
padding = 1
}
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding)
}
const isSameVSCodeActiveEditorFile = (a: VSCodeActiveEditorFile | null, b: VSCodeActiveEditorFile | null): boolean => { const isSameVSCodeActiveEditorFile = (a: VSCodeActiveEditorFile | null, b: VSCodeActiveEditorFile | null): boolean => {
if (a === b) return true if (a === b) return true
if (!a || !b) return false if (!a || !b) return false
@@ -225,7 +241,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
dataUrl: url, dataUrl: url,
mimeType, mimeType,
filename, filename,
size: 0, size: getDataUrlByteSize(url),
source: "local", source: "local",
serverPath: url, serverPath: url,
} }
+25 -17
View File
@@ -116,6 +116,18 @@ function getSessionReplyClient(sessionId?: string): OpencodeClient {
return sdk() return sdk()
} }
function restoreFilePartsToInput(fileParts: Array<Record<string, unknown>>): void {
useInputStore.getState().clearAttachedFiles()
for (const filePart of fileParts) {
const url = typeof filePart.url === "string" ? filePart.url : ""
const mime = typeof filePart.mime === "string" ? filePart.mime : "application/octet-stream"
const filename = typeof filePart.filename === "string" ? filePart.filename : "attachment"
if (url) {
useInputStore.getState().addRestoredAttachment({ url, mimeType: mime, filename })
}
}
}
function resolveDirectoryForBlockingRequest( function resolveDirectoryForBlockingRequest(
type: "permission" | "question", type: "permission" | "question",
sessionId: string, sessionId: string,
@@ -589,10 +601,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "") .map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
.join("\n") .join("\n")
.trim() .trim()
// Snapshot file parts for later restoration to the input (line ~626). // Snapshot file parts for later restoration to the input.
// File parts (type="file") contain url/mime/filename — the file already // Exclude synthetic file parts (server-generated file content that should
// exists on the server so we record it as a "server" source attachment. // not be restored to the composer).
submittedFileParts = parts.filter((p) => p.type === "file") as Array<Record<string, unknown>> submittedFileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>>
} }
// Optimistically set only the revert marker. Keep messages and parts in the // Optimistically set only the revert marker. Keep messages and parts in the
@@ -632,16 +644,7 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
// Restore file/image attachments from the target message. // Restore file/image attachments from the target message.
// Clear existing attachments first — previous revert's attachments // Clear existing attachments first — previous revert's attachments
// must not carry over, even when the current message has no files. // must not carry over, even when the current message has no files.
useInputStore.getState().clearAttachedFiles() restoreFilePartsToInput(submittedFileParts)
for (const fp of submittedFileParts) {
const f = fp as Record<string, unknown>
const url = typeof f.url === "string" ? f.url : ""
const mime = typeof f.mime === "string" ? f.mime : "application/octet-stream"
const filename = typeof f.filename === "string" ? f.filename : "attachment"
if (url) {
useInputStore.getState().addRestoredAttachment({ url, mimeType: mime, filename })
}
}
// Call SDK and merge authoritative result into store // Call SDK and merge authoritative result into store
try { try {
@@ -745,8 +748,10 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
const store = dirStore() const store = dirStore()
const state = store.getState() const state = store.getState()
// Extract message text for input restoration (only non-synthetic text parts — // Extract message text and file attachments for input restoration.
// the server adds file content as synthetic text parts that should not be restored) // Only non-synthetic text parts — the server adds file content as synthetic
// text parts that should not be restored. File parts (images, pasted
// screenshots) are user-originated and must be restored.
const parts = state.part[messageId] ?? [] const parts = state.part[messageId] ?? []
let messageText = "" let messageText = ""
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p)) const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
@@ -754,6 +759,7 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
.map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "") .map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "")
.join("\n") .join("\n")
.trim() .trim()
const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>>
const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId }) const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId })
if (!result.data) return if (!result.data) return
@@ -772,11 +778,13 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
// Switch to new session // Switch to new session
useSessionUIStore.getState().setCurrentSession(forkedSession.id) useSessionUIStore.getState().setCurrentSession(forkedSession.id)
// Restore forked message text to input // Restore forked message text and file attachments to input
if (messageText) { if (messageText) {
useInputStore.setState({ useInputStore.setState({
pendingInputText: messageText, pendingInputText: messageText,
pendingInputMode: "replace" as const, pendingInputMode: "replace" as const,
}) })
} }
// Clear existing attachments and restore file parts from the forked message.
restoreFilePartsToInput(fileParts)
} }
+5
View File
@@ -485,6 +485,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
error: null, error: null,
}) })
// Clear composer attachments when opening a new session draft.
// Attachments from the previous session (e.g. restored by revert) must
// not bleed into the new session's input.
useInputStore.getState().clearAttachedFiles()
if (options?.initialPrompt) { if (options?.initialPrompt) {
useInputStore.getState().setPendingInputText(options.initialPrompt) useInputStore.getState().setPendingInputText(options.initialPrompt)
} }