feat(improvise-2mqz): add wire forms for SaveAs and ExportCsv downloads

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>
This commit is contained in:
Cursor Agent
2026-09-25 19:15:40 +00:00
parent 6da6a0401d
commit e0f04ee334
15 changed files with 289 additions and 52 deletions
+26
View File
@@ -155,9 +155,35 @@ function onFrame(text) {
console.error("improvise: frame rejected", text, err);
return;
}
// Check for pending downloads (improvise-2mqz). The WASM client stores
// download bytes from DocumentBytes/CsvBytes frames; we trigger the download
// here via Blob + <a download>.
try {
const downloadJson = client.take_pending_download();
if (downloadJson) {
const download = JSON.parse(downloadJson);
triggerDownload(download.filename, download.bytes, download.kind);
}
} catch (err) {
console.error("improvise: download failed", err);
}
repaint();
}
/** Trigger a browser download from bytes (improvise-2mqz). */
function triggerDownload(filename, bytes, kind) {
const mimeType = kind === "Document" ? "text/plain" : "text/csv";
const blob = new Blob([new Uint8Array(bytes)], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Transports.
/** The in-page worker: the standalone deployment, no server process. */