Skip to content

Upload attachments immediately with progress - #76

Open
lvwerra wants to merge 3 commits into
mainfrom
fix/immediate-upload-progress
Open

Upload attachments immediately with progress#76
lvwerra wants to merge 3 commits into
mainfrom
fix/immediate-upload-progress

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 15, 2026

Copy link
Copy Markdown
Member

Summary

  • upload files as soon as they are chosen, pasted, or dropped in reader, Overview, quick creation, and terminal flows
  • replace fetch for file bodies with an XHR transport that reports transferred bytes/percentage and distinguishes server, proxy, offline, and interrupted-connection failures
  • keep Send/launch disabled until every attachment is server-confirmed; retry failed files in place and reuse successful attachment ids without reuploading
  • preserve the specific failure reason when a later file in a terminal batch fails, instead of replacing it with “another failed to upload”
  • document the attachment-time lifecycle and extend the browser suite for timing, progress, large files, interrupted requests, proxy errors, and quickstart id reuse

Quick 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:

  • A 99 MiB file completed locally with HTTP 201.
  • A 101 MiB request returned the explicit server error 413 file is larger than 100 MB.
  • Cutting a throttled upload after two seconds left no .part file; the server cleanup path worked.

The actionable error was being lost in the browser/UI:

  1. Uploads in reader and Overview did not start until Send, so there was no opportunity to see or recover from a transfer failure beforehand.
  2. Chromium reduces an interrupted fetch request to the statusless TypeError: Failed to fetch; that opaque string was the only reason the chip could show.
  3. If a later terminal-batch file failed, the UI deliberately discarded even that error and displayed only “another failed to upload.”
  4. A non-JSON ingress/proxy rejection was reduced to a bare HTTP number by the JSON-only response parser. The regression suite simulates an HTML 413 response 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 passed
  • npm run typecheck && npm test (web) — passed
  • SCREENSHOT_PORT=17899 PLAYWRIGHT_BROWSERS_PATH=$AM_LOCAL/pw-browsers/uploads npm run test:screenshots (server) — all checks passed
  • real Chromium upload throttled to 256 KiB/s: a 2 MiB body emitted 79 progress events (32 KiB through 2 MiB) and completed with HTTP 201
  • interrupted browser request: immediate chip error named the interrupted connection and exposed Retry; retry succeeded
  • client-side 101 MiB file: rejected on attachment with too large (100 MB max) before upload
  • mobile-width visual inspection: long connection errors wrap without truncation; retry/remove remain reachable; the 20% / byte-count progress state remains compact
  • git diff --check — clean

No production Space was changed or deployed.

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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) → onPrepareQuickStartapi.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.

@lvwerra

lvwerra commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

Pushed a7e619f. I agree that all three findings were real; I am not pushing back on any of them.

What changed:

  • Abandoning quick creation now aborts its transfers, deletes any server-confirmed unsent files, and calls a conditional session delete. The server honors DELETE /api/sessions/:id?ifNeverStarted=1 only while everStarted is false; it returns 409 once the target has started. Successful Create/send deliberately preserves the session.
  • An empty or stale active selection now falls back to Overview, not tree.order[0]. This prevents the just-created upload target from becoming visible and mounting a terminal on a first visit.
  • Upload XHR now accepts an AbortSignal and has a finite 20-minute timeout. The chip × stays enabled during transfer and cancels/removes that file, so the remaining draft can send. Removing a successfully uploaded unsent chip also deletes its stored file. Composer unmounts clean up unsent uploads.
  • The terminal upload surface now has Cancel while transferring, plus Retry/Remove for a file saved but not inserted.
  • Added a single-attachment DELETE endpoint and serialized it with per-session upload writes.

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 everStarted guard preserves a target the operator intentionally opened.

Evidence:

  • Added a fresh 390×844 isolated-browser regression that attaches a file with no saved selection, verifies the only session is running:false / everStarted:false, Overview remains selected, and no terminal pane mounts. It then presses Escape, waits for zero sessions, reloads, and verifies zero sessions again.
  • The same suite starts a target and verifies conditional cleanup returns 409 and retains it.
  • A held upload verifies Cancel is enabled, removes the chip, and immediately makes Send available.
  • Removing a confirmed unsent chip is verified to remove the file from the managed attachment directory.
  • The earlier connection-reset and HTML proxy-413 reproductions still verify that the actual failure reason appears at attachment time with Retry.

Verified:

  • cd web && npm run typecheck
  • cd web && npm test
  • cd server && npm test
  • SCREENSHOT_PORT=17978 PLAYWRIGHT_BROWSERS_PATH=/home/node/local/pw-browsers/uploads node server/screenshot-input.test.mjs (all checks passed)

No merge or deployment performed.

@lvwerra
lvwerra force-pushed the fix/immediate-upload-progress branch from a7e619f to b21ca00 Compare August 15, 2026 19:19
lvwerra pushed a commit that referenced this pull request Aug 18, 2026
…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>
lvwerra pushed a commit that referenced this pull request Aug 18, 2026
…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>
@lvwerra
lvwerra force-pushed the fix/immediate-upload-progress branch from b21ca00 to a560716 Compare August 18, 2026 14:16
@lvwerra

lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current main (90c04f1) and force-pushed the same branch at a560716.

What changed because #84 moved attachment selection into the pane header:

  • Kept Trace download on the session, and the reader's facts behind one i #84's single pane-header paperclip for both terminal and reader modes. In reader mode, ConversationView registers its hidden file input with that header control; there is no second picker in the composer.
  • Kept the reader's chips, byte progress, exact failure, Cancel, and Retry above the fixed composer. Selection starts in the header, but transfer state stays with the draft whose Send action it gates, so an interrupted upload is visible/actionable without adding state to the compact attach/search/info/close cluster.
  • Kept the terminal's progress and recovery in .term-image-status over the terminal. The header paperclip starts the flow, but xterm has no Agent Manager composer queue to hold chips; saved-but-not-inserted still offers Retry/Remove, and in-flight transfer still offers Cancel.
  • Updated the browser test to click the header paperclip and prove its file chooser belongs to the reader, then require progress/error/retry chips under .cxv-composer. The terminal header/recovery checks remain in the same run.
  • Resolved Sidebar against Trace download on the session, and the reader's facts behind one i #84/Draw static states as the completed braille path #87/Sidebar: one mark for agent state, and drop the waiting count #82 intentionally: retained quick-create prepare/abandon behavior, removed the obsolete shared-trace prop/widget path and removed waiting-count code already gone on main.

The review-round fixes remain intact and were re-exercised:

  • a fresh attachment-created target stays stopped and Overview remains selected (no tree.order[0] pane launch)
  • Escape aborts/discards the unstarted target and its uploaded file across reload
  • conditional cleanup refuses to delete a target that has actually started

Verification on the rebased branch:

  • web: npm run typecheck — pass
  • web: npm test — pass
  • server: SCREENSHOT_PORT=17976 PLAYWRIGHT_BROWSERS_PATH=$AM_LOCAL/pw-review npm run test:screenshots — all checks pass, including header ownership, progress, connection/proxy/large-file errors, Cancel/Retry/Remove, terminal recovery, quick-create id reuse, Overview fallback, and abandoned-target cleanup
  • git diff --check — clean

No merge or deployment performed.

lvwerra pushed a commit that referenced this pull request Aug 18, 2026
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>
lvwerra pushed a commit that referenced this pull request Aug 18, 2026
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>
lvwerra pushed a commit that referenced this pull request Aug 18, 2026
`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant