Skip to content

Commit 994fa5b

Browse files
committed
feat(cli): improve image paste UX with instant feedback and status tracking
- Add typed status field to PendingImage (processing/ready/error) instead of string matching - Show image banner immediately on Ctrl+V before clipboard check completes - Display "X images attached, Y images processing" in banner header - Move processing indicator to note area, show generic thumbnail fallback - Add tests for image lifecycle status transitions - Simplify pending images filtering with single-pass loop
1 parent 44c6d72 commit 994fa5b

File tree

6 files changed

+353
-32
lines changed

6 files changed

+353
-32
lines changed

cli/src/chat.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { useShallow } from 'zustand/react/shallow'
1212

1313
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
14-
import { addPendingImageFromFile } from './utils/add-pending-image'
14+
import { addClipboardPlaceholder, addPendingImageFromFile } from './utils/add-pending-image'
1515
import { getProjectRoot } from './project-files'
1616
import { AnnouncementBanner } from './components/announcement-banner'
1717
import { hasClipboardImage, readClipboardImage } from './utils/clipboard-image'
@@ -1015,20 +1015,31 @@ export const Chat = ({
10151015
onBashHistoryUp: navigateUp,
10161016
onBashHistoryDown: navigateDown,
10171017
onPasteImage: () => {
1018-
if (!hasClipboardImage()) {
1019-
return false
1020-
}
1018+
// Show placeholder immediately so user sees the banner right away
1019+
const placeholderPath = addClipboardPlaceholder()
1020+
1021+
// Check and process clipboard image in background
1022+
setTimeout(() => {
1023+
// Check if clipboard actually has an image
1024+
if (!hasClipboardImage()) {
1025+
// No image - quietly remove placeholder (brief flash is acceptable)
1026+
useChatStore.getState().removePendingImage(placeholderPath)
1027+
return
1028+
}
10211029

1022-
const result = readClipboardImage()
1023-
if (!result.success || !result.imagePath || !result.filename) {
1024-
showClipboardMessage(result.error || 'Failed to paste image', {
1025-
durationMs: 3000,
1026-
})
1027-
return true
1028-
}
1030+
const result = readClipboardImage()
1031+
if (!result.success || !result.imagePath) {
1032+
useChatStore.getState().removePendingImage(placeholderPath)
1033+
showClipboardMessage(result.error || 'Failed to paste image', {
1034+
durationMs: 3000,
1035+
})
1036+
return
1037+
}
1038+
1039+
const cwd = getProjectRoot() ?? process.cwd()
1040+
void addPendingImageFromFile(result.imagePath, cwd, placeholderPath)
1041+
}, 0)
10291042

1030-
const cwd = getProjectRoot() ?? process.cwd()
1031-
void addPendingImageFromFile(result.imagePath, cwd)
10321043
return true
10331044
},
10341045
}),

cli/src/components/image-card.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ const truncateFilename = (filename: string): string => {
3434
export interface ImageCardImage {
3535
path: string
3636
filename: string
37-
note?: string // Status: "processing…" | "compressed" | error message
37+
status?: 'processing' | 'ready' | 'error' // Defaults to 'ready' if not provided
38+
note?: string // Display note: "compressed" | error message
3839
}
3940

4041
interface ImageCardProps {
@@ -111,9 +112,7 @@ export const ImageCard = ({
111112
alignItems: 'center',
112113
}}
113114
>
114-
{image.note === 'processing…' ? (
115-
<text style={{ fg: theme.muted }}></text>
116-
) : thumbnailSequence ? (
115+
{thumbnailSequence ? (
117116
<text>{thumbnailSequence}</text>
118117
) : (
119118
<ImageThumbnail
@@ -141,14 +140,14 @@ export const ImageCard = ({
141140
>
142141
{truncatedName}
143142
</text>
144-
{image.note && (
143+
{((image.status ?? 'ready') === 'processing' || image.note) && (
145144
<text
146145
style={{
147146
fg: theme.muted,
148147
wrapMode: 'none',
149148
}}
150149
>
151-
{image.note}
150+
{(image.status ?? 'ready') === 'processing' ? 'processing…' : image.note}
152151
</text>
153152
)}
154153
</box>

cli/src/components/pending-images-banner.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,21 @@ export const PendingImagesBanner = () => {
1212
const pendingImages = useChatStore((state) => state.pendingImages)
1313
const removePendingImage = useChatStore((state) => state.removePendingImage)
1414

15-
// Separate error messages from actual images
16-
const errorImages = pendingImages.filter((img) => img.isError)
17-
const validImages = pendingImages.filter((img) => !img.isError)
15+
// Separate error messages from actual images, and count processing
16+
const errorImages: typeof pendingImages = []
17+
const validImages: typeof pendingImages = []
18+
let processingCount = 0
19+
for (const img of pendingImages) {
20+
if (img.status === 'error') {
21+
errorImages.push(img)
22+
} else {
23+
validImages.push(img)
24+
if (img.status === 'processing') {
25+
processingCount++
26+
}
27+
}
28+
}
29+
const readyCount = validImages.length - processingCount
1830

1931
if (pendingImages.length === 0) {
2032
return null
@@ -72,7 +84,10 @@ export const PendingImagesBanner = () => {
7284

7385
{/* Header */}
7486
<text style={{ fg: theme.info }}>
75-
📎 {pluralize(validImages.length, 'image')} attached
87+
📎{' '}
88+
{readyCount > 0 && `${pluralize(readyCount, 'image')} attached`}
89+
{readyCount > 0 && processingCount > 0 && ', '}
90+
{processingCount > 0 && `${pluralize(processingCount, 'image')} processing`}
7691
</text>
7792

7893
{/* Image cards in a horizontal row - only valid images */}

cli/src/state/chat-store.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,16 @@ export type AskUserState = {
4343
otherTexts: string[] // Custom text input for each question (empty string if not used)
4444
} | null
4545

46+
export type PendingImageStatus = 'processing' | 'ready' | 'error'
47+
4648
export type PendingImage = {
4749
path: string
4850
filename: string
51+
status: PendingImageStatus
4952
size?: number
5053
width?: number
5154
height?: number
52-
note?: string // Status: "processing…" | "compressed" | error message
53-
isError?: boolean // True if this is an error entry (e.g., file not found)
55+
note?: string // Display note: "compressed" | error message
5456
processedImage?: {
5557
base64: string
5658
mediaType: string

0 commit comments

Comments
 (0)