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.
This commit is contained in:
Brennan Benson
2026-10-04 00:23:49 -07:00
parent ad7b1a9276
commit 5ff07e6730
12 changed files with 135 additions and 50 deletions
@@ -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)
})
})
@@ -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(
<NativeChatImageAttachmentPreview
attachment={{ id: 'm2', path: '', unavailableName: 'diagram.png' }}
onRemove={vi.fn()}
/>
)
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()
})
})
@@ -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 (
<div className="relative size-14 shrink-0">
<div className="relative h-14 max-w-40 shrink-0">
<div
role="img"
aria-label={explanation}
title={explanation}
className="flex size-full items-center justify-center rounded-md border border-dashed border-border bg-background"
className="flex h-full items-center gap-2 rounded-md border border-dashed border-border bg-background px-2"
>
<ImageOff className="size-5 text-muted-foreground" />
<ImageOff className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col text-xs">
<span className="truncate text-foreground">{label}</span>
<span className="truncate text-muted-foreground">{hint}</span>
</div>
</div>
<RemoveAttachmentButton onRemove={() => onRemove(id)} />
</div>
@@ -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()
@@ -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."
)
}
@@ -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')
+6 -3
View File
@@ -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.",
+6 -3
View File
@@ -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.",
+6 -3
View File
@@ -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.",
+6 -3
View File
@@ -17490,9 +17490,12 @@
"localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。",
"removeAttachment": "添付ファイルを削除する",
"pastedImageLabel": "貼り付けた画像",
"imageNotSavedWithDraft": "{{name}} はこの下書きに保存されませんでした。送信するにはもう一度添付してください。",
"attachImageAgain": "画像をもう一度添付するか、削除してください",
"attachImagesAgain": "画像をもう一度添付するか、削除してください",
"imageNotBroughtBack": "{{name}} をこの下書きと一緒に復元できませんでした。もう一度添付するか、削除してください。",
"pastedImageNotBroughtBack": "この貼り付けた画像をこの下書きと一緒に復元できませんでした。削除して、必要であればもう一度貼り付けてください。",
"removeImageToSend": "復元できなかった画像があります。送信するには削除してください。",
"removeImagesToSend": "復元できなかった画像があります。送信するには削除してください。",
"imageAttachAgainLabel": "もう一度添付",
"pastedImageNotKeptLabel": "保持されていません",
"imagePasteFailed": "画像の貼り付けに失敗しました。",
"worktreeNotReady": "ワークツリーが準備できていません — しばらくしてから再試行してください。",
"pasteUnavailable": "貼り付けできません — このチャットは現在入力を受け付けていません。",
+6 -3
View File
@@ -17490,9 +17490,12 @@
"localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.",
"removeAttachment": "첨부파일 삭제",
"pastedImageLabel": "붙여넣은 이미지",
"imageNotSavedWithDraft": "{{name}}은(는) 이 초안에 저장되지 않았습니다. 보내려면 다시 첨부하세요.",
"attachImageAgain": "이미지를 다시 첨부하거나 제거하세요",
"attachImagesAgain": "이미지를 다시 첨부하거나 제거하세요",
"imageNotBroughtBack": "{{name}}을(를) 이 초안과 함께 복원하지 못했습니다. 다시 첨부하거나 제거하세요.",
"pastedImageNotBroughtBack": "이 붙여넣은 이미지를 이 초안과 함께 복원하지 못했습니다. 제거한 후 필요하면 다시 붙여넣으세요.",
"removeImageToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.",
"removeImagesToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.",
"imageAttachAgainLabel": "다시 첨부",
"pastedImageNotKeptLabel": "보존되지 않음",
"imagePasteFailed": "이미지 붙여넣기 실패.",
"worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.",
"pasteUnavailable": "붙여넣을 수 없습니다 — 이 채팅은 지금 입력을 받을 수 없습니다.",
+6 -3
View File
@@ -17507,9 +17507,12 @@
"localAttachmentUnsupported": "远程会话不支持本地附件。",
"removeAttachment": "移除附件",
"pastedImageLabel": "粘贴的图片",
"imageNotSavedWithDraft": "{{name}} 未随此草稿保存。请重新附加后再发送。",
"attachImageAgain": "请重新附加该图片或将其移除",
"attachImagesAgain": "请重新附加这些图片或将其移除",
"imageNotBroughtBack": "无法随此草稿恢复 {{name}}。请重新附加或将其移除。",
"pastedImageNotBroughtBack": "无法随此草稿恢复这张粘贴的图片。请将其移除,如仍需要请重新粘贴。",
"removeImageToSend": "有一张图片无法恢复。请将其移除后再发送。",
"removeImagesToSend": "有些图片无法恢复。请将其移除后再发送。",
"imageAttachAgainLabel": "重新附加",
"pastedImageNotKeptLabel": "未保留",
"imagePasteFailed": "图片粘贴失败。",
"worktreeNotReady": "工作树未就绪 — 请稍后重试。",
"pasteUnavailable": "无法粘贴 — 此聊天当前不接受输入。",