Skip to content

feat(web): improve non-image attachment UX and persistence - #3531

Closed
italic-jinxin wants to merge 9 commits into
mainfrom
feat/web-attachments-ui-polish-main
Closed

italic-jinxin wants to merge 9 commits into
mainfrom
feat/web-attachments-ui-polish-main

Conversation

@italic-jinxin

Copy link
Copy Markdown
Contributor

Summary

  1. Closes the user-visible polish for Support non-image file attachments in web gateway (PDF, audio, documents) #1341 ("Support non-image file
    attachments in web gateway"). Backend MIME allowlist + magic-byte
    sniffing already shipped in [codex] Support web document uploads #2332 / feat(gateway): add attachment flows, v2 skill install coverage, and e2e stabilization #2385 / [codex] Fix gateway slash autocomplete and attachment rendering #2763; this PR finishes
    the per-file budget bump and the staging-strip UI.

  2. User-uploaded image attachments now survive a page refresh — instead of
    degrading into a generic file card, the chat surface re-fetches the
    original bytes and renders them inline.

The persisted <attachments> XML carries metadata only (filename,
mime, size). Without disk persistence + a URL the browser can fetch,
images visibly "disappear" on every refresh. This PR closes that gap.

  1. Redesigns the chat composer's attachment trigger from a 📎 emoji button into
    a properly styled 32×32 circular button with a Lucide-style + icon, in the
    visual tradition of iMessage / Slack / WhatsApp. Closes Replace paperclip emoji with styled + button for attachment trigger #1342.

Changes

Backend

  • MAX_INLINE_ATTACHMENT_BYTES: 5 MiB → 7 MiB. Total budget (10 MiB),
    file count (5), and 14 MiB request body cap unchanged.
  • Update web/CLAUDE.md to match.

