Skip to content

fix(workspace): a sent message shows its attachments, and keeps them after a reload (#3265) - #3269

Open
dolho wants to merge 5 commits into
devfrom
feature/3265-sent-message-attachments
Open

dolho wants to merge 5 commits into
devfrom
feature/3265-sent-message-attachments

Conversation

@dolho

@dolho dolho commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • In a Workspace chat, the composer uploaded a file on attach, then cleared its chip, and the user's message showed only the typed text. Nothing about the attachment was stored, so a reload could not show it either. The agent did get the file.
  • Now the sent message shows what it carried:
    • a thumbnail for each image;
    • a chip with name and size for any other file;
    • a failed chip with its reason for an upload that didn't land.
  • The attachments are stored with the user turn, so they survive a reload and a chat switch. Clicking any of them downloads the file through the existing upload route.

Before/after screenshots (light + dark, send → reply → reload): https://claude.ai/artifact/Wswqq7GVLBtjyiHcNaCTHs

How it works

  • Frontend:
    • The 1:1 send waits for in-flight uploads, as the room escalation already does. Only a settled chip can say whether the file was sent or failed.
    • It then puts the files on the message (portalMessageAttachments.js::sentAttachments), clears the composer, and sends their names as attachments on /chat/stream and the sync /chat fallback. Retry resends them.
    • The composable now keeps the filename the upload route stored the file under, which can differ from the picked name.
  • Backend:
    • resolve_turn_attachments reads the sender's own inbox once. It keeps a sent file only when that filename is there, with size and type taken from the listing, so a request can't put a file it never uploaded on its message.
    • A failed upload is stored with its reason; an unknown name is stored as failed, never trusted.
    • The request is capped at 20 entries, the upload batch limit.
  • Schema: a new nullable attachments TEXT column on enterprise_portal_messages, on both tracks (SQLite portal_messages_attachments + Alembic 0090_portal_messages_attachments), plus schema.py/tables.py. Existing rows stay NULL. History returns it on PortalHistoryMessage.attachments.
  • Rendering: PortalMessageAttachments.vue.
    • Fixed-size thumbnail boxes, so nothing shifts on arrival. Semantic tokens only, both themes.
    • The upload read route is rate-limited per person (20/min, 100/h). So each thumbnail is fetched once per tab, only when the message scrolls into view, and falls back to a chip if refused. A message sent from this tab uses the local file and costs no request.

Changes

  • Backend: client_portal/{models,service,router,db}.py, db/{schema,tables,migrations}.py, migrations/versions/0090_portal_messages_attachments.py
  • Frontend: PortalConversation.vue, new PortalMessageAttachments.vue and portalMessageAttachments.js (separate from the bug(workspace): attachments are dropped when a 1:1 chat is escalated into a room via @mention #2794 portalAttachments.js), usePortalFileDrop.js, stores/clientPortal.js
  • Docs: docs/memory/architecture/workspace.md
  • Three source-text pins updated: each pinned the exact text of a call this PR extends, and what each checks is unchanged.
    • test_ent473_chat_titles.py and test_ent551_voice_background_tasks.py pinned the _persist_user_turn(...) call text.
    • portalModelChoice.spec.js had a 400-character proximity window over the store signature; it's now 480.

Test Plan

  • tests/unit/test_3265_portal_message_attachments.py: 16 passed, on real SQLite through the real history and streaming routes. Mutations all turn tests red:
    • reverting router.py, db.py or models.py;
    • trusting any filename;
    • skipping the history decode;
    • treating a failed upload as sent.
  • src/frontend/tests/unit/portalMessageAttachments.mount.spec.js: 13 passed (mounted). Covers:
    • thumbnail fetched once, or from the local file;
    • file chip with size;
    • failed chip with reason;
    • fallback chip on a refused read;
    • download and its error;
    • the helpers, and the composable keeping the stored name.
  • Full frontend unit suite: 263 files, 4,657 tests passed. check:tokens OK.
  • 167 backend unit files touching the Workspace and the schema, shuffled: all pass except test_ent751_gate_callers.py::TestPaidA2A (3 tests), which fail the same way on unmodified dev.
  • check_alembic_heads: 1 head. check_alembic_parity origin/dev HEAD: PASS.
  • Live on a local instance (screenshots linked above): before, a text-only bubble that stays text-only after reload. After, a thumbnail, file chip and failed chip that survive a reload in light and dark.

Before merge

Out of scope / follow-ups

  • Room messages (enterprise_room_messages) don't carry attachments yet.
  • An image reaches the model as a picture only when the message text refers to it, by filename or words like "chart". This is visible in the screenshots. Now that the turn knows exactly which files it carries, that rule could use the list.

Fixes #3265

🤖 Generated with Claude Code

…after a reload (#3265)

The composer uploaded a file on attach, then cleared its chip when the
turn went out, and nothing about the attachment reached the user's row.
The bubble showed only the typed text, so the person could not tell
whether the file went with the message, and a reload could not show it.

The 1:1 send now waits for in-flight uploads, puts the settled files on
the message and sends their names. The server keeps a sent file only
when that name is in the caller's own uploads to the agent (size and
type from there), records a failed upload with its reason, and stores
the list on the user row (new nullable `attachments` column, SQLite
migration + Alembic 0090). History returns it, so a reload shows it.
The bubble renders a thumbnail per image, a chip per other file and a
failed chip; clicking downloads through the existing upload route.
Thumbnails are fetched once per tab and only when in view, because
that route is rate-limited per person.

Fixes #3265

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 6, 2026
@dolho
dolho requested a review from vybe October 6, 2026 10:37
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

vybe pushed a commit that referenced this pull request Oct 6, 2026
…3127) — mechanical, per the merge-train note on the PR

#3255, #3145 and #3269 each added an 0090 revision off
0089_supersede_queue_flood_backlog, which is the #2068 two-heads
fork once two of them land. The tables are disjoint (operator_queue
vs chat_sessions), so this is a re-parent: 0090_chat_session_claude_id
becomes 0091_chat_session_claude_id with down_revision
0090_platform_alert_subjects. The SQLite entry is keyed by name and
needs nothing; only the two doc references to the id change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability and others added 3 commits October 6, 2026 17:25
) — mechanical, per the merge-train note on the PR

