diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 7c436d52..7e87e7e5 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1652,7 +1652,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); } - if (!primaryText && additionalParts.length === 0) return; + if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return; // Clear queue and input if (currentSessionId && hasQueuedMessages) { diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts index 62984664..a5e81a8e 100644 --- a/packages/ui/src/sync/input-store.ts +++ b/packages/ui/src/sync/input-store.ts @@ -42,6 +42,22 @@ const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, 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 => { if (a === b) return true if (!a || !b) return false @@ -225,7 +241,7 @@ export const useInputStore = create()((set, get) => ({ dataUrl: url, mimeType, filename, - size: 0, + size: getDataUrlByteSize(url), source: "local", serverPath: url, } diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index ae10afdf..4cc48b80 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -116,6 +116,18 @@ function getSessionReplyClient(sessionId?: string): OpencodeClient { return sdk() } +function restoreFilePartsToInput(fileParts: Array>): 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( type: "permission" | "question", sessionId: string, @@ -589,10 +601,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro .map((p: Record) => (p as { text?: string }).text || (p as { content?: string }).content || "") .join("\n") .trim() - // Snapshot file parts for later restoration to the input (line ~626). - // File parts (type="file") contain url/mime/filename — the file already - // exists on the server so we record it as a "server" source attachment. - submittedFileParts = parts.filter((p) => p.type === "file") as Array> + // Snapshot file parts for later restoration to the input. + // 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> } // 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. // Clear existing attachments first — previous revert's attachments // must not carry over, even when the current message has no files. - useInputStore.getState().clearAttachedFiles() - for (const fp of submittedFileParts) { - const f = fp as Record - 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 }) - } - } + restoreFilePartsToInput(submittedFileParts) // Call SDK and merge authoritative result into store try { @@ -745,8 +748,10 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro const store = dirStore() const state = store.getState() - // Extract message text for input restoration (only non-synthetic text parts — - // the server adds file content as synthetic text parts that should not be restored) + // Extract message text and file attachments for input restoration. + // 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] ?? [] let messageText = "" 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).text as string) || ((p as Record).content as string) || "") .join("\n") .trim() + const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array> const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId }) if (!result.data) return @@ -772,11 +778,13 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro // Switch to new session useSessionUIStore.getState().setCurrentSession(forkedSession.id) - // Restore forked message text to input + // Restore forked message text and file attachments to input if (messageText) { useInputStore.setState({ pendingInputText: messageText, pendingInputMode: "replace" as const, }) } + // Clear existing attachments and restore file parts from the forked message. + restoreFilePartsToInput(fileParts) } diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 9e367fe1..70d6236a 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -485,6 +485,11 @@ export const useSessionUIStore = create()((set, get) => ({ 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) { useInputStore.getState().setPendingInputText(options.initialPrompt) }