From 5ff07e67306fa239af1fb1083320bbd5b2e377b9 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:23:49 -0700
Subject: [PATCH] fix(native-chat): placeholder chips say what happened and
only a user attach replaces one
A placeholder now shows the image's name and a short hint on the chip
itself, "Attach again" for a file and "Not kept" for a pasted image, with
the longer explanation in its tooltip. The copy no longer says the image
wasn't saved: it says it couldn't be brought back, and for a pasted image
it asks only for removal, since a new paste can't match it. The send
button now says to remove the image to send.
Only an image the user attaches (picking, dropping or pasting) takes the
place of a placeholder with its file name. An image Stop gives back is
added beside it, so a different file with the same name no longer hides
the reminder.
---
.../NativeChatComposerActions.test.tsx | 6 ++--
.../NativeChatImageAttachmentPreview.test.tsx | 25 ++++++++++++--
.../NativeChatImageAttachmentPreview.tsx | 33 ++++++++++++++-----
.../native-chat-composer-draft-store.test.ts | 28 ++++++++++++++--
.../native-chat/native-chat-image-reattach.ts | 11 ++++---
.../use-native-chat-composer-attachments.ts | 28 +++++++++-------
src/renderer/src/i18n/locales/en.json | 9 +++--
src/renderer/src/i18n/locales/es.json | 9 +++--
src/renderer/src/i18n/locales/fr.json | 9 +++--
src/renderer/src/i18n/locales/ja.json | 9 +++--
src/renderer/src/i18n/locales/ko.json | 9 +++--
src/renderer/src/i18n/locales/zh.json | 9 +++--
12 files changed, 135 insertions(+), 50 deletions(-)
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx
index ae315b4530a..ecffdbe9f94 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx
@@ -121,7 +121,7 @@ describe('NativeChatComposerActions', () => {
attachDisabled={false}
dictationDisabled={false}
sendDisabled
- sendBlockedReason="Attach the image again or remove it"
+ sendBlockedReason="An image couldn't be brought back. Remove it to send."
isWorking={false}
isDictating={false}
isDictationHoldMode={false}
@@ -135,7 +135,9 @@ describe('NativeChatComposerActions', () => {
/>
)
- const send = screen.getByRole('button', { name: 'Attach the image again or remove it' })
+ const send = screen.getByRole('button', {
+ name: "An image couldn't be brought back. Remove it to send."
+ })
expect(send.hasAttribute('disabled')).toBe(true)
})
})
diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx
index e2ac63dda29..879c71bc991 100644
--- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx
@@ -54,7 +54,7 @@ describe('NativeChatImageAttachmentPreview', () => {
expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined)
})
- it('shows an image the draft could not keep as one to attach again, and lets it be removed', () => {
+ it('shows a pasted image that could not come back by name, says it was not kept, and lets it be removed', () => {
mocks.useLocalImageSrc.mockReturnValue(undefined)
const onRemove = vi.fn()
vi.stubGlobal('IntersectionObserver', undefined)
@@ -67,10 +67,31 @@ describe('NativeChatImageAttachmentPreview', () => {
expect(
screen.getByRole('img', {
- name: "Pasted image wasn't saved with this draft. Attach it again to send it."
+ name: "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it."
})
).toBeTruthy()
+ expect(screen.getByText('Pasted image')).toBeTruthy()
+ expect(screen.getByText('Not kept')).toBeTruthy()
screen.getByRole('button', { name: 'Remove attachment' }).click()
expect(onRemove).toHaveBeenCalledWith('m1')
})
+
+ it('shows a file that could not come back by name, with a visible attach-again hint', () => {
+ mocks.useLocalImageSrc.mockReturnValue(undefined)
+ vi.stubGlobal('IntersectionObserver', undefined)
+ render(
+
+ )
+
+ expect(
+ screen.getByRole('img', {
+ name: "diagram.png couldn't be brought back with this draft. Attach it again or remove it."
+ })
+ ).toBeTruthy()
+ expect(screen.getByText('diagram.png')).toBeTruthy()
+ expect(screen.getByText('Attach again')).toBeTruthy()
+ })
})
diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx
index f27687af0c3..f1ce3d2c730 100644
--- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx
@@ -51,7 +51,8 @@ function RemoveAttachmentButton({ onRemove }: { onRemove: () => void }): React.J
)
}
-/** An image the draft names but couldn't keep: it waits to be attached again, or removed. */
+/** An image the draft names but couldn't bring back. A file can be attached again in its place; a
+ * pasted image can't be matched by a new paste, so its copy asks only for removal. */
function NativeChatUnavailableImageChip({
id,
name,
@@ -61,20 +62,34 @@ function NativeChatUnavailableImageChip({
name: string
onRemove: (id: string) => void
}): React.JSX.Element {
- const explanation = translate(
- 'components.native-chat.composer.imageNotSavedWithDraft',
- "{{name}} wasn't saved with this draft. Attach it again to send it.",
- { name: attachmentLabel(name) }
- )
+ const pasted = isNativeChatPastedImagePath(name)
+ const label = attachmentLabel(name)
+ const explanation = pasted
+ ? translate(
+ 'components.native-chat.composer.pastedImageNotBroughtBack',
+ "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it."
+ )
+ : translate(
+ 'components.native-chat.composer.imageNotBroughtBack',
+ "{{name}} couldn't be brought back with this draft. Attach it again or remove it.",
+ { name: label }
+ )
+ const hint = pasted
+ ? translate('components.native-chat.composer.pastedImageNotKeptLabel', 'Not kept')
+ : translate('components.native-chat.composer.imageAttachAgainLabel', 'Attach again')
return (
-
+
-
+
+
+ {label}
+ {hint}
+
onRemove(id)} />
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts
index 6bf65ee8ab2..f8a7ec1f830 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts
@@ -203,15 +203,37 @@ describe('native-chat composer draft store', () => {
)
const reloaded = await reload()
- reloaded.attachments.appendNativeChatAttachmentCache('tab-1:pane', [
- { id: 'again', path: '/Users/me/Desktop/shot.png' }
- ])
+ reloaded.attachments.appendNativeChatAttachmentCache(
+ 'tab-1:pane',
+ [{ id: 'again', path: '/Users/me/Desktop/shot.png' }],
+ { fromUser: true }
+ )
expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([
{ id: 'again', path: '/Users/me/Desktop/shot.png' },
IMAGES[0]
])
})
+ it('adds an image Stop gives back next to a placeholder with its name, never in its place', async () => {
+ storage.setItem(
+ `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}`,
+ JSON.stringify({
+ text: 'compare with this',
+ images: [{ id: 'm', path: '', unavailableName: 'image.png' }],
+ savedAt: 1
+ })
+ )
+ const reloaded = await reload()
+
+ reloaded.attachments.appendNativeChatAttachmentCache('tab-1:pane', [
+ { id: 'withdrawn-cm-1-1', path: '/Users/me/Downloads/image.png' }
+ ])
+ expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([
+ { id: 'm', path: '', unavailableName: 'image.png' },
+ { id: 'withdrawn-cm-1-1', path: '/Users/me/Downloads/image.png' }
+ ])
+ })
+
it('appends a given-back message after a draft restored from a reload', async () => {
modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'mine')
modules.store.flushNativeChatComposerDrafts()
diff --git a/src/renderer/src/components/native-chat/native-chat-image-reattach.ts b/src/renderer/src/components/native-chat/native-chat-image-reattach.ts
index 3591eb3487e..29d35cf90e9 100644
--- a/src/renderer/src/components/native-chat/native-chat-image-reattach.ts
+++ b/src/renderer/src/components/native-chat/native-chat-image-reattach.ts
@@ -1,7 +1,8 @@
import { translate } from '@/i18n/i18n'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
-/** Why the composer can't send while it holds images Orca couldn't keep; null when it holds none. */
+/** Why the composer can't send while it holds images that couldn't be brought back; null when it
+ * holds none. Removing always works; re-attaching only for a file, so the reason names removing. */
export function nativeChatAttachImagesAgainReason(
attachments: readonly NativeChatComposerImageAttachment[]
): string | null {
@@ -11,12 +12,12 @@ export function nativeChatAttachImagesAgainReason(
}
return count === 1
? translate(
- 'components.native-chat.composer.attachImageAgain',
- 'Attach the image again or remove it'
+ 'components.native-chat.composer.removeImageToSend',
+ "An image couldn't be brought back. Remove it to send."
)
: translate(
- 'components.native-chat.composer.attachImagesAgain',
- 'Attach the images again or remove them'
+ 'components.native-chat.composer.removeImagesToSend',
+ "Some images couldn't be brought back. Remove them to send."
)
}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
index 21b37ddbb7c..f2049ba88a2 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
@@ -157,7 +157,8 @@ export function useNativeChatComposerAttachments({
id: nextAttachmentId(),
path,
...(connectionId ? { connectionId } : {})
- }))
+ })),
+ { fromUser: true }
)
},
[attachmentScopeKey, nextAttachmentId]
@@ -233,9 +234,11 @@ export function useNativeChatComposerAttachments({
const resolvePendingImageAttachment = useCallback(
(id: string, path: string, connectionId?: string | null) => {
if (forgetLocalAttachment(id, true)) {
- appendNativeChatAttachmentCache(attachmentScopeKey, [
- { id, path, ...(connectionId ? { connectionId } : {}) }
- ])
+ appendNativeChatAttachmentCache(
+ attachmentScopeKey,
+ [{ id, path, ...(connectionId ? { connectionId } : {}) }],
+ { fromUser: true }
+ )
}
},
[attachmentScopeKey, forgetLocalAttachment]
@@ -298,12 +301,13 @@ export function readNativeChatAttachmentCache(
return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image }))
}
-/** Adds settled images after the ones the draft holds now; one with the name of an image to
- * attach again takes its place. Saved at once: when Stop gives images back, the copy they came
- * from goes right after this. */
+/** Adds settled images after the ones the draft holds now. Saved at once: when Stop gives images
+ * back, the copy they came from goes right after this. Only an image the user attaches
+ * (`fromUser`) takes the place of a placeholder with its file name, as a re-pick does. */
export function appendNativeChatAttachmentCache(
scopeKey: string,
- appended: readonly NativeChatComposerImageAttachment[]
+ appended: readonly NativeChatComposerImageAttachment[],
+ options?: { fromUser?: boolean }
): void {
if (appended.length === 0) {
return
@@ -312,11 +316,13 @@ export function appendNativeChatAttachmentCache(
for (const { id, path, connectionId } of appended) {
// Preview URLs can retain the full clipboard Blob, so only the path is kept.
const image = { id, path, ...(connectionId ? { connectionId } : {}) }
- const marker = images.findIndex((held) => held.unavailableName === basename(path))
- if (marker === -1) {
+ const placeholder = options?.fromUser
+ ? images.findIndex((held) => held.unavailableName === basename(path))
+ : -1
+ if (placeholder === -1) {
images.push(image)
} else {
- images[marker] = image
+ images[placeholder] = image
}
}
updateNativeChatComposerDraft(scopeKey, { images }, 'immediate')
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index b803ccda70d..7fa1cbcf2db 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -17765,9 +17765,12 @@
"localAttachmentUnsupported": "Local attachments are not available for remote sessions.",
"removeAttachment": "Remove attachment",
"pastedImageLabel": "Pasted image",
- "imageNotSavedWithDraft": "{{name}} wasn't saved with this draft. Attach it again to send it.",
- "attachImageAgain": "Attach the image again or remove it",
- "attachImagesAgain": "Attach the images again or remove them",
+ "imageNotBroughtBack": "{{name}} couldn't be brought back with this draft. Attach it again or remove it.",
+ "pastedImageNotBroughtBack": "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it.",
+ "removeImageToSend": "An image couldn't be brought back. Remove it to send.",
+ "removeImagesToSend": "Some images couldn't be brought back. Remove them to send.",
+ "imageAttachAgainLabel": "Attach again",
+ "pastedImageNotKeptLabel": "Not kept",
"imagePasteFailed": "Image paste failed.",
"imageSaving": "Saving pasted image…",
"worktreeNotReady": "Worktree not ready — try again in a moment.",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index d89dff132b9..ab4630b73a4 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -15084,9 +15084,12 @@
"localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.",
"removeAttachment": "Quitar archivo adjunto",
"pastedImageLabel": "Imagen pegada",
- "imageNotSavedWithDraft": "{{name}} no se guardó con este borrador. Adjúntala de nuevo para enviarla.",
- "attachImageAgain": "Adjunta la imagen de nuevo o quítala",
- "attachImagesAgain": "Adjunta las imágenes de nuevo o quítalas",
+ "imageNotBroughtBack": "No se pudo recuperar la imagen {{name}} con este borrador. Vuelve a adjuntarla o quítala.",
+ "pastedImageNotBroughtBack": "No se pudo recuperar esta imagen pegada con este borrador. Quítala y vuelve a pegarla si aún la necesitas.",
+ "removeImageToSend": "No se pudo recuperar una imagen. Quítala para enviar.",
+ "removeImagesToSend": "No se pudieron recuperar algunas imágenes. Quítalas para enviar.",
+ "imageAttachAgainLabel": "Adjuntar de nuevo",
+ "pastedImageNotKeptLabel": "No se conservó",
"imagePasteFailed": "Error al pegar imagen.",
"worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.",
"pasteUnavailable": "No se puede pegar — este chat no acepta entradas en este momento.",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index f5b6686a760..f0ddc4f86d7 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -17568,9 +17568,12 @@
"localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.",
"removeAttachment": "Retirer la pièce jointe",
"pastedImageLabel": "Image collée",
- "imageNotSavedWithDraft": "{{name}} n'a pas été enregistrée avec ce brouillon. Joignez-la à nouveau pour l'envoyer.",
- "attachImageAgain": "Joignez à nouveau l'image ou retirez-la",
- "attachImagesAgain": "Joignez à nouveau les images ou retirez-les",
+ "imageNotBroughtBack": "L'image {{name}} n'a pas pu être récupérée avec ce brouillon. Joignez-la à nouveau ou retirez-la.",
+ "pastedImageNotBroughtBack": "Cette image collée n'a pas pu être récupérée avec ce brouillon. Retirez-la, puis collez-la à nouveau si vous en avez encore besoin.",
+ "removeImageToSend": "Une image n'a pas pu être récupérée. Retirez-la pour envoyer.",
+ "removeImagesToSend": "Certaines images n'ont pas pu être récupérées. Retirez-les pour envoyer.",
+ "imageAttachAgainLabel": "Joindre à nouveau",
+ "pastedImageNotKeptLabel": "Non conservée",
"imagePasteFailed": "Échec du collage de l'image.",
"worktreeNotReady": "Worktree pas encore prêt — réessayez dans un instant.",
"pasteUnavailable": "Impossible de coller — ce chat n'accepte pas de saisie pour le moment.",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 2c204b78840..67443dcb9ac 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -17490,9 +17490,12 @@
"localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。",
"removeAttachment": "添付ファイルを削除する",
"pastedImageLabel": "貼り付けた画像",
- "imageNotSavedWithDraft": "{{name}} はこの下書きに保存されませんでした。送信するにはもう一度添付してください。",
- "attachImageAgain": "画像をもう一度添付するか、削除してください",
- "attachImagesAgain": "画像をもう一度添付するか、削除してください",
+ "imageNotBroughtBack": "{{name}} をこの下書きと一緒に復元できませんでした。もう一度添付するか、削除してください。",
+ "pastedImageNotBroughtBack": "この貼り付けた画像をこの下書きと一緒に復元できませんでした。削除して、必要であればもう一度貼り付けてください。",
+ "removeImageToSend": "復元できなかった画像があります。送信するには削除してください。",
+ "removeImagesToSend": "復元できなかった画像があります。送信するには削除してください。",
+ "imageAttachAgainLabel": "もう一度添付",
+ "pastedImageNotKeptLabel": "保持されていません",
"imagePasteFailed": "画像の貼り付けに失敗しました。",
"worktreeNotReady": "ワークツリーが準備できていません — しばらくしてから再試行してください。",
"pasteUnavailable": "貼り付けできません — このチャットは現在入力を受け付けていません。",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 3a8e5a3b822..4828e4e3d4a 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -17490,9 +17490,12 @@
"localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.",
"removeAttachment": "첨부파일 삭제",
"pastedImageLabel": "붙여넣은 이미지",
- "imageNotSavedWithDraft": "{{name}}은(는) 이 초안에 저장되지 않았습니다. 보내려면 다시 첨부하세요.",
- "attachImageAgain": "이미지를 다시 첨부하거나 제거하세요",
- "attachImagesAgain": "이미지를 다시 첨부하거나 제거하세요",
+ "imageNotBroughtBack": "{{name}}을(를) 이 초안과 함께 복원하지 못했습니다. 다시 첨부하거나 제거하세요.",
+ "pastedImageNotBroughtBack": "이 붙여넣은 이미지를 이 초안과 함께 복원하지 못했습니다. 제거한 후 필요하면 다시 붙여넣으세요.",
+ "removeImageToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.",
+ "removeImagesToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.",
+ "imageAttachAgainLabel": "다시 첨부",
+ "pastedImageNotKeptLabel": "보존되지 않음",
"imagePasteFailed": "이미지 붙여넣기 실패.",
"worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.",
"pasteUnavailable": "붙여넣을 수 없습니다 — 이 채팅은 지금 입력을 받을 수 없습니다.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index b7af46e09fd..299df53d235 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -17507,9 +17507,12 @@
"localAttachmentUnsupported": "远程会话不支持本地附件。",
"removeAttachment": "移除附件",
"pastedImageLabel": "粘贴的图片",
- "imageNotSavedWithDraft": "{{name}} 未随此草稿保存。请重新附加后再发送。",
- "attachImageAgain": "请重新附加该图片或将其移除",
- "attachImagesAgain": "请重新附加这些图片或将其移除",
+ "imageNotBroughtBack": "无法随此草稿恢复 {{name}}。请重新附加或将其移除。",
+ "pastedImageNotBroughtBack": "无法随此草稿恢复这张粘贴的图片。请将其移除,如仍需要请重新粘贴。",
+ "removeImageToSend": "有一张图片无法恢复。请将其移除后再发送。",
+ "removeImagesToSend": "有些图片无法恢复。请将其移除后再发送。",
+ "imageAttachAgainLabel": "重新附加",
+ "pastedImageNotKeptLabel": "未保留",
"imagePasteFailed": "图片粘贴失败。",
"worktreeNotReady": "工作树未就绪 — 请稍后重试。",
"pasteUnavailable": "无法粘贴 — 此聊天当前不接受输入。",