Repository navigation
feat(workspace): remove a pending attachment from the composer with an × on its chip (#3738) - #3743
Conversation
…h in the tab overflow menu (#3737) The OverflowTabs "N more" menu row was `flex justify-between`, and the pinned/starred glyphs were its first flex children. Free space was spread between the glyph and the label, so a shorter title drifted further right. The same rule floated the Draft mark to mid-row. The glyphs and the label now sit in one `[data-menu-lead]` group (`flex min-w-0 flex-1 items-center`), and `justify-between` is dropped. Trailing items (Draft, badge or dot) cluster at the right edge, and the #2794 v-if/v-else-if chain is unchanged. The inline row and the measuring mirror row are untouched. New mount spec overflowTabsMenuRow.mount.spec.js: 7 of 8 tests were red on the unfixed template; the scope guard is green both ways. Each mutation was restored from a scratch copy: (a) wrapper removed -> 7 red; (b) justify-between restored -> 5 red (starred/pinned/plain/closable/fixedWidth); (c) label moved out of the group -> 7 red; (d) DraftMark moved between badge and signal -> 2 red (trailing order, #2794 chain). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ×, upload discard (#3738) A composer chip now carries a × that takes the file back before sending. - usePortalFileDrop: remove(entry) splices by identity and sets `removed`. The batch loop walks its own list, so a queued file that is removed never uploads; one removed while in flight is deleted once it lands; one already landed is deleted at once. The delete goes through the caller's `discard`, is never awaited, never shows on a chip, and logs a console.warn with the file name and HTTP status on failure. It is skipped when another live chip has the same stored name, because the inbox overwrites by name. removeAt(i) now goes through remove(). - clientPortal.discardComposerUpload: forgets the carry-log entry first (so an @mention escalation cannot carry the removed file back), then DELETEs the stored upload and marks the rail's Files listing stale. - PortalAttachmentChip.vue: the chip, shared by both composers. The × is a type="button" with click.stop and mousedown.prevent, so it never submits the form or takes focus. - PortalConversation: uses the chip; the upload fn returns the agent it uploaded to, so the delete hits that inbox. The × is locked while the send settles, while an escalation runs, and after the chips are handed to the shell (released when the shell hands the text back). Tests: portalAttachmentChipRemove.spec.js (composable, store, jsdom mount of the chip inside a form). With the loop's `removed` skip reverted, "a queued file: never uploads once removed" goes red. portalRailFiles.spec.js re-pinned to the new upload fn. Raw-colour baseline: PortalConversation raw_gray 90 -> 85 (paid down), edited by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#3738) PortalRoom renders its chips through PortalAttachmentChip (the "· to {recipients}" arm moves into the chip's `sent` slot) and wires the × to remove(). The × is locked while a send is in flight. The room's upload fn now returns { filename, stored: [{agent, filename}] } — one stored name per participant, because the server sanitises names and each inbox is separate. If one participant refuses after others took the file, the partial receipt rides on the error so a removal can still take those copies back. discard deletes every stored copy through store.discardComposerUpload with Promise.allSettled and rethrows the first refusal, so it is logged once. Tests: portalAttachmentChipRemove.spec.js mounts PortalRoom with two agent participants, drops a file, clicks ×: two DELETEs by each stored name, both carry entries forgotten; with one DELETE refused the other still runs and one console.warn names the 429. With the upload fn's receipt reverted to `undefined`, both room cases go red. Template AST case over both composers: no hand-rolled chip remains, and every chip removes its own loop entry behind a bound lock. Raw-colour baseline: PortalRoom raw_gray 47 -> 42 (paid down), edited by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ps, partly-landed room file still discarded (#3738) - The same-stored-name delete skip now counts only live chips (uploading, queued, or sent); a failed or client-refused chip holds no inbox copy, so it no longer leaves the removed sent copy orphaned in the inbox. - A room chip that reached some participants and then failed, removed after the failure, now deletes the copies named in its partial receipt — the same outcome as removing it before it landed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability
left a comment
There was a problem hiding this comment.
PR Validation Report — #3743 (stacked)
Branch: feature/3738-attachment-chip-remove @ 099c59c6b → feature/3737-overflow-starred-row (#3742, @ 3c843907b). I validated this against that stacked base, not dev. · Lane B (frontend only; no schema) · 8 files, +664/−38 · Fixes #3738 (P2 bug, closing keyword present)
Summary
| Category | Status | Notes |
|---|---|---|
| Commit messages | ✅ | 3 conventional commits, each says what changed and why |
| Base branch | Stacked on #3742 by design. This PR retargets dev after #3742 merges. It is still a draft |
|
| Status auto-promotion | ✅ | Fixes #3738 (same repo) |
| Requirements / Architecture / Feature flows | ➖ | Bug fix: tiered docs say a commit message is enough |
| Security (§4.1–4.10) | ✅ | No keys, emails, IPs, env or credential files, symlinks, mode changes or host paths. The one reachable surface is the existing roster-gated DELETE …/uploads/{filename} (see the /cso --diff in the review file) |
| Build / config packaging | ➖ | No backend file in range |
| Structural review | ✅ | /review → MERGEABLE (plan file 3738-review.md). It is recorded in the PR body, not posted as a review |
| Code quality | ✅ | Scoped to the issue. The chip extraction de-duplicates two byte-identical blocks (#2662). Baseline only shrinks (90→85, 47→42); rawColorRatchet (including its exactness test) is green |
| Execution coverage (§5.4) | ❌ | The hand-off lock has no executing test. Details below |
| Merge gate | Tier 1 is only partly evaluated on a feature-branch base. Details below |
Evidence (from the PR's own worktree)
npx vitest run portalAttachmentChipRemove roomEscalationAttachments roomComposerChain portalComposerWiring portalRailFiles roomFileReach portalSendWhileUploading.mount portalMessageAttachments.mount loadingGateRatchet rawColorRatchet sourceTextRatchet → 11 files, 180/180 passed.
Mutation run: one change at a time, 6 related spec files (114 tests), each file restored after its run:
| mutation | result |
|---|---|
M1 loop ignores entry.removed (queued file uploads anyway) |
red (1) |
| M2 same-name skip counts a never-uploaded chip as live (I1 fix reverted) | red (1) |
M3 remove() ignores a partial receipt (I3 fix reverted) |
red (1) |
| M4 no discard after an in-flight upload lands | red (2) |
| M5 store does not forget the carry-log entry | red (5) |
M8 × loses type="button" |
red (1) |
M10 room upload drops err.receipt on a partial failure |
red (1) |
M6 delete handedOff.value = true after the escalation emit |
green (114/114) |
M7 delete handedOff.value = false in the prefill watcher |
green (114/114) |
M11 :locked="escalatingNow || settlingUploads" (drop handedOff) |
green (114/114) |
| M9 conversation discard ignores the receipt's agent | green; moot in production, because convKey includes the agent name, so an agent switch remounts the composer and its chips |
The composable, store, chip and room are properly executed and mutation-proven.
Execution coverage (§5.4)
| changed symbol | executed by | live consumer | verdict |
|---|---|---|---|
usePortalFileDrop.remove / discardQuietly / removed skip |
spec §1 (10 cases) | chip @remove, both composers |
✅ |
clientPortal.discardComposerUpload |
spec §2 | both composers' discard |
✅ |
PortalAttachmentChip.vue × |
spec §3, mounted in a <form @submit> |
both composers | ✅ |
PortalRoom upload receipt + discard |
spec §4, a real PortalRoom mount |
— | ✅ |
template call-site set (@source-text-pin) |
spec §5 AST walk | — | 🛡 guard (accepted: the reason names the call-site set, which a mount cannot prove) |
handedOff lock in PortalConversation.vue (set after the emit at :2641, cleared at :1709, bound at :627) |
— | :locked → :disabled on a × that deletes |
❌ |
Fix-PR mutation: M1–M4 go red with the fix reverted ✅. The lock does not (M6, M7, M11).
Critical (blocks merge)
-
The hand-off lock gates a destructive verb and nothing executes it. It exists because a × in that window would delete the origin agent's copy while the shell is carrying it into the room (R2). Under §5.4 / trinity#2918, a predicate that gates a delete with no executing coverage is ❌ whatever the PR type. The recorded reason (I2: "the conversation component has no mount harness") is not accurate.
PortalConversation.vueis mounted by about 11 existing specs.portalSendWhileUploading.mount.spec.jsmounts it with the file-drop composable, andportalInChatReply.mount.spec.js:341already setsstore.multiAgentChatAvailable = truewith a two-agent roster, which is the escalation precondition. One mount test in that harness closes it:- Drop a file, let it land, then type
@sage …and send. - Assert
escalate-to-roomwas emitted and the chip's-removebutton isdisabled. setProps({ prefill: 'text' })and assert the button is enabled again.
M6, M7 and M11 should then all go red.
- Drop a file, let it land, then type
Warnings
- Merge gate on a stacked base. CI on the tip is green for everything that ran (
frontend-build,frontend-e2e,gitleaks,schema-parity,verify-non-root, container-security). But the dev-filtered Tier 1 jobs did not run on a feature-branch base: the requiredAnalyze (python)/Analyze (javascript-typescript),pytest (head)+regression diff(backend-unit-test), and the lints. That gap closes when this PR is retargeted todevafter #3742 merges, and Tier 1 must be green then, before merge. The backend suite is formally out of scope here (no backend file changed), but it still has to report.dev-ciis currentlysuccess, and nodev-ci redissue is open. - TD4 (a re-dropped and then removed name makes an earlier message's attachment 404) is accepted and documented, per the operator ruling.
Suggestions (optional)
- The × hit box (
after:-inset-2) reaches about 2px into the next chip across the 6px gap. This is a design call; flagged only.
Recommendation: REQUEST CHANGES
The core fix is correct, well scoped and mutation-proven. One test is missing: the hand-off lock that guards a delete, which the existing mount harness can execute. After that test lands and #3742 merges, retarget to dev and check that Tier 1 is green.
This PR requires the following before merge:
- Add a
PortalConversationmount test that sets and clearshandedOff(M6/M7/M11 red) - After #3742 merges, retarget to
devand confirm Tier 1 (Analyze×2,pytest (head)+regression diff) is green
…alation hand-off lock (#3738) Mounts the real PortalConversation with the real PortalAttachmentChip, pastes a file, escalates on an @mention (rooms on, two agents), and proves the × is disabled with no delete while the chips are handed to the shell, then enabled and deleting once the shell hands the text back through `prefill`. Each of the three handedOff mutations (set, clear, :locked binding) turns it red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability
left a comment
There was a problem hiding this comment.
PR Validation Report — #3743, round 2 (stacked)
Branch: feature/3738-attachment-chip-remove @ a4a22dab0 → feature/3737-overflow-starred-row (#3742, @ 3c843907b, which is also the merge-base). I validated this against that stacked base, not dev. · Lane B (frontend only; no schema) · 9 files, +803/−38 · Fixes #3738 (closing keyword present)
What changed since round 1
Commits 7b6f207, 493725a and 099c59c are unchanged. The only new commit is a4a22dab0, which adds one file: src/frontend/tests/unit/portalAttachmentChipHandoff.mount.spec.js (+139, test only).
Round-1 blocker: hand-off lock coverage (§5.4) is now ✅
The new spec mounts the real PortalConversation with the real PortalAttachmentChip (// @vitest-environment jsdom, no readFileSync). It runs the escalation path a user hits: paste a file, type @sage …, press Enter. It then checks three things:
- During the hand-off: the × is
disabled. A click deletes nothing, whether sent through VTUtriggeror nativeelement.click(), and the chip stays. - After the hand-back (
setProps({ prefill }), which is what Portal.vue does after a failed escalation): the × is enabled. One click removes the chip and callsdeleteUpload('scout','stored_report.pdf')exactly once. - The live consumers are
PortalConversation.vue:629(:locked),:2641(the set) and:1709(the clear in theprefillwatcher).
Mutations I re-ran myself in the PR worktree, against portalAttachmentChipHandoff.mount (tree restored after each run):
| mutation | result |
|---|---|
M1: drop the set (:2641 handedOff.value = true) |
2 red / 1 green |
M2: drop the clear (:1709 handedOff.value = false) |
1 red / 2 green |
| M3: drop ` | |
| M4 (extra): ` |
All three round-1 mutations, which stayed green last round, now fail. So does the extra operator-swap mutation.
Executable evidence
npx vitest run portalAttachmentChipHandoff.mount portalAttachmentChipRemove portalInChatReply.mount roomEscalationAttachments roomComposerChain portalComposerWiring portalRailFiles roomFileReach portalSendWhileUploading.mount portalMessageAttachments.mount loadingGateRatchet rawColorRatchet sourceTextRatchet → 13 files passed, 203 tests passed. The new spec reads no source text, so sourceTextRatchet stays green.
Summary
| Category | Status | Notes |
|---|---|---|
| Commit messages | ✅ | 4 conventional commits |
| Base branch | Stacked on #3742 by design. Retarget to dev once #3742 merges |
|
| Status auto-promotion | ✅ | Fixes #3738 |
| Requirements / Architecture / Flows | ➖ | Bug fix: a commit message is enough |
| Security (§4.1–4.10) | ✅ | 0 hits for keys, emails, IPs, symlinks, mode changes or host paths over the full diff. The round-1 /cso --diff still applies: the delta is test-only |
| Structural review | ✅ | /review → MERGEABLE (round 1). The delta is a test file |
| Execution coverage (§5.4) | ✅ | Hand-off lock executed and mutation-pinned (above). The rest of the coverage is unchanged from round 1 |
| Merge gate (Tier 1, stacked) | ✅ / ➖ | All 11 check runs on a4a22dab0 are success or skipped (report, auto-merge), with no cancelled rows. The dev-only contexts (Analyze (python), Analyze (javascript-typescript), the backend unit test / regression diff) only run after the retarget to dev. They have to be green on the retargeted PR before merge |
Carry-overs (non-blocking, as in round 1)
I1 (same-name skip vs a never-uploaded chip) and I3 (partial receipt ignored on × after a failure) are still informational.
Recommendation
APPROVE. The single round-1 blocker is fixed, and I have mutation evidence for it. Merge order: #3742 first, then retarget this PR to dev and let the dev-only checks go green.
Fixes #3738 — second of two Workspace composer/tab fixes in chain-3737 (epic #3471). Stacked on #3742 — base is
feature/3737-overflow-starred-row; the stack merges together in order on the operator's call (#3742 first, then this PR retargeted todev). Draft until the chain is in.What
src/frontend/src/components/portal/PortalAttachmentChip.vue— new shared chip for a pending attachment (paperclip, name, upload spinner / failure text) with a real<button type="button">× that removes exactly that file (aria-label="Remove <name>",@click.stop, chip ink, hover + focus-visible, touch-sized hit area, chip height unchanged). Both composers' chip markup was identical, so both now use it.src/frontend/src/composables/usePortalFileDrop.js—remove(entry): aremovedflag the batch loop honours (a queued file never uploads once removed), an in-flight file is dropped from the chip row at once and its inbox copy deleted after the upload lands, a sent file's copy is deleted at once, a failed file deletes nothing. The delete is best effort: never awaited by the UI path, never writes a chip error, a failure is oneconsole.warnwith the filename and status. Another live chip with the same stored name skips the delete (the server overwrites on a repeated name).src/frontend/src/stores/clientPortal.js—discardComposerUpload: forgets the escalation carry-log entry first (socarryableUploadsForcannot re-attach a withdrawn file to a room), then deletes, then refreshes the rail's Files list.src/frontend/src/components/portal/PortalConversation.vue— chip row on the shared chip; the × is locked during the escalation hand-off.src/frontend/src/components/portal/PortalRoom.vue— chip row on the shared chip; the room's upload function now returns{ filename, stored: [{agent, filename}] }(plus a partial receipt when only some agents took the file), so removing a chip deletes every agent's copy — and only those.src/frontend/raw-color-baseline.json— the two composers' raw-gray counts go down (90→85, 47→42) because the chip markup moved into the new component; edited by hand because the regenerate script clobbers therefrozennotes block.src/frontend/tests/unit/portalAttachmentChipRemove.spec.js— 24 tests: composable state rules, same-name skip (live siblings only), failing delete (warn spied), double-click, the store's carry-log forget, the chip mounted inside a<form>(× removes exactly that entry, zero submits), a realPortalRoommount with two agents, partial-landing then failure, and a template check over both composers.portalRailFiles.spec.jsre-pinned to the new upload function.Rulings carried (orchestrator, on the operator's behalf — plan file)
Review + security
/review(Fable, report-only): MERGEABLE — 0 critical; all ten rider checks hold with file:line cites (no submit path from the ×, the batch loop honoursremoved, carry log forgotten byFileidentity before the DELETE, no window before the hand-off lock, every consumer of the room upload's return value read, baseline matches the scanner). Informational I1 (same-name skip counted a never-uploaded chip as live) and I3 (a partly-landed room chip removed after its failure deleted nothing) fixed in099c59c6bwith red-before/green-after test rows; I2 closed ina4a22dab0:/validate-prround 1 (REQUEST CHANGES) found that the component does have a mount harness, soportalAttachmentChipHandoff.mount.spec.jsnow mounts the real conversation composer with the escalation setup and proves the × is disabled and deletes nothing during the hand-off, then is enabled and deletes once after the hand-back; dropping the lock's set, its clear, or its binding each turns it red./cso --diff: zero findings; the one security surface is the existing DELETE route, whose ownership check lives in the client-portal router and service (roster gate, caller-scoped inbox path, traversal refusal).Tests
npm run test:unitisland and e2e — CI's. Stacked PR:backend-unit-testdoes not run on a feature-branch base, so the first full run comes when this retargetsdevafter fix(workspace): keep a starred or pinned chat's title beside its glyph in the tab overflow menu (#3737) #3742 merges.Before merge
dev. Merge-tree is clean against the base (3c84390) and against livedev(a32a9c8). No migration.clientPortal.js: its five conflicts against livedevare entirely its own; this branch adds none.Handoffs
🤖 Generated with Claude Code