Skip to content

Commit c566f4c

Browse files
committed
feat(cli): unify image and text attachments into PendingAttachment type
- Create unified PendingAttachment discriminated union with kind: image | text - Replace separate pendingImages/pendingTextAttachments with single pendingAttachments array - Add capturePendingAttachments() unified capture function - Update message queue to use attachments: PendingAttachment[] - Fix text attachment sending bug where attachments were not sent with messages - Fix /init edge case where text attachments were dropped during streaming - Consolidate chat-store methods to have compat APIs delegate to canonical functions - Remove unused PendingImagesBanner and PendingTextBanner components - Remove deprecated capturePendingImages and capturePendingTextAttachments functions - Inline preservePendingAttachments helper in chat.tsx - Add missing type guard for text attachment filter in PendingAttachmentsBanner - Rename add-pending-image.ts to pending-attachments.ts - Create shared AttachmentCard base component for consistent card styling
1 parent 5adeffc commit c566f4c

19 files changed

+390
-419
lines changed

cli/src/chat.tsx

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ import { usePublishStore } from './state/publish-store'
6161
import {
6262
addClipboardPlaceholder,
6363
addPendingImageFromFile,
64+
capturePendingAttachments,
6465
validateAndAddImage,
65-
} from './utils/add-pending-image'
66+
} from './utils/pending-attachments'
6667
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
6768
import { showClipboardMessage } from './utils/clipboard'
6869
import { readClipboardImage } from './utils/clipboard-image'
@@ -589,7 +590,7 @@ export const Chat = ({
589590
sendMessageRef.current?.({
590591
content: message.content,
591592
agentMode,
592-
images: message.images,
593+
attachments: message.attachments,
593594
}) ?? Promise.resolve(),
594595
isChainInProgressRef,
595596
activeAgentStreamsRef,
@@ -707,14 +708,18 @@ export const Chat = ({
707708
return { text, cursorPosition, lastEditDueToNav }
708709
})()
709710
: null
710-
const preservedPendingImages =
711-
preserveInput && useChatStore.getState().pendingImages.length > 0
712-
? [...useChatStore.getState().pendingImages]
713-
: null
714711

715-
if (preserveInput && preservedPendingImages) {
716-
useChatStore.getState().clearPendingImages()
717-
}
712+
// Preserve attachments if needed (inline logic to avoid abstraction overhead)
713+
const preservedAttachments = preserveInput
714+
? (() => {
715+
const items = useChatStore.getState().pendingAttachments
716+
if (items.length > 0) {
717+
useChatStore.getState().clearPendingAttachments()
718+
return [...items]
719+
}
720+
return null
721+
})()
722+
: null
718723

719724
try {
720725
const result = await routeUserPrompt({
@@ -750,13 +755,11 @@ export const Chat = ({
750755
})
751756
}
752757

753-
if (preserveInput && preservedPendingImages) {
754-
const currentPending = useChatStore.getState().pendingImages
755-
if (currentPending.length === 0) {
756-
useChatStore.setState((state) => {
757-
state.pendingImages = preservedPendingImages
758-
})
759-
}
758+
// Restore attachments if they were preserved and none have been added since
759+
if (preservedAttachments && useChatStore.getState().pendingAttachments.length === 0) {
760+
useChatStore.setState((state) => {
761+
state.pendingAttachments = preservedAttachments
762+
})
760763
}
761764
}
762765
},

cli/src/commands/command-registry.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import { WEBSITE_URL } from '../login/constants'
1212
import { useChatStore } from '../state/chat-store'
1313
import { useFeedbackStore } from '../state/feedback-store'
1414
import { useLoginStore } from '../state/login-store'
15-
import { capturePendingImages } from '../utils/add-pending-image'
15+
import { capturePendingAttachments } from '../utils/pending-attachments'
1616
import { AGENT_MODES } from '../utils/constants'
1717
import { getSystemMessage, getUserMessage } from '../utils/message-history'
1818

1919
import type { MultilineInputHandle } from '../components/multiline-input'
20-
import type { InputValue, PendingImage } from '../state/chat-store'
20+
import type { InputValue, PendingAttachment } from '../state/chat-store'
2121
import type { ChatMessage } from '../types/chat'
2222
import type { SendMessageFn } from '../types/contracts/send-message'
2323
import type { User } from '../utils/auth'
@@ -33,7 +33,7 @@ export type RouterParams = {
3333
isStreaming: boolean
3434
logoutMutation: UseMutationResult<boolean, Error, void, unknown>
3535
streamMessageIdRef: React.MutableRefObject<string | null>
36-
addToQueue: (message: string, images?: PendingImage[]) => void
36+
addToQueue: (message: string, attachments?: PendingAttachment[]) => void
3737
clearMessages: () => void
3838
saveToHistory: (message: string) => void
3939
scrollToLatest: () => void
@@ -352,8 +352,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
352352
params.streamMessageIdRef.current ||
353353
params.isChainInProgressRef.current
354354
) {
355-
const pendingImages = capturePendingImages()
356-
params.addToQueue(trimmed, pendingImages)
355+
const pendingAttachments = capturePendingAttachments()
356+
params.addToQueue(trimmed, pendingAttachments)
357357
params.setInputFocused(true)
358358
params.inputRef.current?.focus()
359359
return

cli/src/commands/image.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getProjectRoot } from '../project-files'
2-
import { validateAndAddImage } from '../utils/add-pending-image'
2+
import { validateAndAddImage } from '../utils/pending-attachments'
33

44
/**
55
* Handle the /image command to attach an image file.

cli/src/commands/router.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import { handleClaudeAuthCode } from '../components/claude-connect-banner'
1919
import { getProjectRoot } from '../project-files'
2020
import { useChatStore } from '../state/chat-store'
2121
import {
22-
capturePendingImages,
22+
capturePendingAttachments,
2323
hasProcessingImages,
2424
validateAndAddImage,
25-
} from '../utils/add-pending-image'
25+
} from '../utils/pending-attachments'
2626
import {
2727
buildBashHistoryMessages,
2828
createRunTerminalToolResult,
@@ -267,11 +267,16 @@ export async function routeUserPrompt(
267267

268268
const inputMode = useChatStore.getState().inputMode
269269
const setInputMode = useChatStore.getState().setInputMode
270-
const pendingImages = useChatStore.getState().pendingImages
270+
const pendingAttachments = useChatStore.getState().pendingAttachments
271+
const pendingImages = pendingAttachments.filter((a) => a.kind === 'image')
272+
const pendingTextAttachments = pendingAttachments.filter(
273+
(a) => a.kind === 'text',
274+
)
271275

272276
const trimmed = inputValue.trim()
273-
// Allow empty messages if there are pending images attached
274-
if (!trimmed && pendingImages.length === 0) return
277+
// Allow empty messages if there are pending attachments (images or text)
278+
const hasAttachments = pendingAttachments.length > 0
279+
if (!trimmed && !hasAttachments) return
275280

276281
// Track user input complete
277282
// Count @ mentions (simple pattern match - more accurate than nothing)
@@ -282,6 +287,8 @@ export async function routeUserPrompt(
282287
inputMode,
283288
hasImages: pendingImages.length > 0,
284289
imageCount: pendingImages.length,
290+
hasTextAttachments: pendingTextAttachments.length > 0,
291+
textAttachmentCount: pendingTextAttachments.length,
285292
isSlashCommand: isSlashCommand(trimmed),
286293
isBashCommand: trimmed.startsWith('!'),
287294
hasMentions: mentionMatches.length > 0,
@@ -453,9 +460,9 @@ export async function routeUserPrompt(
453460
streamMessageIdRef.current ||
454461
isChainInProgressRef.current
455462
) {
456-
const pendingImagesForQueue = capturePendingImages()
457-
// Pass a copy of pending images to the queue
458-
addToQueue(trimmed, pendingImagesForQueue)
463+
const pendingAttachmentsForQueue = capturePendingAttachments()
464+
// Pass a copy of pending attachments to the queue
465+
addToQueue(trimmed, pendingAttachmentsForQueue)
459466

460467
setInputFocused(true)
461468
inputRef.current?.focus()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useState } from 'react'
2+
3+
import { Button } from './button'
4+
import { useTheme } from '../hooks/use-theme'
5+
import { IMAGE_CARD_BORDER_CHARS } from '../utils/ui-constants'
6+
7+
import type { ReactNode } from 'react'
8+
9+
export interface AttachmentCardProps {
10+
/** Width of the card in characters */
11+
width: number
12+
/** Content to render inside the bordered card. */
13+
children: ReactNode
14+
/** Callback when the remove button is clicked. If omitted, the button is hidden. */
15+
onRemove?: () => void
16+
/** Whether to show the remove button (default: true). Ignored if onRemove is not provided. */
17+
showRemoveButton?: boolean
18+
}
19+
20+
/**
21+
* Shared attachment card shell used by image and text attachments.
22+
* Renders a bordered card with an optional 'X' remove button to the right.
23+
*/
24+
export const AttachmentCard = ({
25+
width,
26+
children,
27+
onRemove,
28+
showRemoveButton = true,
29+
}: AttachmentCardProps) => {
30+
const theme = useTheme()
31+
const [isCloseHovered, setIsCloseHovered] = useState(false)
32+
33+
const shouldShowClose = showRemoveButton && !!onRemove
34+
35+
return (
36+
<box style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
37+
<box
38+
style={{
39+
flexDirection: 'column',
40+
borderStyle: 'single',
41+
borderColor: theme.imageCardBorder,
42+
width,
43+
padding: 0,
44+
}}
45+
customBorderChars={IMAGE_CARD_BORDER_CHARS}
46+
>
47+
{children}
48+
</box>
49+
50+
{shouldShowClose ? (
51+
<Button
52+
onClick={onRemove}
53+
onMouseOver={() => setIsCloseHovered(true)}
54+
onMouseOut={() => setIsCloseHovered(false)}
55+
style={{ paddingLeft: 0, paddingRight: 0 }}
56+
>
57+
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>X</text>
58+
</Button>
59+
) : (
60+
// Keep layout aligned when there is no close button
61+
<box style={{ width: 1 }} />
62+
)}
63+
</box>
64+
)
65+
}

cli/src/components/image-card.tsx

Lines changed: 44 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import fs from 'fs'
22

3-
import React, { useEffect, useState } from 'react'
3+
import { useEffect, useState } from 'react'
44

5-
import { Button } from './button'
5+
import { AttachmentCard } from './attachment-card'
66
import { ImageThumbnail } from './image-thumbnail'
77
import { useTheme } from '../hooks/use-theme'
88
import {
99
supportsInlineImages,
1010
renderInlineImage,
1111
} from '../utils/terminal-images'
12-
import { IMAGE_CARD_BORDER_CHARS } from '../utils/ui-constants'
1312

1413
// Image card display constants
1514
const MAX_FILENAME_LENGTH = 16
@@ -18,7 +17,6 @@ const THUMBNAIL_WIDTH = 14
1817
const THUMBNAIL_HEIGHT = 3
1918
const INLINE_IMAGE_WIDTH = 4
2019
const INLINE_IMAGE_HEIGHT = 3
21-
const CLOSE_BUTTON_WIDTH = 1
2220

2321
const truncateFilename = (filename: string): string => {
2422
if (filename.length <= MAX_FILENAME_LENGTH) {
@@ -34,8 +32,8 @@ const truncateFilename = (filename: string): string => {
3432
export interface ImageCardImage {
3533
path: string
3634
filename: string
37-
status?: 'processing' | 'ready' | 'error' // Defaults to 'ready' if not provided
38-
note?: string // Display note: "compressed" | error message
35+
status?: 'processing' | 'ready' | 'error' // Defaults to 'ready' if not provided
36+
note?: string // Display note: 'compressed' | error message
3937
}
4038

4139
interface ImageCardProps {
@@ -50,7 +48,6 @@ export const ImageCard = ({
5048
showRemoveButton = true,
5149
}: ImageCardProps) => {
5250
const theme = useTheme()
53-
const [isCloseHovered, setIsCloseHovered] = useState(false)
5451
const [thumbnailSequence, setThumbnailSequence] = useState<string | null>(
5552
null,
5653
)
@@ -92,80 +89,60 @@ export const ImageCard = ({
9289
const truncatedName = truncateFilename(image.filename)
9390

9491
return (
95-
<box style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
96-
{/* Main card with border */}
92+
<AttachmentCard
93+
width={IMAGE_CARD_WIDTH}
94+
onRemove={onRemove}
95+
showRemoveButton={showRemoveButton}
96+
>
97+
{/* Thumbnail or icon area */}
9798
<box
9899
style={{
99-
flexDirection: 'column',
100-
borderStyle: 'single',
101-
borderColor: theme.imageCardBorder,
102-
width: IMAGE_CARD_WIDTH,
103-
padding: 0,
100+
height: THUMBNAIL_HEIGHT,
101+
justifyContent: 'center',
102+
alignItems: 'center',
104103
}}
105-
customBorderChars={IMAGE_CARD_BORDER_CHARS}
106104
>
107-
{/* Thumbnail or icon area */}
108-
<box
109-
style={{
110-
height: THUMBNAIL_HEIGHT,
111-
justifyContent: 'center',
112-
alignItems: 'center',
113-
}}
114-
>
115-
{thumbnailSequence ? (
116-
<text>{thumbnailSequence}</text>
117-
) : (
118-
<ImageThumbnail
119-
imagePath={image.path}
120-
width={THUMBNAIL_WIDTH}
121-
height={THUMBNAIL_HEIGHT}
122-
fallback={<text style={{ fg: theme.info }}>🖼️</text>}
123-
/>
124-
)}
125-
</box>
105+
{thumbnailSequence ? (
106+
<text>{thumbnailSequence}</text>
107+
) : (
108+
<ImageThumbnail
109+
imagePath={image.path}
110+
width={THUMBNAIL_WIDTH}
111+
height={THUMBNAIL_HEIGHT}
112+
fallback={<text style={{ fg: theme.info }}>🖼️</text>}
113+
/>
114+
)}
115+
</box>
126116

127-
{/* Filename - full width */}
128-
<box
117+
{/* Filename - full width */}
118+
<box
119+
style={{
120+
paddingLeft: 1,
121+
paddingRight: 1,
122+
flexDirection: 'column',
123+
}}
124+
>
125+
<text
129126
style={{
130-
paddingLeft: 1,
131-
paddingRight: 1,
132-
flexDirection: 'column',
127+
fg: theme.foreground,
128+
wrapMode: 'none',
133129
}}
134130
>
131+
{truncatedName}
132+
</text>
133+
{((image.status ?? 'ready') === 'processing' || image.note) && (
135134
<text
136135
style={{
137-
fg: theme.foreground,
136+
fg: theme.muted,
138137
wrapMode: 'none',
139138
}}
140139
>
141-
{truncatedName}
140+
{(image.status ?? 'ready') === 'processing'
141+
? 'processing…'
142+
: image.note}
142143
</text>
143-
{((image.status ?? 'ready') === 'processing' || image.note) && (
144-
<text
145-
style={{
146-
fg: theme.muted,
147-
wrapMode: 'none',
148-
}}
149-
>
150-
{(image.status ?? 'ready') === 'processing' ? 'processing…' : image.note}
151-
</text>
152-
)}
153-
</box>
144+
)}
154145
</box>
155-
156-
{/* Close button outside the card */}
157-
{showRemoveButton && onRemove ? (
158-
<Button
159-
onClick={onRemove}
160-
onMouseOver={() => setIsCloseHovered(true)}
161-
onMouseOut={() => setIsCloseHovered(false)}
162-
style={{ paddingLeft: 0, paddingRight: 0 }}
163-
>
164-
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>X</text>
165-
</Button>
166-
) : (
167-
<box style={{ width: CLOSE_BUTTON_WIDTH }} />
168-
)}
169-
</box>
146+
</AttachmentCard>
170147
)
171148
}

0 commit comments

Comments
 (0)