fix(web): show an image placeholder for image-only queued prompts

A prompt queued while the agent is busy can carry image attachments
with no text; the queue strip rendered its bare text — an empty string,
so the row was just a blank button next to a remove cross. Queue items
now expose {text, attachmentCount}: image-only prompts render an
'image ×N' placeholder with a small badge, and any item carrying
images disables the load-back-into-input edit action (the uploaded
files can't be restored to the composer; editing would silently drop
them — remove stays available). Verified with component render tests
for the three shapes (image-only / text-only / text+images).
This commit is contained in:
qer 2026-06-10 12:47:07 +08:00 committed by haozhe.yang
parent d992719560
commit d65b499084
6 changed files with 49 additions and 14 deletions

View file

@ -7,7 +7,7 @@ import MentionMenu from './MentionMenu.vue';
import type { SlashCommand } from '../lib/slashCommands';
import { filterCommands, parseSlash } from '../lib/slashCommands';
import type { FileItem } from './MentionMenu.vue';
import type { ConversationStatus, PermissionMode } from '../types';
import type { ConversationStatus, PermissionMode, QueuedPromptView } from '../types';
import type { AppModel, ThinkingLevel } from '../api/types';
// ---------------------------------------------------------------------------
@ -35,7 +35,7 @@ interface Attachment {
const props = withDefaults(defineProps<{
running?: boolean;
queued?: string[];
queued?: QueuedPromptView[];
searchFiles?: (q: string) => Promise<FileItem[]>;
/** If undefined, attach button is hidden and paste/drag are no-ops. */
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
@ -592,7 +592,10 @@ function selectModel(modelId: string): void {
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<!-- Queued message strip -->
<!-- Queued message strip. Items carrying image attachments can't be
edited (the uploaded files can't be loaded back into the input);
they show an image badge and an "image ×N" placeholder when the
prompt has no text. -->
<div v-if="queued && queued.length > 0" class="queue-strip">
<span class="queue-label">{{ t('composer.queueLabel') }}</span>
<div
@ -603,9 +606,13 @@ function selectModel(modelId: string): void {
<button
class="queue-text"
type="button"
:title="t('composer.editQueued')"
@click="editQueued(i, msg)"
>{{ msg }}</button>
:disabled="msg.attachmentCount > 0"
:title="msg.attachmentCount > 0 ? t('composer.queuedHasImage', { n: msg.attachmentCount }) : t('composer.editQueued')"
@click="msg.attachmentCount === 0 && editQueued(i, msg.text)"
>
<svg v-if="msg.attachmentCount > 0" class="queue-img" viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><circle cx="5.5" cy="6.5" r="1.2"/><path d="M2.5 12l3.5-3.5 2.5 2.5 3-3 2 2"/></svg>
<span class="queue-text-inner" :class="{ placeholder: !msg.text }">{{ msg.text || t('composer.queuedImageOnly', { n: msg.attachmentCount }) }}</span>
</button>
<button class="queue-rm" :title="t('composer.remove')" @click="emit('unqueue', i)">
<svg viewBox="0 0 12 12" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</button>
@ -896,9 +903,10 @@ function selectModel(modelId: string): void {
}
.queue-text {
display: inline-flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: none;
border: none;
padding: 0;
@ -910,9 +918,19 @@ function selectModel(modelId: string): void {
max-width: 168px;
text-align: left;
}
.queue-text:hover {
.queue-text:hover:not(:disabled) {
color: var(--blue);
}
.queue-text:disabled {
cursor: default;
}
.queue-img { flex: none; color: var(--muted); }
.queue-text-inner {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-text-inner.placeholder { color: var(--muted); }
.queue-rm {
display: flex;

View file

@ -2,7 +2,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivityState, ApprovalBlock, ChatTurn, ConnectionState, ContentAlign, ConversationStatus, DiffViewLine, PaneKey, PermissionMode, TaskItem, UIQuestion } from '../types';
import type { ActivityState, ApprovalBlock, ChatTurn, ConnectionState, ContentAlign, ConversationStatus, DiffViewLine, PaneKey, PermissionMode, QueuedPromptView, TaskItem, UIQuestion } from '../types';
import type { AppModel, ApprovalDecision, FsEntry, QuestionResponse, ThinkingLevel } from '../api/types';
import type { FileItem } from './MentionMenu.vue';
import type { FileData } from './FilePreview.vue';
@ -33,7 +33,7 @@ const props = defineProps<{
planMode?: boolean;
questions?: UIQuestion[];
running?: boolean;
queued?: string[];
queued?: QueuedPromptView[];
searchFiles?: (q: string) => Promise<FileItem[]>;
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
connection?: ConnectionState;

View file

@ -34,6 +34,7 @@ import type {
DiffLine,
DiffViewLine,
PermissionMode,
QueuedPromptView,
Session,
TaskItem,
TaskState,
@ -818,11 +819,16 @@ const permission = computed<PermissionMode>(() => rawState.permission);
const thinking = computed<ThinkingLevel>(() => rawState.thinking);
const planMode = computed<boolean>(() => rawState.planMode);
/** Queued messages for the active session (display texts). */
const queued = computed<string[]>(() => {
/** Queued messages for the active session (text + attachment count for the
composer strip an image-only prompt would otherwise render as an empty
string). */
const queued = computed<QueuedPromptView[]>(() => {
const sid = rawState.activeSessionId;
if (!sid) return [];
return (rawState.queuedBySession[sid] ?? []).map((q) => q.text);
return (rawState.queuedBySession[sid] ?? []).map((q) => ({
text: q.text,
attachmentCount: q.attachments?.length ?? 0,
}));
});
/** Pending warnings list */

View file

@ -4,6 +4,8 @@ export default {
queue: 'Queue',
queueLabel: 'Queue',
editQueued: 'Edit (load back into the input)',
queuedImageOnly: 'image ×{n}',
queuedHasImage: 'Contains {n} image(s) — remove only, not editable',
remove: 'Remove',
removeNamed: 'Remove {name}',
uploading: 'Uploading',

View file

@ -4,6 +4,8 @@ export default {
queue: '排队',
queueLabel: '队列',
editQueued: '编辑(载入到输入框)',
queuedImageOnly: '图片 ×{n}',
queuedHasImage: '包含 {n} 张图片 — 只能移除,不能编辑',
remove: '移除',
removeNamed: '移除 {name}',
uploading: '上传中',

View file

@ -149,6 +149,13 @@ export interface ConversationStatus {
// preview for unchanged ones). 'diff' is gone; 'files' is the merged key.
export type PaneKey = 'chat' | 'files' | 'tasks';
/** A queued prompt as shown in the composer's queue strip. */
export interface QueuedPromptView {
text: string;
/** Number of image attachments waiting with this prompt. */
attachmentCount: number;
}
/** Horizontal alignment of the conversation reading column within the pane. */
export type ContentAlign = 'left' | 'center';