Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
62f7165
fix(agent-chat): make dropping files behave at the edges
ardaerzin Jul 22, 2026
82abecf
feat(agent-chat): enable the attach button and give each kind its own…
ardaerzin Jul 22, 2026
29435a9
feat(agent-chat): let model audio capability steer voice, without eve…
ardaerzin Jul 22, 2026
0c5c8c6
fix(agent-chat): align the audio player name and time on one row
ardaerzin Jul 22, 2026
f55603e
refactor(drives): add a byte-source seam to the file viewer
ardaerzin Jul 22, 2026
6ca1713
feat(drives): explicit-files mode for the file drawer
ardaerzin Jul 22, 2026
1675f86
feat(agent-chat): preview attachments in the Files drawer
ardaerzin Jul 22, 2026
a386f8f
feat(agent-chat): per-item upload states, retry, and preview errors —…
ardaerzin Jul 22, 2026
0b3c77c
feat(drives): mount upload transport + in-flight state hook
ardaerzin Jul 22, 2026
a4a2a3a
feat(drives): upload files into a mount from the Files drawer
ardaerzin Jul 22, 2026
5ad6e67
feat(drives): foundation for drag-drop upload β€” folder-scoped items +…
ardaerzin Jul 22, 2026
24393f1
feat(drives): drag-drop upload in the grid β€” spring-load + optimistic…
ardaerzin Jul 22, 2026
7012196
feat(drives): drag-drop upload in the tree β€” spring-load + folder dro…
ardaerzin Jul 22, 2026
7da56ca
feat(drives): ride the list motion for optimistic uploads in grid and…
ardaerzin Jul 22, 2026
bee6501
feat(drives): drop files onto a recents peek to stage them for upload
ardaerzin Jul 23, 2026
d8ae88d
fix(drives): correct optimistic tile sizing + show pending items in t…
ardaerzin Jul 23, 2026
654fdad
fix(drives): drop the upload banner, restyle pending tiles to match f…
ardaerzin Jul 23, 2026
7abb7a0
fix(drives): dedupe upload state so a file is never staged and upload…
ardaerzin Jul 23, 2026
49d33ff
fix(drives): reconcile staged inbox against in-flight so a file shows…
ardaerzin Jul 23, 2026
0be8c6b
style(drives): make dropped-file tiles read like plain file tiles
ardaerzin Jul 23, 2026
f0e22e3
fix(drives): keep dropped-tile caption plain text so tiles match the …
ardaerzin Jul 23, 2026
373ec2f
fix(drives): force dropped-tile thumb to 4:3 with a padding-bottom box
ardaerzin Jul 23, 2026
4d5aefe
feat(drives): render in-flight uploads as real tree/grid rows under t…
ardaerzin Jul 23, 2026
2dd90bd
refactor(drives): one context-aware upload button in the header
ardaerzin Jul 23, 2026
65dc3f9
style(drives): tint pending items so they read as distinct at a glance
ardaerzin Jul 23, 2026
a4823f5
fix(drives): always show Dismiss on a failed upload tile
ardaerzin Jul 23, 2026
854fccf
refactor(drives): unify duplicated drop-to-stage + pending-tile code
ardaerzin Jul 23, 2026
4670419
refactor(drives): share object-URL previews via useImagePreviews + cl…
ardaerzin Jul 23, 2026
ce68d4a
chore(drives): remove the SIMULATE_UPLOAD test stub
ardaerzin Jul 23, 2026
29448e3
fix(agent-chat): never let a blocked file drop navigate the tab away
ardaerzin Jul 23, 2026
c4dafe5
fix(drives): create/revoke image-preview URLs in an effect, not durin…
ardaerzin Jul 23, 2026
0b5dd83
fix(drives): assemble the HTML preview for local (mount-less) attachm…
ardaerzin Jul 23, 2026
54cdae6
Merge fe-feat/agent-voice-input into fe-feat/agent-attachments
ardaerzin Jul 29, 2026
8638a57
feat(agent-chat): gate uploads + attachments behind NEXT_PUBLIC_AGENT…
ardaerzin Jul 29, 2026
fd8730e
Merge branch 'fe-feat/agent-voice-input' into fe-feat/agent-attachments
ardaerzin Jul 29, 2026
032d52b
Merge branch 'fe-feat/agent-voice-input' into fe-feat/agent-attachments
bekossy Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 139 additions & 29 deletions web/oss/src/components/AgentChatSlice/AgentConversation.tsx

Large diffs are not rendered by default.

117 changes: 86 additions & 31 deletions web/oss/src/components/AgentChatSlice/assets/attachments.ts
Original file line number Diff line number Diff line change
@@ -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<AttachmentKind, string[]> = {
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<AttachmentKind, string> = {
image: "image/*",
audio: "audio/*",
document: "application/pdf,text/plain,text/markdown,text/csv,.md,.csv,application/json",
}

const KIND_NOUN: Record<AttachmentKind, string> = {
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<AttachmentKind, number>
}

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 => {
Expand All @@ -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
}

Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions web/oss/src/components/AgentChatSlice/assets/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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, number>): 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<string, string>
}

const buildNodes = (uploads: UploadFile[]): BuiltNodes => {
const seen = new Map<string, number>()
const files: MountFile[] = []
const source: DriveFileSource = new Map()
const pathByUid = new Map<string, string>()

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<BuiltNodes | null>(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<SessionDriveData>(
() => ({
mount: null,
files: nodes?.files ?? [],
fileCount: nodes?.files.length ?? 0,
totalSize: 0,
recents: [],
lastTouchedAt: null,
summary: "",
isLoading: false,
errored: false,
resolveMount: () => null,
}),
[nodes],
)

return (
<DriveFileSourceContext.Provider value={nodes?.source ?? null}>
<FilesDrawer
open={open}
onClose={onClose}
drive={drive}
explicitFiles={nodes?.files ?? []}
initialPath={openUid ? (nodes?.pathByUid.get(openUid) ?? null) : null}
/>
</DriveFileSourceContext.Provider>
)
}

export {isViewable}
export default AttachmentViewerDrawer
20 changes: 12 additions & 8 deletions web/oss/src/components/AgentChatSlice/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,25 @@ const AudioPlayer = ({src, name, className}: {src: string; name: string; classNa
>
{playing ? <Pause size={14} weight="fill" /> : <Play size={14} weight="fill" />}
</button>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Text className="!text-xs truncate" title={name}>
{name}
</Text>
{/* 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. */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<Text className="!text-xs truncate" title={name}>
{name}
</Text>
<Text type="secondary" className="!text-[11px] shrink-0 tabular-nums">
{fmt(current)}
{duration > 0 ? ` / ${fmt(duration)}` : ""}
</Text>
</div>
<div className="h-0.5 w-full overflow-hidden rounded-full bg-colorFillTertiary">
<div
className="h-full rounded-full bg-colorPrimary"
style={{width: `${progress * 100}%`}}
/>
</div>
</div>
<Text type="secondary" className="!text-[11px] shrink-0 tabular-nums">
{fmt(current)}
{duration > 0 ? ` / ${fmt(duration)}` : ""}
</Text>
<audio ref={audioRef} src={src} preload="metadata" className="hidden" />
</div>
)
Expand Down
Loading
Loading