tests/registry.json was the only conflict; rebuilt from the git stages
(dev's list deduped + this branch's one new entry), never spliced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…) — mechanical, per the merge-train note on the PR

`build` was red on roomEscalationAttachments.spec.js "does NOT clear
them". The slice ran from `async function send()` to `submitUserText`,
so it now read #3265's ordinary-send `clearAttachments()`, which runs
only after the escalation branch has returned. Behaviour was right; the
pin matched by accident. The slice now ends at `const reply =
replyTo.value`, the first line of the ordinary-send tail. Moving a
`clearAttachments()` into the escalation branch still turns it red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#3265) — mechanical, per the merge-train note on the PR

#3255, #3145 and this PR each added an 0090 revision off
0089_supersede_queue_flood_backlog (the #2068 two-heads fork). The
tables are disjoint (operator_queue, chat_sessions,
enterprise_portal_messages), so this is a re-parent:
0090_portal_messages_attachments becomes 0092_portal_messages_attachments
with down_revision 0091_chat_session_claude_id. The SQLite entry is
keyed by name and needs nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe

vybe commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-06): riding this train. Mechanical changes pushed to your branch:

  • 71886c5f0 merges dev. tests/registry.json was the only conflict, rebuilt from the git stages.
  • 1771ae380 fixes the red build. roomEscalationAttachments.spec.js "does NOT clear them" sliced all of send() up to submitUserText, so it read your new ordinary-send clearAttachments(), which only runs after the escalation branch has returned. The behaviour was right and the pin matched by accident. The slice now ends at const reply = replyTo.value. Moving a clearAttachments() into the escalation branch still turns it red (checked).
  • 9f465dbb8 re-parents the Alembic revision: 0090_portal_messages_attachments → 0092_portal_messages_attachments, down_revision = "0091_chat_session_claude_id" (feat(pull): /chat on the durable queue for pull pilots (#3127) #3145's, which is itself on fix(operator-queue): platform alerts are conditions — one pending row per subject (#3246, part 1) #3255's 0090). The tables are disjoint. A local DB stamped with the old id needs alembic downgrade 0089_supersede_queue_flood_backlog && alembic upgrade head.

Expected until #3145 lands: schema-parity reads two heads on this branch alone; it goes green on the dev merge pushed after #3145 merges.

Follow-up for you (needs your call, not blocking this train): resolve_turn_attachments → _read_inbox returns [] both when the agent is offline and when Docker can't be read. Either way every real attachment is stored permanently as "not in your uploads" (the #2196 no-container vs Docker-unreadable collapse). Separately, the front-end send → store → reload chain has no test that runs it end to end.

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

✅ Alembic head check clear — merging this PR into dev leaves one head (0091_chat_session_claude_id).

Previously flagged; resolved.

Evaluated on the version line only — this PR also conflicts with dev in 2 unrelated file(s), which do not change this verdict but must be resolved before merge:

src/backend/db/migrations.py
tests/registry.json

Advisory — this check does not block merge. · head_sha: 9f465dbb8dff65cfaf9542a7d41a5f64d8f74672 · run

@AndriiPasternak31 AndriiPasternak31 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.

Thanks, the backend design is good. Attachments are stored as names only and checked against the sender's own inbox. The read route is roster-gated, and the path always comes from the caller's own inbox, so nobody can read another person's file by name. Both migration tracks are there.

CI: as vybe's note says, the three reds are only the 0092 → 0091 parent. #3255 is in now, so this goes green once #3145 lands and dev is merged. Locally, re-pointing 0092 at 0089 turns all 7 single-head tests green, so nothing else is hiding behind it.

Two regressions this PR introduces. Please fix them before merge:

  1. Double send while uploads finish. The ordinary send() now clears the input and does await attachmentsSettled() with no guard (PortalConversation.vue ~2513-2531). sending is only set later, in deliver(). While a big file uploads, the text disappears and no bubble shows. A second Enter with new text gets past the guard at :2459, and two turns run at once. The escalation branch already guards this exact window with escalatingNow (the #2794 note at :2447). Please do the same here, and ideally show the bubble as pending before waiting.

  2. More than 20 files means the message can't be sent. PortalChatRequest.attachments is max_length=20, but the composer only caps each drop (usePortalFileDrop.js:229). Entries pile up across drops and pastes, so 2×11 files gives a 422, and Retry sends the same list again. Before this PR, the same message sent fine. Cap the total in the composer, and on the server keep the first 20 instead of rejecting the turn. Same for error (max 300): Pydantic rejects it before the [:300] in resolve_turn_attachments can run.

Non-blocking (in line with vybe's follow-up):
3. An inbox read failure becomes "not in your uploads" for good. _read_inbox returns [] when Docker can't be read, so a file that did upload is stored as failed, and the retry dedupe keeps that row. Telling "couldn't read" apart from "not there" would fix it. If it's cheap, do it here.
4. Two hops have no test. With the kwarg removed at service.py:4051 (start_portal_turn → portal_chat) or service.py:3029 (portal_chat → _persist_user_turn), or with the sync /chat attachments removed at router.py:1293, all 16 tests still pass. I checked each one. One test from the stream route down to the stored row, plus one sync-route test, would cover them.

Smaller:

  • Each thumbnail goes through the download route, so it spends the 20/min and 100/h file budget and writes a portal_upload_download audit row. Reloading an image-heavy thread can block Files-tab downloads, and the audit log fills with downloads nobody clicked.
  • A refused thumbnail turns the fixed 160×112 box into a small chip, which shifts the layout.
  • A re-uploaded file with the same name changes what older messages show.

The frontend unit suite (274 files, ratchets included) and check:tokens pass locally.

@AndriiPasternak31 AndriiPasternak31 added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 6, 2026
vybe pushed a commit that referenced this pull request Oct 6, 2026
* feat(pull): /chat on the durable queue for pull pilots (#3127)

On a pull-pilot agent, POST /api/agents/{name}/chat (MCP chat_with_agent,
the connector tool and the trinity CLI) is admitted onto the durable
queue and claimed by a worker. Every interactive trigger is now
pull-owned on a pilot.

Memory on a pilot is one Claude conversation per chat_sessions row,
i.e. per (agent, user), resumed by id through run_resumable_turn
(ResumeLock, persist_session, cold retry on resume-not-found). The id is
cached on chat_sessions.cached_claude_session_id (SQLite migration +
Alembic 0084) and joins the session reaper's keep set.

The post-turn work /chat did inline runs in the caller after the
terminal: assistant message, collaboration close, response shape,
idempotency complete, timeout receipt. GET /chat/history serves the
caller's session from the database on a pilot; DELETE /chat/history
also clears the cached ids. execute_task carries chain_depth so a
cold-retry row keeps its depth.

Non-pilot agents are unchanged.

Fixes #3127

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pull): /chat reset on a pilot closes the sessions /chat used (#3127)

DELETE /chat/history forgot the cached Claude ids but left the sessions
active, so GET /chat/history still showed the old conversation. The
sessions that carry a cached id are now closed with it; sessions /chat
never used stay open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(security): /cso --diff report for #3127

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(pull): renumber the chat_sessions migration to 0086 after dev's 0085 (#3127)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(pull): renumber the chat_sessions migration to 0089 after dev's 0088 (#3127)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pull): address review on pulled /chat (#3127)

- Skill gate runs once: the pulled turn passes gate_checked=True, and the
  pilot admission branch audits a self-approved gate like the push branch.
- Agent-to-agent /chat (trigger agent) on a pilot: run_resumable_turn opts
  into the claim phase (caller_waiting=True), the claim orders session:-keyed
  rows with interactive turns, and the collaboration activity rides the
  queue payload so the pull sink closes it.
- Resume lock waits one turn's lock TTL; lock-busy closes the activity and
  emits the terminal event only on a CAS win, and answers 429 capacity.
- Full backlog returns FAILED/CAPACITY and maps to 429 capacity.
- Docs: chat_with_agent description, execution.md, three feature flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(security): CSO report names the 0090 revision (#3127)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* merge-train: re-parent the chat_sessions revision onto #3255's 0090 (#3127) — mechanical, per the merge-train note on the PR

#3255, #3145 and #3269 each added an 0090 revision off
0089_supersede_queue_flood_backlog, which is the #2068 two-heads
fork once two of them land. The tables are disjoint (operator_queue
vs chat_sessions), so this is a re-parent: 0090_chat_session_claude_id
becomes 0091_chat_session_claude_id with down_revision
0090_platform_alert_subjects. The SQLite entry is keyed by name and
needs nothing; only the two doc references to the id change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): pulled /chat keeps the default 30s resume-lock wait (#3127)

A longer wait outlived the #106 no-session sweep: the waiting turn's
admission row is running with no Claude session, so the sweep failed it at
60s and the enqueue CAS then refused it, answering 429 for a turn that
never ran. Drop the lock_wait plumbing; a test pins the default wait under
NO_SESSION_TIMEOUT_SECONDS. chat_with_agent's parallel description notes
per-user sessions on a pilot; execution.md says the self-approval audit
rides the row's setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
) — mechanical

#3255 (0090) and #3145 (0091) landed, so this branch's 0092 extends a
single Alembic head. The SQLite MIGRATIONS tail keeps dev's entries first;
tests/registry.json is a per-entry three-way merge (no dev entry lost or
changed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@vybe vybe 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.

merge-train: batch validated on train #3279 (green); dev re-merged after #3145, one Alembic head.

@vybe vybe 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.

merge-train: batch validated on train #3279 (green); dev re-merged after #3145, one Alembic head.

@vybe

vybe commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-06): ejected at the merge step. Rides the next train once fixed.

This was the last member of train #3279; the other six landed. It was held back by @AndriiPasternak31's changes-requested review (18:27), which found two regressions that the train's validation missed:

  1. Double send while uploads finish: PortalConversation.vue ~2513-2531. send() clears the input and awaits attachmentsSettled() with no in-flight guard, so a second Enter starts a parallel turn. Guard it the way the escalation branch does with escalatingNow (bug(workspace): attachments are dropped when a 1:1 chat is escalated into a room via @mention #2794), and ideally show the bubble as pending first.
  2. More than 20 files can't be sent: PortalChatRequest.attachments (max_length=20) vs a per-drop-only cap in usePortalFileDrop.js:229, so 2×11 files gets a 422 and Retry repeats it. Cap the total in the composer, and keep the first 20 server-side rather than rejecting. Do the same for error (max_length=300, which is rejected before the [:300] trim can run).

The branch is in good shape otherwise. It is up to date with dev (02296cd93), with Alembic 0092_portal_messages_attachments on dev's 0091 and a single head. Your next push clears status-needs-fix. The non-blocking items from the earlier train comment and Andrii's review still stand: the inbox-read collapse, the two untested hops, and thumbnails spending the download budget.

This branch has not been deployed

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

Labels

status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants