diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 5ac0668a07..67886275eb 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -20,6 +20,7 @@ import {markTraceAsFresh} from "@agenta/entities/trace" import { contextWindowForModel, harnessCapabilitiesAtomFamily, + modalitiesForModel, invalidateAgentCommittedRevisionCache, workflowBuildKitOverlayReadyAtomFamily, workflowMolecule, @@ -61,6 +62,7 @@ import {DriveSessionProvider} from "@/oss/components/Drives/driveSessionContext" import { SessionFilesDrawer, filesDrawerOpenAtomFamily, + filesDrawerStagedAtomFamily, } from "@/oss/components/Drives/SessionFilesDrawer" import { IDE_INSTALL_COMMAND, @@ -90,9 +92,14 @@ import {AgentChatTransport} from "./assets/AgentChatTransport" import { type AttachmentRejection, DEFAULT_ATTACHMENT_LIMITS, + describeAccepted, validateIncoming, } from "./assets/attachments" -import {doesAgentChatStopKillSession, isAgentVoiceInputEnabled} from "./assets/constants" +import { + doesAgentChatStopKillSession, + isAgentFileUploadsEnabled, + isAgentVoiceInputEnabled, +} from "./assets/constants" import {filesToParts} from "./assets/files" import {loadSessionMessages} from "./assets/loadSession" import {messageText, sideEffectingToolsInRange} from "./assets/rewind" @@ -103,6 +110,7 @@ import AgentChatHistoryUnavailable from "./components/AgentChatHistoryUnavailabl import {ComposerSkeleton, TranscriptSkeleton} from "./components/AgentChatSkeleton" import AgentMessage from "./components/AgentMessage" import ApprovalDock, {getPendingApprovals} from "./components/ApprovalDock" +import AttachmentViewerDrawer from "./components/AttachmentViewerDrawer" import type {ClientToolOutputHandler} from "./components/clientTools" import ComposerAttachments from "./components/ComposerAttachments" import ConnectModelBanner from "./components/ConnectModelBanner" @@ -123,6 +131,7 @@ import RightPanelSplit from "./components/RightPanel/RightPanelSplit" import VoiceInputButton from "./components/VoiceInputButton" import {useAgentChatQueue, type QueuedMessage} from "./hooks/useAgentChatQueue" import {useAgentModelKeyStatus} from "./hooks/useAgentModelKeyStatus" +import {useAttachmentUploads} from "./hooks/useAttachmentUploads" import {useAudioRecorder} from "./hooks/useAudioRecorder" import {useFileActivityDetector} from "./hooks/useFileActivityDetector" import {expandedKeysForMessages, pruneExpandedAtom} from "./state/expandState" @@ -437,6 +446,8 @@ const AgentConversation = ({ }, [files, sessionId]) // Files turned away by the guardrails (too big, wrong type, over the count), shown inline. const [rejections, setRejections] = useState([]) + // The attachment currently open in the Files-drawer preview (its uid), or null when closed. + const [viewingUid, setViewingUid] = useState(null) const [attachmentsOpen, setAttachmentsOpen] = useState(false) // Single limits object so it can later be swapped for capability-derived limits. const limits = DEFAULT_ATTACHMENT_LIMITS @@ -666,6 +677,7 @@ const AgentConversation = ({ const quickLookHost = const filesWindowHost = const setFilesWindowOpen = useSetAtom(filesDrawerOpenAtomFamily(sessionId)) + const setFilesStaged = useSetAtom(filesDrawerStagedAtomFamily(sessionId)) // Hybrid history: localStorage holds only the session INDEX; the durable conversation CONTENT // lives in the backend record log. Cache-first — when this tab opens with no locally-cached @@ -801,6 +813,16 @@ const AgentConversation = ({ // component and its logic stay wired up; flip this to `true` to bring the UI back. const showContextBudget = false + /** + * Whether the selected model can actually take audio in. `null` means the catalog does not + * say — treated as unknown, never as "no", so a missing field can't quietly demote voice. + * Drives which voice mode leads; it never refuses an attachment (design decision D6). + */ + const audioPerceivable = useMemo(() => { + const modalities = modalitiesForModel(harnessCapabilities, modelKey.harness, modelKey.model) + return modalities ? modalities.includes("audio") : null + }, [harnessCapabilities, modelKey.harness, modelKey.model]) + // ── Playground-native onboarding ────────────────────────────────────────── // This chat panel IS the onboarding surface while the agent is ephemeral: the empty state shows the // "what do you want to build?" hero and the composer renders Create-agent / Continue-in-IDE controls @@ -1704,14 +1726,26 @@ const AgentConversation = ({ originFileObj: file as UploadFile["originFileObj"], }) + // Upload lifecycle for the tray (progress / error / retry). The transport is not wired yet, so + // no uploader is passed — files stay "done" and enqueue is a no-op. When upload lands, provide + // an uploader here and the whole flow runs; the tray already renders every state. + const uploads = useAttachmentUploads(files, setFiles, undefined) + // A staged attachment blocks send only once uploads exist: while any is uploading or has failed, + // the message isn't ready. All-"done" today, so this is inert until the transport is wired. + const attachmentsSettled = !files.some((f) => f.status === "uploading" || f.status === "error") + /** Add files from paste / programmatic sources through the guardrails. */ - const addFiles = (incoming: File[]) => { - const {accepted, rejections: rej} = validateIncoming(incoming, files.length, limits) + const addFiles = (incoming: File[], extraRejections: AttachmentRejection[] = []) => { + const {accepted, rejections} = validateIncoming(incoming, files.length, limits) + const allRejections = [...extraRejections, ...rejections] if (accepted.length) { setFiles((prev) => [...prev, ...accepted.map(toUploadFile)]) - setAttachmentsOpen(true) + uploads.enqueue(accepted.map((f) => `${f.name}-${f.lastModified}-${f.size}`)) } - setRejections(rej) + setRejections(allRejections) + // Open for rejections too. Otherwise dropping something unsupported writes a message into + // a closed panel and reads as nothing having happened at all. + if (accepted.length || allRejections.length) setAttachmentsOpen(true) } const removeFile = (uid: string) => setFiles((prev) => prev.filter((f) => f.uid !== uid)) @@ -1728,6 +1762,9 @@ const AgentConversation = ({ */ // Flagged off until the agent service accepts audio parts (`NEXT_PUBLIC_AGENT_VOICE_INPUT`). const voiceEnabled = isAgentVoiceInputEnabled() + // Attach button + attachment preview + drive uploads (`NEXT_PUBLIC_AGENT_FILE_UPLOADS`). Paste + // and drag-to-attach predate the flag and stay on. + const uploadsEnabled = isAgentFileUploadsEnabled() const [voiceWillSend, setVoiceWillSend] = useState(false) const voiceRecorder = useAudioRecorder((file) => { if (voiceWillSend) handleSubmit("", [file]) @@ -1756,16 +1793,30 @@ const AgentConversation = ({ // now could take the last tray slot — which would reject (and destroy) the recording on // attach. Guarded at the entry points, not in `addFiles`, because the recorder's own // completion goes straight there and must always get through. - const attachmentsBusy = () => voiceRecorder.active + /** + * Attachments are refused while a take is in flight (the composer is covered, and a late drop + * could steal the tray slot the recording needs) and while the composer itself is disabled — + * accepting files into an input you cannot send from is a dead end. + */ + const composerDisabled = onboardingActive ? ideHandoffActive : modelBlocked + const attachmentsBlocked = () => voiceRecorder.active || composerDisabled + // The panel is ALWAYS a drop target for file drags — preventDefault on enter/over/drop even when + // blocked. Otherwise the browser's default action for a file drop is to navigate to it, which + // unloads the SPA and discards the whole conversation. Whether we ACCEPT the files is signalled + // separately (the overlay + the drop cursor), not by declining to be a drop target. const onDragEnter = (e: React.DragEvent) => { - if (attachmentsBusy()) return if (!isFileDrag(e)) return + e.preventDefault() + if (attachmentsBlocked()) return dragDepthRef.current += 1 setIsDragging(true) } const onDragOver = (e: React.DragEvent) => { - if (isFileDrag(e)) e.preventDefault() + if (!isFileDrag(e)) return + e.preventDefault() + // Honest cursor: "no drop" while blocked — but we stay a target so the drop is still swallowed. + e.dataTransfer.dropEffect = attachmentsBlocked() ? "none" : "copy" } const onDragLeave = (e: React.DragEvent) => { if (!isFileDrag(e)) return @@ -1776,16 +1827,29 @@ const AgentConversation = ({ } } const onDrop = (e: React.DragEvent) => { - if (attachmentsBusy()) return if (!isFileDrag(e)) return e.preventDefault() dragDepthRef.current = 0 setIsDragging(false) - const dropped = Array.from(e.dataTransfer.files) - if (dropped.length) { - addFiles(dropped) - setAttachmentsOpen(true) - } + if (attachmentsBlocked()) return + + // A dropped folder still arrives in `files` — as a typeless, zero-byte entry that the + // guardrails would reject as "not a supported file type", which is misleading. Name it. + // `webkitGetAsEntry` is only valid synchronously, during this event. + const folderNames = Array.from(e.dataTransfer.items ?? []) + .map((item) => (item.kind === "file" ? item.webkitGetAsEntry() : null)) + .filter((entry): entry is FileSystemEntry => !!entry?.isDirectory) + .map((entry) => entry.name) + + const dropped = Array.from(e.dataTransfer.files).filter( + (f) => !folderNames.includes(f.name), + ) + const folderRejections = folderNames.map((name) => ({ + name, + reason: "is a folder — drop the files inside it", + })) + + if (dropped.length || folderRejections.length) addFiles(dropped, folderRejections) } const handleSubmit = async (text: string, extraFiles: File[] = []) => { @@ -1797,6 +1861,7 @@ const AgentConversation = ({ ...extraFiles, ] if (!trimmed && fileObjs.length === 0) return + if (!attachmentsSettled) return const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined // Glide to the bottom; the min-h-full active turn makes that show the new question at the top // with the answer streaming below. Park during the glide, follow again on settle. Clear any @@ -2060,6 +2125,13 @@ const AgentConversation = ({ {modalContextHolder} {quickLookHost} {filesWindowHost} + {uploadsEnabled ? ( + setViewingUid(null)} + /> + ) : null} {/* Resizable [chat | right panel] split. The panel (turn inspector OR session content) pushes the chat aside rather than overlaying it, and collapses to 0 when closed. */} + {/* At the limit the overlay says so rather than inviting a drop it is + about to reject wholesale. */} {isDragging && ( -
- - - Drop files here +
+ + + {atMax ? "Attachment limit reached" : "Drop files here"} - {limits.label} · up to {limits.maxCount},{" "} - {Math.round(limits.maxBytes / 1024 / 1024)} MB each + {atMax + ? `Remove one to add another (${limits.maxCount} max)` + : `${describeAccepted(limits)} · up to ${limits.maxCount} files`}
)} @@ -2404,9 +2492,12 @@ const AgentConversation = ({ initialMarkdown={initialDraft} onChange={handleComposerChange} onPasteFile={(pasted) => { - if (!attachmentsBusy()) addFiles(Array.from(pasted)) + if (!attachmentsBlocked()) + addFiles(Array.from(pasted)) }} - sendForceEnabled={files.length > 0} + sendForceEnabled={ + files.length > 0 && attachmentsSettled + } streaming={busy} onStop={handleStop} prefix={ @@ -2429,6 +2520,7 @@ const AgentConversation = ({ voiceRecorder.supported } audioPending={voiceRecorder.pending} + audioPerceivable={audioPerceivable} attachmentsFull={atMax} onDictationError={setDictationError} onDictatingChange={setDictating} @@ -2439,20 +2531,28 @@ const AgentConversation = ({ } /> ) : null} - {/* Attach button is gated until the agent service is ready for - inline file parts (big-agents d4b119af26); paste / drag-to-add - still work. */} + {/* Attach button stays dead until the agent service is ready + for inline file parts (`NEXT_PUBLIC_AGENT_FILE_UPLOADS`); + paste / drag-to-add still work either way. */}
diff --git a/web/oss/src/components/AgentChatSlice/assets/attachments.ts b/web/oss/src/components/AgentChatSlice/assets/attachments.ts index fcf8d05a3c..ab4533acfa 100644 --- a/web/oss/src/components/AgentChatSlice/assets/attachments.ts +++ b/web/oss/src/components/AgentChatSlice/assets/attachments.ts @@ -1,39 +1,94 @@ /** * Attachment guardrails for the agent composer. Files are sent inline as base64 `data:` URLs * (see `files.ts`), so an unbounded picker puts arbitrary bytes straight into the request body. - * These limits cap the count, per-file size, and types. + * These limits cap the count, per-kind size, and types. * - * The limits are a single value object, not scattered constants, so they can later be derived - * from the selected model / harness capabilities (e.g. an image-only model, a larger context - * window) and passed down in place of `DEFAULT_ATTACHMENT_LIMITS`. That wiring is out of scope - * here; today everything reads the default. + * NOTE on ceilings: while attachments ride the request body, the real limit is the gateway's + * `client_max_body_size` (10 MB on the compose stack), and base64 inflates by ~33% on top of the + * whole resent history. These caps are deliberately per-kind rather than generous. They can rise + * once attachments travel as references instead of bytes. + * + * The limits are a single value object, not scattered constants, so they can be derived from the + * selected model / harness capabilities and passed down in place of `DEFAULT_ATTACHMENT_LIMITS` — + * narrowing `kinds` is the seam that capability gating plugs into. */ +export type AttachmentKind = "image" | "audio" | "document" + +/** Media types per kind: exact types (`application/pdf`) or `type/` prefixes (`image/`). */ +const KIND_TYPES: Record = { + image: ["image/"], + audio: ["audio/"], + document: ["application/pdf", "text/", "application/json"], +} + +/** `accept` hints for the native picker (a hint only — drag/paste is validated regardless). */ +const KIND_ACCEPT_ATTR: Record = { + image: "image/*", + audio: "audio/*", + document: "application/pdf,text/plain,text/markdown,text/csv,.md,.csv,application/json", +} + +const KIND_NOUN: Record = { + image: "images", + audio: "audio", + document: "documents", +} + export interface AttachmentLimits { - /** Max files per message. */ + /** Max files per message, across all kinds. */ maxCount: number - /** Max bytes per file (before base64 inflation, which adds ~33% on the wire). */ - maxBytes: number - /** Accepted media types: exact types (`application/pdf`) or `type/` prefixes (`image/`). */ - accept: string[] - /** `accept` attribute for the native file picker (a hint; drag/paste is validated too). */ - acceptAttr: string - /** Human label for the kinds accepted, e.g. "Images and documents". */ - label: string + /** Kinds the composer accepts. Narrowing this is how capability gating plugs in. */ + kinds: AttachmentKind[] + /** Max bytes per file, per kind (before base64 inflation, which adds ~33% on the wire). */ + maxBytes: Record } +const MB = 1024 * 1024 + export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = { maxCount: 5, - maxBytes: 5 * 1024 * 1024, - accept: ["image/", "audio/", "application/pdf", "text/", "application/json"], - acceptAttr: - "image/*,audio/*,application/pdf,text/plain,text/markdown,text/csv,.md,.csv,application/json", - label: "Images, audio, and documents", + kinds: ["image", "audio", "document"], + maxBytes: { + // A photo off a phone clears 5 MB routinely. + image: 10 * MB, + // Our own recordings cap near 2.4 MB; the headroom is for uploaded clips. + audio: 15 * MB, + document: 10 * MB, + }, +} + +/** Which kind a media type belongs to, or null when it is not something we take at all. */ +export const kindForType = (mediaType: string): AttachmentKind | null => { + for (const kind of Object.keys(KIND_TYPES) as AttachmentKind[]) { + const matches = KIND_TYPES[kind].some((t) => + t.endsWith("/") ? mediaType.startsWith(t) : mediaType === t, + ) + if (matches) return kind + } + return null } -/** Whether a media type is allowed under the limits (prefix or exact match). */ -export const isAcceptedType = (mediaType: string, limits: AttachmentLimits): boolean => - limits.accept.some((a) => (a.endsWith("/") ? mediaType.startsWith(a) : mediaType === a)) +/** Whether a media type is allowed under the limits (right kind, and that kind is enabled). */ +export const isAcceptedType = (mediaType: string, limits: AttachmentLimits): boolean => { + const kind = kindForType(mediaType) + return !!kind && limits.kinds.includes(kind) +} + +/** `accept` attribute for the native file picker, built from the enabled kinds. */ +export const acceptAttrFor = (limits: AttachmentLimits): string => + limits.kinds.map((k) => KIND_ACCEPT_ATTR[k]).join(",") + +/** Human summary of what is accepted, e.g. "Images, audio, and documents". */ +export const describeAccepted = (limits: AttachmentLimits): string => { + const nouns = limits.kinds.map((k) => KIND_NOUN[k]) + if (nouns.length === 0) return "No attachments" + const sentence = + nouns.length === 1 + ? nouns[0] + : `${nouns.slice(0, -1).join(", ")}, and ${nouns[nouns.length - 1]}` + return sentence.charAt(0).toUpperCase() + sentence.slice(1) +} /** Compact human size: `820 KB`, `4.2 MB`. */ export const formatBytes = (n: number): string => { @@ -45,7 +100,7 @@ export const formatBytes = (n: number): string => { export interface AttachmentRejection { /** The file's name, for the inline message. */ name: string - /** Why it was rejected (verb phrase): "is too large (8.2 MB) · max 5 MB". */ + /** Why it was rejected (verb phrase): "is too large (8.2 MB) · max 10 MB for images". */ reason: string } @@ -70,22 +125,22 @@ export const validateIncoming = ( for (const file of incoming) { const type = file.type || "application/octet-stream" - if (!isAcceptedType(type, limits)) { - rejections.push({name: file.name, reason: `isn't a supported file type`}) + const kind = kindForType(type) + + if (!kind || !limits.kinds.includes(kind)) { + rejections.push({name: file.name, reason: "isn't a supported file type"}) continue } - if (file.size > limits.maxBytes) { + const maxBytes = limits.maxBytes[kind] + if (file.size > maxBytes) { rejections.push({ name: file.name, - reason: `is too large (${formatBytes(file.size)}) · max ${formatBytes(limits.maxBytes)} per file`, + reason: `is too large (${formatBytes(file.size)}) · max ${formatBytes(maxBytes)} for ${KIND_NOUN[kind]}`, }) continue } if (remaining <= 0) { - rejections.push({ - name: file.name, - reason: `exceeds the ${limits.maxCount}-file limit`, - }) + rejections.push({name: file.name, reason: `exceeds the ${limits.maxCount}-file limit`}) continue } accepted.push(file) diff --git a/web/oss/src/components/AgentChatSlice/assets/constants.ts b/web/oss/src/components/AgentChatSlice/assets/constants.ts index 49bac0b033..068293bf47 100644 --- a/web/oss/src/components/AgentChatSlice/assets/constants.ts +++ b/web/oss/src/components/AgentChatSlice/assets/constants.ts @@ -31,3 +31,13 @@ export const isAgentChatSteerEnabled = (): boolean => */ export const isAgentVoiceInputEnabled = (): boolean => (getEnv("NEXT_PUBLIC_AGENT_VOICE_INPUT") || "").toLowerCase() === "true" + +/** + * File uploads and attachments — the composer attach button + attachment preview, and every drive + * upload entry point (upload button, drop-to-upload in the Files drawer, drop-to-stage on a recents + * peek). Off by default: the composer→model delivery contract is still open on the backend. Enable + * with `NEXT_PUBLIC_AGENT_FILE_UPLOADS=true`. Paste/drag-to-attach on the composer predates this and + * is not gated. + */ +export const isAgentFileUploadsEnabled = (): boolean => + (getEnv("NEXT_PUBLIC_AGENT_FILE_UPLOADS") || "").toLowerCase() === "true" diff --git a/web/oss/src/components/AgentChatSlice/components/AttachmentViewerDrawer.tsx b/web/oss/src/components/AgentChatSlice/components/AttachmentViewerDrawer.tsx new file mode 100644 index 0000000000..3beda79377 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/AttachmentViewerDrawer.tsx @@ -0,0 +1,138 @@ +import {useEffect, useMemo, useState} from "react" + +import {type MountFile} from "@agenta/entities/session" +import type {UploadFile} from "antd" + +import {type DriveFileSource, DriveFileSourceContext} from "@/oss/components/Drives/driveFileSource" +import {FilesDrawer} from "@/oss/components/Drives/FilesDrawer" +import {type SessionDriveData} from "@/oss/components/Drives/useSessionDrive" + +/** + * Previews the composer's attachments in the shared Files drawer — the same tree + viewer used for + * agent files — instead of a parallel viewer. The files are local in-memory blobs, so it hands the + * drawer an explicit file list plus a `DriveFileSourceContext` that resolves bytes from object URLs + * (see `DriveExplorer` `explicitFiles` and `driveFileSource`). + */ + +/** A file kind the drawer can actually preview; audio plays inline in the tray instead. */ +const isViewable = (mediaType: string): boolean => + mediaType.startsWith("image/") || + mediaType === "application/pdf" || + mediaType.startsWith("text/") || + mediaType === "application/json" + +const EXT_FOR_TYPE: Record = { + "application/pdf": "pdf", + "application/json": "json", + "text/plain": "txt", + "text/markdown": "md", + "text/csv": "csv", + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", +} + +const extForType = (type: string): string => { + if (EXT_FOR_TYPE[type]) return EXT_FOR_TYPE[type] + const [group, sub] = type.split("/") + if (group === "image" || group === "audio" || group === "video") return sub || group + return "" +} + +/** Kind resolution keys on the extension, so give a name that lacks one one derived from its type. */ +const withExtension = (name: string, type: string): string => { + if (/\.[^./]+$/.test(name)) return name + const ext = extForType(type) + return ext ? `${name}.${ext}` : name +} + +const dedupe = (name: string, seen: Map): string => { + const n = seen.get(name) ?? 0 + seen.set(name, n + 1) + if (n === 0) return name + const dot = name.lastIndexOf(".") + return dot > 0 ? `${name.slice(0, dot)}-${n + 1}${name.slice(dot)}` : `${name}-${n + 1}` +} + +interface BuiltNodes { + files: MountFile[] + source: DriveFileSource + /** Composer uid → the path we present it under, so a click can open the drawer onto it. */ + pathByUid: Map +} + +const buildNodes = (uploads: UploadFile[]): BuiltNodes => { + const seen = new Map() + const files: MountFile[] = [] + const source: DriveFileSource = new Map() + const pathByUid = new Map() + + for (const upload of uploads) { + const file = upload.originFileObj as File | undefined + if (!file) continue + const path = dedupe(withExtension(upload.name || file.name || "file", file.type), seen) + files.push({path, size: file.size, is_folder: false}) + source.set(path, {file, objectUrl: URL.createObjectURL(file)}) + pathByUid.set(upload.uid, path) + } + return {files, source, pathByUid} +} + +const AttachmentViewerDrawer = ({ + uploads, + openUid, + onClose, +}: { + uploads: UploadFile[] + /** The attachment being viewed, or null when the drawer is closed. */ + openUid: string | null + onClose: () => void +}) => { + const open = openUid !== null + + // Build (and own the object URLs of) the node set only while open, revoking on close/change. + const [nodes, setNodes] = useState(null) + useEffect(() => { + if (!open) { + setNodes(null) + return + } + const built = buildNodes(uploads) + setNodes(built) + return () => built.source.forEach((v) => URL.revokeObjectURL(v.objectUrl)) + }, [open, uploads]) + + // Local mode: no mount, so nothing resolves to a download; bytes come from the context. + const drive = useMemo( + () => ({ + mount: null, + files: nodes?.files ?? [], + fileCount: nodes?.files.length ?? 0, + totalSize: 0, + recents: [], + lastTouchedAt: null, + summary: "", + isLoading: false, + errored: false, + resolveMount: () => null, + }), + [nodes], + ) + + return ( + + + + ) +} + +export {isViewable} +export default AttachmentViewerDrawer diff --git a/web/oss/src/components/AgentChatSlice/components/AudioPlayer.tsx b/web/oss/src/components/AgentChatSlice/components/AudioPlayer.tsx index 6544045d5f..bcb85f23ad 100644 --- a/web/oss/src/components/AgentChatSlice/components/AudioPlayer.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AudioPlayer.tsx @@ -94,10 +94,18 @@ const AudioPlayer = ({src, name, className}: {src: string; name: string; classNa > {playing ? : } -
- - {name} - + {/* Name and time share the top row so they align on one baseline; the bar spans the + full width beneath both, rather than the time floating between the two rows. */} +
+
+ + {name} + + + {fmt(current)} + {duration > 0 ? ` / ${fmt(duration)}` : ""} + +
- - {fmt(current)} - {duration > 0 ? ` / ${fmt(duration)}` : ""} -
) diff --git a/web/oss/src/components/AgentChatSlice/components/ComposerAttachments.tsx b/web/oss/src/components/AgentChatSlice/components/ComposerAttachments.tsx index f58cd4f6ba..ad65706dbd 100644 --- a/web/oss/src/components/AgentChatSlice/components/ComposerAttachments.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ComposerAttachments.tsx @@ -1,9 +1,12 @@ import {useEffect, useRef, useState, type ReactNode} from "react" import { + ArrowClockwise, + EarSlash, File as FileIcon, FileText, Image as ImageIcon, + ImageBroken, Plus, UploadSimple, WarningCircle, @@ -13,9 +16,16 @@ import {Tooltip, Typography} from "antd" import type {UploadFile} from "antd" import {AnimatePresence, MotionConfig, motion} from "motion/react" -import {type AttachmentLimits, type AttachmentRejection, formatBytes} from "../assets/attachments" +import { + acceptAttrFor, + type AttachmentLimits, + type AttachmentRejection, + describeAccepted, + formatBytes, +} from "../assets/attachments" import {SESSION_SPRING} from "../assets/sessionMotion" +import {isViewable} from "./AttachmentViewerDrawer" import AudioPlayer from "./AudioPlayer" const {Text} = Typography @@ -52,7 +62,10 @@ const RemoveButton = ({ ) +/** Upload state drawn over a tile: a progress scrim while uploading, a retry-able error otherwise. + * Reads antd's `UploadFile` fields, so the (future) upload flow only has to set status/percent. */ +const StatusOverlay = ({file, onRetry}: {file: UploadFile; onRetry?: (uid: string) => void}) => { + if (file.status === "uploading") { + const pct = Math.max(0, Math.min(100, Math.round(file.percent ?? 0))) + return ( + <> +
+
+
+
+
+ {pct}% +
+ + ) + } + if (file.status === "error") { + return ( + +
+ {onRetry && ( + + )} +
+
+ ) + } + return null +} + /** Shared chip shell: fixed height, one border treatment, room for a trailing remove. */ -const Chip = ({children, className}: {children: ReactNode; className?: string}) => ( +const Chip = ({ + children, + className, + onClick, +}: { + children: ReactNode + className?: string + onClick?: () => void +}) => (
{children}
@@ -76,10 +143,17 @@ interface ComposerAttachmentsProps { files: UploadFile[] rejections: AttachmentRejection[] limits: AttachmentLimits + /** Whether the model can take audio in; `null` when unknown. `false` marks an attached clip + * as workspace-only, since the model itself won't hear it (design decision D6). */ + audioPerceivable: boolean | null /** Add picked files through the caller's guardrails (`validateIncoming`). */ onAdd: (incoming: File[]) => void onRemove: (uid: string) => void onDismissRejections: () => void + /** Open a viewable attachment (image/document) in the Files drawer. */ + onView?: (uid: string) => void + /** Retry a failed upload (wired to the upload flow). */ + onRetry?: (uid: string) => void } /** @@ -93,14 +167,18 @@ const ComposerAttachments = ({ files, rejections, limits, + audioPerceivable, onAdd, onRemove, onDismissRejections, + onView, + onRetry, }: ComposerAttachmentsProps) => { const inputRef = useRef(null) const [previews, setPreviews] = useState>({}) + // Thumbnails whose object URL failed to decode (corrupt / unsupported image) — show a fallback. + const [previewFailed, setPreviewFailed] = useState>(new Set()) const atMax = files.length >= limits.maxCount - const maxMb = Math.round(limits.maxBytes / 1024 / 1024) // Object URLs for image previews and audio playback, recreated when the list changes and // revoked on cleanup (the list is small, ≤ maxCount). Without revoking, removed files leak. @@ -146,7 +224,7 @@ const ComposerAttachments = ({ ref={inputRef} type="file" multiple - accept={limits.acceptAttr} + accept={acceptAttrFor(limits)} onChange={onInput} className="hidden" /> @@ -197,7 +275,7 @@ const ComposerAttachments = ({ Attach files - {limits.label} · up to {limits.maxCount}, {maxMb} MB each + {describeAccepted(limits)} · up to {limits.maxCount} files ) : ( @@ -225,32 +303,67 @@ const ComposerAttachments = ({ {type.startsWith("audio/") && url ? ( - - - - + + + + {audioPerceivable === false && ( + + )} + + + ) : type.startsWith("image/") && url ? (
onView?.(f.uid)} + className={`group relative ${TILE} w-12 overflow-hidden rounded-lg border border-solid border-colorBorderSecondary ${onView ? "cursor-pointer" : ""}`} > - {/* Local object URL — next/image can't optimize a blob. */} - {/* eslint-disable-next-line @next/next/no-img-element */} - {f.name} + {previewFailed.has(f.uid) ? ( +
+ +
+ ) : ( + <> + {/* Local object URL — next/image can't optimize a blob. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {f.name} + setPreviewFailed((prev) => + new Set(prev).add(f.uid), + ) + } + className="h-full w-full object-cover" + /> + + )}
) : ( - + onView(f.uid) + : undefined + } + > )} +
) })} diff --git a/web/oss/src/components/AgentChatSlice/components/VoiceInputButton.tsx b/web/oss/src/components/AgentChatSlice/components/VoiceInputButton.tsx index 28e29fd6a2..df950cf40f 100644 --- a/web/oss/src/components/AgentChatSlice/components/VoiceInputButton.tsx +++ b/web/oss/src/components/AgentChatSlice/components/VoiceInputButton.tsx @@ -18,7 +18,9 @@ import {useVoiceInput} from "../hooks/useVoiceInput" type VoiceMode = "transcribe" | "audio" -const voiceModeAtom = atomWithStorage("agenta:agent-chat:voice-mode", "audio") +/** `null` = the person has not picked a mode, so capability decides which one leads. An explicit + * choice always wins and is never overridden. */ +const voiceModeAtom = atomWithStorage("agenta:agent-chat:voice-mode.v2", null) const MODE_LABEL: Record = { audio: "Voice message", @@ -46,6 +48,7 @@ const VoiceInputButton = ({ onStartAudio, audioSupported, audioPending, + audioPerceivable, attachmentsFull, onDictationError, onDictatingChange, @@ -65,9 +68,15 @@ const VoiceInputButton = ({ /** Awaiting the browser's mic prompt — shown here rather than as a composer takeover, since * the prompt is the browser's own UI and a page cannot dismiss it. */ audioPending: boolean + /** Whether the model can take audio in; `null` when the catalog does not say. Decides which + * mode leads and explains itself in the menu — it never blocks recording (D6). */ + audioPerceivable: boolean | null disabled?: boolean }) => { - const [mode, setMode] = useAtom(voiceModeAtom) + const [chosenMode, setMode] = useAtom(voiceModeAtom) + // Dictation produces text, so it works against any model; a voice message needs one that can + // hear. With no explicit choice, lead with whichever the model can actually use. + const mode: VoiceMode = chosenMode ?? (audioPerceivable === false ? "transcribe" : "audio") const transcribe = useVoiceInput() useEffect(() => { @@ -120,11 +129,26 @@ const VoiceInputButton = ({ } } + // A voice message on a model that can't take audio still records and attaches (the agent can + // use the file with its tools) — it just won't be heard. Say so where the mode is chosen, + // rather than blocking it (D6). + const audioNotHeard = audioPerceivable === false + // Icons in the menu too, so the mapping between a mode and the button's icon is taught here. const menuItems: MenuProps["items"] = available.map((m) => ({ key: m.key, - label: MODE_LABEL[m.key], icon: modeIcon(m.key), + label: + m.key === "audio" && audioNotHeard ? ( + + {MODE_LABEL[m.key]} + + This model can’t listen to audio + + + ) : ( + MODE_LABEL[m.key] + ), })) // Dictation writes into the editor, so it is unaffected by the attachment limit. diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAttachmentUploads.ts b/web/oss/src/components/AgentChatSlice/hooks/useAttachmentUploads.ts new file mode 100644 index 0000000000..fe83f12ce1 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/hooks/useAttachmentUploads.ts @@ -0,0 +1,102 @@ +import {useCallback, useEffect, useRef} from "react" + +import type {UploadFile} from "antd" + +/** + * Owns the per-attachment upload lifecycle, expressed on antd's `UploadFile` fields so the tray + * renders straight off them: `status` (`uploading` | `done` | `error`), `percent`, and `error` + * (a message). Everything is driven through here so the transport is the ONLY thing left to wire. + * + * `upload` is the seam. Until an upload transport exists it is left undefined: `enqueue` is then a + * no-op and files stay `done` (ready to send), exactly as today. Provide an `upload` and the whole + * progress / success / failure / retry flow runs with no other change — the tray already renders + * every state. + */ +export type AttachmentUploader = ( + file: File, + ctx: {onProgress: (percent: number) => void; signal: AbortSignal}, +) => Promise + +export interface AttachmentUploads { + /** Start (or restart) upload for these uids. No-op without an `upload` transport. */ + enqueue: (uids: string[]) => void + /** Retry one failed upload. */ + retry: (uid: string) => void +} + +export function useAttachmentUploads( + files: UploadFile[], + setFiles: (updater: (prev: UploadFile[]) => UploadFile[]) => void, + upload?: AttachmentUploader, +): AttachmentUploads { + // Read the latest files (for the File blob) without making `run` depend on every list change. + const filesRef = useRef(files) + filesRef.current = files + const controllers = useRef(new Map()) + + const patch = useCallback( + (uid: string, next: Partial) => { + setFiles((prev) => prev.map((f) => (f.uid === uid ? {...f, ...next} : f))) + }, + [setFiles], + ) + + const run = useCallback( + (uid: string) => { + if (!upload) return + const file = filesRef.current.find((f) => f.uid === uid)?.originFileObj as + | File + | undefined + if (!file) return + + controllers.current.get(uid)?.abort() + const controller = new AbortController() + controllers.current.set(uid, controller) + + patch(uid, {status: "uploading", percent: 0, error: undefined}) + upload(file, { + onProgress: (percent) => patch(uid, {status: "uploading", percent}), + signal: controller.signal, + }) + .then(() => { + if (controller.signal.aborted) return + controllers.current.delete(uid) + patch(uid, {status: "done", percent: 100}) + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return + controllers.current.delete(uid) + patch(uid, { + status: "error", + error: e instanceof Error ? e.message : "Upload failed", + }) + }) + }, + [upload, patch], + ) + + const enqueue = useCallback((uids: string[]) => uids.forEach(run), [run]) + const retry = useCallback((uid: string) => run(uid), [run]) + + // Abort in-flight uploads on unmount (session switch / pane close). + useEffect(() => { + const map = controllers.current + return () => map.forEach((c) => c.abort()) + }, []) + + return {enqueue, retry} +} + +/** Aggregate upload state for composer-level messaging and send-gating. */ +export interface AttachmentUploadSummary { + uploading: number + failed: number + /** True while any upload is in flight — send should wait. */ + busy: boolean +} + +export const summarizeUploads = (files: UploadFile[]): AttachmentUploadSummary => { + const uploading = files.filter((f) => f.status === "uploading").length + const failed = files.filter((f) => f.status === "error").length + return {uploading, failed, busy: uploading > 0} +} diff --git a/web/oss/src/components/Drives/ContextRail.tsx b/web/oss/src/components/Drives/ContextRail.tsx index 7700bdaab9..ae727dc0c2 100644 --- a/web/oss/src/components/Drives/ContextRail.tsx +++ b/web/oss/src/components/Drives/ContextRail.tsx @@ -27,6 +27,7 @@ import {useDriveArtifactId} from "./driveSessionContext" import {relativeTime} from "./driveTree" import {driveQuickLookAtomFamily} from "./quickLook" import {isRecentlyChanged, useRecentChangeClock} from "./recentChange" +import {type FileDropProps, useStageDrop} from "./useDriveDrop" import {driveHasMixedOrigins, useSessionDriveSummary} from "./useSessionDrive" const {Text} = Typography @@ -52,6 +53,7 @@ export function ContextRail({ busy, hidden = false, onOpenFiles, + onStageFiles, }: { sessionId: string /** The conversation is currently running a turn (drives the running indicator). */ @@ -61,8 +63,21 @@ export function ContextRail({ hidden?: boolean /** Open the Files drawer. */ onOpenFiles: () => void + /** Files dropped on the rail → stage them and open the drawer to pick a destination. Omit to + * disable drop-to-stage. */ + onStageFiles?: (files: File[]) => void }) { const [open, setOpen] = useAtom(contextRailOpenAtom) + // Drop-to-stage: a file drag over the rail (strip or expanded) opens the drawer with the files + // staged, so the destination is chosen there (recents has no folder of its own). + const {dropActive, dropProps: stageDropProps} = useStageDrop( + onStageFiles + ? (files) => { + setOpen(true) + onStageFiles(files) + } + : undefined, + ) // A brand-new never-run tab has no server data — hold the queries off until its first run. const artifactId = useDriveArtifactId() const drive = useSessionDriveSummary( @@ -93,8 +108,9 @@ export function ContextRail({ } }} aria-label="Show files" - className="group flex h-full cursor-pointer flex-col items-center gap-2.5 border-0 border-l border-solid pt-3 transition-colors hover:bg-[var(--ag-colorFillTertiary)]" + className={`group flex h-full cursor-pointer flex-col items-center gap-2.5 border-0 border-l border-solid pt-3 transition-colors hover:bg-[var(--ag-colorFillTertiary)] ${dropActive ? "bg-[var(--ant-color-primary-bg)]" : ""}`} style={{width: STRIP_WIDTH, borderColor: BORDER}} + {...stageDropProps} > @@ -144,6 +160,8 @@ export function ContextRail({ onOpenFiles={onOpenFiles} onCollapse={() => setOpen(false)} onQuickLook={(path) => openQuickLook({path})} + dropProps={stageDropProps} + dropActive={dropActive} /> )}
@@ -155,11 +173,16 @@ const ExpandedRail = ({ onOpenFiles, onCollapse, onQuickLook, + dropProps, + dropActive, }: { drive: ReturnType onOpenFiles: () => void onCollapse: () => void onQuickLook: (path: string) => void + /** Drop-to-stage handlers + highlight, forwarded from ContextRail (shared with the strip). */ + dropProps?: FileDropProps + dropActive?: boolean }) => { const now = useRecentChangeClock(drive.lastTouchedAt) const showOrigin = driveHasMixedOrigins(drive.recents) @@ -167,8 +190,9 @@ const ExpandedRail = ({ const download = useDriveItemDownload(drive) return (
+ + {name} + + + {caption} + +
+) + +/** In-flight upload tile: maps the upload item to PendingTile (progress / done / retry-on-failure). */ +const UploadTile = ({ + item, + onRetry, + onDismiss, +}: { + item: MountUploadItem + onRetry?: (id: string) => void + onDismiss?: (id: string) => void +}) => ( + = 100 ? "done" : "uploading"} + percent={item.percent} + caption={ + item.error + ? "Upload failed" + : item.percent >= 100 + ? "Uploaded" + : `Uploading ${item.percent}%` + } + captionTone={item.error ? "error" : "muted"} + onRetry={onRetry ? () => onRetry(item.id) : undefined} + onRemove={onDismiss && item.error ? () => onDismiss(item.id) : undefined} + /> +) + +/** A file dropped onto a recents peek and awaiting a destination — shown until it's committed with + * "Upload here" or removed. */ +interface StagedTileItem { + id: string + name: string + file: File + /** Object URL for an image preview, else null (icon fallback). Owned by DriveExplorer. */ + previewUrl: string | null +} + +const StagedTile = ({item, onRemove}: {item: StagedTileItem; onRemove?: (id: string) => void}) => ( + onRemove(item.id) : undefined} + /> +) + /** Right pane when a FOLDER is selected: fixed header (clickable breadcrumb + folder name) over a * grid of the folder's immediate children — subfolders drill in, files open the preview. Reuses the * chat grid's file tile (DriveFileRow). */ @@ -681,12 +871,29 @@ const FolderView = ({ autoFocus, anticipateShift, onSelect, + drop, + pendingUploadByPath, + onRetryUpload, + onDismissUpload, + stagedItems, + onRemoveStaged, }: { folderPath: string nodes: DriveTreeNode[] rootLabel: string drive: SessionDriveData showOrigin: boolean + /** Drag-and-drop upload behaviour (folder highlight, spring-load, drop) — absent = disabled. */ + drop?: DriveDrop + /** In-flight uploads keyed by their real tree path — the matching node (injected into the tree) is + * drawn as a progress/error tile instead of a plain file. */ + pendingUploadByPath?: Map + onRetryUpload?: (id: string) => void + onDismissUpload?: (id: string) => void + /** Files staged (dropped on a recents peek) awaiting a destination — ghost tiles shown in EVERY + * folder until committed with "Upload here", regardless of this folder's path. */ + stagedItems?: StagedTileItem[] + onRemoveStaged?: (id: string) => void /** This folder's level is still loading (lazy) — show the tile skeleton, not "Empty folder". */ loading?: boolean /** Chrome mode: the drawer's single header owns the breadcrumb/name/repo toggle, so drop this @@ -716,10 +923,27 @@ const FolderView = ({ const [repoExpanded, setRepoExpanded] = useState(false) // Folders first (matching the tree's sort), then files — one combined list so the grid windows // uniformly even when a folder holds thousands of immediate children. - const entries = useMemo( - () => [...nodes].sort((a, b) => (a.isFolder === b.isFolder ? 0 : a.isFolder ? -1 : 1)), - [nodes], + // Staged files (no destination yet) get synthetic `__staged__/` ghost tiles prepended; in-flight + // uploads are already REAL nodes in `nodes` (injected into the tree under their folder) and are + // decorated in renderTile via pendingUploadByPath. + const stagedByPath = useMemo( + () => + new Map( + (stagedItems ?? []).map((it) => [`__staged__/${it.id}`, it]), + ), + [stagedItems], ) + const entries = useMemo(() => { + const synthetic: DriveTreeNode[] = (stagedItems ?? []).map((it) => ({ + name: it.name, + path: `__staged__/${it.id}`, + isFolder: false, + children: [], + })) + return [...synthetic, ...nodes].sort((a, b) => + a.isFolder === b.isFolder ? 0 : a.isFolder ? -1 : 1, + ) + }, [stagedItems, nodes]) // Only surface the skeleton if the level is genuinely slow to load (>140ms); a quick load skips // straight to the grid so the user never sees a one-frame skeleton flash. const showSkeleton = useDelayedTrue(Boolean(loading) && nodes.length === 0, 140) @@ -824,9 +1048,14 @@ const FolderView = ({ {/* The content region crossfades between its states (absolute + overlapping), so a folder swap or skeleton→grid never hard-cuts. The skeleton is DELAYED — a fast load skips it entirely and the grid fades straight in from the previous folder. */} -
+
- {nodes.length > 0 ? ( + {entries.length > 0 ? ( onSelect(parentOf(folderPath))} getKey={(n) => n.path} renderTile={(n) => { + // Staged ghost tile (synthetic node) — awaiting a destination, no + // progress yet. Fades itself in like the upload tile. + const stagedItem = stagedByPath.get(n.path) + if (stagedItem) { + return ( + + + + ) + } + // In-flight upload: this is a REAL node (injected into the tree), + // drawn as a progress/error tile instead of a plain file. + const uploadItem = pendingUploadByPath?.get(n.path) + if (uploadItem) { + return ( + + + + ) + } const open = () => onSelect(n.path) const file = recentsByPath.get(n.path) const resolved = drive.resolveMount(n.path) @@ -901,9 +1176,20 @@ const FolderView = ({ // One-shot staggered entrance (see gridRevealNow) — cascades the // tiles in by index when the level first reveals; `min-w-0` keeps // the wrapper a shrinkable grid cell so tiles don't overflow. + // Folder tiles are drop targets: spring-load + upload, with a + // ring while hovered. + const folderDrop = + n.isFolder && drop + ? drop.folderDropProps(n.path) + : undefined return ( {content} @@ -968,10 +1254,19 @@ const DriveHeader = ({ partialErrored, onRetry, retrying, + onUpload, + stagedCount = 0, + onUploadStaged, }: { selectedPath: string | null isFolder: boolean rootLabel: string + /** Pick files to upload into the current folder — shown only for a writable mount. */ + onUpload?: () => void + /** Count of files staged (dropped on a recents peek) awaiting a destination. >0 → show the + * primary "Upload here" action that commits them into the current folder. */ + stagedCount?: number + onUploadStaged?: () => void /** Immediate-child count for a non-root folder (null when unknown / at root). */ itemCount: number | null /** Whole-drive file count — the chip at the root, preserving the old "N files". */ @@ -1088,6 +1383,31 @@ const DriveHeader = ({ ) : null}
+ {/* ONE upload button, context-dependent: with files staged it commits them into this + folder (primary-tinted); otherwise it opens the file picker (neutral). */} + {stagedCount > 0 && onUploadStaged ? ( + + ) @@ -137,7 +135,7 @@ const TextBody = ({ path: string kind: DriveFileKind }) => { - const contentQuery = useAtomValue(mountFileContentQueryFamily({mountId: mount?.id ?? "", path})) + const contentQuery = useDriveFileText(mount, path) const content = contentQuery.data if (contentQuery.isPending) @@ -174,7 +172,7 @@ const TextBody = ({ /** Syntax-highlighted body for code (and structured-data) files — the same lexical/Shiki block * the playground drawers use, read-only, horizontal scroll (code must not soft-wrap). */ const CodeBody = ({mount, path}: {mount: Mount | null; path: string}) => { - const contentQuery = useAtomValue(mountFileContentQueryFamily({mountId: mount?.id ?? "", path})) + const contentQuery = useDriveFileText(mount, path) const content = contentQuery.data const value = useMemo(() => { @@ -207,7 +205,7 @@ const CodeBody = ({mount, path}: {mount: Mount | null; path: string}) => { const CSV_ROW_CAP = 500 const CsvBody = ({mount, path}: {mount: Mount | null; path: string}) => { - const contentQuery = useAtomValue(mountFileContentQueryFamily({mountId: mount?.id ?? "", path})) + const contentQuery = useDriveFileText(mount, path) const content = contentQuery.data // +2 (header + CSV_ROW_CAP body + 1 probe): parse one row PAST the display cap so `capped` below // can tell "exactly CSV_ROW_CAP body rows" from "more than that" and show the truncation banner. @@ -328,16 +326,21 @@ const HTML_NAV_INTERCEPTOR = */ async function inlineHtmlAssets( html: string, - mountId: string, + mountId: string | null, dir: string, - projectId: string, + projectId: string | null, ): Promise { try { const doc = new DOMParser().parseFromString(html, "text/html") - await Promise.all( - Array.from(doc.querySelectorAll('link[rel~="stylesheet"][href]')).map( - async (link) => { + // Inline relative stylesheets/images from the mount. Skipped for a LOCAL preview (a composer + // attachment has no mount) — the sanitize + interceptor pipeline below still runs, so the + // Preview tab assembles instead of loading forever. + if (mountId && projectId) { + await Promise.all( + Array.from( + doc.querySelectorAll('link[rel~="stylesheet"][href]'), + ).map(async (link) => { const href = link.getAttribute("href") ?? "" if (!href || isExternalUrl(href)) return const css = await fetchMountText(mountId, projectId, resolveRel(dir, href)) @@ -345,18 +348,18 @@ async function inlineHtmlAssets( const style = doc.createElement("style") style.textContent = css link.replaceWith(style) - }, - ), - ) + }), + ) - await Promise.all( - Array.from(doc.querySelectorAll("img[src]")).map(async (img) => { - const src = img.getAttribute("src") ?? "" - if (!src || isExternalUrl(src) || src.startsWith("data:")) return - const uri = await fetchMountDataUri(mountId, projectId, resolveRel(dir, src)) - if (uri) img.setAttribute("src", uri) - }), - ) + await Promise.all( + Array.from(doc.querySelectorAll("img[src]")).map(async (img) => { + const src = img.getAttribute("src") ?? "" + if (!src || isExternalUrl(src) || src.startsWith("data:")) return + const uri = await fetchMountDataUri(mountId, projectId, resolveRel(dir, src)) + if (uri) img.setAttribute("src", uri) + }), + ) + } // Strip every agent-authored script vector so allow-scripts only runs our interceptor. doc.querySelectorAll("script").forEach((s) => s.remove()) @@ -398,19 +401,21 @@ const HtmlBody = ({ onNavigate?: (path: string) => void }) => { const projectId = useAtomValue(projectIdAtom) - const contentQuery = useAtomValue(mountFileContentQueryFamily({mountId: mount?.id ?? "", path})) + const contentQuery = useDriveFileText(mount, path) const content = contentQuery.data const [view, setView] = useState<"preview" | "source">("preview") const [assembled, setAssembled] = useState(null) const frameRef = useRef(null) - // Assemble the self-contained preview document once the source lands. + // Assemble the self-contained preview document once the source lands. Works for a local composer + // attachment too (mount === null): inlineHtmlAssets skips mount-asset fetches but still sanitizes + // + injects the interceptor, so the Preview tab assembles instead of loading forever. useEffect(() => { setAssembled(null) - if (typeof content !== "string" || !mount?.id || !projectId) return + if (typeof content !== "string") return let alive = true const dir = path.includes("/") ? path.split("/").slice(0, -1).join("/") : "" - void inlineHtmlAssets(content, mount.id, dir, projectId).then((html) => { + void inlineHtmlAssets(content, mount?.id ?? null, dir, projectId || null).then((html) => { if (alive) setAssembled(html) }) return () => { @@ -493,7 +498,7 @@ const MediaLoading = () => ( const ImageBody = ({mount, path}: {mount: Mount | null; path: string}) => { // Direct URL first: the browser streams/decodes outside the JS heap; blob only on auth error. - const {src: url, isPending, failed, onError} = useMountFileMediaSrc(mount, path) + const {src: url, isPending, failed, onError} = useDriveMediaSrc(mount, path) const [zoomed, setZoomed] = useState(false) if (isPending) return if (failed || !url) @@ -532,7 +537,7 @@ const ImageBody = ({mount, path}: {mount: Mount | null; path: string}) => { } const PdfBody = ({mount, path}: {mount: Mount | null; path: string}) => { - const {url, isPending, failed} = useMountFileObjectUrl(mount, path) + const {url, isPending, failed} = useDriveObjectUrl(mount, path) if (isPending) return if (failed || !url) return @@ -545,7 +550,7 @@ const PdfBody = ({mount, path}: {mount: Mount | null; path: string}) => { const AudioBody = ({mount, path}: {mount: Mount | null; path: string}) => { // Direct URL first: progressive playback, no JS-heap buffering; blob only on auth error. - const {src: url, isPending, failed, onError} = useMountFileMediaSrc(mount, path) + const {src: url, isPending, failed, onError} = useDriveMediaSrc(mount, path) if (isPending) return if (failed || !url) return @@ -560,7 +565,7 @@ const AudioBody = ({mount, path}: {mount: Mount | null; path: string}) => { const VideoBody = ({mount, path}: {mount: Mount | null; path: string}) => { // Direct URL first: progressive playback, no JS-heap buffering; blob only on auth error. - const {src: url, isPending, failed, onError} = useMountFileMediaSrc(mount, path) + const {src: url, isPending, failed, onError} = useDriveMediaSrc(mount, path) if (isPending) return if (failed || !url) return diff --git a/web/oss/src/components/Drives/useDriveDrop.ts b/web/oss/src/components/Drives/useDriveDrop.ts new file mode 100644 index 0000000000..c08bd45a47 --- /dev/null +++ b/web/oss/src/components/Drives/useDriveDrop.ts @@ -0,0 +1,194 @@ +import {useCallback, useEffect, useRef, useState} from "react" + +/** + * Drag-and-drop upload behaviour shared by the drive's tree and grid: highlight the folder under + * the cursor, spring-load into it after a short hover (drill to a nested destination without + * dropping), and upload on drop — into the hovered folder, or the current folder for a background + * drop. The views wire the returned handler props onto folder targets and their container. + */ + +const SPRING_MS = 700 + +export const isFileDrag = (e: React.DragEvent): boolean => + Array.from(e.dataTransfer?.types ?? []).includes("Files") + +export interface DriveDrop { + /** A file drag is in progress anywhere over the drive (for a subtle drop-affordance). */ + dragging: boolean + /** Folder path currently hovered as a drop target, or null. */ + hoverPath: string | null + /** Handlers for a folder drop target — spring-loads into it, uploads on drop. */ + folderDropProps: (path: string) => { + onDragEnter: (e: React.DragEvent) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void + } + /** Handlers for the view container — clears the hover, uploads into `currentFolder` on drop. */ + containerDropProps: (currentFolder: string) => { + onDragEnter: (e: React.DragEvent) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void + } +} + +export function useDriveDrop({ + enabled = true, + onUpload, + onNavigate, +}: { + /** False leaves the hook mounted but inert — no window listeners, nothing to hover. */ + enabled?: boolean + onUpload: (files: File[], folder: string) => void + onNavigate: (folder: string) => void +}): DriveDrop { + const [dragging, setDragging] = useState(false) + const [hoverPath, setHoverPath] = useState(null) + + const springTimer = useRef(undefined) + const springPath = useRef(null) + const clearSpring = useCallback(() => { + window.clearTimeout(springTimer.current) + springTimer.current = undefined + springPath.current = null + }, []) + + // Window-level drag tracking for the overall `dragging` flag (depth counter absorbs the + // dragenter/leave flicker from moving across child elements). + const depth = useRef(0) + useEffect(() => { + if (!enabled) return + const has = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files") + const onEnter = (e: DragEvent) => { + if (has(e)) { + depth.current += 1 + setDragging(true) + } + } + const onLeave = () => { + depth.current = Math.max(0, depth.current - 1) + if (depth.current === 0) setDragging(false) + } + const onEnd = () => { + depth.current = 0 + setDragging(false) + setHoverPath(null) + clearSpring() + } + window.addEventListener("dragenter", onEnter) + window.addEventListener("dragleave", onLeave) + window.addEventListener("drop", onEnd) + window.addEventListener("dragend", onEnd) + return () => { + window.removeEventListener("dragenter", onEnter) + window.removeEventListener("dragleave", onLeave) + window.removeEventListener("drop", onEnd) + window.removeEventListener("dragend", onEnd) + } + }, [enabled, clearSpring]) + + const startSpring = useCallback( + (path: string) => { + if (springPath.current === path) return // already counting down on this folder + clearSpring() + springPath.current = path + springTimer.current = window.setTimeout(() => { + onNavigate(path) + clearSpring() + setHoverPath(null) + }, SPRING_MS) + }, + [clearSpring, onNavigate], + ) + + const folderDropProps = useCallback( + (path: string) => ({ + // Folder targets stop propagation, so the container's onDragEnter only fires over empty + // space — which is how the hover clears when you move off a folder. + onDragEnter: (e: React.DragEvent) => { + if (!isFileDrag(e)) return + e.preventDefault() + e.stopPropagation() + setHoverPath(path) + startSpring(path) + }, + onDragOver: (e: React.DragEvent) => { + if (!isFileDrag(e)) return + e.preventDefault() + e.stopPropagation() + }, + onDrop: (e: React.DragEvent) => { + if (!isFileDrag(e)) return + e.preventDefault() + e.stopPropagation() + const files = Array.from(e.dataTransfer.files) + if (files.length) onUpload(files, path) + setHoverPath(null) + clearSpring() + }, + }), + [onUpload, startSpring, clearSpring], + ) + + const containerDropProps = useCallback( + (currentFolder: string) => ({ + onDragEnter: (e: React.DragEvent) => { + if (!isFileDrag(e)) return + setHoverPath(null) + clearSpring() + }, + onDragOver: (e: React.DragEvent) => { + if (isFileDrag(e)) e.preventDefault() + }, + onDrop: (e: React.DragEvent) => { + if (!isFileDrag(e)) return + e.preventDefault() + const files = Array.from(e.dataTransfer.files) + if (files.length) onUpload(files, currentFolder) + setHoverPath(null) + clearSpring() + }, + }), + [onUpload, clearSpring], + ) + + return {dragging, hoverPath, folderDropProps, containerDropProps} +} + +/** Handler props for a drop target — the shape both drop hooks hand to a host element. */ +export type FileDropProps = Pick< + React.HTMLAttributes, + "onDragOver" | "onDragLeave" | "onDrop" +> + +/** + * Drop-to-STAGE: the lighter sibling of {@link useDriveDrop} for the recents peeks (chat ContextRail / + * config StorageSection), which have no folder of their own. A file drag anywhere over the target + * highlights it, and a drop hands the files to `onFiles` (which stages them + opens the drawer where a + * destination is chosen). Pass a falsy `onFiles` to disable (e.g. no writable mount) — then `dropProps` + * is empty and nothing highlights. + */ +export function useStageDrop(onFiles: ((files: File[]) => void) | false | null | undefined): { + dropActive: boolean + dropProps: FileDropProps +} { + const [dropActive, setDropActive] = useState(false) + if (!onFiles) return {dropActive: false, dropProps: {}} + return { + dropActive, + dropProps: { + onDragOver: (e) => { + if (!isFileDrag(e)) return + e.preventDefault() + setDropActive(true) + }, + onDragLeave: () => setDropActive(false), + onDrop: (e) => { + if (!isFileDrag(e)) return + e.preventDefault() + setDropActive(false) + const files = Array.from(e.dataTransfer.files) + if (files.length) onFiles(files) + }, + }, + } +} diff --git a/web/oss/src/components/Drives/useImagePreviews.ts b/web/oss/src/components/Drives/useImagePreviews.ts new file mode 100644 index 0000000000..fe7bd8dd13 --- /dev/null +++ b/web/oss/src/components/Drives/useImagePreviews.ts @@ -0,0 +1,58 @@ +import {useEffect, useRef, useState} from "react" + +/** + * Object-URL previews for a list of image files: created lazily per file, revoked when a file leaves + * the list or the component unmounts. Non-image files are skipped. Returns a Map keyed by File whose + * REFERENCE changes only when the previewed set changes — so it's safe in downstream `useMemo` deps + * without per-render churn. + * + * `createObjectURL`/`revokeObjectURL` run ONLY in a commit effect, never in the render body: under + * React 19 concurrent rendering a render can be discarded, and creating/revoking URLs there would + * leak created ones or invalidate committed references. The live map lives in a ref (reconciled + + * revoked-on-unmount in effects); a state snapshot is published so consumers still re-render when a + * preview appears/disappears. + * + * Shared by the staged-file tiles (DriveExplorer) and in-flight uploads (useMountUpload), which both + * previously hand-rolled the same create/revoke dance. + */ +export function useImagePreviews(files: File[]): Map { + // The live map — mutated only inside effects, and the source of truth for unmount revocation. + const liveRef = useRef(new Map()) + // What consumers read. A fresh instance is published whenever the previewed set changes. + const [snapshot, setSnapshot] = useState>(liveRef.current) + + // Reconcile after every commit: mint URLs for newly-committed image files, revoke departed ones. + // No dep array — the reconcile is cheap and the guarded setState makes it converge in one extra + // commit (nothing to do → no state change → no re-render). + useEffect(() => { + const map = liveRef.current + const wanted = files.filter((f) => f.type.startsWith("image/")) + const wantedSet = new Set(wanted) + let changed = false + for (const f of wanted) { + if (!map.has(f)) { + map.set(f, URL.createObjectURL(f)) + changed = true + } + } + for (const [f, url] of [...map]) { + if (!wantedSet.has(f)) { + URL.revokeObjectURL(url) + map.delete(f) + changed = true + } + } + if (changed) setSnapshot(new Map(map)) + }) + + // Revoke everything on unmount. + useEffect( + () => () => { + for (const url of liveRef.current.values()) URL.revokeObjectURL(url) + liveRef.current.clear() + }, + [], + ) + + return snapshot +} diff --git a/web/oss/src/components/Drives/useMountUpload.ts b/web/oss/src/components/Drives/useMountUpload.ts new file mode 100644 index 0000000000..d019e9daed --- /dev/null +++ b/web/oss/src/components/Drives/useMountUpload.ts @@ -0,0 +1,169 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import {type Mount} from "@agenta/entities/session" +import {useAtomValue} from "jotai" +import {queryClientAtom} from "jotai-tanstack-query" + +import {projectIdAtom} from "@/oss/state/project" + +import {uploadMountFile} from "./driveMedia" +import {useImagePreviews} from "./useImagePreviews" + +/** + * Uploads files INTO a mount from the Files drawer (writing to the agent's working files), with + * per-item progress and retry — distinct from delivering a composer attachment to the model, which + * is held on the reference contract. Reuses `driveMedia.uploadMountFile` for the transport. + * + * A finished upload invalidates the mount's file queries (host-agnostic: keyed on project + mount, + * so it refreshes the open directory whether the host is a session or the config panel). + */ + +/** Stable identity for a picked File — so a re-drop, or staging then dropping the same file, is + * recognised as the SAME upload and never duplicated. */ +export const fileKey = (f: File): string => `${f.name}::${f.size}::${f.lastModified}` + +export interface MountUploadItem { + id: string + /** Stable file identity (see fileKey) — lets a host reconcile staged files against in-flight ones. */ + key: string + name: string + size: number + /** The picked file — drives the image preview (via useImagePreviews). */ + file: File + percent: number + /** Failure message, or null while pending. */ + error: string | null + /** Mount-relative destination folder (transport). */ + destFolder: string + /** Drive-root-relative destination folder ("" = root) — where the item slots into the tree/grid. */ + presentedFolder: string + /** Object URL for an image preview (derived from `file`), else null. */ + previewUrl: string | null +} + +/** Where an upload lands: the resolved mount (cwd or the folded agent-files mount) + folder. */ +export interface MountUploadTarget { + mount: Mount + /** Mount-relative folder ("" = root) — the transport path. */ + destFolder: string + /** Drive-root-relative folder the user chose — for placing the item in the tree/grid. */ + presentedFolder: string +} + +export interface MountUpload { + items: MountUploadItem[] + upload: (files: File[], target: MountUploadTarget) => void + retry: (id: string) => void + dismiss: (id: string) => void +} + +export function useMountUpload(): MountUpload { + const projectId = useAtomValue(projectIdAtom) + const queryClient = useAtomValue(queryClientAtom) + const [items, setItems] = useState([]) + + // Per-item inputs kept for retry, plus abort controllers for cleanup. + const sources = useRef(new Map()) + const controllers = useRef(new Map()) + + const patch = useCallback((id: string, next: Partial) => { + setItems((prev) => prev.map((it) => (it.id === id ? {...it, ...next} : it))) + }, []) + + const refreshListing = useCallback(() => { + // Prefix-match every mount file-query root for the project (dir listing, root, latest, summary). + for (const root of ["files", "files-latest", "files-root", "files-dir"]) { + void queryClient.invalidateQueries({queryKey: ["mounts", root, projectId]}) + } + }, [queryClient, projectId]) + + const run = useCallback( + (id: string) => { + const src = sources.current.get(id) + if (!src) return + controllers.current.get(id)?.abort() + const controller = new AbortController() + controllers.current.set(id, controller) + patch(id, {percent: 0, error: null}) + uploadMountFile({ + mountId: src.target.mount.id ?? "", + destFolder: src.target.destFolder, + file: src.file, + projectId, + onProgress: (percent) => patch(id, {percent}), + signal: controller.signal, + }) + .then(() => { + if (controller.signal.aborted) return + controllers.current.delete(id) + // On success the real file arrives via the listing refetch, so drop the optimistic + // item — removing its file from the list also lets useImagePreviews revoke the URL. + sources.current.delete(id) + setItems((prev) => prev.filter((it) => it.id !== id)) + refreshListing() + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return + controllers.current.delete(id) + patch(id, {error: e instanceof Error ? e.message : "Upload failed"}) + }) + }, + [projectId, patch, refreshListing], + ) + + const upload = useCallback( + (files: File[], target: MountUploadTarget) => { + // Skip files already in flight (same key) so a re-drop, or a drop of a file that's also + // staged, can't create a second upload item for the same file. + const inFlight = new Set( + Array.from(sources.current.values()).map((s) => fileKey(s.file)), + ) + const fresh = files.filter((f) => !inFlight.has(fileKey(f))) + if (!fresh.length) return + const started: MountUploadItem[] = [] + fresh.forEach((file, i) => { + const id = `${Date.now()}-${i}-${file.name}` + sources.current.set(id, {file, target}) + started.push({ + id, + key: fileKey(file), + name: file.name, + size: file.size, + file, + percent: 0, + error: null, + destFolder: target.destFolder, + presentedFolder: target.presentedFolder, + previewUrl: null, // derived below via useImagePreviews + }) + }) + setItems((prev) => [...prev, ...started]) + started.forEach((it) => run(it.id)) + }, + [run], + ) + + const retry = useCallback((id: string) => run(id), [run]) + const dismiss = useCallback((id: string) => { + controllers.current.get(id)?.abort() + controllers.current.delete(id) + sources.current.delete(id) + // Removing the item frees its file from the preview list → useImagePreviews revokes the URL. + setItems((prev) => prev.filter((it) => it.id !== id)) + }, []) + + useEffect(() => { + const map = controllers.current + return () => map.forEach((c) => c.abort()) + }, []) + + // Image previews are owned by the shared hook (created lazily, revoked when a file leaves / unmount); + // the returned items carry the derived URL so consumers keep reading `item.previewUrl`. + const previews = useImagePreviews(useMemo(() => items.map((it) => it.file), [items])) + const itemsWithPreviews = useMemo( + () => items.map((it) => ({...it, previewUrl: previews.get(it.file) ?? null})), + [items, previews], + ) + + return {items: itemsWithPreviews, upload, retry, dismiss} +} diff --git a/web/oss/src/lib/helpers/dynamicEnv.ts b/web/oss/src/lib/helpers/dynamicEnv.ts index 2f5b9d7210..957734f5c3 100644 --- a/web/oss/src/lib/helpers/dynamicEnv.ts +++ b/web/oss/src/lib/helpers/dynamicEnv.ts @@ -33,6 +33,10 @@ export const processEnv = { // dropdown exposes the Virtualization section and the chat can window its settled history. Gated // so it's off everywhere unless explicitly enabled while the approach is evaluated. NEXT_PUBLIC_AGENT_CHAT_VIRTUALIZATION: process.env.NEXT_PUBLIC_AGENT_CHAT_VIRTUALIZATION, + // Agent file uploads/attachments: when "true", the composer attach button + attachment preview + // and every drive upload entry point (upload button, drop-to-upload, drop-to-stage) are shown. + // Off by default — the composer→model attachment delivery contract is still open on the backend. + NEXT_PUBLIC_AGENT_FILE_UPLOADS: process.env.NEXT_PUBLIC_AGENT_FILE_UPLOADS, // Template-strip onboarding: when "true", template presentation on Home, playground // onboarding, and every agent's empty chat becomes one shared always-visible strip // (card click fills the composer + chip instead of creating/opening a drawer). Unset/ diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 68f46c33a6..d337a60a91 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -55,6 +55,7 @@ export { export { harnessCapabilitiesAtomFamily, contextWindowForModel, + modalitiesForModel, type HarnessCapabilities, type HarnessCapabilitiesMap, type ModelCatalogEntry, diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index eb1d01d431..ab7a9f0f11 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -17,6 +17,7 @@ export {workflowMolecule, type WorkflowMolecule} from "./molecule" export { harnessCapabilitiesAtomFamily, contextWindowForModel, + modalitiesForModel, type HarnessCapabilities, type HarnessCapabilitiesMap, type ModelCatalogEntry, diff --git a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts index 6f79b42722..03ad65830f 100644 --- a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts +++ b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts @@ -138,3 +138,18 @@ export function contextWindowForModel( const entry = capabilities[harness]?.model_catalog?.find((e) => e.id === modelId) return entry?.context_window ?? null } + +/** + * The input modalities a model declares (e.g. `["text", "image"]`), or null when the catalog does + * not say. Null means UNKNOWN, never "unsupported" — not every entry carries the field, so callers + * must not read a missing value as a capability the model lacks. + */ +export function modalitiesForModel( + capabilities: HarnessCapabilitiesMap | null | undefined, + harness: string | null | undefined, + modelId: string | null | undefined, +): string[] | null { + if (!capabilities || !harness || !modelId) return null + const entry = capabilities[harness]?.model_catalog?.find((e) => e.id === modelId) + return entry?.modalities?.length ? entry.modalities : null +}