支持在会话输入框直接进行文件/图片的拖拽和粘贴

This commit is contained in:
xintaofei
2026-03-08 10:54:06 +08:00
parent 68e2c7f989
commit 7a4cbcb73e
24 changed files with 1335 additions and 78 deletions

View File

@@ -3,6 +3,7 @@
import { useTranslations } from "next-intl"
import type {
ConnectionStatus,
PromptCapabilitiesInfo,
PromptDraft,
SessionConfigOptionInfo,
SessionModeInfo,
@@ -12,6 +13,7 @@ import { MessageInput } from "@/components/chat/message-input"
interface ChatInputProps {
status: ConnectionStatus | null
promptCapabilities: PromptCapabilitiesInfo
defaultPath?: string
onFocus?: () => void
onSend: (draft: PromptDraft, modeId?: string | null) => void
@@ -30,6 +32,7 @@ interface ChatInputProps {
export function ChatInput({
status,
promptCapabilities,
defaultPath,
onFocus,
onSend,
@@ -54,6 +57,7 @@ export function ChatInput({
<div className="p-4 pt-0">
<MessageInput
onSend={onSend}
promptCapabilities={promptCapabilities}
onFocus={onFocus}
defaultPath={defaultPath}
disabled={!isConnected}

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from "react"
import type {
ConnectionStatus,
PromptCapabilitiesInfo,
PromptDraft,
SessionConfigOptionInfo,
SessionModeInfo,
@@ -12,6 +13,7 @@ import { PermissionDialog } from "@/components/chat/permission-dialog"
interface ConversationShellProps {
status: ConnectionStatus | null
promptCapabilities: PromptCapabilitiesInfo
defaultPath?: string
error: string | null
pendingPermission: PendingPermission | null
@@ -34,6 +36,7 @@ interface ConversationShellProps {
export function ConversationShell({
status,
promptCapabilities,
defaultPath,
error,
pendingPermission,
@@ -64,6 +67,7 @@ export function ConversationShell({
<ChatInput
status={status}
promptCapabilities={promptCapabilities}
defaultPath={defaultPath}
onFocus={onFocus}
onSend={onSend}

View File

@@ -1,14 +1,18 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { getCurrentWebview } from "@tauri-apps/api/webview"
import { open } from "@tauri-apps/plugin-dialog"
import Image from "next/image"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { FileSearch, Plus, Send, Square, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { readFileBase64 } from "@/lib/tauri"
import type {
AvailableCommandInfo,
PromptCapabilitiesInfo,
PromptDraft,
PromptInputBlock,
SessionConfigOptionInfo,
@@ -45,17 +49,33 @@ interface MessageInputProps {
onModeChange?: (modeId: string) => void
onConfigOptionChange?: (configId: string, valueId: string) => void
availableCommands?: AvailableCommandInfo[] | null
promptCapabilities: PromptCapabilitiesInfo
attachmentTabId?: string | null
draftStorageKey?: string | null
}
interface InputAttachment {
path: string
interface ResourceInputAttachment {
id: string
type: "resource"
kind: "link" | "embedded"
uri: string
name: string
mimeType: string | null
text?: string | null
blob?: string | null
}
interface ImageInputAttachment {
id: string
type: "image"
data: string
uri: string | null
name: string
mimeType: string
}
type InputAttachment = ResourceInputAttachment | ImageInputAttachment
const MIME_BY_EXT: Record<string, string> = {
txt: "text/plain",
md: "text/markdown",
@@ -104,6 +124,102 @@ function toFileUri(path: string): string {
return `file:///${encoded}`
}
function hasDragFiles(dataTransfer: DataTransfer | null): boolean {
if (!dataTransfer?.types) return false
return Array.from(dataTransfer.types).includes("Files")
}
function pointWithinElement(
position: { x: number; y: number },
element: HTMLElement
): boolean {
const rect = element.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const candidates = [
{ x: position.x, y: position.y },
{ x: position.x / dpr, y: position.y / dpr },
]
return candidates.some(
(point) =>
point.x >= rect.left &&
point.x <= rect.right &&
point.y >= rect.top &&
point.y <= rect.bottom
)
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => {
reject(reader.error ?? new Error("Failed to read blob"))
}
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected non-string blob reader result"))
return
}
const markerIndex = reader.result.indexOf(",")
resolve(
markerIndex >= 0 ? reader.result.slice(markerIndex + 1) : reader.result
)
}
reader.readAsDataURL(blob)
})
}
function getFilePath(file: File): string | null {
const withPath = file as File & { path?: string; webkitRelativePath?: string }
if (typeof withPath.path === "string" && withPath.path.trim().length > 0) {
return withPath.path
}
if (
typeof withPath.webkitRelativePath === "string" &&
withPath.webkitRelativePath.trim().length > 0
) {
return withPath.webkitRelativePath
}
return null
}
const TEXT_LIKE_MIME_PREFIXES = [
"text/",
"application/json",
"application/xml",
"application/yaml",
"application/x-yaml",
"application/toml",
"application/javascript",
"application/typescript",
]
const DRAG_DROP_IMAGE_MAX_BYTES = 20_000_000
function isTextLikeFile(file: File): boolean {
const mime = file.type.toLowerCase()
if (mime) {
if (TEXT_LIKE_MIME_PREFIXES.some((prefix) => mime.startsWith(prefix))) {
return true
}
}
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext) return false
return Boolean(
MIME_BY_EXT[ext]?.startsWith("text/") ||
["json", "yaml", "yml", "xml", "toml", "md", "csv"].includes(ext)
)
}
function buildClipboardResourceUri(name: string): string {
const normalizedName = name.trim() || "clipboard-resource"
return `clipboard://${encodeURIComponent(normalizedName)}-${crypto.randomUUID()}`
}
function buildDataUri(base64Data: string, mimeType: string | null): string {
const safeMime =
mimeType && mimeType.trim() ? mimeType : "application/octet-stream"
return `data:${safeMime};base64,${base64Data}`
}
function SelectorLoadingChip({ label }: { label: string }) {
return (
<div className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground">
@@ -131,6 +247,7 @@ export function MessageInput({
onModeChange,
onConfigOptionChange,
availableCommands,
promptCapabilities,
attachmentTabId,
draftStorageKey,
}: MessageInputProps) {
@@ -142,8 +259,12 @@ export function MessageInput({
return loadMessageInputDraft(effectiveDraftStorageKey) ?? ""
})
const [attachments, setAttachments] = useState<InputAttachment[]>([])
const [isDragActive, setIsDragActive] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const lastDomDropAtRef = useRef(0)
const composingRef = useRef(false)
const textRef = useRef(text)
const canAttachImages = promptCapabilities.image
useEffect(() => {
textRef.current = text
@@ -176,6 +297,22 @@ export function MessageInput({
hasModes && Boolean(effectiveModeId) && !hasConfigOptions
const showModeLoading = modeLoading && !hasConfigOptions && !showModeSelector
const showConfigLoading = configOptionsLoading && !hasConfigOptions
const imageAttachments = useMemo(
() =>
attachments.filter(
(attachment): attachment is ImageInputAttachment =>
attachment.type === "image"
),
[attachments]
)
const resourceAttachments = useMemo(
() =>
attachments.filter(
(attachment): attachment is ResourceInputAttachment =>
attachment.type === "resource"
),
[attachments]
)
const hasAttachments = attachments.length > 0
const hasSendableContent = text.trim().length > 0 || hasAttachments
@@ -196,24 +333,292 @@ export function MessageInput({
)
}, [slashMenuOpen, slashCommands, text])
const appendAttachments = useCallback((paths: string[]) => {
setAttachments((prev) => {
const seen = new Set(prev.map((item) => item.path))
const next = [...prev]
for (const path of paths) {
if (typeof path !== "string" || !path || seen.has(path)) continue
seen.add(path)
next.push({
path,
uri: toFileUri(path),
name: fileNameFromPath(path),
mimeType: mimeTypeFromPath(path),
const appendResourceLinks = useCallback(
(
links: Array<{
uri: string
name: string
mimeType: string | null
dedupeKey: string
}>
) => {
if (links.length === 0) return
setAttachments((prev) => {
const seen = new Set(
prev.flatMap((item) =>
item.type === "resource" && item.kind === "link" ? [item.uri] : []
)
)
const next = [...prev]
for (const link of links) {
if (!link.uri || seen.has(link.dedupeKey)) continue
seen.add(link.dedupeKey)
next.push({
id: `resource-link:${link.dedupeKey}`,
type: "resource",
kind: "link",
uri: link.uri,
name: link.name,
mimeType: link.mimeType,
})
}
return next
})
},
[]
)
const appendResourceAttachments = useCallback(
(paths: string[]) => {
const normalized = paths
.filter(
(path): path is string => typeof path === "string" && path.length > 0
)
.map((path) => {
const uri = toFileUri(path)
return {
uri,
name: fileNameFromPath(path),
mimeType: mimeTypeFromPath(path),
dedupeKey: uri,
}
})
appendResourceLinks(normalized)
},
[appendResourceLinks]
)
const appendEmbeddedResources = useCallback(
(
resources: Array<{
uri: string
name: string
mimeType: string | null
text?: string | null
blob?: string | null
}>
) => {
if (resources.length === 0) return
setAttachments((prev) => [
...prev,
...resources.map((resource) => ({
id: `resource-embedded:${crypto.randomUUID()}`,
type: "resource" as const,
kind: "embedded" as const,
uri: resource.uri,
name: resource.name,
mimeType: resource.mimeType,
text: resource.text ?? null,
blob: resource.blob ?? null,
})),
])
},
[]
)
const appendFilesAsResources = useCallback(
async (files: File[]) => {
if (files.length === 0) return
const pathLinks: Array<{
uri: string
name: string
mimeType: string | null
dedupeKey: string
}> = []
const fallbackDataLinks: Array<{
uri: string
name: string
mimeType: string | null
dedupeKey: string
}> = []
const embeddedResources: Array<{
uri: string
name: string
mimeType: string | null
text?: string | null
blob?: string | null
}> = []
for (const file of files) {
const path = getFilePath(file)
const name = file.name || `resource-${crypto.randomUUID()}`
const mimeType = file.type || mimeTypeFromPath(name)
if (path) {
const uri = toFileUri(path)
pathLinks.push({
uri,
name: fileNameFromPath(path),
mimeType: mimeTypeFromPath(path) ?? mimeType ?? null,
dedupeKey: uri,
})
continue
}
if (!promptCapabilities.embedded_context) {
const base64 = await blobToBase64(file)
const dataUri = buildDataUri(base64, mimeType ?? null)
fallbackDataLinks.push({
uri: dataUri,
name,
mimeType: mimeType ?? null,
dedupeKey: `${name}:${file.size}:${file.lastModified}`,
})
continue
}
const uri = buildClipboardResourceUri(name)
if (isTextLikeFile(file)) {
const textContent = await file.text()
embeddedResources.push({
uri,
name,
mimeType: mimeType ?? null,
text: textContent,
})
} else {
const blobContent = await blobToBase64(file)
embeddedResources.push({
uri,
name,
mimeType: mimeType ?? null,
blob: blobContent,
})
}
}
return next
})
appendResourceLinks(pathLinks)
appendResourceLinks(fallbackDataLinks)
appendEmbeddedResources(embeddedResources)
},
[
appendEmbeddedResources,
appendResourceLinks,
promptCapabilities.embedded_context,
]
)
const appendImageAttachments = useCallback(async (files: File[]) => {
if (files.length === 0) return
const parsed = await Promise.all(
files.map(async (file, index) => {
const mimeType =
file.type && file.type.startsWith("image/")
? file.type
: (mimeTypeFromPath(file.name) ?? "image/png")
const base64Data = await blobToBase64(file)
return {
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
type: "image" as const,
data: base64Data,
uri: null,
name: file.name || `image-${Date.now()}-${index + 1}`,
mimeType,
}
})
)
setAttachments((prev) => [...prev, ...parsed])
}, [])
const appendImagePathAttachments = useCallback(
async (paths: string[]) => {
if (paths.length === 0 || !canAttachImages) return
const settled = await Promise.allSettled(
paths.map(async (path, index) => {
const data = await readFileBase64(path, DRAG_DROP_IMAGE_MAX_BYTES)
return {
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
type: "image" as const,
data,
uri: toFileUri(path),
name: fileNameFromPath(path),
mimeType: mimeTypeFromPath(path) ?? "image/png",
}
})
)
const parsed: ImageInputAttachment[] = []
settled.forEach((result, index) => {
if (result.status === "fulfilled") {
parsed.push(result.value)
return
}
console.error(
`[MessageInput] drop image path failed (${paths[index]}):`,
result.reason
)
})
if (parsed.length === 0) return
setAttachments((prev) => [...prev, ...parsed])
},
[canAttachImages]
)
const appendPathsFromDrop = useCallback(
async (paths: string[]) => {
if (paths.length === 0) return
const normalized = paths.filter(
(path): path is string => typeof path === "string" && path.length > 0
)
if (normalized.length === 0) return
const imagePaths: string[] = []
const resourcePaths: string[] = []
for (const path of normalized) {
const mimeType = mimeTypeFromPath(path) ?? ""
if (canAttachImages && mimeType.startsWith("image/")) {
imagePaths.push(path)
} else {
resourcePaths.push(path)
}
}
if (imagePaths.length > 0) {
await appendImagePathAttachments(imagePaths)
}
if (resourcePaths.length > 0) {
appendResourceAttachments(resourcePaths)
}
},
[appendImagePathAttachments, appendResourceAttachments, canAttachImages]
)
const appendFilesFromInput = useCallback(
async (files: File[]) => {
if (files.length === 0) return
const imageFiles: File[] = []
const resourceFiles: File[] = []
for (const file of files) {
const mimeType = file.type || mimeTypeFromPath(file.name) || ""
if (canAttachImages && mimeType.startsWith("image/")) {
imageFiles.push(file)
} else {
resourceFiles.push(file)
}
}
if (imageFiles.length > 0) {
await appendImageAttachments(imageFiles)
}
if (resourceFiles.length > 0) {
await appendFilesAsResources(resourceFiles)
}
},
[appendFilesAsResources, appendImageAttachments, canAttachImages]
)
const handlePaste = useCallback(
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (disabled || isPrompting) return
const files = Array.from(event.clipboardData?.files ?? [])
if (files.length === 0) return
event.preventDefault()
void appendFilesFromInput(files).catch((error) => {
console.error("[MessageInput] paste files failed:", error)
})
},
[appendFilesFromInput, disabled, isPrompting]
)
useEffect(() => {
if (!showModeSelector) return
if (!effectiveModeId || !onModeChange) return
@@ -258,11 +663,11 @@ export function MessageInput({
})
if (!selected) return
const picked = Array.isArray(selected) ? selected : [selected]
appendAttachments(picked.filter((item): item is string => !!item))
appendResourceAttachments(picked.filter((item): item is string => !!item))
} catch (error) {
console.error("[MessageInput] pick files failed:", error)
}
}, [appendAttachments, defaultPath, disabled])
}, [appendResourceAttachments, defaultPath, disabled])
useEffect(() => {
if (!attachmentTabId) return
@@ -271,17 +676,61 @@ export function MessageInput({
const customEvent = event as CustomEvent<AttachFileToSessionDetail>
if (!customEvent.detail) return
if (customEvent.detail.tabId !== attachmentTabId) return
appendAttachments([customEvent.detail.path])
appendResourceAttachments([customEvent.detail.path])
}
window.addEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
return () => {
window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
}
}, [appendAttachments, attachmentTabId])
}, [appendResourceAttachments, attachmentTabId])
const removeAttachment = useCallback((path: string) => {
setAttachments((prev) => prev.filter((item) => item.path !== path))
useEffect(() => {
let unlisten: (() => void) | null = null
let cancelled = false
getCurrentWebview()
.onDragDropEvent((event) => {
const host = containerRef.current
if (!host) return
const payload = event.payload
if (payload.type === "leave") {
setIsDragActive(false)
return
}
const inside = pointWithinElement(payload.position, host)
if (payload.type === "drop") {
setIsDragActive(false)
if (Date.now() - lastDomDropAtRef.current < 250) return
if (!inside || disabled || isPrompting) return
void appendPathsFromDrop(payload.paths).catch((error) => {
console.error("[MessageInput] drag drop paths failed:", error)
})
return
}
setIsDragActive(inside && !disabled && !isPrompting)
})
.then((fn) => {
if (cancelled) {
fn()
} else {
unlisten = fn
}
})
.catch(() => {
// Ignore non-Tauri environments.
})
return () => {
cancelled = true
if (unlisten) {
unlisten()
}
}
}, [appendPathsFromDrop, disabled, isPrompting])
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((item) => item.id !== id))
}, [])
const handleSend = useCallback(() => {
@@ -293,18 +742,37 @@ export function MessageInput({
blocks.push({ type: "text", text: trimmed })
}
for (const attachment of attachments) {
blocks.push({
type: "resource_link",
uri: attachment.uri,
name: attachment.name,
mime_type: attachment.mimeType,
description: null,
})
if (attachment.type === "resource") {
if (attachment.kind === "link") {
blocks.push({
type: "resource_link",
uri: attachment.uri,
name: attachment.name,
mime_type: attachment.mimeType,
description: null,
})
} else {
blocks.push({
type: "resource",
uri: attachment.uri,
mime_type: attachment.mimeType,
text: attachment.text ?? null,
blob: attachment.blob ?? null,
})
}
} else {
blocks.push({
type: "image",
data: attachment.data,
mime_type: attachment.mimeType,
uri: attachment.uri,
})
}
}
const displayText =
trimmed ||
`Attached ${attachments.length} resource${attachments.length > 1 ? "s" : ""}`
`Attached ${attachments.length} attachment${attachments.length > 1 ? "s" : ""}`
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
if (effectiveDraftStorageKey) {
clearMessageInputDraft(effectiveDraftStorageKey)
@@ -372,11 +840,70 @@ export function MessageInput({
]
)
const handleContainerDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!hasDragFiles(event.dataTransfer)) return
event.preventDefault()
if (!disabled && !isPrompting) {
setIsDragActive(true)
}
},
[disabled, isPrompting]
)
const handleContainerDragLeave = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
const related = event.relatedTarget
if (
related &&
related instanceof Node &&
event.currentTarget.contains(related)
) {
return
}
setIsDragActive(false)
},
[]
)
const handleContainerDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!hasDragFiles(event.dataTransfer)) return
event.preventDefault()
lastDomDropAtRef.current = Date.now()
setIsDragActive(false)
if (disabled || isPrompting) return
const files = Array.from(event.dataTransfer.files ?? [])
if (files.length > 0) {
void appendFilesFromInput(files).catch((error) => {
console.error("[MessageInput] drop files failed:", error)
})
}
},
[appendFilesFromInput, disabled, isPrompting]
)
const hasImageAttachments = imageAttachments.length > 0
const hasResourceAttachments = resourceAttachments.length > 0
const topPaddingClass =
hasImageAttachments && hasResourceAttachments
? "pt-24"
: hasImageAttachments
? "pt-16"
: hasResourceAttachments
? "pt-10"
: "pt-3"
const bottomPaddingClass = "pb-10"
const topPaddingClass = hasAttachments ? "pt-10" : ""
const showDragActive = isDragActive && !disabled && !isPrompting
return (
<div className="relative">
<div
ref={containerRef}
className="relative"
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
onDrop={handleContainerDrop}
>
{slashMenuOpen && filteredSlashCommands.length > 0 && (
<SlashCommandMenu
commands={filteredSlashCommands}
@@ -390,39 +917,77 @@ export function MessageInput({
onKeyDown={handleKeyDown}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
onPaste={handlePaste}
onFocus={onFocus}
placeholder={resolvedPlaceholder}
className={cn(
"text-sm pr-12 resize-none bg-transparent",
showDragActive && "ring-1 ring-primary/40",
topPaddingClass,
bottomPaddingClass,
className
)}
autoFocus={autoFocus}
/>
{hasAttachments && (
<div className="absolute left-2 right-2 top-2">
<div className="flex items-center gap-1 overflow-x-auto">
{attachments.map((attachment) => (
<div
key={attachment.path}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
>
<FileSearch className="h-3 w-3" />
<span className="max-w-40 truncate">{attachment.name}</span>
<button
type="button"
onClick={() => removeAttachment(attachment.path)}
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
aria-label={t("removeAttachmentAria", {
name: attachment.name,
})}
{(hasImageAttachments || hasResourceAttachments) && (
<div className="absolute left-2 right-12 top-2 z-10 flex flex-col gap-1">
{hasImageAttachments && (
<div className="flex items-center gap-1 overflow-x-auto pb-0.5">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
<Image
src={`data:${attachment.mimeType};base64,${attachment.data}`}
alt={attachment.name}
width={56}
height={56}
unoptimized
className="h-14 w-14 object-cover"
/>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
className="absolute right-1 top-1 rounded-sm bg-background/70 p-0.5 hover:bg-background"
aria-label={t("removeAttachmentAria", {
name: attachment.name,
})}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{hasResourceAttachments && (
<div className="flex items-center gap-1 overflow-x-auto">
{resourceAttachments.map((attachment) => (
<div
key={attachment.id}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
>
<FileSearch className="h-3 w-3" />
<span className="max-w-40 truncate">{attachment.name}</span>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
aria-label={t("removeAttachmentAria", {
name: attachment.name,
})}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
)}
{showDragActive && (
<div className="pointer-events-none absolute inset-1 z-20 flex items-center justify-center rounded-md border border-dashed border-primary/50 bg-background/80 text-xs text-muted-foreground">
{t("dropFilesToAttach")}
</div>
)}
<div className="absolute left-2 right-24 bottom-2 flex flex-col gap-1">

View File

@@ -16,6 +16,7 @@ import {
} from "@/lib/adapters/ai-elements-adapter"
import {
buildUserMessageTextPartsFromDraft,
extractUserImagesFromDraft,
extractUserResourcesFromDraft,
getPromptDraftDisplayText,
} from "@/lib/prompt-draft"
@@ -41,6 +42,7 @@ import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
import { LiveTurnStats } from "@/components/message/live-turn-stats"
import { TurnStats } from "@/components/message/turn-stats"
import { UserResourceLinks } from "@/components/message/user-resource-links"
import { UserImageAttachments } from "@/components/message/user-image-attachments"
import { ConversationShell } from "@/components/chat/conversation-shell"
import {
MessageThread,
@@ -604,6 +606,7 @@ export function WelcomeInputPanel({
draft,
sharedT("attachedResources")
),
userImages: extractUserImagesFromDraft(draft),
userResources: extractUserResourcesFromDraft(draft),
timestamp: new Date().toISOString(),
}
@@ -683,6 +686,7 @@ export function WelcomeInputPanel({
draft,
sharedT("attachedResources")
),
userImages: extractUserImagesFromDraft(draft),
userResources: extractUserResourcesFromDraft(draft),
timestamp: new Date().toISOString(),
}
@@ -791,6 +795,7 @@ export function WelcomeInputPanel({
<MessageInput
key={newConversationDraftStorageKey}
onSend={handleWelcomeSend}
promptCapabilities={conn.promptCapabilities}
defaultPath={workingDir}
placeholder={
agentsLoaded && usableAgentCount === 0
@@ -827,6 +832,7 @@ export function WelcomeInputPanel({
return (
<ConversationShell
status={connStatus}
promptCapabilities={conn.promptCapabilities}
defaultPath={workingDir}
error={conn.error}
pendingPermission={conn.pendingPermission}
@@ -854,6 +860,12 @@ export function WelcomeInputPanel({
<MessageContent>
<ContentPartsRenderer parts={msg.content} role={msg.role} />
</MessageContent>
{msg.role === "user" && msg.userImages?.length ? (
<UserImageAttachments
images={msg.userImages}
className="self-end"
/>
) : null}
{msg.role === "user" && msg.userResources?.length ? (
<UserResourceLinks
resources={msg.userResources}

View File

@@ -16,6 +16,7 @@ import type { AgentType, PromptDraft } from "@/lib/types"
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
import {
buildUserMessageTextPartsFromDraft,
extractUserImagesFromDraft,
extractUserResourcesFromDraft,
} from "@/lib/prompt-draft"
import { buildConversationDraftStorageKey } from "@/lib/message-input-draft"
@@ -119,6 +120,7 @@ const ExistingConversationView = memo(function ExistingConversationView({
draft,
sharedT("attachedResources")
),
userImages: extractUserImagesFromDraft(draft),
userResources: extractUserResourcesFromDraft(draft),
timestamp: new Date().toISOString(),
},
@@ -209,6 +211,7 @@ const ExistingConversationView = memo(function ExistingConversationView({
return (
<ConversationShell
status={conn.status}
promptCapabilities={conn.promptCapabilities}
defaultPath={folder?.path}
error={conn.error}
pendingPermission={conn.pendingPermission}

View File

@@ -8,6 +8,7 @@ import {
type AdaptedMessage,
type AdaptedContentPart,
type MessageGroup,
type UserImageDisplay,
type UserResourceDisplay,
groupAdaptedMessages,
extractUserResourcesFromText,
@@ -15,6 +16,7 @@ import {
import { TurnStats } from "./turn-stats"
import { LiveTurnStats } from "./live-turn-stats"
import { UserResourceLinks } from "./user-resource-links"
import { UserImageAttachments } from "./user-image-attachments"
import { useSessionStats } from "@/contexts/session-stats-context"
import { LiveMessageBlock } from "@/components/chat/live-message-block"
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
@@ -45,6 +47,7 @@ interface MessageListViewProps {
interface ResolvedMessageGroup extends MessageGroup {
parts: AdaptedContentPart[]
resources: UserResourceDisplay[]
images: UserImageDisplay[]
}
function fallbackExtractUserResources(
@@ -53,11 +56,13 @@ function fallbackExtractUserResources(
): {
parts: AdaptedContentPart[]
resources: UserResourceDisplay[]
images: UserImageDisplay[]
} {
if (group.role !== "user") {
return {
parts: group.parts,
resources: group.userResources ?? [],
images: group.userImages ?? [],
}
}
@@ -94,7 +99,11 @@ function fallbackExtractUserResources(
parsedParts.push({ type: "text", text: attachedResourcesText })
}
return { parts: parsedParts, resources: dedupedResources }
return {
parts: parsedParts,
resources: dedupedResources,
images: group.userImages ?? [],
}
}
function resolveMessageGroup(
@@ -106,6 +115,7 @@ function resolveMessageGroup(
...group,
parts: resolved.parts,
resources: resolved.resources,
images: resolved.images,
}
}
@@ -125,6 +135,9 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
</MessageContent>
{group.role === "user" && group.images.length > 0 ? (
<UserImageAttachments images={group.images} className="self-end" />
) : null}
{group.role === "user" && group.resources.length > 0 ? (
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}
@@ -152,6 +165,9 @@ const PendingMessageGroup = memo(function PendingMessageGroup({
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
</MessageContent>
{group.role === "user" && group.images.length > 0 ? (
<UserImageAttachments images={group.images} className="self-end" />
) : null}
{group.role === "user" && group.resources.length > 0 ? (
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}

View File

@@ -0,0 +1,38 @@
"use client"
import Image from "next/image"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
interface UserImageAttachmentsProps {
images: UserImageDisplay[]
className?: string
}
export function UserImageAttachments({
images,
className,
}: UserImageAttachmentsProps) {
if (images.length === 0) return null
return (
<div className={className}>
<div className="flex flex-wrap gap-1.5">
{images.map((image, index) => (
<div
key={`${image.uri ?? image.name}-${index}`}
className="overflow-hidden rounded-md border border-border/70 bg-muted/30"
>
<Image
src={`data:${image.mime_type};base64,${image.data}`}
alt={image.name}
width={56}
height={56}
unoptimized
className="h-14 w-14 object-cover"
/>
</div>
))}
</div>
</div>
)
}