attach a PDF and the agent reads it; the microphone button is gone

Two requests, one file apart.

── The microphone ──

Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav`
are also the file browser's DictateDialog, which is not what was asked to go.

── PDFs, by not extracting anything ──

A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered
it and did nothing, because nothing here could extract PDF text. It is back and real,
and it still extracts nothing: the file is uploaded, left on disk, and the agent is
given its PATH. Its own `Read` handles PDFs by page, better than any extraction this
could have shipped, and it can go back for a section later without paying for it
twice.

The alternative was base64 in the message. That is megabytes in the turn, re-sent for
the rest of the conversation, to produce worse text.

── What this uncovered ──

`attachmentIds` has been on this websocket and in `types.ts` for a long time and
NOTHING on the server read it. Images work because they travel separately as base64,
so the ids were dead on the wire — and a PDF is exactly the case that needs them. They
resolve to paths now, appended to the prompt as `Attached files (read from disk)`.

Ids arrive in a websocket message and become a filesystem path, so they are checked
rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only
in the two directories uploads actually write to. Verified — a valid id resolves, an
unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield
nothing.

Both upload directories are searched because the first message of a chat carries ids
written to `tmp/` (no session yet) and later ones carry ids under the session.

PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the
ceiling is storage rather than context.

tsgo clean, frontend builds, 914 pass / 7 fail unchanged.

`[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether
their Linux user can read it depends on the confinement ACLs. Untested — the owner
path is what was verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:08:24 +00:00
co-authored by Claude Opus 5
parent f4f8ea32d1
commit 92bac490d7
9 changed files with 175 additions and 45 deletions
@@ -589,6 +589,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
attachments: attachmentManager.attachments,
attachWebpage: attachmentManager.attachWebpage,
attachImage: attachmentManager.attachImage,
attachFile: attachmentManager.attachFile,
removeAttachment: attachmentManager.removeAttachment,
availableModels,
showJumpToBottom,
@@ -14,6 +14,7 @@ type AttachButtonProps = {
* attachment that would be discarded before the model saw it. See `supportsImages` in InputArea.
*/
onAttachImage?: (file: File) => void;
onAttachFile?: (file: File) => void;
onAttachWebpage: () => void;
/** Text files are inlined into the composer rather than sent as an attachment — see below. */
onAttachText: (text: string) => void;
@@ -29,8 +30,15 @@ const TEXT_ACCEPT =
const NUL = String.fromCharCode(0);
export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, size = 'sm' }: AttachButtonProps) {
export function AttachButton({
onAttachImage,
onAttachFile,
onAttachWebpage,
onAttachText,
size = 'sm',
}: AttachButtonProps) {
const imageInputRef = useRef<HTMLInputElement>(null);
const pdfInputRef = useRef<HTMLInputElement>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const sizeClasses = size === 'md' ? 'h-10 w-10' : 'h-7 w-7 md:h-9 md:w-9';
@@ -81,9 +89,17 @@ export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, siz
Image
</DropdownMenuItem>
)}
{/* "PDF" used to sit beside this one. Both were inert — no onSelect at all — so the menu offered
four things and did two. There is no PDF text extraction anywhere in the platform, client or
server, so that entry could not be made honest without building one first. */}
{/* PDF sat here once with no `onSelect` at all — the menu offered it and did nothing, because
nothing in the platform could extract PDF text. It is back and real, by not extracting
anything: the file is uploaded, left on disk, and the agent is given its PATH. Its own
`Read` handles PDFs by page, which is better than any extraction this could have shipped —
and nothing has to carry megabytes of base64 through every turn of the conversation. */}
{onAttachFile && (
<DropdownMenuItem className="cursor-pointer" onSelect={() => pdfInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" />
PDF
</DropdownMenuItem>
)}
<DropdownMenuItem className="cursor-pointer" onSelect={() => textInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" />
Text File
@@ -105,6 +121,17 @@ export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, siz
ev.target.value = '';
}}
/>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
className="hidden"
onChange={(ev) => {
const file = ev.target.files?.[0];
if (file) onAttachFile?.(file);
ev.target.value = '';
}}
/>
<input
ref={textInputRef}
type="file"
@@ -1,4 +1,4 @@
import { Loader2, Image, Link, X } from 'lucide-react';
import { FileText, Image, Link, Loader2, X } from 'lucide-react';
import type { Attachment } from '../types';
type AttachmentListProps = {
@@ -22,10 +22,16 @@ export function AttachmentList({ attachments, onRemove }: AttachmentListProps) {
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
) : a.type === 'image' ? (
<Image className="h-3 w-3 shrink-0" />
) : a.type === 'file' ? (
<FileText className="h-3 w-3 shrink-0" />
) : (
<Link className="h-3 w-3 shrink-0" />
)}
<span className="truncate">{a.type === 'image' ? a.filename : a.loading ? 'Loading...' : a.title}</span>
{/* A file has no preview and no title — its name is all there is, and all that is needed:
the agent is told where it is, not handed its contents. */}
<span className="truncate">
{a.type === 'image' || a.type === 'file' ? a.filename : a.loading ? 'Loading...' : a.title}
</span>
<button type="button" onClick={() => onRemove(i)} className="shrink-0 hover:text-foreground cursor-pointer">
<X className="h-3 w-3" />
</button>
@@ -1,9 +1,8 @@
import type { DragEvent } from 'react';
import { useRef, useState } from 'react';
import { Loader2, Mic, Send, Square, ListPlus, X } from 'lucide-react';
import { Loader2, Send, Square, ListPlus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import { useAudioRecording } from '../useAudioRecording';
import { ModelSelector } from './ModelSelector';
import { BackgroundJobDialog } from './BackgroundJobDialog';
import { armBackgroundJob } from '../../../background-job-origin';
@@ -37,6 +36,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
attachments,
attachWebpage,
attachImage,
attachFile,
removeAttachment,
appendToInput,
thinkingLevel,
@@ -48,7 +48,6 @@ export const InputArea = ({ manager }: InputAreaProps) => {
sessionId,
} = manager;
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const [jobDialogOpen, setJobDialogOpen] = useState(false);
// The panel's own directory, so a background job needs no path typed into it.
@@ -95,10 +94,10 @@ export const InputArea = ({ manager }: InputAreaProps) => {
<div className="flex items-end gap-1 md:gap-2">
<AttachButton
onAttachImage={supportsImages ? attachImage : undefined}
onAttachFile={attachFile}
onAttachWebpage={() => setUrlDialogOpen(true)}
onAttachText={appendToInput}
/>
<MicButton recording={recording} transcribing={transcribing} onToggle={toggleRecording} />
<textarea
ref={textareaRef}
@@ -289,29 +288,3 @@ const QueuedList = ({
</div>
);
};
type MicButtonProps = {
recording: boolean;
transcribing: boolean;
onToggle: () => void;
};
const MicButton = ({ recording, transcribing, onToggle }: MicButtonProps) => (
<button
type="button"
disabled={transcribing}
onClick={onToggle}
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{transcribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : recording ? (
<>
<span className="absolute inset-0 rounded-lg animate-ping bg-destructive/30" />
<Square className="h-3.5 w-3.5 text-destructive" />
</>
) : (
<Mic className="h-4 w-4" />
)}
</button>
);
@@ -1,6 +1,13 @@
export type Attachment =
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean }
/**
* A document left on disk for the agent to read, rather than carried in the message.
*
* A PDF as base64 would be megabytes in every turn of the conversation, to produce text the agent's own
* `Read` extracts better — and by page, which a prompt cannot do.
*/
| { type: 'file'; filename: string; attachmentId: string; loading?: boolean };
export type MessageCost = {
inputTokens: number;
@@ -62,6 +62,33 @@ export function useAttachments(params?: UseAttachmentsParams) {
}
};
/**
* A PDF. Uploaded like an image and then deliberately forgotten by the browser.
*
* Nothing is kept but the id: there is no preview to render and no bytes to send. The id becomes a path
* server-side and the agent reads the file itself.
*/
const attachFile = async (file: File) => {
const idx = attachments.length;
setAttachments((prev) => [...prev, { type: 'file', filename: file.name, attachmentId: '', loading: true }]);
try {
const formData = new FormData();
formData.append('file', file);
if (params?.sessionId) formData.append('sessionId', params.sessionId);
formData.append('provider', 'chat');
const res = await client.post<{ attachmentId: string }>('/upload', formData);
setAttachments((prev) =>
prev.map((a, i) => (i === idx ? { ...a, attachmentId: res.attachmentId, loading: false } : a)),
);
} catch (err) {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
// The server's own words — it is the one that knows the size limit and the accepted types.
toast.error(err instanceof Error ? err.message : 'Failed to upload file');
}
};
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
@@ -82,6 +109,9 @@ export function useAttachments(params?: UseAttachmentsParams) {
} else if (a.type === 'image' && a.dataUrl) {
prefix = `[Attached image: ${a.filename}]\n\n${prefix}`;
images.push({ filename: a.filename, dataUrl: a.dataUrl });
} else if (a.type === 'file' && a.attachmentId) {
// Named, not carried. The server turns the id into a path and tells the agent to read it.
prefix = `[Attached file: ${a.filename}]\n\n${prefix}`;
}
ids.push(a.attachmentId);
}
@@ -93,6 +123,7 @@ export function useAttachments(params?: UseAttachmentsParams) {
attachments,
attachWebpage,
attachImage,
attachFile,
removeAttachment,
clearAttachments,
processAttachments,