Skip to content

Deterministic UI audit harness + map of what's broken - #34

Open
17Amir17 wants to merge 13 commits into
masterfrom
session/status-checl
Open

Deterministic UI audit harness + map of what's broken#34
17Amir17 wants to merge 13 commits into
masterfrom
session/status-checl

Conversation

@17Amir17

@17Amir17 17Amir17 commented Sep 3, 2026

Copy link
Copy Markdown

Summary

  • Adds a repeatable, source-derived UI audit of every interactive affordance in apps/web — replacing ad-hoc "click around and see" passes that kept missing things — plus a checked-in map of what's broken (docs/audit/BROKEN-MAP.md).
  • scripts/audit/: a TestSpec grammar (schema.md), a deterministic driver that boots an isolated vite+browser stack per shard (local in-browser engine) and drives each spec to PASS/FAIL/BLOCKED from objective evidence (page/console errors, feedback text, viewport pixel-diff, DOM state), a 5-phase multi-agent workflow (inventory → execute → adversarial verify → synthesize), and gen-map.mjs.
  • docs/audit/: the enumerated test map (specs/*.json — 1,928 affordances across 25 areas), per-spec verdicts (results/*.json), curated evidence screenshots, and the map. Bulk screenshots are gitignored.

Results of the first full run

  • 582 pass · 409 blocked · 937 raw failures. Blocked = untestable in this sandbox (no remote PyMOL bridge, no offscreen GL, no server file-service) — recorded as BLOCKED, never as passing.
  • Adversarial re-verification rejected ~96% of raw failures as over-strict-check false positives and confirmed 11 real defects (4 distinct root causes):
    1. bg_color never repaints the live WebGL viewport (setting not wired to the renderer).
    2. Movie frame scrollbar permanently disabled (get_movie_status stub always returns nframes:0).
    3. Scene ▸ Blank throws "No scenes" instead of clearing the view (empty-key recall bug).
    4. Builder dock first-click race — quick button doesn't open the dock on first click; 7 controls unreachable.
  • 77 low-signal candidates (mostly mouse/shortcuts, many "undrivable by design") were not re-verified before the sandbox hit its process limit; listed in the appendix, not counted as defects, with ready-to-run re-verify shards.

Test plan

  • Read docs/audit/BROKEN-MAP.md — the funnel table and the 4 confirmed defects.
  • Reproduce a defect: node scripts/audit/run-shard.mjs docs/audit/specs/viewport.json /tmp/out.json /tmp/shots and inspect viewport.render.bgColor.
  • Spot-check a spec file (docs/audit/specs/menubar.json) against scripts/audit/schema.md.
  • Optional: complete the unverified appendix after a session restart via the shards in docs/audit/reverify/.

🤖 Generated with Claude Code

Adds a repeatable, source-derived audit of every interactive affordance in
apps/web, replacing ad-hoc "click around and see" passes that missed things.

- scripts/audit: TestSpec grammar (schema.md), a deterministic driver that boots
  an isolated vite+browser stack per shard and drives each spec to PASS/FAIL/
  BLOCKED from objective evidence, a 5-phase multi-agent workflow (inventory ->
  execute -> adversarial verify -> synthesize), and gen-map.mjs.
- docs/audit: the enumerated test map (specs/*.json, 1,928 affordances across 25
  areas), per-spec verdicts (results/*.json), and BROKEN-MAP.md.

First full run: 582 pass, 409 blocked (no remote bridge / GL / file-service in
this sandbox), 937 raw failures. Adversarial re-verification rejected ~96% of
raw failures as over-strict-check false positives and confirmed 11 real defects
(4 root causes): bg_color never repaints the live viewport; movie frame
scrollbar permanently disabled (get_movie_status stub); Scene > Blank throws
"No scenes"; Builder dock first-click race leaves 7 controls unreachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mintlify

mintlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
10play 🟢 Ready View Preview Sep 3, 2026, 10:30 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

Comment thread scripts/audit/gen-map.mjs Outdated
m += `- The ${confirmed.length} confirmed defects collapse to **4 distinct root causes** (7 of them are one Builder-dock bug hitting different controls).\n`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: hardcoded 7 doesn't match the data. confByArea.builder actually has 8 confirmed entries (see docs/audit/_summary.jsonbuilder.bonds.order.triple, .model.rest, .model.sculpt, .bonds.delete, .settings.bumps, .atoms.clear.confirm, .wizard.cancel, .wizard.dismiss). This script is supposed to be deterministic/data-driven (per its own header comment and the README), but this count is a literal string, not computed from confByArea.builder.length. The checked-in docs/audit/BROKEN-MAP.md already shows the resulting contradiction: the headline and section-4 title both say "7", while the coverage table on the same page (line 51, computed from st.conf) correctly shows 8 for builder, and the "Affected controls" list right below the title enumerates 8 ids. Should be ${confByArea.builder?.length ?? 0} (or similar) in both this line and line 52's title, not a hardcoded number.

Comment thread scripts/audit/gen-map.mjs Outdated
{ title: '1. `bg_color` never repaints the live viewport', ids: ['viewport.render.bgColor'] },
{ title: '2. Movie frame scrollbar is permanently disabled', ids: ['movie.seek.range'] },
{ title: '3. Scene ▸ Blank throws "No scenes" instead of clearing the view', ids: ['scmenu.blank'] },
{ title: '4. Builder dock does not open on first click (7 controls unreachable)', ids: confByArea.builder?.map((c) => c.id) ?? [] },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: same hardcoded-7 issue as line 43 — the group title says "7 controls unreachable" but ids (confByArea.builder) resolves to 8 entries at render time, so the title contradicts the "Affected controls" list generated a few lines below it for the same group.

…ore)

- Rename ui-audit.workflow.mjs -> .workflow.js so eslint's **/*.workflow.js
  ignore applies (the file uses injected Workflow globals + top-level return).
- driver.mjs: /* global document */ for the browser page.evaluate callback.
- gen-map.mjs: drop an unused variable.
- docs/.mintignore: ignore audit/ (BROKEN-MAP.md contains source links and
  literal HTML/JSX-looking evidence snippets; it is a GitHub-facing artifact,
  not a Mintlify site page), matching feature-parity.md / scoreboard.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread scripts/audit/gen-map.mjs Outdated
m += `- **${totalSpecs}** affordances enumerated across **${Object.keys(perArea).length}** areas.\n`;
m += `- **${totalPass}** pass · **${totalBlocked}** blocked (untestable in this sandbox — see below) · **${totalRawFail}** raw failures before verification.\n`;
m += `- Raw failures are dominated by over-strict spec checks. Adversarial re-verification confirmed **${confirmed.length} real defects** out of ${S.verifiedCount ?? '~300'} verified candidates — a ~96% false-positive rejection rate, which is the whole point of the verify layer.\n`;
m += `- The ${confirmed.length} confirmed defects collapse to **4 distinct root causes** (7 of them are one Builder-dock bug hitting different controls).\n`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: The "7 of them are one Builder-dock bug" count is hardcoded rather than derived from confByArea.builder.length. The actual builder-area confirmed-defect count is 8 (see the table and the "Affected controls" list a few lines below, both of which correctly show 8), so the generated docs/audit/BROKEN-MAP.md headline currently contradicts its own table/list. Suggest computing this from confByArea.builder?.length ?? 0 so it can't drift again on the next regen.

17Amir17 and others added 4 commits September 3, 2026 10:55
…data is 8)