Frontend

  • Replace "DOCX"-text icon with Lucide-style SVGs (audio = music note,
    others = generic file). Show type • size in card meta.
  • Reject video/* at the chat-input boundary with an i18n alert (backend
    allowlist excludes video).
  • Click any image (.image-preview, .message-attachment-image,
    .generated-image) to open a fullscreen lightbox; ESC / backdrop closes.
  • Fix the × remove button — .chat-input button (Send accent-pill) was
    outranking the bare selector and rendering it as a stretched green pill.
  • Drop dead stagedImages / handleImageFiles legacy paths.

Backend

  • New persist_legacy_image_attachments runs on user-message ingest
    (thread_ops::process_user_input), writing image bytes to disk and
    stamping local_path on the attachment so format_attachment can
    emit project_path="..." in the persisted XML.
  • Shared helpers extracted into channels/attachments.rs:
    sanitize_attachment_segment, persist_attachment_at,
    legacy_attachment_relative_path. Engine v2's existing
    persist_project_attachments was refactored to call them — pure
    refactor, no behavior change.
  • Disk layout (under ~/.ironclaw/attachments/):
    • v1 (default): <owner>/.legacy/<msg>/<file>
    • v2: <owner>/<project>/<date>/<file> (unchanged)

HTTP route

  • New GET /api/attachments/{owner}/{*path}, behind the regular
    gateway Bearer-auth middleware. Authorization is layered:
    • URL <owner> segment must match the authenticated user.
      Mismatch → 404 (deliberately not 403 — we don't confirm
      files exist for a different user).
    • .. and \ rejected at the URL boundary.
    • Canonical-path containment check inside the owner directory.
    • Streamed with tokio::fs::File::open + ReaderStream per
      .claude/rules/safety-and-sandbox.md "Bounded Resources".

History surface

  • New HistoryResponse.turns[].user_attachments field shape:
    { filename, mime_type, size_label, url, kind }. Deliberately
    out-of-band from the LLM-facing user_input text — the
    <attachments> XML stays clean and the frontend gets the URLs it
    needs without us polluting the agent's context.
  • Server-side extract_user_attachments parses each persisted
    <attachment project_path="..."> element and maps it to a
    /api/attachments/{owner}/... URL.

Frontend

  • renderMessageAttachments now fetches images with
    Authorization: Bearer ... and swaps a blob URL onto the <img>.
    Token never enters a URL — no leakage via logs / Referer / browser
    history. Fetch failure (404 / auth / network) falls back to the
    existing file-card render so the bubble never shows a broken-image
    icon.
  • Pre-upload JPEG canvas resize: source ≥ 1600 px gets re-encoded as
    JPEG q=0.88 before upload, capping request body size and LLM
    context. PNG / GIF / WebP pass through untouched to preserve
    transparency and animation.

HTML* (crates/ironclaw_gateway/static/index.html)

  • Wrap [file-input][attach-btn][textarea][send-btn] in a new
    .chat-input-row so the preview strip sits cleanly above the input row
    (no more flex-wrap reflow hack).
  • Move #attach-btn to the left of the textarea (was: right).
  • Swap the 📎 emoji for an inline Lucide plus SVG with aria-hidden.

CSS (crates/ironclaw_gateway/static/styles/surfaces/chat.css)

  • .chat-input → flex-direction: column (was row + flex-wrap).
  • New .chat-input-row for the actual input row.
  • .chat-input .attach-btn:
    • 32×32 circular (width/height: 32px, border-radius: 50%)
    • 1.5px border in var(--border), transparent background
    • 6px padding → 44×44 total touch target (WCAG 2.5.5)
    • Hover: border + icon brighten, subtle bg-tertiary fill, 150ms ease
    • Active: transform: scale(0.95)
    • Focus: 2px accent outline (:focus-visible)
    • SVG inside fixed at 18×18, display: block
    • Selector specificity (0,2,0) wins over the broader
      .chat-input button accent-pill rule (0,1,1) from both base.css
      mobile @media block and the Send button styles — explicit padding /
      border-radius / background resets are no longer needed.

JS (crates/ironclaw_gateway/static/js/core/widgets.js)

  • Comment-only fix: "paperclip in the composer" → "plus button in the
    composer". No logic change.

No JS behavior changes. All wiring (#attach-btn / #image-file-input
event listeners) preserved untouched.

Screenshots

Attachment in chat input Attachment in chat message Image Preview Paper clip enhance
attachment in chat input attachment in chat message preview model paper clip enhance

Change Type

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • CI/Infrastructure
  • Security
  • Dependencies

Linked Issue

Closes #1341 and #3272 and #1342

Validation

  • cargo fmt --all -- --check
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings
  • cargo build
  • Relevant tests pass:
  • cargo test --features integration if database-backed or integration behavior changed
  • Manual testing:
  • If a coding agent was used and supports it, review-pr or pr-shepherd --fix was run before requesting review

Security Impact

Database Impact

Blast Radius

Rollback Plan

Review Follow-Through


Review track:

- Bump per-file cap 5 MiB → 7 MiB
- Reject video/* at chat-input boundary with i18n alert
- Lucide SVG icons (audio = note, others = generic file)
- Show type + size in card meta line
- Fix × button styling clobbered by .chat-input button accent-pill
- Add click-to-zoom image lightbox (ESC / backdrop closes)
- Remove dead stagedImages legacy paths
- 9 new regression tests for attachment validation
Address review feedback on the non-image attachment UI:

- Show filename in the video-rejection alert (i18n string was missing
  the `{name}` placeholder while the call site was passing one).
- i18n the lightbox `aria-label` via the new `chat.imagePreview` key
  in en/ko/zh-CN.
- Replace fragile `.replace('-name', '-icon')` class derivation in
  `appendAttachmentFileCard` with explicit per-element class names
  passed via a `classes` object.
- Don't hijack clicks on `<a><img></a>` markup — let the link navigate.
- Make the lightbox click listener idempotent so re-injection (e.g.
  hot-reload) doesn't stack handlers.
- Drop the unreachable empty-string fallback in `displayContent` and
  the dead `.image-preview-container` rule the new CSS shadowed.
)

Before this change, user images degraded to file cards after a page
refresh because the persisted XML carried metadata only — no pixels.
Now they're written to disk on ingest, served via a new authenticated
route, and re-rendered inline.

- Backend: extract `sanitize_attachment_segment` / `persist_attachment_at`
  shared by v1 and v2. v1 (default `ENGINE_V2=false`) now calls a new
  `persist_legacy_image_attachments` from `thread_ops::process_user_input`,
  matching v2's existing project-aware persist; both land under
  `~/.ironclaw/attachments/<owner>/...`.
- New route `GET /api/attachments/{owner}/{*path}`, Bearer-auth'd.
  Defense-in-depth: cross-user → 404 (no info leak), `..`/`\` rejected
  at URL boundary, canonical-path containment, streamed via
  `tokio::fs::File::open` + `ReaderStream`.
- `HistoryResponse.turns[].user_attachments` surfaces URLs out-of-band
  from the LLM-facing XML. Frontend fetches with Bearer and renders
  each image as a blob URL — token stays out of the URL.
- Frontend canvas resize: JPEGs over 1600 px are re-encoded to JPEG
  q=0.88 before upload, capping body size and LLM context. PNG /
  GIF / WebP pass through to preserve transparency / animation.
- `turn_info_from_in_memory_turn` and `in_progress_from_thread` were
  returning `Vec::new()` for user_attachments, leaving every refresh
  during a still-in-memory session falling back to file-card render
  even though the persisted XML already carried `project_path`. Both
  builders now call `extract_user_attachments(&t.user_input)`, same
  as the DB-persisted path. Adds 2 caller-level regression tests.
- Adds `user_attachments` to `InProgressInfo` so the frontend's
  in-progress fallback path renders correctly.
- Generalizes the canvas resize from JPEG-only to all bitmap MIMEs.
  Opaque PNGs (the common screenshot case) now re-encode as JPEG,
  saving ~80% of bytes; PNGs with real alpha keep PNG output.
  GIF / SVG continue to pass through to preserve animation / vector.
- Persist each attachment under a per-index subdirectory so filenames
  that sanitize to the same string (e.g. "a b.png" and "a_b.png") no
  longer overwrite each other on disk.
- Switch serve_user_attachment to tokio::fs::canonicalize so the async
  handler doesn't stall a runtime worker on blocking syscalls.
- Set Cache-Control: private, max-age=300 + Vary: Authorization on
  attachment responses so shared proxies don't cache per-user bytes
  while the browser still gets a short-lived cache.
- Revoke blob URLs once <img> finishes decoding so long chat sessions
  don't leak megabytes of object URLs across history prunes.
Aligns the attachment trigger with the iMessage / Slack / WhatsApp
pattern: a 32px circular button with a thin border sitting to the
left of the textarea, replacing the paperclip emoji on the right.

- HTML: `.chat-input` becomes a column container; new `.chat-input-row`
  holds `[+][textarea][Send]`. The preview strip sits above without
  relying on `flex-wrap: wrap`.
- CSS: `.attach-btn` is 32×32 with a 1.5px border, transparent
  background, 18px Lucide plus SVG centered. Padding lifts the touch
  target to 44px (WCAG). Hover lightens border/text and adds a
  subtle background; `:focus-visible` adds an accent outline.
- The Lucide SVG replaces the paperclip emoji for cross-platform
  visual consistency with the rest of the icon set.
- No JS or wiring changes — element IDs preserved.
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure size: XL 500+ changed lines scope: channel/web Web gateway channel scope: docs Documentation risk: medium Business logic, config, or moderate-risk modules contributor: experienced 6-19 merged PRs labels May 12, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust attachment handling system that ensures user-uploaded images persist correctly across page refreshes. Key changes include client-side image compression (downscaling and opaque PNG-to-JPEG conversion), a new authenticated API endpoint for serving attachments, and a lightbox for image previews. Additionally, the per-file attachment limit was increased to 7MB. Feedback identifies a functional issue where immediate blob URL revocation breaks the lightbox and suggests implementing a time-based cache for attachment fetches to reduce redundant network requests.

Comment thread crates/ironclaw_gateway/static/js/surfaces/chat.js
Comment thread crates/ironclaw_gateway/static/js/surfaces/chat.js Outdated
@italic-jinxin italic-jinxin changed the title Feat/web attachments UI polish main feat(web): improve non-image attachment UX and persistence May 12, 2026
Add inline `// safety:` comments to the three regex compile sites in
`extract_user_attachments` so the pre-commit panic-style check
recognizes them as infallible static patterns rather than production
.expect() violations.
a007a2c revoked the blob URL inside the inline `<img>`'s `load`
handler. That works for the inline render in Chrome's happy path, but
two scenarios produced broken images:

1. Lightbox click: `showImageLightbox(target.src, ...)` feeds the same
   blob URL to a fresh `<img>` in an overlay. That second load reused
   the revoked URL and silently rendered the broken-image placeholder
   (Chrome reports `complete === true` but `naturalWidth === 0`).
2. Repaint after bitmap eviction: a backgrounded tab or memory pressure
   can drop the decoded bitmap; the next paint needs to re-fetch the
   URL, which is now dead.

Tie the revoke to DOM removal instead. The blob URL is stamped on
`image.dataset.blobUrl`; a one-shot MutationObserver wired on
`#chat-messages` revokes it when the bubble (or any ancestor carrying
it) is removed. That keeps memory tracking the visible history
(pruneOldMessages, thread switch, ad-hoc remove) without breaking
either the inline render or click-to-preview.

Adds an e2e regression that uploads a PNG, refreshes the page to
exercise the persist-then-fetch path, clicks the inline image, and
asserts the lightbox `<img>` decodes (`naturalWidth > 0`). The old
revoke-on-load code fails this assertion because the lightbox `<img>`
gets a dead blob URL.
a007a2c revoked the blob URL inside the inline `<img>`'s `load`
handler. That works for the inline render in Chrome's happy path, but
two scenarios produced broken images:

1. Lightbox click: `showImageLightbox(target.src, ...)` feeds the same
   blob URL to a fresh `<img>` in an overlay. That second load reused
   the revoked URL and silently rendered the broken-image placeholder
   (Chrome reports `complete === true` but `naturalWidth === 0`).
2. Repaint after bitmap eviction: a backgrounded tab or memory pressure
   can drop the decoded bitmap; the next paint needs to re-fetch the
   URL, which is now dead.

Tie the revoke to DOM removal instead. The blob URL is stamped on
`image.dataset.blobUrl`; a one-shot MutationObserver wired on
`#chat-messages` revokes it when the bubble (or any ancestor carrying
it) is removed. That keeps memory tracking the visible history
(pruneOldMessages, thread switch, ad-hoc remove) without breaking
either the inline render or click-to-preview.

Adds an e2e regression that uploads a PNG, refreshes the page to
exercise the persist-then-fetch path, clicks the inline image, and
asserts the lightbox `<img>` decodes (`naturalWidth > 0`). The old
revoke-on-load code fails this assertion because the lightbox `<img>`
gets a dead blob URL.

Also bumps the oversized-file threshold in
`test_gateway_attachment_limits_block_batched_uploads` from 5 MiB to
7 MiB + 1 byte. `MAX_ATTACHMENT_SIZE_BYTES` was raised to 7 MiB in
7e368b8 (#1341 polish) but this test still allocated a 5 MiB + 1 byte
file, which now sits below the limit and never fires the alert the
test waits for.
@italic-jinxin

Copy link
Copy Markdown
Contributor Author

feature implemented for v1, closed as conflict with reborn

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: experienced 6-19 merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

1 participant