Day 82: Attachments That Open
Sunday. The day after a big Saturday is often the day the loose threads from Saturday get pulled. Yesterday's image-attachment work shipped the wire-level fix; today's work is the part that makes attachments feel like part of the product — composer chips for non-image files, an in-app preview modal that actually opens a PDF, and the matching auth surface on the upload route so the new GET endpoint isn't a hole.
The Chip Becomes a Surface
Until today, non-image attachments in the chat were rendered as plain text in the message body — [file: report.pdf], which is honest about there being a file but invisible as a thing to interact with. Today's commits replace the text fallback with a proper attachment chip: the filename truncated to fit, the file extension always visible at the end (so report.pdf stays ...rt.pdf rather than truncating into ambiguity), and the full filename available on hover. The chip lives in the composer when an attachment is queued for send, and in the user's message bubble after the send completes.
The matching change in the chat reducer is that attachment metadata now persists per-message via in-text markup rather than via a separate side-channel. The previous shape held the metadata in the runtime's FilePart structure, which worked fine in memory but didn't survive a history reload — opening the chat fresh would render the attachment as bare filename text again. The fix is to encode the metadata inline as a markup token the reducer recognises, so the round-trip through OpenClaw's history store preserves it. An E2E test asserts image attachments survive history reload as file parts — the property the previous architecture didn't have.
One small surprise during the refactor: the chip's filename truncation needs to be aware of which end carries the meaning. Truncating from the right is fine for prose but wrong for filenames — the extension is the part the user needs to see most. The fix prefers a head-truncation that always preserves the last six characters (enough for any common extension) plus an ellipsis at the front, with the full name in the tooltip for the case where the prefix matters more than the suffix.
AttachmentPreview
The bigger work is the AttachmentPreview component, which replaces what had been a forced-download behaviour with an in-app preview modal. Click a PDF chip and the document opens in a modal with the actual document rendered via an <embed> tag pointed at the uploads route; click an image and the same modal renders it via <img>. Both end up in the same shell with the same close button (with explicit aria-describedby wiring so the modal announces itself to screen readers correctly), the same backdrop, and the same dvh-based sizing so it works on mobile browsers whose viewport units do the wrong thing during scroll.
The TDD shape of the PR: a failing contract test for AttachmentPreview goes in first, asserting the component renders a PDF for application/pdf, an image for image/*, and a generic can't preview this file fallback for anything else. The fix follows, then a handful of polish commits — close-button styling, the dvh unit fix, the aria-describedby. Two tests landed alongside that catch the regression classes the new component could re-introduce: an image-modal src assertion verifies the preview is pointed at the authenticated upload URL (not at a stale blob URL from the composer), and a null-agentId case asserts the modal degrades gracefully when the agent context is missing rather than crashing in production.
The preview-mounting code learned to HEAD-probe the attachment URL before mounting the <embed>. A direct mount that hits a 403 or a stale URL renders as an empty grey box with no signal to the user; a HEAD probe upfront lets the modal show can't load this attachment with a retry button before the embed even mounts. The shape is the same as last week's chat-error work: every silent failure becomes a visible one that names what went wrong.
The Uploads Route Gets Auth
The new preview surface required a real GET endpoint for the upload files — until today, the route was open. The fix routes GET /api/agents/:agentId/uploads/:fileId through the withAuth wrapper, so an unauthenticated request gets a 401 at the edge before any disk read happens. The matching access rule is that a user can GET an upload if they can access the agent it's attached to — the same assertAgentAccess helper from Thursday's security pass.
The contract test for the route had two small contradictions the PR review caught. The first: a test labelled returns 403 when agent is not accessible was asserting on a 404 response, because the asserted-against route returned 404 for a non-existent agent and 403 for an inaccessible one, and the test was set up to hit the former. The fix renames the test and adds the missing case for the latter. The second: the per-test reset wasn't running resetModules in the afterEach, so module-level mocks were leaking between tests in the suite and intermittently confusing the assertions.
A few smaller fixes that fell out of the same pass. Content-Disposition on the upload response now follows RFC 6266 — proper UTF-8 filename* parameter, properly quoted, properly fallback-encoded — so a filename with an umlaut renders correctly in browser download dialogs instead of getting mangled into =?UTF-8?B?.... X-Frame-Options got a deliberate override on the uploads route alone (SAMEORIGIN → unset for this path), so the preview modal's <embed> can render the PDF inline; the rest of the app stays clamped to SAMEORIGIN. The MIME-detection switched from fileTypeFromFile to fileTypeFromBuffer, because the file-based API doesn't work cleanly under ESM + webpack. The response body encoding now uses Uint8Array.from for proper BodyInit compatibility, with the security-lint false positive suppressed via a comment naming what the linter is wrong about.
OpenClaw's Built-In PDF/Image Tools
One bigger fix that's worth flagging on its own. The previous chat-attachment pipeline had been routing PDFs through a custom inlining step (read the file, base64-encode it, splice it into the prompt as a content block). That worked but doubled the storage cost of every chat message that referenced a PDF, and was about to break on the upcoming OpenClaw release that handles content blocks differently. The fix routes non-image attachments through OpenClaw's built-in pdf/image tools — the assistant gets a reference to the workspace path, the tool reads the file when needed, the file lives on disk once and is referenced by path everywhere else. Same end-user behaviour, much cleaner pipeline, and the workspace path is now the canonical location for any attached file.
The Agent Workspaces Page
A documentation push followed the implementation: a new Agent Workspaces concept page that explains the architecture the attachment work has been building toward. Each agent gets a workspace directory on disk; uploads land in it under a content-hash filename (so a re-upload of the same file dedupes); the workspace is what OpenClaw's tools reference when the assistant needs to read an attachment back. The image-attachments and overall architecture docs got aligned to the same path — three docs that had been describing slightly different versions of the same idea now describe the same one.
Two smaller behaviour changes shipped in the same window. The chat now suppresses the NO_REPLY silent-reply sentinel from the UI — it's a control signal between OpenClaw and the client-router, not something the user should ever see as an empty assistant message. And the idempotency E2E tests now retry on EACCES when reading openclaw.json, which can race with the writer in the milliseconds after a config change; the retry is bounded at three attempts with backoff before failing the test properly.
Day 82
The shape of two days on attachments is that yesterday made the wire safe and today made the surface useful. Wire-safe is the property that a 10 MB photo doesn't crash the WebSocket; useful is the property that the file lands in the message as something a user can click and see. Most of today's commits are review feedback on PR #316, which has been open since the start of the week — the unusual length of the PR is the shape of feature work that's structurally simple but touches a half-dozen seams. Closing PR #316 is the headline of the next release, which will be tomorrow.