The map's headline and section-4 title hardcoded "7" while the coverage table
and affected-controls list (computed from the data) showed 8 — an internal
contradiction flagged in review. Compute the count from confByArea.builder so it
can't drift on regeneration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A durable task engine for the browser-only migration. The working tree — not an
in-memory checkpoint — is the source of truth: each task has a machine-checkable
postcondition, reconcile re-derives done-ness from the repo (idempotent, survives
session-limit kills / process exits), and a task is only "verified" when its gates
pass (a gate that can't run in a degraded sandbox defers, never false-greens).
Proven on a crash-simulation demo before wiring any real removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documented in docs/audit/impossible.md and executed through the crash-safe
harness (scripts/migrate/plan/remove-impossible.json); both tasks verified by
postcondition + typecheck + build.

Removed from the shipped browser UI (backend in packages/bridge/engine kept):
- APBS Electrostatics — native external solver (also a stub): feature dir,
  launcher icon, Storybook stories, Plugin-menu leaf.
- Install Plugin… — writes server plugin dir + loads Python.
- Working Directory ▸ Change… / File Browser — server filesystem navigation.
- Recent files — server-side MRU of host paths.
- Edit pymolrc — edits server-side ~/.pymolrc.
- Log File ▸ Open/Resume/Append — streams a log to a host path.

Kept: Plugin Manager (read-only viewer), Get PDB (fetch), and Open/Save/Export
(reworkable to file-picker/blob-download in the follow-up implement phase).

Also: harness gates now resolve pnpm via a corepack shim, and remove a stray
demo file accidentally included in the harness commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First task of the browser-only rework (docs/audit/impossible.md bucket B),
driven through the migration harness. Adds the single blob-download helper
(download.ts: downloadBlob/downloadBytes/downloadText) the File-menu reworks
will use to replace host-path writes, plus its jsdom test. Harness gains
task-level verify commands (targeted vitest) so each implement task is verified
by its own test. I0 verified: postcondition + test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
{ kind: 'command', label: 'Edit pymolrc', dialog: 'edit-pymolrc' },
sep,
{
kind: 'menu',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocking] Docs contradict the code they describe.

This PR removes the apbs dialog leaf (and the whole features/apbs/** implementation, per scripts/migrate/plan/remove-impossible.json), but docs/feature-parity.md:481-483 still asserts, unedited:

  • Row "APBS Electrostatics — menu entry stays visible" ([x], i.e. confirmed done) — the menu entry does not stay visible anymore; it was deleted here.
  • "Covered by" cites apps/web/src/features/apbs/p13apbsEntry.dom.test.tsx, ApbsPanel.dom.test.tsx, apbsProbe.test.ts — all three were deleted in this same PR (commit 735449a).

docs/code-ownership.md:247 (WP-30) also still lists apps/web/src/features/apbs/** as an owned path that no longer exists.

docs/feature-parity.md describes itself as "a reference, not a work log" and its [x]/[-] verdict column exists precisely to record descoping decisions like this one — the row should be updated to [-] (descoped, browser-only decision) rather than left claiming the feature is present with tests that no longer exist. The removal's own plan (scripts/migrate/plan/remove-impossible.json) has no task for updating these docs, which is the gap.

17Amir17 and others added 4 commits September 3, 2026 21:05
…task I1)

File ▸ Open… (and the drag-drop single-molecule path) now open a browser
<input type=file>, read file.text(), and load the CONTENTS through
session.act cmd.load(content, name, 0, '') — no cmd.tenmol_files.*, no server
path. Works on both backends (cmd.load accepts content either way). The .pwg
refusal gate is preserved via new engine-independent helpers in globalDrop.ts
(browserClassification, objectNameForFile). Traj/map/mtz/mae/.pse modal +
presentation pipeline is untouched and still reached via Open Recent / deep link.

Also fixes a latent download.ts Uint8Array→BlobPart type error (I0 gated only on
its test, not build; I1's build gate caught it) and retargets two tests that
pinned the removed server-picker Open path. Harness: I1 verified (postcondition
+ build + new test + files/menubar regression suites, 281 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… I2, I3)

- Save Session / Save Session As… (and Ctrl+S): serialize via cmd.get_session
  and download a .pse Blob (downloadText). No ensure/PathPicker/save <path>
  (the engine's save writes to disk and throws in the browser).
- Export Molecule: seed the dialog from cmd.get_names (not the bridge), export
  the chosen format via cmd.get_str (pdb→get_pdbstr, fasta→get_fastastr) and
  download. Multi-file patterns and Export Map/Alignment left bridge-bound.

Both work on either backend (get_session/get_str exist in engine-ts and over the
bridge). Harness: I2, I3 verified (postcondition + build + new tests); 285
files/menubar tests pass. Retargeted p9f1MenuHooks Save-Session cases to the
browser-save path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (task I4)

Export Image ▸ PNG and the Draw/Ray "Save Image to File" now run the pngCommands
setup lines (draw / opaque_background) then read pixels via cmd.png(['',0,0,-1],
{ray}) and download the bytes (downloadBytes, image/png) — no png <path> (a
no-op disk write in the browser), no PathPicker. cmd.png is the correct pixel
source since the viewport canvas is a pull surface, not preserveDrawingBuffer.

Harness: I4 verified (postcondition + build + new test; 164 files tests pass).
Completes bucket B of docs/audit/impossible.md (Open/Save/Export all browser-native).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-tagging settings/seqview/ray as local and re-running (exploratory) confirmed
they run in the browser, but the raw verdicts aren't trustworthy yet — most
failures are the over-strict-spec artifact (specs assume a menu/panel already
open and time out on a setup selector). Documented the follow-up: fix ~105 spec
setups + adversarial-verify, and directly check whether the sequence viewer
renders in local mode (a likely genuine gap). Audit specs/results left unchanged
to avoid committing unverified verdicts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines 1102 to 1105
const bytes = await session.call<number[]>('cmd.png', ['', 0, 0, -1], {
prior: 1,
dpi,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: dpi is passed both positionally and as a kwarg here.

args: ['', 0, 0, -1] fills PyMOL's cmd.png(filename, width, height, dpi=…) signature through the dpi slot (index 3, value -1), and then { prior: 1, dpi } passes dpi again as a keyword. Over the real PyMOL bridge this reaches target(*call_args, **call_kwargs) (packages/bridge/tenmol_bridge/dispatch.py:143) unchanged, i.e. cmd.png('', 0, 0, -1, prior=1, dpi=<value>) — Python raises TypeError: png() got multiple values for argument 'dpi'. The local engine-ts backend masks this because its pick() helper (packages/engine-ts/src/cmd/render.ts:109) tolerates the duplicate, and wfExportPng.dom.test.tsx never exercises this RenderPanel Draw/Ray save path, so nothing catches it.

The existing bridge convention avoids exactly this (see panels/files.py::copy_image_png, which only ever passes filename positionally and puts everything else in kwargs). This call should do the same, e.g. session.call('cmd.png', [''], { prior: 1, dpi }).

Comment on lines +406 to +419
const files = Array.from(input.files ?? []);
void (async () => {
for (const file of files) {
// The `.pwg`/refusal gate is engine-independent and comes FIRST: a
// `.pwg` classifies as plain and would otherwise reach `cmd.load`,
// which executes its directives (`globalDrop.ts::refusalFor`).
const refusal = refusalFor(browserClassification(file.name), file.name);
if (refusal !== null) {
say(refusal, 'warning');
continue;
}
const content = await file.text();
await session.act({
fn: 'cmd.load',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: fileOpen no longer routes dialog-needed formats (session/map/mtz/mae/trajectory/alignment) to their modal — it silently text-decodes and cmd.loads everything except .pwg.

Before this PR, uploadAndLoad/fileOpen went through load() (FileDropTarget.tsx), which calls dialogNeededFor(classify(...)) and shows dialogRequiredMessage for traj/map/mtz/aln/mae/session before ever touching cmd.load. This new fileOpen (and the equivalent uploadAndLoad in FileDropTarget.tsx:156-171, used by drag-drop and Ctrl+O) only checks refusalFor(browserClassification(...)), which exclusively recognizes .pwg. Every other format — including binary ones like .pse/.mtz/.ccp4/.dsn6 — falls through to file.text() (UTF-8-decoding binary bytes, corrupting them) and an unconditional cmd.load(content, name, 0, '').

The inline comment on FileDropTarget.tsx:152-154 claims "Dialog-only formats … keep their planFromDataTransfer/dialogNeededFor handling on the URL branch below" — but that's only true for the plan.kind === 'url' branch; the files branch (the common case: local Open…/drag-drop/Ctrl+O) never calls dialogNeededFor at all. wfBrowserOpen.dom.test.tsx doesn't test any of these formats, so this regression isn't caught by the new suite.

Opening a .pse (partial-session merge), .mtz/map (import settings), .mae (Maestro), or alignment file via File ▸ Open…, Ctrl+O, or drag-and-drop will now silently mis-load or fail instead of prompting the user with the required dialog.

1. cmd.png dpi passed twice — the Draw/Ray "Save Image to File" call put -1 in
   PyMOL's positional dpi slot AND passed dpi= kwarg, a TypeError over the real
   bridge (local engine masked it). Pass filename positionally, rest as kwargs.
2. Open…/drag-drop silently text-decoded dialog-needed & binary formats
   (.pse/.mtz/.ccp4/.mae/.dcd/.aln), corrupting them. Restore the
   dialogNeededFor(classify()) guard in fileOpen + uploadAndLoad: those formats
   now show the dialog-required message instead of a bad cmd.load; only plain
   single-molecule text formats load their contents.
3. Docs contradicted the APBS removal: feature-parity.md row marked [-]
   (descoped, backend kept) with dead test citations replaced; code-ownership.md
   WP-30 drops the deleted features/apbs/** path. Descoping one done row lowers
   the parity floor 362→361 (the workflow's own "conscious decision" case).

Tests extended (wfBrowserOpen: .mtz/.pse refused not loaded; wfExportPng:
Draw/Ray save asserts no duplicate dpi). Build + 167 files tests + parity +
ownership + harness reconcile (I1/I4 still verified) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
const content = await file.text();
await session.act({
fn: 'cmd.load',
args: [content, objectNameForFile(file.name), 0, ''],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: Dropping/opening a .pml, .py, or .pym script now silently fails.

classify() maps these extensions to dialog: 'script' (globalDrop.ts:164-166), and dialogNeededFor deliberately excludes 'script' from NEEDS_DIALOG — the comment there says "a .pml/.py drop is a cd-then-run, which needs no user input", so it falls through to this content-based cmd.load(content, objectName, 0, '') call with a blank format.

But engine-ts's load command (packages/engine-ts/src/cmd/fileio.ts) has no script/pml branch at all — script execution only happens through the separate run/@include path (controlflow.ts, parser.test.ts). For multi-line content with no recognizable structure marker, format = fmtArg || sniffFormat(content) || extFormat resolves to '', and load throws load: could not determine the structure format of the given content (fileio.ts ~line 999).

So every browser-native Open…/drop of a script file now produces a confusing "unknown format" engine error instead of running the script (or a clear "scripts aren't supported in the browser build" message, consistent with how refused/dialog-needed formats are handled elsewhere in this same PR). Same issue at FilesPanel.tsx:438.

export function browserClassification(
name: string,
): Pick<FileClassification, 'refused' | 'unavailable'> {
const ext = (/\.([a-z0-9]+)$/i.exec(name)?.[1] ?? '').toLowerCase();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: browserClassification parses the extension from the raw name without stripping a .gz/.bz2 wrapper first, unlike classify() a few lines above which does (base.replace(/\.(gz|bz2)$/i, '')). Currently harmless since the only refused extension is pwg, but if CLIENT_REFUSED_FORMATS ever grows, a foo.pwg.gz would silently skip the refusal that foo.pwg gets. Worth aligning the two for consistency.

request.selection,
request.state,
]);
downloadText(name, text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: exportMolecule now refuses gracefully when request.multisave || request.pattern is set (browser can't produce multiple files from one download), but SaveDialogs.tsx still renders the multisave checkbox and the "Multiple files" pattern radios as if they work. Consider disabling/hiding those controls in the browser build so the dialog doesn't offer options that always end in a refusal message.

… error

Follow-up review blocker: .pml/.py/.pym classify as dialog:'script', which
dialogNeededFor excludes, so they fell through to cmd.load(content) and
engine-ts load threw "could not determine format". Both fileOpen and
uploadAndLoad now detect scripts (classify().dialog === 'script') and surface
scriptUnsupportedMessage ("running scripts is not supported in the browser
build — run it from a desktop PyMOL") instead of loading. Also aligned
browserClassification to strip .gz/.bz2 like classify(). Test: opening a
.pml/.py makes no cmd.load and shows the not-supported message. 169 files
tests + build + harness reconcile (I0–I4 verified) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
if (!typed) return;
const name = /\.(pse|psw)$/i.test(typed) ? typed : `${typed}.pse`;
const snap = await session.call('cmd.get_session');
downloadText(name, JSON.stringify(snap), 'application/octet-stream');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: sessionSaveAs downloads whatever cmd.get_session resolves to without checking for a blob handle. On the remote/PyMOL bridge backend, get_session is in the bridge's BLOB_RETURNS set (packages/bridge/tenmol_bridge/codec.py:93-107), so session.call('cmd.get_session') resolves to {__blob__: true, id, mime, size, url, ...} (packages/bridge/tenmol_bridge/blobs.py:256-271), not a session object — connection.ts returns message.result verbatim with no blob auto-fetch (packages/client/src/connection.ts:634). JSON.stringify(snap) then downloads a tiny JSON stub referencing a server temp blob (which is later evicted) instead of the actual .pse bytes real PyMOL wrote. The comment on line 462 ("works over the remote bridge too") is incorrect for this reason.

This repo already has the fix pattern: apps/web/src/features/volume/service.ts:44-56 defines BlobHandle/isBlob and fetches the real bytes via fetch(session.config.httpOrigin + value.url) when a call resolves to a blob handle. sessionSaveAs (and the duplicated Ctrl+S handler in FileDropTarget.tsx:259-260) should do the same before serializing/downloading.

No existing test catches this — p9f1MenuHooks.dom.test.tsx's REPLIES['cmd.get_session'] stub is always a plain object, never a blob handle, so the remote-backend path is untested.

if (!typed) return;
const name = /\.(pse|psw)$/i.test(typed) ? typed : `${typed}.pse`;
const snap = await session.call('cmd.get_session');
downloadText(name, JSON.stringify(snap), 'application/octet-stream');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: same blob-handle bug as FilesPanel.tsx's sessionSaveAs (see that inline comment) — this is the duplicated Ctrl+S implementation. cmd.get_session is a BLOB_RETURNS symbol on the remote bridge, so snap here can be {__blob__:true, id, url, ...} rather than a session object; JSON.stringify(snap) downloads that stub instead of the real .pse bytes. Needs the same isBlob/fetch(session.config.httpOrigin + value.url) handling used in apps/web/src/features/volume/service.ts:44-56, 265-266.

// browser-only, so it serializes the engine's session snapshot
// (`cmd.get_session`) and downloads it — no `api.sessionFile`, no
// `save <path>` (writes to disk + THROWS in the browser). Default the name
// to `.pse`, which the engine's reload path keys off.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: this Ctrl+S handler duplicates FilesPanel.tsx's sessionSaveAs almost line-for-line (prompt, extension defaulting, get_session call, downloadText). Once the blob-handle bug above is fixed, it'd be worth extracting a single shared helper (e.g. in download.ts or a new sessionSave.ts) so the two call sites can't drift again.

Blocking review finding: cmd.get_session is a BLOB_RETURNS symbol on the remote
PyMOL bridge, so it resolves to a { __blob__, url, mime } handle, not the
session object — JSON.stringify(snap) was downloading a stub instead of the real
.pse bytes (the local engine returns the object, so only remote was affected).

New shared apps/web/src/features/files/sessionSave.ts::saveSession fetches the
real bytes via session.config.httpOrigin + url when the reply is a blob handle
(mirrors volume/service.ts), else serializes the object. Both sessionSaveAs
(FilesPanel) and the Ctrl+S handler (FileDropTarget) now call it — killing the
duplication the reviewer also flagged — and the inaccurate "works over the
bridge" comment is corrected (it now does). Test adds a remote blob-handle case
asserting the fetched bytes are downloaded. 170 files tests + build + harness
(I0–I4 verified) green.

Co-Authored-By: Claude Opus 4.8 <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