mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(web): refill attachments when editing a queued or undone message (#1357)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* fix(web): refill attachments when editing a queued or undone message
Queued prompts that carry images/video are no longer remove-only: clicking one loads its text and attachments back into the composer. Undo ("edit & resend") now restores the message's attachments too, not just its text. useAttachmentUpload gains loadAttachments, which reuses the existing fileIds (no re-upload) and fetches authenticated blob URLs for protected getFileUrl previews so the refilled thumbnails don't 401.
* fix(web): forward attachment refills through ChatDock
When the normal chat dock is mounted (non-empty conversation), bindChatDock receives the ChatDock instance, but ChatDock only exposed loadForEdit/focus — so the new loadAttachmentsForEdit fell back to a no-op and editing a queued media prompt or undoing a media message still dropped the attachments. ChatDock now forwards loadAttachmentsForEdit to the underlying Composer.
* fix(web): replace composer attachments when refilling edits
loadForEdit(text) overwrites the composer text, but loadAttachments appended to any unsent draft attachments, so a later submit sent the stale draft files together with the edited message's files. Make loadAttachments replace the current session's attachments (revoking their object URLs) so an edit/undo replaces the whole composer, mirroring loadForEdit.
* fix(web): clear stale attachments on text-only edits
When the composer already has attachments loaded (for example after editing a queued media prompt) and the user then edits a text-only queued prompt or undoes a text-only message, loadComposerForEdit skipped loadAttachmentsForEdit (the only path that clears the strip) because the new attachment list was empty/undefined, so the next submit would send the stale media with the new text. Always call loadAttachmentsForEdit(attachments ?? []) so text-only edits replace the strip with an empty set.
* fix(web): make fileId-less refilled media resendable
When editing a user turn whose media was base64-inlined by the server (no fileId), loadAttachments used to add a non-uploading chip with no fileId, which handleSubmit silently drops on resend (and an image-only edit would not submit at all). Re-upload the data URL to obtain a fileId so the attachment is actually resendable; when re-upload is unavailable, skip the chip instead of showing a misleading ready attachment. Also factor a patchAttachment helper.
* chore: prefix web changeset entry
The gen-changesets rules require web-app changelog entries to start with 'web: ' so the synced release notes classify web UI changes correctly.
* fix(web): don't dequeue a prompt when the composer is hidden
When a queued media item is clicked while the dock is showing a pending question or approval, ChatDock has no nested Composer (only rendered in its v-else), so loadComposerForEdit no-ops — but handleEditQueued still dequeued the item, losing it. Make ChatDock.loadForEdit report whether the nested composer is present, have loadComposerForEdit return success, and only dequeue when the load actually succeeds.
* fix(web): preserve URL-backed media when refilling composer
When an undone turn contains media with source.kind === 'url' (a URL but no fileId), loadAttachments used to fall through and drop it. Re-upload the URL (data: or http(s):) to obtain a fileId so URL-backed media is preserved on edit/resend; if the URL can't be fetched (CORS / non-2xx) the chip is dropped instead of shown as a misleading ready attachment.
This commit is contained in:
parent
fbb9dc3772
commit
be7c9916b0
8 changed files with 261 additions and 29 deletions
5
.changeset/refill-composer-attachments.md
Normal file
5
.changeset/refill-composer-attachments.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
web: Fix queued media messages not loading back into the composer and keep attachments when undoing a message.
|
||||
|
|
@ -377,10 +377,13 @@ async function handleLoginSuccess(): Promise<void> {
|
|||
|
||||
// Edit + resend the last user message: undo the latest exchange on the daemon,
|
||||
// then drop that message's text back into the composer for editing.
|
||||
async function handleEditMessage(text: string): Promise<void> {
|
||||
async function handleEditMessage(payload: {
|
||||
text: string;
|
||||
images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
|
||||
}): Promise<void> {
|
||||
await client.undo(1);
|
||||
await nextTick();
|
||||
conversationPaneRef.value?.loadComposerForEdit(text);
|
||||
conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.images);
|
||||
}
|
||||
|
||||
// Handler for slash commands emitted by Composer (via ConversationPane)
|
||||
|
|
|
|||
|
|
@ -80,12 +80,25 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const composerRef = ref<{ loadForEdit: (value: string) => void; focus: () => void } | null>(null);
|
||||
const composerRef = ref<{
|
||||
loadForEdit: (value: string) => boolean;
|
||||
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
|
||||
focus: () => void;
|
||||
} | null>(null);
|
||||
const workPanelRef = ref<HTMLElement | null>(null);
|
||||
const workbarRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function loadForEdit(value: string): void {
|
||||
composerRef.value?.loadForEdit(value);
|
||||
function loadForEdit(value: string): boolean {
|
||||
// The nested Composer is only rendered in ChatDock's v-else — when a pending
|
||||
// question or approval is shown it is unmounted, so report unavailability so
|
||||
// the caller doesn't dequeue a prompt it can't actually load.
|
||||
if (!composerRef.value) return false;
|
||||
composerRef.value.loadForEdit(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
|
||||
composerRef.value?.loadAttachmentsForEdit(atts);
|
||||
}
|
||||
|
||||
function focus(): void {
|
||||
|
|
@ -117,7 +130,7 @@ onUnmounted(() => {
|
|||
}
|
||||
});
|
||||
|
||||
defineExpose({ loadForEdit, focus });
|
||||
defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ const emit = defineEmits<{
|
|||
/** Show an Edit/Write tool call's diff in the right-side panel. */
|
||||
openToolDiff: [id: string];
|
||||
/** Edit + resend the last user message (parent undoes, then refills composer). */
|
||||
editMessage: [text: string];
|
||||
editMessage: [payload: { text: string; images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[] }];
|
||||
/** Fetch the next older page of messages (triggered by top sentinel visibility or click). */
|
||||
loadOlderMessages: [];
|
||||
/** Remove a queued message by index. */
|
||||
|
|
@ -220,10 +220,10 @@ function hasImages(item: QueuedPromptView): boolean {
|
|||
return (item.attachments?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function onQueueEdit(index: number, item: QueuedPromptView): void {
|
||||
// Image-carrying prompts can't be round-tripped through the text composer, so
|
||||
// they are remove-only (matches the previous dock queue behaviour).
|
||||
if (hasImages(item)) return;
|
||||
function onQueueEdit(index: number): void {
|
||||
// Image/video attachments round-trip through the composer now (the composer
|
||||
// can hold fileIds), so a queued prompt can be loaded back for edit whether or
|
||||
// not it carries media.
|
||||
emit('editQueued', index);
|
||||
}
|
||||
|
||||
|
|
@ -353,7 +353,7 @@ async function onUndo(turn: ChatTurn): Promise<void> {
|
|||
function confirmEditMessage(turn: ChatTurn): void {
|
||||
if (undoingTurnId.value !== null) return;
|
||||
undoingTurnId.value = turn.id;
|
||||
emit('editMessage', turn.text);
|
||||
emit('editMessage', { text: turn.text, images: turn.images });
|
||||
// Fallback: if the server rewind never removes the turn (e.g. it failed),
|
||||
// release the guard so the user can retry.
|
||||
undoFallbackTimer = setTimeout(() => {
|
||||
|
|
@ -721,9 +721,8 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
|
|||
<button
|
||||
type="button"
|
||||
class="q-body"
|
||||
:title="hasImages(item) ? t('composer.queuedHasImage', { n: item.attachments?.length ?? 0 }) : t('composer.editQueued')"
|
||||
:disabled="hasImages(item)"
|
||||
@click="onQueueEdit(qi, item)"
|
||||
:title="t('composer.editQueued')"
|
||||
@click="onQueueEdit(qi)"
|
||||
>
|
||||
<span v-if="item.text" class="u-text q-text">{{ item.text }}</span>
|
||||
<span v-else class="q-text q-text-placeholder">
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ const {
|
|||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAfterSubmit,
|
||||
loadAttachments,
|
||||
} = useAttachmentUpload({ uploadImage: () => props.uploadImage, sessionId: () => props.sessionId });
|
||||
|
||||
// Silence noUnusedLocals: fileInputRef is used as a template ref (ref="fileInputRef").
|
||||
|
|
@ -277,7 +278,10 @@ function focus(): void {
|
|||
// or if focus is triggered during an animation/transition.
|
||||
textareaRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
defineExpose({ loadForEdit, focus });
|
||||
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
|
||||
loadAttachments(atts);
|
||||
}
|
||||
defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
|
||||
|
||||
function handleSubmit(): void {
|
||||
const trimmed = text.value.trim();
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const emit = defineEmits<{
|
|||
openChanges: [];
|
||||
refreshGitStatus: [];
|
||||
/** Edit + resend the last user message (App undoes, then refills composer). */
|
||||
editMessage: [text: string];
|
||||
editMessage: [payload: { text: string; images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[] }];
|
||||
/** Empty-composer workspace picker: start a new conversation elsewhere. */
|
||||
selectWorkspace: [workspaceId: string];
|
||||
/** Empty-composer workspace picker: create a new workspace. */
|
||||
|
|
@ -190,10 +190,24 @@ const copyConversationCopied = ref(false);
|
|||
const goalExpandSignal = ref(0);
|
||||
let copyConversationCopiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Load text into whichever composer is currently mounted (docked vs the
|
||||
empty-session composer). Used by App for "edit & resend the last message". */
|
||||
function loadComposerForEdit(value: string): void {
|
||||
(dockedComposerRef.value ?? emptyComposerRef.value)?.loadForEdit(value);
|
||||
/** Load text (and any attachments) into whichever composer is currently mounted
|
||||
(docked vs the empty-session composer). Used by App for "edit & resend the
|
||||
last message", and by the queue when a pending prompt is loaded for edit.
|
||||
Returns false when no composer is actually able to receive the content (e.g.
|
||||
the dock is showing a pending question/approval and the composer is hidden),
|
||||
so the caller can avoid dropping the prompt. */
|
||||
function loadComposerForEdit(
|
||||
value: string,
|
||||
attachments?: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[],
|
||||
): boolean {
|
||||
const composer = dockedComposerRef.value ?? emptyComposerRef.value;
|
||||
if (!composer) return false;
|
||||
// loadForEdit returns false when the dock's nested Composer is hidden; the
|
||||
// empty composer's loadForEdit returns void (treat as success).
|
||||
const ok = composer.loadForEdit(value);
|
||||
if (ok === false) return false;
|
||||
composer.loadAttachmentsForEdit(attachments ?? []);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCopyConversationCopied(): void {
|
||||
|
|
@ -365,7 +379,11 @@ const dockHeight = ref(0);
|
|||
const chatDockStyle = computed(() => ({
|
||||
'--panes-scrollbar-width': `${panesScrollbarWidth.value}px`,
|
||||
}));
|
||||
type ComposerHandle = { loadForEdit: (value: string) => void; focus: () => void };
|
||||
type ComposerHandle = {
|
||||
loadForEdit: (value: string) => boolean | void;
|
||||
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
|
||||
focus: () => void;
|
||||
};
|
||||
type RefArg = Element | (ComponentPublicInstance & Partial<ComposerHandle>) | null;
|
||||
|
||||
function toHtmlEl(el: RefArg): HTMLElement | null {
|
||||
|
|
@ -396,6 +414,10 @@ function bindChatDock(el: RefArg): void {
|
|||
) {
|
||||
dockedComposerRef.value = {
|
||||
loadForEdit: el.loadForEdit.bind(el),
|
||||
loadAttachmentsForEdit:
|
||||
'loadAttachmentsForEdit' in el && typeof el.loadAttachmentsForEdit === 'function'
|
||||
? el.loadAttachmentsForEdit.bind(el)
|
||||
: () => {},
|
||||
focus: el.focus.bind(el),
|
||||
};
|
||||
} else {
|
||||
|
|
@ -759,19 +781,26 @@ function handleComposerSubmit(payload: { text: string; attachments: { fileId: st
|
|||
// returns. Scrolling here would target the pre-rewind bottom and fight the
|
||||
// bubble-exit animation, so we only arm the follow state; the scrollKey watcher
|
||||
// smooth-scrolls once the truncated turns actually land.
|
||||
function handleEditMessage(text: string): void {
|
||||
function handleEditMessage(payload: {
|
||||
text: string;
|
||||
images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
|
||||
}): void {
|
||||
following.value = true;
|
||||
showPill.value = false;
|
||||
userActionFollowUntil = Date.now() + USER_ACTION_FOLLOW_LOCK_MS;
|
||||
emit('editMessage', text);
|
||||
emit('editMessage', payload);
|
||||
}
|
||||
|
||||
// A queued message was clicked for editing: load its text back into the active
|
||||
// composer, then let the parent dequeue it (mirrors the old dock-queue flow).
|
||||
// A queued message was clicked for editing: load its text (and any attachments)
|
||||
// back into the active composer, then let the parent dequeue it (mirrors the old
|
||||
// dock-queue flow). Only dequeue when the load actually succeeds — if the dock is
|
||||
// showing a pending question/approval the composer is hidden and the load no-ops,
|
||||
// so dequeuing would drop the prompt instead of making it editable.
|
||||
function handleEditQueued(index: number): void {
|
||||
const text = props.queued?.[index]?.text ?? '';
|
||||
if (text) loadComposerForEdit(text);
|
||||
emit('editQueued', index);
|
||||
const item = props.queued?.[index];
|
||||
const text = item?.text ?? '';
|
||||
const loaded = loadComposerForEdit(text, item?.attachments);
|
||||
if (loaded) emit('editQueued', index);
|
||||
}
|
||||
|
||||
function handleReorderQueue(payload: { from: number; to: number }): void {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
// paste listener + object-URL cleanup lifecycle.
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { getKimiWebApi } from '../api';
|
||||
|
||||
export interface Attachment {
|
||||
/** Unique local id (used as :key) */
|
||||
|
|
@ -208,6 +209,99 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
|
|||
setForSession(sid, []);
|
||||
}
|
||||
|
||||
function patchAttachment(sid: string, localId: string, patch: Partial<Attachment>): void {
|
||||
const current = attachmentsBySession.value[sid] ?? [];
|
||||
if (!current.some((a) => a.localId === localId)) return;
|
||||
setForSession(
|
||||
sid,
|
||||
current.map((a) => (a.localId === localId ? { ...a, ...patch } : a)),
|
||||
);
|
||||
}
|
||||
|
||||
function urlToBlob(url: string): Promise<Blob> {
|
||||
return fetch(url).then((r) => {
|
||||
if (!r.ok) throw new Error(`fetch failed: ${r.status}`);
|
||||
return r.blob();
|
||||
});
|
||||
}
|
||||
|
||||
/** Refill the attachment strip from already-uploaded files (used when a queued
|
||||
* prompt or an undone message is loaded back into the composer). The fileIds
|
||||
* are reused directly (no re-upload); for a protected getFileUrl preview we
|
||||
* fetch an authenticated blob URL so the thumbnail doesn't 401. Replaces any
|
||||
* unsent draft attachments (mirroring loadForEdit(text), which overwrites) so
|
||||
* a later submit sends exactly the edited message's files, not a mix. */
|
||||
function loadAttachments(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
|
||||
const sid = sessionId() ?? '';
|
||||
for (const existing of attachmentsBySession.value[sid] ?? []) revokeAttachment(existing);
|
||||
setForSession(sid, []);
|
||||
for (const att of atts) {
|
||||
const localId = nextLocalId();
|
||||
const isData = /^data:/i.test(att.url);
|
||||
const isBlob = /^blob:/i.test(att.url);
|
||||
const name = att.name ?? att.kind;
|
||||
|
||||
if (att.fileId) {
|
||||
// Ready as-is; fetch an authenticated thumbnail for protected URLs.
|
||||
const entry: Attachment = {
|
||||
localId,
|
||||
name,
|
||||
kind: att.kind,
|
||||
previewUrl: att.url,
|
||||
uploading: false,
|
||||
fileId: att.fileId,
|
||||
};
|
||||
setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), entry]);
|
||||
if (!isData && !isBlob) {
|
||||
void getKimiWebApi().getFileBlob(att.fileId).then((blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const current = attachmentsBySession.value[sid] ?? [];
|
||||
if (!current.some((a) => a.localId === localId)) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return;
|
||||
}
|
||||
patchAttachment(sid, localId, { previewUrl: blobUrl });
|
||||
}).catch(() => {
|
||||
// Keep the fallback previewUrl (honest broken state if it 401s).
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No fileId (e.g. a server-base64-inlined image, or a URL-backed source
|
||||
// from the wire/REST prompt path): re-upload the URL so the chip is
|
||||
// actually resendable — otherwise handleSubmit silently drops it. If the
|
||||
// URL can't be fetched (CORS / non-2xx) or upload is unavailable, skip
|
||||
// the chip rather than show a misleading ready attachment.
|
||||
const upload = uploadImage();
|
||||
if (!upload) continue;
|
||||
const entry: Attachment = {
|
||||
localId,
|
||||
name,
|
||||
kind: att.kind,
|
||||
previewUrl: att.url,
|
||||
uploading: true,
|
||||
};
|
||||
setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), entry]);
|
||||
void urlToBlob(att.url)
|
||||
.then((blob) => {
|
||||
const fname = name.includes('.') ? name : `${name}.${blob.type.split('/')[1] ?? 'bin'}`;
|
||||
return upload(blob, fname);
|
||||
})
|
||||
.then((result) => {
|
||||
if (result === null) {
|
||||
const current = attachmentsBySession.value[sid] ?? [];
|
||||
setForSession(sid, current.filter((a) => a.localId !== localId));
|
||||
return;
|
||||
}
|
||||
patchAttachment(sid, localId, { uploading: false, fileId: result.fileId });
|
||||
})
|
||||
.catch(() => {
|
||||
const current = attachmentsBySession.value[sid] ?? [];
|
||||
setForSession(sid, current.filter((a) => a.localId !== localId));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close the preview lightbox when switching sessions — it may reference an
|
||||
// attachment that belongs to the previous session.
|
||||
watch(sessionId, () => {
|
||||
|
|
@ -241,5 +335,6 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
|
|||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAfterSubmit,
|
||||
loadAttachments,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,90 @@ describe('useAttachmentUpload', () => {
|
|||
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('loadAttachments refills an already-uploaded attachment without re-uploading', () => {
|
||||
const att = setup(undefined);
|
||||
att.loadAttachments([
|
||||
{ fileId: 'f_existing', kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' },
|
||||
]);
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
expect(att.attachments.value[0]).toMatchObject({
|
||||
fileId: 'f_existing',
|
||||
kind: 'image',
|
||||
name: 'a.png',
|
||||
uploading: false,
|
||||
previewUrl: 'data:image/png;base64,AAAA',
|
||||
});
|
||||
});
|
||||
|
||||
it('loadAttachments replaces any unsent draft attachments instead of appending', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
|
||||
const att = setup(uploadImage);
|
||||
att.handleFileInputChange(inputEvent([imageFile('draft.png')]));
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
|
||||
att.loadAttachments([
|
||||
{ fileId: 'f_existing', kind: 'image', url: 'data:image/png;base64,AAAA', name: 'refill.png' },
|
||||
]);
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
expect(att.attachments.value[0].name).toBe('refill.png');
|
||||
});
|
||||
|
||||
it('loadAttachments with an empty list clears the attachment strip', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
|
||||
const att = setup(uploadImage);
|
||||
att.handleFileInputChange(inputEvent([imageFile('draft.png')]));
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
|
||||
att.loadAttachments([]);
|
||||
expect(att.attachments.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loadAttachments re-uploads a fileId-less data URL so it becomes resendable', async () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f_new', name: 'a.png', mediaType: 'image/png' });
|
||||
const att = setup(uploadImage);
|
||||
const blob = new Blob(['x'], { type: 'image/png' });
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }));
|
||||
|
||||
att.loadAttachments([{ kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' }]);
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
expect(att.attachments.value[0].uploading).toBe(true);
|
||||
|
||||
// Flush the fetch → blob → upload promise chain so the re-upload resolves.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(att.attachments.value[0].uploading).toBe(false);
|
||||
expect(att.attachments.value[0].fileId).toBe('f_new');
|
||||
expect(uploadImage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('loadAttachments skips a fileId-less data URL when re-upload is unavailable', () => {
|
||||
const att = setup(undefined);
|
||||
att.loadAttachments([{ kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' }]);
|
||||
expect(att.attachments.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loadAttachments re-uploads a fileId-less http URL so it becomes resendable', async () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f_http', name: 'x.png', mediaType: 'image/png' });
|
||||
const att = setup(uploadImage);
|
||||
const blob = new Blob(['x'], { type: 'image/png' });
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }));
|
||||
|
||||
att.loadAttachments([{ kind: 'image', url: 'https://example.test/x.png', name: 'x.png' }]);
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(att.attachments.value[0].fileId).toBe('f_http');
|
||||
});
|
||||
|
||||
it('loadAttachments drops a fileId-less URL whose fetch fails', async () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f_x', name: 'x.png', mediaType: 'image/png' });
|
||||
const att = setup(uploadImage);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 }));
|
||||
|
||||
att.loadAttachments([{ kind: 'image', url: 'https://example.test/protected.png', name: 'protected.png' }]);
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(att.attachments.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('isolates attachments between sessions', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
|
||||
const sessionId = ref<string | undefined>('sess-a');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue