mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -17490,9 +17490,12 @@
|
||||
"localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。",
|
||||
"removeAttachment": "添付ファイルを削除する",
|
||||
"pastedImageLabel": "貼り付けた画像",
|
||||
"imageNotSavedWithDraft": "{{name}} はこの下書きに保存されませんでした。送信するにはもう一度添付してください。",
|
||||
"attachImageAgain": "画像をもう一度添付するか、削除してください",
|
||||
"attachImagesAgain": "画像をもう一度添付するか、削除してください",
|
||||
"imageNotBroughtBack": "{{name}} をこの下書きと一緒に復元できませんでした。もう一度添付するか、削除してください。",
|
||||
"pastedImageNotBroughtBack": "この貼り付けた画像をこの下書きと一緒に復元できませんでした。削除して、必要であればもう一度貼り付けてください。",
|
||||
"removeImageToSend": "復元できなかった画像があります。送信するには削除してください。",
|
||||
"removeImagesToSend": "復元できなかった画像があります。送信するには削除してください。",
|
||||
"imageAttachAgainLabel": "もう一度添付",
|
||||
"pastedImageNotKeptLabel": "保持されていません",
|
||||
"imagePasteFailed": "画像の貼り付けに失敗しました。",
|
||||
"worktreeNotReady": "ワークツリーが準備できていません — しばらくしてから再試行してください。",
|
||||
"pasteUnavailable": "貼り付けできません — このチャットは現在入力を受け付けていません。",
|
||||
|
||||
@@ -17490,9 +17490,12 @@
|
||||
"localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.",
|
||||
"removeAttachment": "첨부파일 삭제",
|
||||
"pastedImageLabel": "붙여넣은 이미지",
|
||||
"imageNotSavedWithDraft": "{{name}}은(는) 이 초안에 저장되지 않았습니다. 보내려면 다시 첨부하세요.",
|
||||
"attachImageAgain": "이미지를 다시 첨부하거나 제거하세요",
|
||||
"attachImagesAgain": "이미지를 다시 첨부하거나 제거하세요",
|
||||
"imageNotBroughtBack": "{{name}}을(를) 이 초안과 함께 복원하지 못했습니다. 다시 첨부하거나 제거하세요.",
|
||||
"pastedImageNotBroughtBack": "이 붙여넣은 이미지를 이 초안과 함께 복원하지 못했습니다. 제거한 후 필요하면 다시 붙여넣으세요.",
|
||||
"removeImageToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.",
|
||||
"removeImagesToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.",
|
||||
"imageAttachAgainLabel": "다시 첨부",
|
||||
"pastedImageNotKeptLabel": "보존되지 않음",
|
||||
"imagePasteFailed": "이미지 붙여넣기 실패.",
|
||||
"worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.",
|
||||
"pasteUnavailable": "붙여넣을 수 없습니다 — 이 채팅은 지금 입력을 받을 수 없습니다.",
|
||||
|
||||
@@ -17507,9 +17507,12 @@
|
||||
"localAttachmentUnsupported": "远程会话不支持本地附件。",
|
||||
"removeAttachment": "移除附件",
|
||||
"pastedImageLabel": "粘贴的图片",
|
||||
"imageNotSavedWithDraft": "{{name}} 未随此草稿保存。请重新附加后再发送。",
|
||||
"attachImageAgain": "请重新附加该图片或将其移除",
|
||||
"attachImagesAgain": "请重新附加这些图片或将其移除",
|
||||
"imageNotBroughtBack": "无法随此草稿恢复 {{name}}。请重新附加或将其移除。",
|
||||
"pastedImageNotBroughtBack": "无法随此草稿恢复这张粘贴的图片。请将其移除,如仍需要请重新粘贴。",
|
||||
"removeImageToSend": "有一张图片无法恢复。请将其移除后再发送。",
|
||||
"removeImagesToSend": "有些图片无法恢复。请将其移除后再发送。",
|
||||
"imageAttachAgainLabel": "重新附加",
|
||||
"pastedImageNotKeptLabel": "未保留",
|
||||
"imagePasteFailed": "图片粘贴失败。",
|
||||
"worktreeNotReady": "工作树未就绪 — 请稍后重试。",
|
||||
"pasteUnavailable": "无法粘贴 — 此聊天当前不接受输入。",
|
||||
|
||||
Reference in New Issue
Block a user