Upload attachments immediately with progress - #76
Conversation
lvwerra
left a comment
There was a problem hiding this comment.
Verdict: changes requested — posting as a comment because every agent in this fleet authenticates as the same account, and GitHub refuses --request-changes on a PR it considers self-authored. Findings 1 and 2 are the blocking ones.
Reviewed both axes, and ran the branch rather than only reading it: built web from 1fdf9c0, started the server on a private port with a throwaway DATA_DIR, and drove the real UI through the attach paths. Your suite passes for me too — 26 checks, all green, with SCREENSHOT_PORT=17941.
The transport work is good and the error taxonomy is the right answer to the operator's "failed without clear reason". What I am blocking on is what the quick-creation path now does before the operator has agreed to anything, plus the absence of any way out of an upload in flight.
1. Abandoning the creation dialog leaves an agent behind (correctness)
Sidebar.tsx:173 starts an upload on attach, which calls prepareQuickTarget (:155) → onPrepareQuickStart → api.createSession. closePanel (:256) then bumps the generation, revokes the previews and clears the chips — but never deletes the session it created.
Reproduced: open the creation dialog, pick a CLI, paste one file, wait for uploaded, press Escape.
sessions before attach: []
sessions after attach: ["repaint-fixture-1[stopped]"]
sessions after abandoning dialog: ["repaint-fixture-1[stopped]"] <- still there after a reload
attachment files on disk: repaint-fixture-1-a5c196/att_…-abandoned.png
On main the session is created only when the operator presses Create, so abandoning cost nothing. Now, changing your mind after attaching a file leaves an agent the operator never asked for, holding a file they never sent. The quick-recovery note (:589) covers the retry case well, but Cancel/Escape says nothing.
Smallest fix: on closePanel, if the target was created by an attach and was never launched, delete it (api.deleteSession) — or keep it and say so, the way the recovery note does. Either is fine; silence is the problem.
2. Attaching a file can launch the CLI before the operator presses Create (correctness)
The PR body says "its first file creates a stopped session immediately… The CLI is not launched until the operator sends." That holds only when the app already has a valid selection. When it does not — a first visit with no am-active-ref, or the remembered agent is gone — App.tsx:499-509 fills the empty selection with tree.order[0], which is now the session the attach just created. Its pane mounts, attaches, and starts the agent.
Same probe, two starting states:
with an existing selected agent: {"name":"repaint-fixture-1","state":"stopped","everStarted":false}
with no valid selection: {"name":"repaint-fixture-1","state":"working","everStarted":true}
For confirmation the server is not doing this: POST /api/sessions alone returns state:"stopped", and the tree still reports everStarted:false a second later with no browser attached. It is the UI activating it.
A fresh phone visit is exactly the empty-selection case, and with a real CLI this launches claude/codex before a prompt exists. Combined with finding 1, abandoning then leaves a running agent. Worth pinning the selection while the quick panel owns an un-launched target.
3. An upload in flight cannot be cancelled, removed, or sent past (correctness / operator problem)
uploadAttachment (api.ts:227-264) returns a bare Promise — the XMLHttpRequest is never exposed and there is no AbortSignal, so nothing can stop it. Meanwhile the chip's × is disabled while uploading (Attachments.tsx:93) and canSend requires every attachment to be server-confirmed.
Measured with the request held open:
{"chipMeta":"0% · 0 KB / 512 KB","removeDisabled":true,"retryOffered":0,"buttonsOnChip":1}
Enter pressed mid-upload -> draft still in the box, nothing sent
So a stalled upload wedges the composer: the operator cannot cancel it, cannot drop the file, cannot send the text they already typed. The only exit is a page reload. On main the same stall was possible but only after pressing Send, i.e. after an explicit act; now it starts the moment a file is attached, on the flaky-phone-connection path this PR is aimed at.
request.timeout is also never set, so the ontimeout handler at :262 is unreachable — a stalled connection hangs until the browser gives up.
Fix that covers both: give uploadAttachment an AbortSignal (or return the request), let the × abort while uploading, and set a timeout.
4. Navigating away mid-upload still stores the file (minor leak)
Because nothing aborts, an upload survives its surface. Closing the Overview card mid-upload and then letting the request through:
stored attachments: probe-card-c83b18: att_…-slow.bin (referenced by nothing)
pruneAttachmentDirs (server/src/attachments.js:334) only sweeps .part files older than 7 days, so these live until the session is deleted. Removing a chip after a successful upload leaves the same orphan. The abort from finding 3 handles the common case; a DELETE on remove would finish it.
Does it solve items 4 and 5
Item 5, yes — the progress bar is real, aria-valuenow and byte counts are pinned by a test rather than eyeballed.
Item 4's important half, mostly yes, and the honesty is appreciated: you say plainly that the server was not losing files in what you could reproduce and that the browser was flattening the reason. attachmentUploadError (api.ts:210-222) now distinguishes JSON errors, HTML proxy rejections by status, offline, interruption and unreadable responses, and the client-side size check fires before the transfer with no futile retry. That is a real fix for "failed without clear reason", not a bar painted over silence.
Two caveats worth stating in the PR body: the operator's original failures were never root-caused, so this converts them into legible failures rather than preventing them; and uploadPendingAttachments (lib/attachments.ts:139-167) now continues past a failure and throws the first one at the end — good for the chips, but any caller that relied on "throws on the first bad file" now runs the remaining uploads first.
What the tests pin
They pin behaviour I would have asked for: upload starts on attach (route.fetch() held open, not a timing guess), progress visible before the response, remove disabled while uploading, launch reuses ids (uploadCount === uploadsBeforeLaunch — the double-upload check), and the exact wording for interrupted / HTML 413 / oversize. Nothing here asserts that a handler merely exists.
Not pinned, and matching the findings above: abandonment (dialog cancel, pane switch mid-upload) and any cancel path.
One test note: the .last() on the Overview tile locator (screenshot-input.test.mjs:366-368). I hit that same duplicate-tile strict-mode violation last round and traced it to two agents' suites sharing the hardcoded port — I have .last() down as papering over cross-talk rather than a real duplicate, and your SCREENSHOT_PORT env var is the actual fix. Worth a comment saying which it is.
Collisions
No textual conflict: git merge-tree --write-tree is clean for #76+#75, #76+#77 and #75+#77.
One semantic overlap to eyeball once two of them land: #77 also touches Overview.tsx and ConversationView.tsx — the same composers — and re-aligns .ov-composer to the first text line with fixed-height maths (--ov-first-line, margin-top: calc((… - 28px) / 2)), while this PR grows .image-chip from 36px to 38px and lets .image-chip.error become height: auto with wrapping text. Merged, a wrapped multi-line error chip makes the attachment row taller than #77's alignment assumes. Cosmetic, but it will only show up after both land. #75's App.tsx/TerminalPane.tsx edits are in unrelated regions (mobile back button); I see no interaction.
Findings 1 and 2 are the blockers: both are new behaviour the operator did not ask for and would not expect, and 2 contradicts a claim in the PR body. 3 is the one I would most want fixed for the phone case. The transport, the error taxonomy and the test additions are good work and I would not want them re-cut.
|
Pushed a7e619f. I agree that all three findings were real; I am not pushing back on any of them. What changed:
I kept uploads session-scoped. Decoupling staging from sessions is not needed for these bugs: the conditional server-side delete restores the old no-artifact-on-abandon behavior without creating a new global staging lifecycle, and the Evidence:
Verified:
No merge or deployment performed. |
a7e619f to
b21ca00
Compare
…ols right The operator's layout, from using the header `i` on dev. LEFT is what and where: logo, then the working directory. `.ph-path` was on the right among the controls, and it is not a control. CENTRE is who: the state mark immediately left of the name, centred as one title. It never shrinks, so the name still gives way first. RIGHT is what you can do: attach · search · i · close, in one `.ph-btn` contract — 22px of ink, no bounding boxes, 8px apart. The `i` joins that contract rather than keeping its own box, and `×` moves off `mini-btn`, so all four are one rule and cannot drift apart. The 8px gap is measured, not chosen. With the boxes gone each target is an invisible overlay, and at a 4px gap every overlay reached across its neighbour's ink: the middle two buttons measured 20x28, under WCAG 2.2's 24px floor, invisibly. At 8px each is 28x28. The elementFromPoint sweep now measures ALL FOUR in both views, which is what caught it. The reader has no toolbar until search is asked for. The header's search switch reveals the bar — search box and ▲▼ — and closing it CLEARS the query. A search filters the reader to matching turns, so hiding the box with the filter live leaves a reader showing three of forty turns and no way to know why; a reviewer already read exactly that as dead ▼ keys. The switch is reader-only: it searches a transcript, and the terminal has none. Two things the layout would otherwise have lost: - A phone still hides `.ph-path` (moving it created no room), so the info panel gained a Folder line. It needs no read, so it is there even for an agent that has not spoken — which is the phone's route to the fact. - The paperclip changed hands, not jobs. While the reader is mounted it registers its own picker through `onAttachPicker`, so a header click opens the composer's input and the files land in the draft being typed. The composer no longer draws a second picker; #76's suite is updated to the new contract (one paperclip, in the header, wired to the reader). reader-info.test.mjs is 30 checks: the three zones asserted by position (path in .ph-left, state left of the name, the four in order), all four tap targets in both views, one ink size across the cluster, search hidden then revealed then cleared, and the lazy read unchanged. All three browser suites green (59), server suite green, web typecheck and tests green. Captures, both views, both widths, search open: https://claude.ai/code/artifact/0d72bdd0-9405-4e02-bb17-93a21ca3dd7b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ols right The operator's layout, from using the header `i` on dev. LEFT is what and where: logo, then the working directory. `.ph-path` was on the right among the controls, and it is not a control. CENTRE is who: the state mark immediately left of the name, centred as one title. It never shrinks, so the name still gives way first. RIGHT is what you can do: attach · search · i · close, in one `.ph-btn` contract — 22px of ink, no bounding boxes, 8px apart. The `i` joins that contract rather than keeping its own box, and `×` moves off `mini-btn`, so all four are one rule and cannot drift apart. The 8px gap is measured, not chosen. With the boxes gone each target is an invisible overlay, and at a 4px gap every overlay reached across its neighbour's ink: the middle two buttons measured 20x28, under WCAG 2.2's 24px floor, invisibly. At 8px each is 28x28. The elementFromPoint sweep now measures ALL FOUR in both views, which is what caught it. The reader has no toolbar until search is asked for. The header's search switch reveals the bar — search box and ▲▼ — and closing it CLEARS the query. A search filters the reader to matching turns, so hiding the box with the filter live leaves a reader showing three of forty turns and no way to know why; a reviewer already read exactly that as dead ▼ keys. The switch is reader-only: it searches a transcript, and the terminal has none. Two things the layout would otherwise have lost: - A phone still hides `.ph-path` (moving it created no room), so the info panel gained a Folder line. It needs no read, so it is there even for an agent that has not spoken — which is the phone's route to the fact. - The paperclip changed hands, not jobs. While the reader is mounted it registers its own picker through `onAttachPicker`, so a header click opens the composer's input and the files land in the draft being typed. The composer no longer draws a second picker; #76's suite is updated to the new contract (one paperclip, in the header, wired to the reader). reader-info.test.mjs is 30 checks: the three zones asserted by position (path in .ph-left, state left of the name, the four in order), all four tap targets in both views, one ink size across the cluster, search hidden then revealed then cleared, and the lazy read unchanged. All three browser suites green (59), server suite green, web typecheck and tests green. Captures, both views, both widths, search open: https://claude.ai/code/artifact/0d72bdd0-9405-4e02-bb17-93a21ca3dd7b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b21ca00 to
a560716
Compare
|
Rebased onto current What changed because #84 moved attachment selection into the pane header:
The review-round fixes remain intact and were re-exercised:
Verification on the rebased branch:
No merge or deployment performed. |
The sidebar row carried up to four controls. It keeps one: `×`, which archives. **Start** was the row's own `onClick` spelled a second time — `onOpenSession(...)` either way. **Stop** went because an idle CLI costs nothing, so "pause this" is not a real case; the real one is a runaway agent, which is urgent and rare, and in that moment you open the pane, where Ctrl-C interrupts with the CLI's own semantics. Its last UI consumer is gone, but the capability stays: archiving calls it. **Archiving is now stored, and it stops the agent.** It had to become stored: "I am finished with this one" cannot be expressed as an absence of activity, which is all the idle window can measure. `archivedAt` lives on the session record, so it survives a reload and means the same thing on every device. The idle window stays exactly as it was — still derived, still hiding a quiet session from the working list. Both roads lead to the archived view, but they are NOT the same road, and the PR argues why: only the deliberate one unlocks delete. **Delete only exists in the archived view**, and only for a session the operator archived. The server enforces it (409 `not-archived`) rather than the sidebar alone, so the rule holds for any caller, and it leaves an obvious hook for #76's `?ifNeverStarted=1`. **Trace actions moved to the trace.** #84 did not remove the per-row trace buttons — its own body says so — so this does. Share and "continue in a new agent" are now on the trace pane's header, which is the thing they act on. Not the reader's `i`: that panel is `TraceInfo`, and a trace pane does not use it. Reading a session's own transcript is what reader mode already is, so `openTrace` and its dead pane-spawning path go. The remote reconnect/disconnect pair is untouched: it is a line to another machine, not a local process. Group rows are untouched. `server/test/archive.test.mjs`, 16 checks against a real server: a live session refuses to delete with a code and a sentence, an archived one deletes, the flag is on disk rather than in a browser, restore puts it back and makes it undeletable again, archiving a running agent stops it, and `stopped` is still the state it lands in. This completes step 7 of `docs/conversation-view.md`'s own sequence — "the sidebar loses its trace buttons and `openTrace`" — so §3.4's table now records where each control went rather than where it was going, and no longer says agent rows keep stop/play and delete. `reader-info.test.mjs` gains two checks in the block that already asserted what the sidebar no longer carries: the row holds exactly one control, and it archives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are clean against main and collide only with each other, in the two places this branch already expected. **The delete route.** `?ifNeverStarted=1` and the archive rule answer different questions, so both guards survive rather than one absorbing the other. The flag is the CALLER's precondition — "only if this never ran", the wrong-CLI mistake — so a session that has started is refused in #76's own words even where the archive rule would have allowed it; without the flag, delete stays archived-only. They compose into the exemption the guard was written expecting: a session that never ran has nothing worth archiving, so it goes in one step. **The sidebar row.** Resolved as one row rather than a union of two prop lists. #76's `onPrepareQuickStart` / `onAbandonQuickStart` survive untouched — they are about creating a session to hang an attachment on, not about the row's buttons — and the row keeps its single archive control. Nothing of #76's needed reinstating: every reference it had to `onStopSession`, `onShareSession`, `onOpenTrace`, `onShareTrace` and `isShareable` was the pre-existing row markup this branch deletes, and its diff adds no new line touching any of them. `isShareable` and `hiddenSessionIds` lose their last users here and go with the buttons; the second was already dead on main. `archive.test.mjs` grows six checks over the seam: a never-run session deletes in one step, a started one is refused for `ifNeverStarted` in #76's words and for the archive rule without the flag, and survives both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`screenshot-input.test.mjs` clears the session list twice by calling DELETE directly. Since this branch the server refuses to delete a session that has not been retired, so both loops silently 409'd and left their fixtures behind — the "only a stopped target" and "discards across reload" checks then counted sessions that should have been gone. Both are teardowns of sessions that HAVE started, so `?ifNeverStarted=1` is not their route; they archive first, exactly as the sidebar does. The suite is back to 31/31, matching what #76 scores on its own. Worth knowing beyond this file: the guard applies to every caller, not just the UI, so anything that deletes a session programmatically now archives it first. `migration.test.mjs` has three such cleanup calls; they are `.catch(() => {})` with nothing asserted after them, so they no-op harmlessly in a throwaway DATA_DIR and the suite stays green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
fetchfor file bodies with an XHR transport that reports transferred bytes/percentage and distinguishes server, proxy, offline, and interrupted-connection failuresQuick creation needs a session id before it has an attachment scope, so its first file creates a stopped session immediately and uploads there. The CLI is not launched until the operator sends.
Failure investigation
The server-side stream was not silently losing files in the cases I could reproduce:
413 file is larger than 100 MB..partfile; the server cleanup path worked.The actionable error was being lost in the browser/UI:
fetchrequest to the statuslessTypeError: Failed to fetch; that opaque string was the only reason the chip could show.413response and now reports what it means and what to do.This PR changes the browser transport and error presentation while leaving the server's bounded streaming and partial-file cleanup intact.
Verification
npm test(server) — full suite passednpm run typecheck && npm test(web) — passedSCREENSHOT_PORT=17899 PLAYWRIGHT_BROWSERS_PATH=$AM_LOCAL/pw-browsers/uploads npm run test:screenshots(server) — all checks passedtoo large (100 MB max)before uploadgit diff --check— cleanNo production Space was changed or deployed.