This commit implements the download flow for SaveAs and ExportCsv per the
USER RULING on improvise-2mqz. The correct shape is:
- A request frame asks the host for BYTES
- ServerFrame returns the bytes (or Rejected)
- Browser triggers downloads via Blob + <a download>
Key changes:
Protocol layer (improvise-protocol):
- Add Request::DownloadDocument and Request::DownloadCsv variants
- Add ClientFrame::DownloadDocument and ClientFrame::DownloadCsv
- Add ServerFrame::DocumentBytes and ServerFrame::CsvBytes
- Add KeyOutcome::LocalThenRequest variant for operations that need
both local fold (mode change, buffer clear) AND a server request
- Update stranding() to unstrand SavePrompt and ExportPrompt modes
- Update keymap to use rebind() instead of commit() for commit-save
and commit-export (because LocalThenRequest can't be in a Sequence)
Host layer (improvise-command):
- Handle new ClientFrames in host.rs returning bytes or Rejected
- Add Rejection::ExportFailed variant for CSV export failures
- Update CommitSave and CommitExport to clear their buffers
- Make render_csv() public in improvise-io for host use
WASM client layer (improvise-wasm-client):
- Add PendingDownload type with filename, bytes, and kind
- Store pending downloads for JavaScript retrieval via take_pending_download()
Browser layer (web/app.js):
- Add triggerDownload() function using Blob + <a download>
- Poll for pending downloads after frame handling
This unstrands SavePrompt and ExportPrompt which were previously refused
by stranding() due to missing wire forms.
Refs: improvise-2mqz
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
Resolutions:
- resolve.rs: dropped #295's record_at_cursor in favour of 8xq0's
captured-cell path (commit_value / opened_at_cursor); nothing references
it any more. declined_at keeps #292's NonItem arm. #274's two new
exhaustive matches (opened_at_cursor, uncaptured) gain a NonItem arm that
answers None; refused_here already declines a commit over a NonItem cell
when nothing is captured, so uncaptured never reaches it.
- client.rs: kept main's status_seen/status_owner beside #274's
Opened/OpenedCell/OpenedTarget.
- lockstep.rs: #274 made the same sent -> sent_to_host rename #295 did;
kept this branch's sent_or_upstream (main already owns sent_to_host).
- context/test-evidence/clients.md: kept both sides. Because #274 and #295
both carry the 7mro commits, the three 7mro entries
(keys_sent_before_a_scroll_is_answered_agree_with_the_tui,
a_held_cell_is_read_at_its_position_and_not_its_cache_index,
resolve::tests::a_cursor_on_an_empty_grid_waits_for_nothing) arrived
twice. The first two copies were byte-identical; #274's copy of the third
also carries its "Amending the entry ... for #274's capture" paragraph,
so #274's copies are kept and #295's are removed. Heading union against
both parents: nothing missing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Resolutions:
- CellId: #221 gives Coordinate fields { record, category }; main (#292)
adds NonItem { category, item }. Every exhaustive match now names both
(render.rs, core.rs x2, project.rs, resolve.rs x3, wasm-client lib.rs and
render.rs); project.rs's basis arm keeps #221's Coordinate-carries-a-record.
- resolve.rs edit_or_drill: #221 drops the "renaming a coordinate" decline
(Coordinate opens an editor); main's NonItem decline arm is kept.
coordinate_at_cursor's None arm gains NonItem. reopened_at keeps #295's
declined_at shape; declined_at's editable arm names Coordinate { .. }.
- client.rs: kept main's status_seen/status_owner and #221's opened +
OpenedCoordinate; every settle site calls both settle_opened and
settle_status. Main's new rebase() also gets settle_opened, since it
rebuilds the view exactly as apply() does.
- view/mod.rs: re-export both GridEval (main) and record_row (#221).
- context/code/command.md and context/test-evidence/clients.md: both sides
appended independent sections; kept both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Resolutions against gh/main 8d870c15:
- client.rs: kept main's settle_status (1x9h) and #295's
settle_declined_editor (5m9z) side by side.
- resolve.rs reopened_at: took #295's declined_at shape and moved #292's
CellId::NonItem arm into declined_at, which reopened_at consults before
any seed choice, so a NonItem landing still re-opens nothing.
- lockstep.rs: both sides appended at EOF; kept main's tail then #295's.
Both defined sent_to_host with different semantics (main's panics on a
non-local key, #295's lets an upstream key reach a silent host), so
#295's copy is renamed sent_or_upstream with its three callers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
context/test-evidence/command.md: both sides inserted entries at the
same point (this branch's remove_formula_of_a_missing_formula_is_a_refusal;
gh/main's fy9o entries from #227). Both kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This branch carries #282 and #286 (5m9z/7mro). The other side now
carries #227 and #272 (fy9o/p6v6). This applies the #227 x #295 recipe
(resolve-b/fy9o-with-295, 3918b126) on top of 8xq0's captured-target
commit.
- client.rs: `settle_awaited_seed()` then `settle_declined_editor()`.
One `settle_awaited_seed`, which needs both no unanswered write and a
projected cell (`projected_seed`).
- resolve.rs:
- `opened_on(state, ..)` and `seeded` from p6v6. 8xq0's
`opened_at_cursor` is kept.
- 8xq0's `written` over the captured `OpenedCell` and `still_waiting`
are kept, and fy9o's `cleared` is added. The position-based
`written` and `record_at_cursor` from the other side are dropped,
because 8xq0 replaced them.
- `commit_value` and `rename_coordinate`: `cleared` after
`ChangeMode(Normal)`, after every decline's early return (joed).
`advanced(.., writes)`.
- `reopened_at(.., writes)`: `declined_at` first, then
`awaiting_seed` after a write or on an unprojected landing, else
`opened_on(state, ..)`. One `awaiting_seed`.
- The capture still reads through `projected_at` (`cell_at_cursor`),
and `refused_here`'s 3h9w arm is kept (`state.opened.is_some()`).
- lockstep: #286's `sent` renamed `sent_to_host` (E0428). The held-cell
test delivers its held replies before it reads an awaiting editor.
- Ids for the three p6v6 fixture cells that had none.
- context/test-evidence/clients.md: both sides kept, plus the held-cell
amendment.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Conflicts:
- client.rs accepted(): both settles, in `fold`'s order:
settle_editor (fy9o/p6v6 awaited seed), settle_opened (fdk1 capture),
then the revision bump.
- resolve.rs edit_or_drill: a Coordinate opens like a Record, through
p6v6's `opened_on(state, ..)`, which waits while a write is unanswered.
- resolve.rs rename_coordinate: fy9o unwelded `clear-buffer` from the
commit keys, so the rename clears its own buffer on the success path,
after ChangeMode(Normal). It passes `advanced(.., writes)`, where
`writes` is whether the frame composed the rename. The waiting decline
still returns before any clear (the joed constraint).
- resolve.rs reopened_at: the decline arm first (Coordinate { .. }), then
`awaiting_seed` after a write, else `opened_on(state, ..)`.
- lockstep.rs: both sides' new tests kept.
- context/test-evidence/clients.md: both sides kept.
- browser_round_trip `a_second_rename_after_a_reorder_renames_the_row_the_host_shows`:
the reopened editor is now seeded from the answered cell. The test
asserts that seed, then clears it before typing. Evidence amended.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
context/test-evidence/clients.md: both sides inserted entries at the
same point (this branch's two tile-select lockstep entries; gh/main's
fy9o/5wai entries from #227). Both kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Applies the #227 x #295 resolution recipe from resolve-b/fy9o-with-295
(3918b126). The tree equals `merge-tree 3918b126 220abc61`, which
merges cleanly, because main moved past #227 only in tooling files.
- client.rs: `settle_awaited_seed()` then `settle_declined_editor()`.
One `settle_awaited_seed`, which needs both no unanswered write and a
projected cell (`projected_seed`).
- resolve.rs `reopened_at(.., writes)`: the decline arm first, then
`awaiting_seed` after a write, or when the landing is unprojected,
else `opened_on(state, ..)`. One `awaiting_seed`.
- lockstep: #286's `sent` renamed `sent_to_host` (E0428).
- Ids for the three p6v6 fixture cells that had none.
- `a_held_cell_is_read_at_its_position_and_not_its_cache_index` delivers
its held replies before it reads an awaiting editor. Evidence amended.
- `cleared` stays on commit's success path only (the joed constraint).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This head already carries #272 (p6v6), merged at d9c65181.
Conflicts:
- resolve.rs advanced: main's `reopened_at` (decline an aggregate or a
formula's result) now takes this branch's `writes` and answers the
decline arm first, then `awaiting_seed` or `opened_on(state, ..)`, as
the #282 review prescribed for this combination.
- context/test-evidence/{clients,command}.md: both sides' entries kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
context/test-evidence/command.md: both sides inserted a block at the
same point, before the "improvise-i3o5: four declines" subsection. Both
kept: this branch's remove_formula_of_a_missing_formula_is_a_refusal,
then gh/main's totals-strip section (improvise-wm20).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
context/test-evidence/command.md: both sides appended at EOF; both kept
(this branch's two drill tile-select entries, then gh/main's
Host::mutating_frame abort-arm section).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
#274's capture read the cell through the cursor's offsets and stuck, so a
scrolling Enter captured the old window's cell even with every reply
delivered (improvise-7mro's horizontal wrap: Jan/Revenue for Feb/Revenue).
#286's cell_at_cursor reads through projected_at, so the capture waits
until the host projects the cell. Taken by merge, not by port: the matrix
test's held cases need #282/#277's waits too.
Resolutions: committable_cell_key takes #286's uneditable(); the two unit
CellId::Coordinate patterns become struct patterns; evidence is appended
on both sides.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
An awaited seed waits for a cell the host projected (id present), never the
blank a relabel leaves between a frame and its cells. The advance's reopen
keeps #282's shape: on this base the welded clear-buffer empties it anyway.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
cell_at_cursor and seed_at read through projected_at, and cell_at (the
cursor-offset read) is gone. A cell of the grid outside the window the
cache holds is unprojected: i and an advance open an editor awaiting its
seed, which the fold settles once the host projects the cell, and a
commit, x, yy or p on it waits out loud rather than composing anything.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Enter or Tab on a typed-into editor over a fold or a formula's cell went
Upstream, which nothing answers. refused_here declines the step, which
takes the whole binding with it, exactly as the TUI's failed
committable_cell_key does: status said, buffer and mode kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-4k1j, from PR #275's review. hidden_row_item refused on a fold
only when the NEAREST group header was collapsed. Under two row
categories that header can be the inner one's: with Region's Coast
collapsed and Product's Kit expanded, the client composed
HideItem{Region, "Coast"} and a twin host wrote a group name into the
document, where the host refuses.
The frame cannot answer this on gh/main: FrameGroup names no level and
an inner GroupEnd reads as an outer one. So FrameEntry::Item now carries
the layout's own DataItem::collapsed flag (a fold, or under one, at any
level) and AxisFrame::folds reads it, the same bit
GridLayout::row_folds_a_group reads. FrameEntry::item builds an
unfolded one, as AxisEntry::item does.
hiding_agrees_with_the_host_on_every_row_of_nested_groups presses H on
all 25 rows of four nested grouped grids, in two windows each. It was
red on the nearest-header check at rows 4 and 5, as the review found.
Also fixes the fmt gate the review found red: fmt-workspaces.sh is a
check, not a formatter, and its diff output was misread as success.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
reopened_at opens provisionally where the landing cell is not projected
yet; ClientState::settle_declined_editor closes an untouched editor once
the host projects that cell as one i declines. Both ask one
resolve::declined_at. A touched editor is kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-fo0w. Three black-hole keys closed over vocabulary that
already exists, each mirroring its Cmd line for line with a lockstep case:
- enter-advance (Normal Enter) resolves as cursor arithmetic over
GridExtent; the host's bottom-right AbortChain is Ignored here, which
loses nothing because Enter is never bound inside a Sequence.
- hide-selected-row-item (H) resolves to ModelCommand::HideItem over the
outer row category and the selected tuple's first label, and refuses
on a fold with the host's own FOLD_REFUSAL, now in improvise-protocol.
- search (/) and search-navigate (n) decline out loud as a pair: n needs
the whole model, and / alone would open a box that finds nothing.
NEEDS_THE_HOST drops to exit-search-mode, force-quit and
toggle-group-under-cursor. Tests that used Enter or "Start search" as
their upstream example move to Ctrl-Q and "Exit search mode", including
shell.spec.js's not-sent case.
Also improvise-0629: the help render test now asserts every ex row is
drawn dim, not just that its description is marked.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
EnterEditAtCursor asks core::uneditable, the commit door's own question,
now shared with committable_cell_key. The browser's advance asks the
projected id at the landing cell; lockstep pins both ends on a fold and
on a formula's cell.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
resolve.rs::written read its operands at Enter: the record and measure
from the cell under the cursor, and the basis from claim(), i.e. the
version current at commit. So a HideItem from another client that
renumbered the rows under an open editor sent the write to whichever
cell the cursor then named (8xq0), and a write by another client to the
record while the editor was open was silently overwritten instead of
refused RecordMoved (xz2z, 0j0f's rule on the keyboard door).
The fix reuses #221's capture rather than adding a second one.
ClientState::opened becomes OpenedCell { record, target, version,
document_generation }, where OpenedTarget is Value { measure } or
Coordinate { category }. settle_opened captures a Record cell as a
Value target, and written builds the RecordCell and the claim_as_read
basis from it. With no capture while the host has yet to answer this
client's own last write, the commit declines with the rename's "still
waiting" status.
Red first: both round-trip probes failed on gh/main 202e7eac and on
#221's head 39821d5f. Unit-test fixtures that set Editing by assignment
now open the editor through the fold, which is where the capture is
taken.
Stacked on #221 (fix/fdk1-coordinate-rename).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-qnry: a lockstep case compares Tab at the rightmost column with
the host's real CommitAndAdvance, and the tile lockstep case now presses
past the last tile. improvise-ybum: browser_round_trip's tile and column
walks are bounded and fail on a stuck press instead of hanging.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
mdfc put the "any unanswered write" check inside `advanced` alone, so an
editor opened with i/a (`edit_or_drill`) still seeded from the cache the
write had made stale: `i 9 Tab Esc i 5 Enter`, typed faster than the
answer, stored 15 in the browser where the TUI stores 195.
The in-flight half now lives in `opened_on`, so no opener can forget it.
`advanced` keeps the "this frame writes" half, since the commit being
resolved is not in flight yet; dropping it fails the 3tt4/3uzg tests.
Stacked on #227 (fix/fy9o-reopened-editor-reseeded, de4a7d78).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
A pruning commit is answered with ResetCursor and an echo of its own
placement. A frame sent before that answer had already been folded
optimistically, and fold applied the relays straight onto view on top of
it. The echo is per frame, so the later placement was never re-sent: the
client sat at (1,0) while its mirror sat at (2,0), and the next write
landed on West where the TUI writes East.
#246 already queues each relay behind the head frame, so the queue holds
the right order. fold now rebuilds view as settled plus everything still
in flight whenever a relay queues, the same replay a refusal uses.
Replaying from settled, rather than re-applying what queued behind the
relay, is pinned by a ViewBack test: re-applying stepped back twice.
Stacked on #246 (fix/hv0r-jpba-relays, 790a33ba).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-mdfc: an untouched commit seeded the reopened editor from the
cache even while an earlier write from this client was unanswered, so
Tab Tab at the rightmost column stored 505 where the TUI stores 5095.
`advanced` now awaits when this frame writes or any in-flight frame is
awaited. The lockstep test sends three Tabs before answering any.
improvise-4cb5: `settle_awaited_seed` also requires an editing mode, so
an Esc before the answer no longer leaves the seed in Normal's buffer.
improvise-mgis: `OpenedEditor::awaiting` loses `serde(default)`; a frame
missing it fails to parse as the missing field (protocol.md §3, 1wl0).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-xoxh, found in the delta review of #246. deliver hands a client
what the HOST sent, so a relay must go through fold, which queues it
directly behind the awaited head. fold_outgoing queues it at the back as
the client's own frame. That is the pre-jpba ordering, so a test built on
deliver could not see a jpba regression. The bug was latent, because
deliver's one caller sent only view-only frames.
deliver_queues_a_host_relay_behind_the_awaited_head was red at 790a33ba.
With deliver fixed, putting the pre-jpba ordering back in settle_relay
fails this test and nothing else in the lockstep binary.
The renaming test that came in through the gh/main merge now folds its
own commands through fold_outgoing. It also asserts that the commit is
the one awaited frame, so the two doors are told apart.
Stacked on #246 (fix/hv0r-jpba-relays).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze