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

* 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:
qer 2026-07-04 23:32:32 +08:00 committed by GitHub
parent fbb9dc3772
commit be7c9916b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 261 additions and 29 deletions

View 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.

View file

@ -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)

View file

@ -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>

View file

@ -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">

View file

@ -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();

View file

@ -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 {

View file

@ -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,
};
}

View file

@ -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');