Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/refill-composer-attachments.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,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)
Expand Down
21 changes: 17 additions & 4 deletions apps/kimi-web/src/components/chat/ChatDock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -117,7 +130,7 @@ onUnmounted(() => {
}
});

defineExpose({ loadForEdit, focus });
defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
</script>

<template>
Expand Down
17 changes: 8 additions & 9 deletions apps/kimi-web/src/components/chat/ChatPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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);
}

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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)"
Comment thread
wbxl2000 marked this conversation as resolved.
>
<span v-if="item.text" class="u-text q-text">{{ item.text }}</span>
<span v-else class="q-text q-text-placeholder">
Expand Down
6 changes: 5 additions & 1 deletion apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -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();
Expand Down
55 changes: 42 additions & 13 deletions apps/kimi-web/src/components/chat/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
: () => {},
Comment thread
wbxl2000 marked this conversation as resolved.
focus: el.focus.bind(el),
};
} else {
Expand Down Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions apps/kimi-web/src/composables/useAttachmentUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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, () => {
Expand Down Expand Up @@ -241,5 +335,6 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
handleDragLeave,
handleDrop,
clearAfterSubmit,
loadAttachments,
};
}
Loading
Loading