Skip to content

feat(canvas): show and act on a peer's files like local ones - #1880

Merged
damianvtran merged 7 commits into
mainfrom
feat/remote-files-parity
Nov 13, 2026
Merged

damianvtran merged 7 commits into
mainfrom
feat/remote-files-parity

Conversation

@damianvtran

@damianvtran damianvtran commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Operator request (Damian, verbatim)

"I noticed I can't see images and artifacts on remote nodes, can you have a session work on this remotely and test it, make sure that it works. We should be able to preview assets (sounds, music, images, video, etc.) from the local device and also see those show up in the canvas and ideally the library as well, these should be indexed too in order to be performant and not slow down the library view. It might make sense to cache or precache assets on active sessions so that clicking into them is fast and we can see the previews, but make sure it doesn't blow up RAM."

"Right now it just says remote files can't be opened on this device but we need to fix that and make it so that remote files show up just like local files maybe just with a small non-intrusive indication somewhere that they're remote but they should show up under the same classified sticky headers, sections, etc. and we should be able to download them to local, and do other file actions."

Summary

UI half of remote files parity (core design docs/design/remote-files-parity.md §7 "UI PR"; the core PR implements the wire). Everything new is gated on features.remote_files.

  • Transcript bytes (§1): use-attachment-url / use-waveform retry a 504 on 2/4/8/16 s (five attempts), then a click-to-retry retryable state (RetryableAttachment in the transcript, a Retry button in the canvas viewers). 404/409 stay missing. Only bytes are ever cached; a failure never is. fetchAttachmentBlob throws AttachmentOwnerUnreachableError on 504 with its own toast.
  • Files panel (§2, §6): use-session-files lists a remote session only when remote_files is advertised; an older daemon keeps today's sentence (and now makes no request); unsupported_reason: "peer_update_required" → "Update to see its files here." Remote rows go through the same index → indexedDocument → finderSections path, so sticky headers, sections, sort and filter are unchanged.
  • Row + menu (§3): one device line on the Files count line (RemoteFileMark, lucide Monitor, "On "; Library cards keep one mark each) and RemoteFileActionsMenu, driven by the pure remoteFileActions table: preview by digest or the new sessions.files.content media op; Download to this device via the new long-budget sessions.files.download op → receipt toast with Open file / Open folder / Copy path on the landed copy, after which the row's menu offers those too; "Copy path on " (text only) before download; Save as / Copy image ≤ 32 MiB; unreadable rows keep the readable row's entries with the transfers disabled and the owner's reason said once at the top of the menu (and in the row's status slot and tooltip). Opening a non-digest remote file opens a remote-file:<sid>/<fileId>/<name> identifier whose viewer fetches over the content route under one refcounted blob entry released with the tab (no bytes in the persisted store; a reload re-fetches); remote text is read-only; the identifier refuses saves, OS-open and reveal.
  • I3-1: a remote row's ref.path never reaches openFile, showItemInFolder, readFileBytes, the finder thumbnail reader or a save.
  • Library (§5): DesktopLibraryAsset.remote (additive); remote cards carry the same mark; Copy path / Reveal are never offered for them; Download stays on library.content.
  • Contract: sessions.files.content (media relay, GET allowlist, 12-hex session / 16-hex row id), sessions.files.download (POST, MESH_WRITE_OPS → 90 s long budget, not read-only), remote_files feature key.
  • i18n: 18 new keys (16 ui.canvas.files.remote*, 2 ui.chat.attachments) in all eight locales with context entries.

Acceptance → test map (§7 UI)

Acceptance Test
504 → retry ×5 then retryable (2/4/8/16 s, click restarts) scripts/attachment-url.test.mjs "a 504 is retried on 2/4/8/16 s for five attempts, then retryable"
409 → missing, never retried, not cached "a 409 (the owner refused) is missing at once and never retried"; "a 404 stays missing too"
success after one 504 paints "success after one 504 paints the picture"; "unmounting mid-backoff cancels the pending attempt"
504 bytes for an action "fetchAttachmentBlob: a 504 is AttachmentOwnerUnreachableError"
remote action table scripts/remote-files.test.mjs: readable / downloaded / >32 MiB / unreadable rows
I3-1 "I3-1 (table)…" (all reasons × landed × media kinds × sizes) and "I3-1 (dispatch)…" (every action performed with spies on openFile / showItemInFolder / readFileBytes and the transport)
content / download doors "content: …", "download: …" (receipt, 504 pending, unreadable never reaches backend)
capability absent → today's message scripts/session-files-hook.test.mjs "remote session without features.remote_files keeps today's sentence and asks nothing"
peer_update_required → named sentence "peer_update_required names the owner to update"
remote rows on the same path "remote session with features.remote_files lists rows like a local one"; "indexedDocument keeps the remote row on the same classification path…"

Negative control: SESSION_FILES_HOOK_REF=origin/main (the harness's own old-hook switch) fails all 3 new hook tests (3/3 fail).

Xvfb captures (§7 (1)-(6)) are QA/design's; the extended rig fixture is in the manager thread.

Wire assumptions

  • GET /v1/desktop/sessions/{sid}/files/{file_id}/content streams bytes with the row's MIME; 504 retryable, 409 session_files_remote_unreadable / attachment_on_peer permanent, 404 gone.
  • POST …/files/{file_id}/download answers {status, message, result: {path, name, bytes, sha256, created, origin_device}} (the desktop envelope); 504 = still fetching.
  • Row origin_device is {id, name}; remote_read absent/null with no remote_unreadable is treated as unreadable (outside_session_files), never guessed readable.
  • Library remote is {device_id, device_name} and remote assets have ref.path: null (the menu also refuses a path defensively).

Dependency

Depends on the core PR feat/remote-files-parity (damianvtran/local-operator) for the wire. Safe to merge first: with features.remote_files absent every remote path behaves as today (test above), and the new ops are only issued for rows that carry remote: true.

Not in this PR

  • User-attachment rows of a remote session keep their current path actions (materialise is a local route); the core §2 lists …/user-attachments for the peer listing, and their menus are not yet switched to the remote table.
  • Library download of a remote asset > 32 MiB through the source occurrence's download route (design §5) — the card keeps library.content (≤ 32 MiB).
  • Translation ledger: bound at a1bc2437a (round-2 audit receipts 03cedf51c7f4-1880r2-*); ledger-check ok, 0 findings.

Targeted test evidence

Remaining-shard required-CI repair — current head 2d765b8c21b8b534c356b30010af9b71f80336d5

Late predecessor shards 0 and 1 are repaired in this test-only delta from a11513c: response-copy.test.mjs 4 pass / 0 fail / 0 skip (rc=0, 3.3s); speech-user-copy.test.mjs 3 pass / 0 fail / 0 skip (rc=0, 3.1s). Each ran serially through run_bounded.py --timeout 180 -- node scripts/run-desktop-tests.mjs <file> with isolated env/HOME/config. Exact-delta pre-push gate passed. Both failed before with missing showLoadingToast exports; export-only controls exposed retained query-GC timers, now released with explicit fixture-owned QueryClient/jsdom teardown. Added empty-cache assertions; diagnostic final runs have zero referenced timers. Product source/catalogue/i18n bytes remain identical to a1bc243; earlier unaffected matrix retained. See ### CI remediation — remaining shard failures for artifact IDs, before/after commands and full disposition. Supported push-gate bypass disclosed there; no broad/full local gate claimed at this head. Model openai/gpt-6.1-sol, no fallback.

Required-CI repair — desktop shard 3

Current head a11513c4542fef332f7077b507351b2bae6a1440. Delta from a1bc2437a7ad448175554a37e476d0eb2768eb61: one added test-fixture export in scripts/transcript-copy-affordances.test.mjs only. All product source, catalogue/i18n bytes and previous review surfaces remain byte-identical; previous matrix is retained on that compatibility basis, not rerun.

Full-gate run 43283003584, job 131441139340, artifact desktop-suite-log-43283003584-1-shard-3 (ID 13980210049), tmp/desktop-tests.log:337604–337638: esbuild reports No matching export in "fixture:toast" for import "showLoadingToast", imported by remote-file-transfer.tsx:24. Reproduced locally before the fix, rc=1; the file failed at module build before any test body. The fixture now supplies that existing production API using the same toast-id stub convention. No assertions or timeouts changed.

Isolated HOME/config, env -i (no inherited CMUX_* or XPC_FLAGS), no installs, no native/browser lane. Node-heavy slot checked idle before dispatch. Exact commands from the manager-owned worktree:

python3 ~/local-operator/scripts/run_bounded.py --timeout 180 -- node scripts/run-desktop-tests.mjs scripts/transcript-copy-affordances.test.mjs
# before: rc=1, missing showLoadingToast export, 0 pass / 1 failed file, wall=3.9s
# after: rc=0, 28 pass / 0 fail / 0 skip, wall=5.5s
python3 ~/local-operator/scripts/run_bounded.py --timeout 120 -- node_modules/.bin/biome check scripts/transcript-copy-affordances.test.mjs
# rc=0, 0 errors; 2 pre-existing useTopLevelRegex warnings, verified in a1bc2437
python3 ~/local-operator/scripts/run_bounded.py --timeout 180 -- node scripts/pre-push-gate.mjs --since a1bc2437a7ad448175554a37e476d0eb2768eb61
# rc=0, exact one-file scripts lint passed; no TS delta => types leg skipped

Push disclosure: used supported PREPUSH_BYPASS with an explicit reason after the exact-delta gate above, avoiding rerunning the broad branch gate outside this repair's bounded lease. This does not claim a full pre-push gate pass at the new head; required CI remains authoritative. No full-suite local run, workflow rerun, merge or release dispatched. Manager owns delta review and final full gate.

Actual served model: openai/gpt-6.1-sol; no fallback used.

Head a1bc2437a7ad448175554a37e476d0eb2768eb61 (round-2 remediation 4f915a3b8 + i18n binding). Each file run alone as python3 ~/local-operator/scripts/run_bounded.py --timeout 600 --memory-mb 3000 -- node --test scripts/<file>:

file pass fail run
scripts/remote-viewers-round2.test.mjs 5 0 rc=0 wall=2.7s
scripts/file-blob-url.test.mjs 22 0 rc=0 wall=1.5s
scripts/remote-sound-row.test.mjs 5 0 rc=0 wall=1.6s
scripts/remote-files.test.mjs 22 0 rc=0 wall=1.5s
scripts/notification-sound-main.test.mjs 8 0 rc=0 wall=3.8s
scripts/attachment-url.test.mjs 25 0 rc=0 wall=0.6s
scripts/chat-image-expand.test.mjs 45 0 rc=0 wall=9.1s
scripts/canvas-attachment-locale.test.mjs 1 0 rc=0 wall=2.4s
scripts/finder-reveal-scroll.test.mjs 7 0 rc=0 wall=1.2s
scripts/sound-player.test.mjs 12 0 rc=0 wall=2.4s
scripts/image-preview-decode.test.mjs 4 0 rc=0 wall=2.1s
scripts/library-model.test.mjs 14 0 rc=0 wall=1.6s
scripts/library-card.test.mjs 7 0 rc=0 wall=2.1s
scripts/session-files-hook.test.mjs 10 0 rc=0 wall=2.6s
scripts/canvas-file-freshness.test.mjs 40 0 rc=0 wall=1.8s
scripts/desktop-contract.test.mjs 56 0 rc=0 wall=44.9s

Regression tests against 79ae38539's src (this head's tests, git stash push -- src): remote-viewers-round2 4/5 FAIL (R2-1, U2-1 ×2, U2-2; the local control passes) · file-blob-url 3/22 FAIL (R2-2, U2-1 ×2) · remote-sound-row 2/5 FAIL (R2-3, U2-1) · remote-files 4/22 FAIL (U2-1 ×2, I2-3, D2-3) · notification-sound-main 1/8 FAIL (504 code forwarding).

tsc --noEmit -p tsconfig.app.json rc=0 · -p tsconfig.node.json rc=0 · pre-push gate (lint scripts/, lint src/, types) passed against origin/main 5033f64
biome check (20 changed files): 0 errors
i18n: generate --check, check-duplicates, context-parity: ok · ledger-check: ok — 1113 entries complete, fresh and independently audited
git diff 79ae38539..HEAD -- src/i18n/catalogues: empty
check-strings (local, vs origin/main 5033f64): 3 "allowance grew" ids in image-group-lightbox.tsx / sound-batch.tsx. They come from main paying that debt (#1876 removed them), not from this branch. In the CI merge ref, i18n/literal-allowance.json and sound-batch.tsx are byte-identical to main's (blobs 51e9b8a / 0f35a98), and this branch's lightbox hunk adds no literal.

pnpm test:affected is the merge gate's full suite and was not run here. No Xvfb captures in this round.

Release: patch — Preview and download remote-session files in the transcript, Files panel, Canvas and Library, with clear remote and retry states.

Remote sessions' files now list, preview and download on this device when
the daemon advertises features.remote_files (core remote-files-parity):

- use-attachment-url / use-waveform: a 504 (owner unreachable or transfer
  pending) retries on 2/4/8/16 s for five attempts, then a click-to-retry
  `retryable` state (RetryableAttachment, viewer Retry); 404/409 stay
  `missing`; no failure is cached.
- use-session-files: gated on remote_files; an older daemon keeps today's
  sentence without a request; peer_update_required names the owner. Remote
  rows ride the same index/classification/sections/sort/filter path.
- Files rows: a small device mark, and RemoteFileActionsMenu driven by the
  pure remoteFileActions table (core §3): open/preview via digest or the new
  sessions.files.content media op, "Download to this device" via the new
  long-budget sessions.files.download op with a receipt toast (Open file /
  Open folder / Copy path on the LANDED copy), unreadable rows disabled with
  the owner's reason. I3-1: the owner's display path never reaches openFile,
  showItemInFolder, readFileBytes or a save.
- Library: remote cards carry the mark; reveal/copy-path never offered.
- i18n: new keys in all eight locales (ledger audit pending).
@damianvtran

damianvtran commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner Author

i18n audit — round 1

Reviewer: reviewer (i18n auditor), session 1c4ab04dbe3e, receipt instances 1c4ab04dbe3e-1880r1-<locale>. Served model: anthropic/claude-opus-5-5, no fallback. This is a separate agent instance from the coder who translated these strings.
Scope: 83a18c6..a4462e7 (head a4462e72159d8fa9a45931c36b380221880ffb30). The audit covers the 18 added keys (16 ui.canvas.files.remote* and 2 ui.chat.attachments) × 7 locales = 14 (locale, namespace) pairs.
Method: For each key I read the en value with its .context.json note, the target value, and the call sites. I checked the core glossary and the 7 style guides (core rev ad910f5d), and compared each term against the existing locale catalogue. Every value was parsed and rendered with intl-messageformat, with device, the locale's own fallback and the chart.png prefix: 0 parse errors, placeholders byte-exact, and no bidi control characters in ur (args are isolated at render by src/i18n/bidi). No MT service was used.
Read-only gates at head: generate --check, check-duplicates, check-strings, context-parity and check-coverage all exit 0. ledger-check exits 1 with 42 findings, which are the 14 pairs × 3 (stale source, stale target, receipt) the PR body reports.

Verdict: changes requested. 3 MAJOR (glossary violations in hi, ru, ur), 4 MINOR, 4 NIT.

Findings

I1-1 · MAJOR · ur (ui.canvas ×5, ui.chat.attachments ×1). The glossary §2 term for device is ڈیوائس, but the translation uses آلہ/آلے. The ur catalogue has ڈیوائس in 98 values, and آلہ/آلے in 6, all from this PR. ڈیوائس is feminine in the catalogue (دوسری ڈیوائس, چلانے والی ڈیوائس), so the attributive form becomes والی. Fix:

  • remoteUnreachable → اس فائل والی ڈیوائس سے رابطہ نہیں ہو سکا۔ تھوڑی دیر میں دوبارہ کوشش کریں۔
  • remoteUnreachableRetry → اس آواز والی ڈیوائس سے رابطہ نہیں ہو سکا۔ دوبارہ کوشش کے لیے کلک کریں۔
  • remoteDownload → اس ڈیوائس پر ڈاؤن لوڈ کریں
  • remoteReadFailed → یہ فائل اسے رکھنے والی ڈیوائس سے پڑھی نہیں جا سکی۔
  • remoteDeviceFallback → دوسری ڈیوائس. This also fixes the rendered …دوسرا آلہ کو اپ ڈیٹ کریں, which is in the wrong case before کو.
  • ui.chat.attachments.owner_unreachable → اسے رکھنے والی ڈیوائس سے حاصل نہیں کی جا سکی۔

I1-2 · MAJOR · ru (ui.canvas.files.remoteReason.workspace). Glossary §2 and the ru guide set session → сессия (the catalogue has сесси* in 318 values; сеанс appears only here) and workspace → рабочее пространство. Fix: Находится в рабочем пространстве устройства {device}. Попросите сессию скопировать его в свой скретчпад. This also aligns scratchpad with pathCopyNote («скретчпаде»).

I1-3 · MAJOR · hi (ui.canvas.files.remoteReason.workspace). Glossary §2 and hi guide l.28 set workspace → कार्यक्षेत्र (in 7 catalogue values; वर्कस्पेस appears only here). Fix: यह {device} के कार्यक्षेत्र में है। सत्र से इसे अपने स्क्रैचपैड में कॉपी करने को कहें।

I1-4 · MINOR · es, fr, vi, zh-CN (remoteReason.workspace). Scratchpad is rendered differently from the same namespace's ui.canvas.files.pathCopyNote. The user has to name this place to the session, so the two strings must use the same term.

  • es → Está en el espacio de trabajo de {device}. Pide a la sesión que lo copie a su scratchpad.
  • fr → Se trouve dans l’espace de travail de {device}. Demandez à la session de le copier dans son dossier de travail.
  • vi → Nằm trong không gian làm việc của {device}. Hãy yêu cầu phiên sao chép nó vào thư mục nháp của phiên.
  • zh-CN → 位于{device}的工作区中。请让会话将其复制到自己的暂存区。

I1-5 · MINOR · fr (6 ui.canvas values + owner_unreachable). fr guide l.71 requires the apostrophe ’ (U+2019); the catalogue has 2482 values with ’ and 22 with '. Replace ' with ’ in remoteUnreachable, remoteUnreachableRetry, remoteReason.missing, remoteReadFailed, remoteDeviceFallback (l’autre appareil), owner_unreachable (n’a pas pu être récupéré depuis l’appareil qui le contient.), and in remoteReason.workspace as I1-4 already shows.

I1-6 · MINOR · zh-CN (11 ui.canvas values). zh-CN guide l.67 says not to put a space between Chinese text and Latin runs. With the Chinese fallback, the current value renders 请更新 另一台设备,以便在此查看其文件。, a stray space I reproduced with IntlMessageFormat. Fix: 位于{device} · {device}上已没有此文件 · 文件过大,无法从{device}获取 · {device}将此文件设为私密 · {device}已不再共享此文件 · 复制{device}上的路径 · 已下载{name} · 仍在从{device}获取。请稍后重试。 · 无法从{device}下载。 · 请更新{device},以便在此查看其文件。

I1-7 · MINOR · hi (remoteDeviceFallback). Its only use is {device} in remotePeerUpdate, before को (remote-files.ts:253; the context note says so). As written it renders …दूसरा डिवाइस को अपडेट करें।, which is in the wrong case. Fix: दूसरे डिवाइस, matching the catalogue's किसी दूसरे डिवाइस की.

I1-8 · NIT · hi, ur (remoteCopyOwnerPath). Match the sibling ui.chat.attachments.copy_path: hi → {device} पर पाथ कॉपी करें (पाथ 27× vs पथ 5×); ur → {device} پر راستہ نقل کریں (نقل کریں 49× vs کاپی کریں 5×).

I1-9 · NIT · vi (owner_unreachable). After the file name, không thể lấy reads as if the file is the one failing to fetch. Use không lấy được từ thiết bị đang giữ nó.

I1-10 · NIT · zh-CN (remoteReason.sensitive). 设为私密 suggests someone set a privacy flag, but the source means the device withholds files that look like secrets. Use {device}不公开此文件; if taken, this replaces the I1-6 value for this key.

I1-11 · NIT · source/code, not counted in the receipts. attachment-frame.tsx:284-299 builds aria-label/title as ${name} ${detail} ${action}, and the visible line as {name} + ` ${detail}`. That is the fragment concatenation docs/I18N.md asks to avoid. It renders acceptably in all 7 locales today (name-first word order works in each), so this is deferrable: a later {name}-argument ICU message would hand word order to translators.

No findings: es/ru/hi/zh-CN ui.chat.attachments, and every other value not listed above, including all ICU placeholders.

Receipts and binding

Receipts are in /home/ec2-user/.local-operator/sessions/1c4ab04dbe3e/scratchpad/i18n-1880-r1/receipts/, one per locale covering both namespaces, with en, context and target sha256 plus glossary and style-guide hashes:

receipt sha256 ui.canvas ui.chat.attachments
1c4ab04dbe3e-1880r1-es.json 1faacd9e… clean-with-findings clean
1c4ab04dbe3e-1880r1-fr.json 46565c20… clean-with-findings clean-with-findings
1c4ab04dbe3e-1880r1-hi.json 7e7bb0df… changes_requested clean
1c4ab04dbe3e-1880r1-ru.json a5b8dae8… changes_requested clean
1c4ab04dbe3e-1880r1-ur.json ef28c578… changes_requested changes_requested
1c4ab04dbe3e-1880r1-vi.json d2222080… clean-with-findings clean-with-findings (nit)
1c4ab04dbe3e-1880r1-zh-cn.json 4b7b4381… clean-with-findings clean

Exact ledger-assemble lines for the remediation coder: /home/ec2-user/.local-operator/sessions/1c4ab04dbe3e/scratchpad/i18n-1880-r1/COMMANDS.md.

  • I ran all 10 passing-pair lines against a synthetic --repo copy: each was accepted and wrote status audited.
  • --update-translator --translator-agent coder was also accepted there.
  • As a negative control, a one-byte edit to the target makes a line refuse with does not name the target bytes on disk.

Recommended remediation: apply I1-1…I1-7 (and the NITs, at your option) in the ONE remediation commit. Then:

  1. Bind the 4 untouched clean pairs now (section A).
  2. Have an independent round-2 delta audit issue receipts for the edited pairs (section C).
  3. Record the coder as translator (section D).

The worktree was left unmodified (git status clean).

@damianvtran

Copy link
Copy Markdown
Owner Author

Agent review — round 1

Reviewer: reviewer (lopdev team, session 20d867221243) · model anthropic/claude-opus-5-5 (no fallback)
Scope: 83a18c6..a4462e7
Brief: manager's UI-ROUND1 context contract (operator request verbatim, design docs/design/remote-files-parity.md §1–§3, §5–§7 on core branch feat/remote-files-parity).

Verdict: not clean — 1 BLOCKER, 1 MAJOR. The wire handling is solid and well tested; the two gating findings are a remaining I3-1 hole in the Files panel's audio band, and a renderer blob leak on remote opens.

Verified (with evidence)

  • 504 retry/backoff: bounded (2/4/8/16 s, 5 attempts, then retryable + click-to-retry), cancelled on unmount (clearTimeout + live). 409/404 stay missing. No failure caching: only bytes are published; waveform PENDING deletes its cache entry. use-attachment-url.ts, use-waveform.ts.
  • Capability gating: without remote_files, a remote session shows today's sentence and makes no request; peer_update_required gets the named sentence (use-session-files.ts).
  • Same pipeline as local rows: remote rows go through indexedDocument → canvasDocumentForPath, then through the unchanged finderSections, sort and filter.
  • Download: sessions.files.download is in MESH_WRITE_OPS, so it gets the 90 s budget and is not read-only. The receipt is accepted only on 200 with result.path, and only the landed path is acted on.
  • Menu I3-1: the menu's I3-1 table and dispatch hold.
  • Library: the card/menu hide Copy path and Reveal for remote assets.
  • Tests at this head: run_bounded … node --test scripts/remote-files.test.mjs scripts/attachment-url.test.mjs scripts/session-files-hook.test.mjs → # tests 46 # pass 46 # fail 0.

Findings

R1-1 BLOCKER — I3-1 broken: the Files panel reads a remote audio row's owner path from this disk.

  • Evidence:
    • finder-files-view.tsx:1440 renders <AudioDocumentPlayer doc={doc}> for every audio-band row, remote included.
    • For a path row with no artifact, audio-document-player.tsx:38-40 routes it to FinderPathSound.
    • FinderPathSound calls useFileBlobUrl(doc.path), and use-file-blob-url.ts:269 then calls window.api.readFileBytes(filePath) on mount.
  • Repro: jsdom + react-dom, bundling the PR's own indexedDocument and AudioDocumentPlayer, with a spy on readFileBytes. The input is a row with remote: true, ref.path: "~/Music/song.mp3" and remote_unreadable: "outside_session_files":
    doc.path = ~/Music/song.mp3 | doc.remote.unreadable = outside_session_files | artifact = false
    readFileBytes calls: ["~/Music/song.mp3"]
    I3-1 VIOLATED: owner path read from this disk
    
  • Impact:
    • Every remote path sound reads this device's file at the owner's display path. If one exists, it plays the wrong file.
    • Readable scratchpad sounds never stream.
    • The PR body's I3-1 claim ("never reaches readFileBytes") is false for this surface.
  • Fix:
    • In AudioDocumentPlayer, branch on doc.remote:
      • no artifact + readable → a player whose bytes come from fetchRemoteFileBlob on demand;
      • unreadable → the reason line.
    • Never FinderPathSound for a remote row.
    • Add a spy test (below).

R1-2 MAJOR — blob: URLs from remote opens are never revoked, so renderer RAM grows with every open.

  • Evidence:
    • remote-file-transfer.tsx:249 calls URL.createObjectURL(blob) on every open of a non-digest remote file. That covers any scratchpad path row, plus non-media artifacts, each up to 32 MiB.
    • canvas-file-viewer.tsx:111-124 (openDocument) replaces a same-id document without revoking the previous URL.
    • Nothing revokes on tab close either. A repo-wide grep finds revokeObjectURL on a document path only at inline-edit.tsx:650, which handles composer attachments.
    • It is also created for utf-8/base64 kinds, whose bytes already live in content.
  • Impact: opening the same 30 MiB remote PDF five times pins about 150 MiB until the window reloads. The operator asked explicitly that this not blow up RAM.
  • Fix:
    • Hold the URL in the shared refcounted blob-url-cache, keyed remote:<sid>:<fileId>.
    • Release it when the document is replaced or its tab closes.
    • Skip createObjectURL for text/spreadsheet kinds.

R1-3 MINOR — a reloaded remote canvas tab is blank (the coder's self-declared gap).

  • The canvas store persists the dead blob: path, and use-file-blob-url.ts:176 treats it as a ready, direct URL.
  • Fix: at rehydrate, either drop remote blob: documents or mark them "reopen from Files". This fits naturally in the R1-2 change. It does not gate, because reopening from Files works.

R1-4 MINOR — remote files over 32 MiB still try to open in the canvas.

  • canvas-file-viewer.tsx:459-493 fetches without checking remote.sizeBytes. The media relay refuses above 32 MiB (desktop-media.ts, refuse(502)), so the user sees the generic "couldn't be read" toast.
  • "Show in canvas" also stays enabled for these rows in remoteFileActions.
  • Fix: when sizeBytes > REMOTE_PREVIEW_MAX_BYTES, explain the limit and offer Download (acceptance: "disabled or explained").

R1-5 MINOR — test gaps, and they are how R1-1 slipped through.

  • The I3-1 dispatch test covers only performRemoteAction. Nothing covers finderOriginal (thumbnail/lightbox), AudioDocumentPlayer, or the canvas onOpen remote branch.
  • use-waveform's 504 retry and its no-cache rule have no test.
  • Fix: add a readFileBytes/probeFiles spy test over those three sinks, plus one waveform test (504 ×N → no cache entry; 409 → settled).

R1-6 MINOR — the Library preview drawer has no remote guard.

  • The PR guards the menu but not the drawer, which still keys only on ref.path:
    • library-preview.tsx:350-351: openFile
    • library-preview.tsx:545-550: showItemInFolder
    • library-viewers.tsx:114-119: openFile
    • library-viewers.tsx:244: readFileBytes
  • Core I5-1 makes ref.path null for remote rows, so this is not a live defect today.
  • Fix: one localPathOf(asset) helper (returns null when asset.remote) used at all five sites.

R1-7 NIT — canvas-file-viewer.tsx:457: the comment says "into a data: document", but the code builds a blob: document.
R1-8 NIT — finder-thumbnails.ts:19: "A remote unsupported index never produces rows, so it cannot call readFileBytes" is no longer the reason. Point it at the finderOriginal remote guard.
R1-9 NIT — library-model.ts:191: assetDisplayName's JSDoc now sits above remoteDevice, which has its own doc on the next line. Move it back.

The coder's self-declared gaps, judged against acceptance

  • User attachments in a remote session keep their local path actions → out of scope, pre-existing. The attachments section already rendered for remote sessions before this PR (finder-files-view.tsx:284 keeps attachments when localReadAllowed === false), and the core peer user-attachments listing is not in this wire yet. Deferred.
  • Library download over 32 MiB → matches local behaviour: local assets go through the same library.content 32 MiB path (library-download.ts). Deferred until the core source-occurrence route exists.
  • Canvas tab blank after reload → R1-3 (MINOR).
  • No MP4 fixture → QA's matrix; not a code finding.
  • i18n ledger: 42 findings → owned by the i18n stream; the Language Coverage CI job gates the merge independently.

Remediation for the gate: R1-1 and R1-2, each with a regression test. Minors and nits can go in the same commit or be deferred.

@damianvtran

Copy link
Copy Markdown
Owner Author

QA report — round 1

Verdict: FAIL. Two MAJOR findings (Q1-1, Q1-2), two MINOR, two NIT. Everything else in the matrix passed on the real built app.

Context

  • Tester: qa-tester (lopdev), independent. Served model: anthropic/claude-opus-5-5, no fallback.
  • Scope: 83a18c632..a4462e721. Worktree /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ a4462e72159d8fa9a45931c36b380221880ffb30, used read-only. HEAD was the same and src/ was clean at the start and end of every build and run.
  • Targeted tests: node --test scripts/remote-files.test.mjs scripts/attachment-url.test.mjs scripts/session-files-hook.test.mjs → # tests 46 # pass 46 # fail 0 (3.3 s, run_bounded).

Build provenance

Arm Source Build
AFTER worktree src @ a4462e721; git status --porcelain src was empty at 15:30:37Z and again at 15:31:18Z electron-vite build --outDir dist/rfp/after/out, 41.0 s, rc 0
BEFORE git archive 83a18c632 src i18n scripts/vite-plugins + configs into dist/rfp-before. Configs, i18n, resources and build are byte-identical between base and head. tar -d against 83a18c632:src shows no content differences. Same node_modules as the worktree. dist/rfp/before/out, 39.7 s, rc 0

Rig

  • The app: Electron 45.1.0, headless under Xvfb, window 1380×872. Frames come from capturePage() through the dev-driver bridge.
  • Isolation: scratch HOME and config from prepareDriverContext, a separate --user-data-dir per launch, CMUX_* and XPC_FLAGS stripped, synthetic ids c10ad000000{1,2,3}.
  • Memory: run_bounded 3000 MiB per run. Every process group was reaped and kill -0 failed afterwards.
  • Fixture: the coder's remote-files fixture, in a QA copy with these changes (it does not ship):
    • Media rows re-homed. The coder's version puts the media blocks on assistant rows. The transcript never renders those (extractImages only runs for user, tool and custom rows), so the original fixture painted text only. The QA copy carries the media as generate_image, generate_sound_effect and generate_video tool results, in separate turns, which gives the operator's "1 image" and "5 images" folds.
    • Real MP4. I recorded a 3.2 s H.264 MP4 (320×240, 113 KB) inside the app's own Chromium: MediaRecorder('video/mp4;codecs=avc1') over a canvas captureStream. Nothing was installed.
    • legacy mode for BEFORE, matching today's core: peer attachments return 409, the peer files listing returns unsupported, and the Library is local-only.
    • Runtime toggles fx.opts.{legacy,remoteFiles,peerUpdate}.
  • The Electron main process was observed through its --inspect port. shell.showItemInFolder, openPath and openExternal were stubbed to record their arguments.

Frames

Matrix (AFTER = a4462e721 unless the row says BEFORE)

# Surface Action Actual Result
M1 Transcript, 1-image and 5-image folds Open the remote session 4 visible tiles each make one GET by digest → 200 at 378–380 ms. Real PNG bytes paint in both themes. img1 is shared by both folds and fetched once. BEFORE (legacy): the same 4 GETs → 409, broken-image tiles. PASS
M2 504×2, then 200 failNext(img5,2), then expand the 5-image fold 504 @22 · 504 @2035 · 200 @6038. The tagged tile node is still mounted afterwards (sameTileNodeStillMounted:true); only its inner content node is added. Repeated on img3 in the collapsed strip: 504, 504, 200 at 0.38, 2.40 and 6.40 s. PASS
M3 504×99 → retryable → recovery failNext(img4,99); after the retryable state, failNext(img4,0) and click 5 attempts at 0.38, 2.40, 6.40, 14.41 and 30.41 s, then stop. The tile reads "Image 4 couldn't be fetched from the device that holds it. Retry". The click makes 1 GET → 200 (+4 ms). The button is swapped for the image inside the same parent (MutationObserver saw −BUTTON, +DIV). PASS
M4 Canvas viewer 504 → Retry failNext(mp4,99), then open the clip.mp4 tab 5 attempts at 0.02, 2.03, 6.03, 14.06 and 30.06 s, then "Couldn't reach the device that holds this file. Try again in a moment. clip.mp4 [Retry]". Retry after reset makes 1 GET → 200; <video> reaches readyState 4 with duration 3.2 s. PASS
M5 409 is missing, not retried opts.legacy=true, then expand the sound fold attachments/<wav> 409 and …/waveform 409, one each, with no retries over 38 s. Text: "tone.wav could not be played. Its stored copy is not available to this reader." PASS
M6 409 not cached forever legacy=false, switch away and back Bytes are re-requested → 200 and the audio plays. The waveform is not re-requested, so the scrubber stays flat (Q1-4). PASS (bytes) / see Q1-4
M7 No request loop 60 s idle with every surface open 0 /files or /attachments requests. The 39 background polls were: asks 12, sessions 12, health 6, mcp 4, team-bridge 2, capabilities 2, events 1. PASS
M8 Capability absent opts.remoteFiles=false + page reload Today's sentence, "Remote files cannot be opened on this device.", in both themes. Reopening the session with the catalogue already loaded made 0 /files requests. A cold deep-link reload made 1 request, 4 ms before the catalogue answered (Q1-6, pre-existing). BEFORE requests /files every time. PASS
M9 peer_update_required opts.peerUpdate=true "Update lop-remote-node-2 to see its files here." + Retry, in both themes PASS
M10 Files panel sections Open canvas → files 13 files. Highlights 1; Working files 12 = Images 5, Videos 1, Sounds 1, Documents 3, Spreadsheets 1, Other 1. Every row has [data-remote-file-mark] (12×12, "On lop-remote-node-2"), and the tooltip carries the owner reason. Opening the panel made 0 extra requests (thumbnails reuse the transcript's bytes). The unreadable compacted.png was never requested. PASS
M11 Search, filter, sort Type "render"; Filter → Sounds; Sort → Name Search: 5 render rows, then back to 13 when cleared. Filter: ["tone.wav"], label Filter: Sounds. Sort: rows reorder within their sections, label Sort by: Name, Ascending. PASS
M12 Row menu, readable render-2.png ⋯ Show in canvas · Expand image · Download to this device · Save as… · Copy image. notes.md has Show in canvas · Download · Copy path on lop-remote-node-2 · Save as… PASS
M13 Row menu, unreadable .env, main.py, old.csv, dataset.tar, compacted.png Show in canvas / Open file / Open folder are disabled with one reason each: "keeps this file private", "Lives in …'s workspace. Ask the session to copy it to its scratchpad.", "No longer on …", "Too large to fetch from …", "no longer shares this file". Copy path on the device stays enabled (it is not offered for a digest row). PASS
M14 Download to this device render-2.png → Download POST …/files/0000000000000002/download → 200. Toast: Downloaded render-2.png · Open file · Open folder · Copy file path. The file on disk has sha256 6961745e…ca84b964 = source image-2.png. The row menu then offers Open file / Open folder / Copy file path. PASS
M15 Open folder / Open file on the landed copy Row menu main shell.showItemInFolder("<config>/downloads/render-2.png") and shell.openPath(same). Both are the landed local path, never the owner's. PASS
M16 Download collision Plant a different downloads/tone.wav, then download tone.wav Toast Downloaded tone (1).wav. tone.wav is untouched (sha dbf21b2e…); tone (1).wav sha 215be2d4… = source. PASS
M17 Download while the owner is unreachable failNext(file 03,1) → Download 1 POST → 504. Toast "Still fetching from lop-remote-node-2. Try again in a moment.", with no automatic retry. PASS
M18 Canvas, remote doc notes.md → Show in canvas GET …/files/0000000000000008/content → 200. The doc renders "Remote notes / Written on lop-remote-node-2." The strip also says "No longer on disk…" (Q1-3). PASS / Q1-3
M19 Canvas, remote image, video and audio Show in canvas All open by digest with 0 extra requests. Video readyState 4, duration 3.2 s. The audio waveform plays. PASS
M20 App reload with remote canvas tabs CDP Page.reload All 6 tabs remain. notes.md keeps its content (it did not go blank). The digest tabs re-fetch (200) and render. PASS
M21 Header "Open folder" on a remote session Press it Toast "Could not open loading. The file may have been moved, renamed, or deleted.", which means the owner's cwd was stat'd on this disk (Q1-1). BEFORE does not render the header for remote sessions. FAIL
M22 Library grid Navigate to /library 5 cards; the 3 remote cards carry the mark; the remote-render-3 thumbnail 404 shows the glyph. Without any interaction: GET /library/assets/la_0000000000000004/content → 200 for the remote audio card (Q1-2). FAIL
M23 Library, open a remote card Click remote-render-2 Detail view paints the image (content 200 ×2, a duplicate fetch also seen for local cards). Open → content?download=1 (the browser download path, pre-existing for path-less assets; no visible receipt headless). PASS
M24 Transcript lightbox Expand Image 2 Dialog with all 5 images; img5 fetched on demand → 200. PASS
M25 Audio row and waveform Play …/waveform 200; the player runs to 0:01/0:01 and returns to Play, in the transcript, Files and canvas. PASS
M26 Video Real H.264 clip Transcript generate_video card, Files "Open to play" and canvas tab: readyState 4, 320×240, 3.2 s; played to 1.6 s (frame). BEFORE: "could not be played". PASS
M27 Renderer memory Before and after opening every remote asset Largest renderer RSS 325.3 → 415.6 MB after opening all (7 canvas tabs including video), 469.7 MB after reload and lightbox. App process tree 1337.5 → 1539.1 → 1605.9 MB. JS heap at the end (Runtime.getHeapUsage) 71.6 MB. BEFORE, same flow minus the unopenable assets: 327.5 → 410.1 MB, heap 59.7 → 68.0 MB. performance.memory is bucketed in this Electron (constant 73.1 MB), so I did not use it. The assets total about 0.4 MB, so the growth is UI surfaces, not asset buffers. observed (no anomaly; see gaps)

Findings

Q1-1 — MAJOR — Files header "Open folder" sends the owner's cwd to this device's reveal IPC

  • Repro: in AFTER, open the remote session → Canvas → files, then press the header's Open folder.
    • Toast: "Could not open loading. The file may have been moved, renamed, or deleted." (frame).
  • Code path:
    • finder-files-view.tsx:718-726: {cwd && <Button onClick={() => void revealLocalTarget(cwd)}>} is not gated on remote.
    • That reaches main/index.ts:4084-4103: statSync(resolveUserPath(cwd)) and then shell.showItemInFolder(resolved).
  • Why this PR: BEFORE renders only the sentence for a remote session (no header), so the PR makes this button newly reachable.
  • Impact: when the owner's cwd also exists on the viewer (the same home layout on both Macs, e.g. /Users/<me>/project), the button reveals an unrelated local folder. This is the I3-1 class the PR guards for rows, and the acceptance asks for "disabled or explained".
  • Suggested fix: hide or disable the header Open folder when remote, with "On " as its reason. Add a hook or test pinning that.

Q1-2 — MAJOR — Library audio cards pull a remote asset's full bytes on view

  • Repro: navigate to /library and do nothing else. The fixture logs GET /v1/desktop/library/assets/la_0000000000000004/content 200 for remote-tone.wav (remote:{device…}) about 1 s after the grid paints.
  • Code path: library-card.tsx:184-189 CardAudioWell: wantsBytes = asset.exists && near → useLibraryBytes(asset.id, "content", wantsBytes), with no asset.remote gate.
  • Why it matters: core design §5 routes …/content of a remote-occurrence asset through §1 fetch-on-miss, and its "No per-view mesh fetch" rule reserves that for explicit open, lightbox and download. So every remote sound card scrolled into view pulls the whole file over the mesh. That contradicts the acceptance "Library stays fast — no per-view byte fetch over the mesh" (music files make it worse).
  • BEFORE control: the same card code makes the same read when given the same rows (before-dark-e-library-grid-same-remote-rows). The read is old behaviour for local assets, but the remote rows are what this feature adds.
  • Suggested fix: for asset.remote, keep the glyph or flat scrubber and fetch content on the play press. Add a test asserting no library.content call for a remote card on mount.

Q1-3 — MINOR — A remote document in Canvas shows local-disk freshness chrome

  • notes.md opened from the remote row shows "Not read from disk yet · No longer on disk — showing the version we last read." and a Re-read from disk control that does nothing when pressed. The strip persists after reload. Frames: doc, after reload.
  • The doc path is a blob: URL (remoteOpenableDocument), and the freshness layer treats it as a missing disk file. No local path is read, so this is a copy and honesty issue, not I3-1.
  • Fix: skip the freshness strip for blob: or remote documents, or replace it with "From · read-only".

Q1-4 — MINOR — A waveform 409 is cached for the app's lifetime while bytes are not

  • Repro: M5 then M6. After the owner answers 200 again, reopening the session re-fetches the bytes, but /waveform is never asked again, so the scrubber stays flat (frame).
  • Code: use-waveform.ts requestPeaks settles every non-504 as null in cache and settled permanently.
  • §1 makes 409 permanent, so this is defensible. But the operator-derived acceptance says the UI "must not cache the 409 forever", and the bytes path already re-asks on remount.
  • Fix: drop the cached null when the answer was 409, the same way 504 is handled.

Q1-5 — NIT — A download onto an identical existing file reads like a fresh one

  • With created:false (same bytes already in Downloads), the toast still says Downloaded notes.md.
  • Suggested copy: "Already on this device · Open file".

Q1-6 — NIT — out of scope, pre-existing — capability-absent race on a cold deep-link reload

  • The hook issues one /files request 4 ms before the catalogue marks the session remote.
  • BEFORE requests every time, the output is unchanged, and the warm path makes 0 requests as the PR claims.

Coder's self-declared gaps, judged

Gap Verdict
Remote user-attachments keep their local path actions Untested. The fixture's user-attachments returns items: []. It is the same I3-1 class as Q1-1, so it should be either closed or explicitly deferred in the thread.
Library download over 32 MiB Untested. There is no large asset in the fixture.
Canvas tab goes blank after reload Not reproduced for markdown: content persists. Digest-backed tabs re-fetch and render. Binary blob docs (PDF) are untested.
No MP4 in the fixture Resolved in QA with a MediaRecorder H.264 clip. Video works on all three surfaces (M26).
i18n ledger (42 findings) Out of scope here; owned by the i18n stream.

Gaps I could not cover

  • The real core and mesh. The fixture stands in for the core wire, so download landing and naming are the fixture's, and the UI half is what was verified. No live peer was used, per the isolation rules.
  • RAM under large assets. The fixture assets are about 0.4 MB, so "RAM flat" for big media is unmeasured.
  • Clipboard actions. Copy image, Copy path on the device and Copy file path were not read back.
  • Save as… It opens an OS dialog, which this headless run cannot drive.
  • A real Finder or file manager. Shell calls were recorded, not executed.
  • Remote user-attachment rows — see the table above.

@damianvtran

Copy link
Copy Markdown
Owner Author

Design review — round 1

Reviewer: designer (lopdev). Served model anthropic/claude-opus-5-5, no fallback.
Scope: 83a18c632..a4462e721 (head a4462e72159d8fa9a45931c36b380221880ffb30). I used the worktree read-only.
Evidence: QA round 1's 78 frames from the built app under Xvfb at 1380×872, light and dark (published). I ran no capture of my own. Geometry and contrast below come from full-resolution pixel reads with PIL: WCAG relative luminance, with the ink taken as the pixel furthest from the modal ground in the box.

Verdict: changes requested. One MAJOR (D1-1), five MINOR and two NIT. Overall the transcript, canvas and Library now show remote media the way they show local media. The remote indicator is the item that still has to change.

What works (no change needed)

  • Transcript tiles. The before frames show 1 and 5 broken ImageOff tiles, plus "could not be played" wells for the sound and video. The after frames show real thumbnails, the waveform and the video at the same slot sizes (*-a-transcript-folds, *-gh-audio-video-fold).
  • No per-tile remote mark in the transcript. This is correct. The header chip ("On lop-remote-node-2", Monitor glyph) already says it once for the whole session.
  • Sticky headers and sections. These are the same as local: Highlights 1, then Working files 12 broken into Images, Videos, Sounds, Documents, Spreadsheets and Other. The sticky header stays opaque mid-scroll. The mark does not affect the sticky structure.
  • Remote mark contrast. 5.89:1 in light and 5.64:1 in dark, the same ink as the row's size note. Its size and ink are right.
  • Canvas retryable state (*-f-canvas-video-retryable). It has a centred sentence, the file name and the outline Retry button, the same pattern the panel uses for peer-update and unsupported.
  • Download toast (after-dark-d-download-toast). It shows "Downloaded render-2.png" with Open file, Open folder and Copy file path. The links measure 6.61:1 and the title 12.31:1 on the dark toast.

Findings

D1-1 — MAJOR — The remote mark is the wrong glyph, and it sits next to the metadata instead of the name.
Frames: after-{light,dark}-b-files-panel-top.png, after-{light,dark}-c-menu-unreadable-sensitive.png, after-{light,dark}-e-library-grid.png.

  • The glyph reads as a chat icon. At 12px, MonitorSmartphone renders as a rounded box with a tail at lower left. On Library cards it is nearly the same shape as the MessagesSquare origin glyph two lines above it ("Studio: mesh sync notes"). Next to "In conversation only" it reads as that label's chat icon. It is also not the device glyph this app already uses: the header chip draws Monitor at size-3, which reads cleanly as a screen at the same size. MonitorSmartphone is the glyph for the header's "Move to device" action.
  • It groups with the metadata. The name is flex-1, so the mark is pushed against the trailing note:
    • .env list row: the name ends at x=813, the mark spans 1243–1254, and "2 KB" starts at 1263. That is a 429px gap to the name and 8px to the note.
    • Image tiles: the gaps are 9px to the name and 8px to "In session", so the mark could belong to either.
    • Every row then reads as "[icon] 2 KB" or "[icon] In session". The glyph never says "remote".
  • It repeats on every row. In a peer session every row is remote: 13 of 13 rows carry the same glyph, so it tells rows apart from nothing. It is the noise the operator asked us to avoid ("small non-intrusive indication somewhere").
  • Spec:
    1. Glyph: Monitor from lucide, size-3 shrink-0 text-ink-dim, aria-hidden. The wrapper keeps role="img" and aria-label = ui.canvas.files.remoteOn.
    2. Files panel: one mark per panel, not per row. Put it on the count line: 13 files · [Monitor] On lop-remote-node-2.
      • Styling: text-meta text-ink-muted, gap-1, the device name min-w-0 truncate. It sits left of "Open folder", outside the sticky headers, so the classified sections are untouched.
      • Remove RemoteFileMark from FinderEntry (both call sites). Rows keep the "On " line in their tooltip, which they already have.
    3. Library card and list row: keep the per-asset mark in its current slot (the FavoriteMark slot), and only swap the glyph. The grid mixes local and remote cards, so a per-card mark is useful there.
    4. Any future surface that mixes local and remote rows: the mark must sit with the name, not the note. Wrap name and mark in flex min-w-0 flex-1 items-center gap-1 with the name truncate.

D1-2 — MINOR — The compact retryable tile is a bare arrow.
Frames: after-{light,dark}-f1-retryable-tile.png.

  • The 117×78 tile shows only RotateCw. The glyph contrast is fine (5.02:1 light, 6.99:1 dark), and the border-control edge correctly marks it as a control.
  • But next to loaded images, a lone circular arrow reads as "still loading". The cause and the action exist only in title, which a keyboard user never sees.
  • Spec:
    • flex-col items-center gap-1: RotateCw size-4 text-ink-dim, then a text-meta text-ink-muted caption from the existing key ui.chat.attachments.retry_fetch ("Retry").
    • Keep title and aria-label as they are.
    • The ImageOff broken tile stays glyph-only, so the two states remain distinct.

D1-3 — MINOR — The pending state during the owner backoff is a blank, wrongly sized well.
Frames: after-dark-a3-fold5-expanded-504-pending.png and after-dark-a4-fold5-recovered.png; compact version in after-dark-a1-transcript-loading504.png.

  • In the expanded fold, the fifth slot is an empty sunken box of 810×64 (x 393–1202, y 650–713). The four resolved siblings, and the fifth once it recovers, are about 97×65 thumbnails.
  • With the backoff, this state lasts seconds rather than milliseconds. During that time it reads as an empty text field and gives no sign that work is happening. When it resolves, the box collapses horizontally.
  • Spec: while source.state is pending on a remote digest:
    • Keep the slot's own thumbnail box: the image's box, or the 117×78 compact tile.
    • Centre the shared Spinner (size-4 text-ink-dim) in it, as chat-header-device.tsx already does for a pending device.
    • Canvas equivalent: replace "Opening…" with "Fetching from {device}…" (*-f-canvas-video-504-pending). This is copy only, so the i18n stream can own the key.

D1-4 — MINOR — The menu on an unreadable row has a different shape from the readable one, and its disabled labels are barely legible.
Frames: after-{light,dark}-c-menu-unreadable-sensitive.png against after-light-c-menu-readable-path-doc.png.

  • Different entries.
    • Readable remote row: Show in canvas · Download to this device · Copy path on · Save as…
    • Unreadable row: Show in canvas · Open file · Open folder · Copy path on .
    • So the menu disables two actions the readable row never offers, and leaves out the two it does offer (Download, Save as).
  • Low contrast. The disabled labels measure 2.96:1 light and 2.08:1 dark against the menu ground. The repo already has a precedent for disabled-with-reason items: chat-header-device.tsx uses text-ink-muted because ink-disabled measured 1.99:1.
  • The reason looks like a sublabel of one item. It renders only under "Show in canvas", and at 6.14:1 / 5.48:1 it is darker than the labels it explains.
  • Spec:
    • Use the readable row's entries in the same order, with the transfer entries disabled: Show in canvas, Download to this device, Save as…. Copy path stays enabled.
    • Put the reason once, as a non-interactive DropdownMenuLabel at the top: text-meta text-ink-muted, max-w-52 so it wraps (the header's D7 cap).
    • Style disabled items data-[disabled]:text-ink-muted, matching the header.

D1-5 — MINOR — Unreadable rows don't show why at row level; the blank tile looks like a failed thumbnail.
Frames: after-{light,dark}-b-files-panel-top.png (compacted.png), after-light-c-menu-unreadable-sensitive.png (.env, dataset.tar).

  • The three unreadable rows look like readable ones:
    • compacted.png (not_referenced) is an empty sunken tile captioned "In session".
    • .env (sensitive) shows "2 KB".
    • dataset.tar (too_large) shows "314.6 MB".
  • Locally, a refused or gone row shows its status sentence instead of size (old.csv: "No longer on disk"). Here the reason appears only in the hover tooltip and in the menu.
  • Spec:
    • When remote.unreadable, the note is the reason (ui.canvas.files.remoteReason.*). Treat it as a sentenceNote, so the D15 floor and the full-sentence tooltip apply.
    • The name takes text-ink-muted, like missing rows.
    • The tile well shows TypeGlyph, as RowThumbnailReader already does for remote list rows, instead of an empty well.
    • With D1-1's panel-level device line, the tile status can drop the device name. A short form is i18n's call.

D1-6 — MINOR — Library detail of a remote asset doesn't say it is remote.
Frame: after-light-e-library-open-remote-card.png.

  • The card carries the mark, but the detail view it opens does not. The meta run reads "195 B 96×64 Not organized yet" with nothing about the device.
  • The action there is "Download", while the Files menu says "Download to this device" for the same act.
  • Spec:
    • Append [Monitor size-3 text-ink-dim] On {device} (ui.canvas.files.remoteOn) to the meta run after the dimensions, with the run's existing spacing and text-meta text-ink-muted.
    • Label the button with ui.canvas.files.remoteDownload when asset.remote.

D1-7 — NIT — The canvas unreachable copy doesn't name the device it already knows.

  • *-f-canvas-video-retryable reads "Couldn't reach the device that holds this file." Every other new string names {device}.
  • Suggested: "Couldn't reach {device}. Try again in a moment."
  • Same for remoteUnreachableRetry (the sound line). That one is a copy change for the i18n stream.

D1-8 — NIT — The download toast doesn't say where the file landed.

  • after-dark-d-download-toast: "Downloaded render-2.png". The user has to click Open folder to learn where it went.
  • Suggested: "Downloaded render-2.png to Downloads". Pair this with Q1-5's "Already on this device" for created:false.

Not judged / out of scope

  • Toasts not on screen. *-d-download-504-toast and *-d-download-collision-toast were captured after the toast had dismissed, so I have no frame of the pending or collision toast. That is a lead for QA's round 2 capture, not a finding.
  • The remote-render-3 placeholder glyph in the Library grid is the fixture's 404 thumbnail (QA M22). It is correct behaviour.
  • The toast's border on dark, and the toast overlapping the last row's size: out of scope — pre-existing (shared toast component).
  • The large "Open to play" video well in the Files panel uses the existing video-row treatment. Not judged.
  • Already filed elsewhere, not repeated here: the "Not read from disk yet" strip on a remote document is Q1-3, and the header Open folder behaviour is Q1-1.

@damianvtran

Copy link
Copy Markdown
Owner Author

UX review — round 1

Verdict: NOT TERMINAL. Three MAJOR findings (U1-1, U1-2, U1-3), five MINOR, two NIT. They don't duplicate R1-, Q1- or I1-*. The Files panel's sticky sections, sort, filter, row marks and the receipt toast all feel like the local ones. The friction is in the waiting and failure states, and in a remote document that can be edited but never saved.

Context

  • Reviewer: ux-reviewer (lopdev). Served model: anthropic/claude-opus-5-5, no fallback.
  • Scope: 83a18c632..a4462e721. Worktree /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ a4462e72159d8fa9a45931c36b380221880ffb30, read-only.
  • Evidence base: QA round 1's 78 frames of the built app (light and dark, Xvfb, published), QA's timings (M1–M27), and the code paths cited below.
  • No live run of my own. I tried one bounded interactive run with QA's rig, and the harness guardrail refused it because the rig sits in another session's scratchpad. I didn't route around that. So U1-2's no-progress claim and U1-3 come from code paths plus frames, not from a click I watched.
  • Copy scan: I read every new en string by hand (I did not run copy_scan.py). It found no placeholders and no generic "something went wrong". It found one dead end (U1-7) and two places where the same thing is named two ways (U1-5, U1-6).

Flows walked

# Flow Result
1 Transcript fold: loads / retryable / click to retry Works. While it waits, the tile is a blank, unlabelled box for at least 30 s (U1-1).
2 Files: sections → sort/filter → open → Download → toast actions Sections, sort, filter and the mark are consistent with local rows. Download gives no progress, and its 504 wording is wrong (U1-2). The toast is short-lived but you can recover from it (U1-9).
3 Unreadable row: why it's unreadable, and what to do The reason is reachable (hover, click toast, menu). The row itself looks readable (U1-4). The scratchpad hint can be acted on, but only by typing.
4 Canvas open → reload Image, video and audio re-fetch. Markdown opens in a live editor that can't save (U1-3). A blank blob: tab after reload is already R1-3. The "No longer on disk" strip is already Q1-3.
5 Library: browse → open → download The cards carry the mark. Open/Download give no progress and a generic failure (U1-8). Audio fetched on view is already Q1-2.
6 Owner offline: 504 / retryable / missing Retryable and missing look different (retry arrow vs torn picture). But the copy can't separate "a moment" from "turn the other Mac on" (U1-6), and Download says "Still fetching" when nothing is fetching (U1-2).
7 Older peer: "Update " Says what, not how (U1-7).

Findings

U1-1 — MAJOR — A remote picture waits 30 s or more as an empty, unlabelled box, and Retry turns back into that box

  • Step: open a remote session while its owner answers 504, either as the five-image fold strip or with the fold expanded.
  • What happens:
    • Every attempt keeps the tile in resolving (use-attachment-url.ts:396-412), which paints the bare reserved box (canonical-image.tsx:133-145, aria-hidden).
    • QA M3 measured 5 attempts at 0.38 / 2.40 / 6.40 / 14.41 / 30.41 s before the retry tile shows.
    • Against the real core, each attempt may also sit in the route's 20 s wait (design §1 REMOTE_FETCH_WAIT_S), which stretches the blank phase towards about 2 minutes.
    • Frames: collapsed strip (4th tile empty), expanded fold (a full-width empty well).
    • Clicking Retry starts a fresh cycle, so the button vanishes into the same empty box, with nothing announced.
  • Why it matters: this is the operator's original complaint: dark tiles that say nothing. A user can't tell "on its way" from "broken" until the cycle ends.
  • Expected: after the first 504, the tile shows a waiting state ("Fetching from lop-remote-node-2…", with a spinner glyph and title/aria-label). After a click, the same waiting state stays visible.
    • Optional: when the catalogue already says reachable === false (chat-remote.ts:108), go straight to retryable without the 30 s of backoff.

U1-2 — MAJOR — "Download to this device" is silent while it runs, and a 504 always says "Still fetching", even when the owner is offline

  • Step: Files → ⋯ → Download to this device.
  • Code path:
    • downloadRemoteFile (remote-file-transfer.tsx:138-176) shows nothing until the POST settles, and the menu has already closed.
    • Every 504 maps to remoteDownloadPending (:170-171), "Still fetching from {device}. Try again in a moment." (QA M17), whatever the body's code is.
    • Design §1 sends 504 attachment_owner_unreachable for an offline owner and attachment_fetch_pending for a running transfer. The UI words the two the same.
  • Effect:
    • With an offline owner, the user is told something is fetching when nothing is.
    • With a slow transfer, the user waits up to 20 s with no sign the click registered. They then get an error-styled toast, and nothing tells them when the transfer the backend kept running has finished. They have to keep re-clicking.
  • Expected:
    • showLoadingToast("Downloading {name} from {device}…", {id}), replaced by the outcome. This is the existing pattern in use-request-update.ts:124.
    • On fetch_pending, re-POST (it is idempotent and joins the single-flight) on the same 2/4/8/16 s schedule, and land automatically.
    • On owner_unreachable, use U1-6's offline sentence.

U1-3 — MAJOR — A remote text file opens in a live editor whose edits are silently never saved

  • Step: Files → notes.md → Show in canvas, then type and press Ctrl+S.
  • Code path:
    • canvas-content.tsx:61-68 sends only document.artifact text to the read-only ArtifactTextPreview. A remote path-row document falls through to WysiwygMarkdownEditor, which stays contentEditable (wysiwyg-markdown-editor.tsx:2350). Code and CSV files likewise get their editors.
    • saveBuffer returns unavailable for a blob: path (document-buffers.ts:484).
    • Both explicit-save handlers return quietly on any status other than written (wysiwyg-markdown-editor.tsx:629-631, :1810-1811).
    • Frame: the full formatting toolbar and undo/redo on the remote doc.
  • Effect: the user believes they edited the file on lop-remote-node-2. Nothing reaches the owner, and nothing says so. The acceptance asks that an action that can't work remotely be "disabled or explained".
  • Expected: route document.remote text kinds through ArtifactTextPreview, which is already read-only, with a "From {device} · read-only" line. This also retires Q1-3's freshness strip on the same surface.

U1-4 — MINOR — An unreadable row looks readable, and keyboard focus doesn't show the reason

  • Step: Files panel; find .env, main.py, dataset.tar and compacted.png.
  • What happens:
    • These rows are styled exactly like notes.md (frame).
    • compacted.png is a permanently empty dark well, the same as a loading thumbnail (frame). The thumbnail is skipped, and no type glyph replaces it.
    • The reason appears only on hover title, in the click toast, or once under the first disabled menu entry. Radix skips disabled entries, so a keyboard user arrowing through the menu never reaches it.
    • The panel's own pattern for refused rows already puts the reason in the note line on keyboard focus (finder-files-view.tsx:1291-1292, UX R3 U18). Remote unreadable rows don't get it.
  • Expected:
    • Put a type glyph (or lock glyph) in the tile.
    • Put the short reason in the status slot where "In session" sits ("Private on {device}", "In {device}'s workspace").
    • Extend focusReason to remote.unreadable.
    • Optional for the workspace case: an "Ask the session to copy it" entry that pre-fills the composer, so the hint can be acted on in one click.

U1-5 — MINOR — A missing remote row says "No longer on disk", and its own menu says "No longer on lop-remote-node-2"

  • Step: old.csv row (the frame above): the status slot reads "No longer on disk", and the menu reason reads remoteReason.missing.
  • Why it matters: "disk" reads as this device. One state, two wordings, in one row.
  • Fix: for document.remote, use remoteReason.missing in the status slot.

U1-6 — MINOR — Owner-offline copy hides the device name and promises "a moment"

  • The strings:
    • remoteUnreachable: "Couldn't reach the device that holds this file. Try again in a moment." (canvas frame, and the toasts).
    • owner_unreachable, on the transcript tile.
    • remoteUnreachableRetry.
  • Why it matters:
    • The neighbouring strings name the device ("On lop-remote-node-2", "Still fetching from lop-remote-node-2", "Update lop-remote-node-2…"), and document.remote.deviceName is right there.
    • "In a moment" is wrong when the other Mac is asleep, so the user can't tell "wait" from "go wake it".
  • Fix: "{device} isn't answering. Try again when it's online." Use one sentence across the tile, the canvas, the sound line, the toasts and U1-2. The I1 stream would then re-key the translations.

U1-7 — MINOR — "Update lop-remote-node-2 to see its files here." says what, not how

  • Step: a peer that predates the feature (frame). The only control is Retry.
  • Why it matters: the desktop app has no peer-update action (mesh-page.tsx has none). Core's matching 409 sentence names lop network update <device>, and that command exists (local_operator/network/cli.py:8293).
  • Fix: add a detail line: "Run lop network update lop-remote-node-2 here, or update Local Operator on that device, then Retry."

U1-8 — MINOR — Library Open/Download of a remote card gives no progress, and on 504 a generic failure

  • Step: Library → remote card → Open or Download (frame).
  • Code path:
    • For a non-path asset, openExternal (library-preview.tsx:349-357) calls saveAssetCopy. That is a mesh fetch with no progress and no success toast. QA's after-press frame is identical to the frame before the press.
    • Any failure, including the new 504, reads "The asset could not be saved." (library-download.ts:39-45).
  • Scope: "Open saves a copy" is pre-existing for digest assets (out of scope). The remote wait and the 504 wording are new.
  • Fix: when asset.remote, show the same loading toast as U1-2 and map 504 to U1-6's sentence.

U1-9 — NIT — The download receipt toast and its three actions dismiss after Sonner's default 4 s

  • ThemedToastContainer passes no duration.
  • You can recover: the row menu gains Open file / Open folder / Copy file path after landing (QA M14).
  • Fix (optional): duration: 8000 on that one toast.

U1-10 — NIT — The Library detail view for a remote asset carries no remote mark

  • The card has one. The detail header shows only "From Studio: mesh sync notes".
  • Fix: reuse RemoteFileMark beside the origin link.

Retained, no finding

  • Sticky sections, counts, search, filter and sort behave the same as local (QA M10/M11, frames b-files-*).
  • The 12 px mark is non-intrusive and carries a tooltip.
  • Row menus reuse the local row's focus-return and context-key wiring.
  • The canvas viewer's retryable state (message, file name, Retry) is clear, and its "Opening…" waiting state gives feedback.
  • A Retry click recovers in place without remounting the fold (QA M3/M4).
  • The audio "Click to retry" line is discoverable.
  • Light and dark themes both checked from the paired frames. Neither shows a theme-specific defect in these flows.

For the remediation wave

  • U1-1, U1-2 and U1-3 gate.
  • U1-6 is the shared sentence U1-1, U1-2 and U1-8 should use, so fold it in with them.
  • U1-4, U1-5 and U1-7 are small and on the same files.
  • U1-9 and U1-10 may be deferred.

Answers the round-1 ledger of PR #1880 (review, QA, design, UX, i18n):

- R1-1: a remote sound row never reaches FinderPathSound; unreadable rows
  state the reason, readable ones play from the content route on a press.
- R1-2/R1-3: remote canvas documents are `remote-file:` identifiers whose
  viewers fetch over the content route under one refcounted blob entry,
  released with the tab; nothing dead persists across a reload.
- Q1-1: the Files header never reveals a remote session's (owner's) cwd.
- Q1-2: remote Library sound cards read nothing on view; a press arms it.
- D1-1: one Monitor mark on the Files count line; the per-row glyph is gone.
- U1-1: a slow owner shows "Fetching from <device>…" (transcript + canvas);
  the compact retry tile is captioned; Retry starts in the waiting state.
- U1-2: Download shows a loading toast, re-POSTs a still-running transfer on
  the backoff, names the offline device, the landing folder, and an
  identical file already there; the receipt toast lasts 8 s.
- U1-3: remote text opens read-only ("From <device> · read-only") with no
  local freshness strip.
- Folds in D1-4..D1-7, U1-4/5/7/8/10, Q1-3/4, R1-4, R1-6..R1-9 and the
  I1 string corrections across the 7 locales.
@damianvtran

damianvtran commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner Author

Remediation — round 1

Implementer: coder (lopdev), served model anthropic/claude-opus-5-5, no fallback.
Scope: a4462e7..79ae385 · fix commit b1df80525 (code, tests, catalogues) · i18n bind commit 79ae38539 (ledger only).
Ledger: the manager's deduplicated UI-LEDGER-R1 (5 streams).

MUST FIX

id disposition where / regression test
R1-1 BLOCKER fixed b1df80525 AudioDocumentPlayer never routes a remote row to FinderPathSound: unreadable → reason line; digest → by digest; readable path → FinderRemoteSound (press to load, bytes over sessions.files.content via the row's remote-file: identifier). Test scripts/remote-sound-row.test.mjs (JSDOM + real React, readFileBytes spy, local-row negative control). On a4462e7 src: 2 of 3 fail, actual: ['~/Music/song.mp3']. R1-5 coverage of the other sinks: thumbnail/lightbox (finderOriginal → content route, unchanged and covered by the dispatch test), canvas open path (remoteCanvasDocument test below), viewer byte hook (file-blob-url remote cases).
R1-2 MAJOR fixed b1df80525 No createObjectURL at open any more. A remote canvas document is remote-file:<sid>/<fileId>/<name> (shared/lib/remote-file-path.ts); useFileBlobUrl reads it over the content route under ONE refcounted blob-url-cache entry per row, released by the last holder (tab close / replace). Text kinds and CSV get no blob (read-only preview, U1-3); a workbook gets base64 content only. Tests: file-blob-url "a remote-file: identifier … every holder shares one URL" (create/revoke balance, readFileBytes never asked), remote-files "R1-2/R1-3 … no object URL". On a4462e7 src: 3 file-blob-url cases fail.
Q1-1 MAJOR fixed b1df80525 revealableCwd(cwd, remote) drops the header Open folder for a remote session; useSessionFiles now returns remote. Tests: remote-files "Q1-1 …", session-files-hook asserts remote: true. The header shows the D1-1 device line instead.
Q1-2 MAJOR fixed b1df80525 CardAudioWell: a remote card reads nothing on view; the glyph is a "Play from {device}" control that arms library.content/waveform. Test library-card "a remote audio card reads no bytes on view, only on the play press". On a4462e7 src: fails (content read on view).
D1-1 MAJOR fixed b1df80525 RemoteFileMark → Monitor size-3 text-ink-dim; Files panel: one mark on the count line ("13 files · [Monitor] On ", text-meta text-ink-muted), per-row marks removed (row tooltips keep "On "); Library card keeps one mark (new glyph). Visual: QA round 2 frames.
U1-1 (+D1-2, D1-3) MAJOR fixed b1df80525 AttachmentSource.resolving.pending (true from the first 504 and from a Retry click). Transcript image/audio/video paint PendingAttachment (own box: 117×78 tile, or a fitted well; centred Spinner + "Fetching from {device}…"); canvas image/video say "Fetching from {device}…" instead of "Opening…"; compact retry tile gets the "Retry" caption (retry_fetch). Device from the catalogue row (useRemoteSessionDevice → remoteDeviceLabel). Test attachment-url "a slow owner is pending from the first 504, and a Retry starts pending". On a4462e7 src: fails. Filmstrip select tiles keep their reserved button (their action is the point).
U1-2 (+U1-6, D1-7, D1-8, Q1-5, U1-9) MAJOR fixed b1df80525 downloadRemoteFile(remote, name): loading toast (one id, replaced by the outcome); 504 attachment_fetch_pending re-POSTs on ATTACHMENT_RETRY_DELAYS_MS and lands without a re-click; any other 504 → "{device} isn't answering. Try again when it's online." (shared with canvas/transcript/sound/toasts); success "Downloaded {name} to {folder}", 8000 ms; created:false → "{name} is already in {folder} on this device". Tests in remote-files: re-POST lands (3 requests, toasts loading→success), offline (1 request, shared sentence), exhausted pending, already-there.
U1-3 (+Q1-3) MAJOR fixed b1df80525 canvas-content: remote markdown/HTML/code/CSV → ArtifactTextPreview, which now fetches document.remote bytes over the content route and captions "From {device} · read-only"; freshness bar skipped for document.remote; saveBuffer refuses remote-file:. A workbook (xlsx) keeps the spreadsheet viewer, whose save refuses the identifier (see "not addressed").
I1-1..I1-3 MAJOR fixed b1df80525 ur ڈیوائس/والی (remoteDownload, remoteReadFailed, remoteDeviceFallback دوسری ڈیوائس, owner_unreachable; remoteUnreachable re-keyed by U1-6 and translated with ڈیوائس); ru сессия / рабочем пространстве / скретчпад; hi कार्यक्षेत्र. Auditor's exact strings.

FOLD IN

id disposition
D1-4 fixed — unreadable menu = readable entries (Show in canvas, Download to this device, Copy path on , Save as…), transfers disabled; reason once as a DropdownMenuLabel (max-w-52 text-meta text-ink-muted); disabled items data-[disabled]:text-ink-muted. Test: remote-files "unreadable rows: the readable row's entries …".
D1-5 / U1-4 fixed — unreadable row: reason in the status slot (sentence note, visible on keyboard focus too), muted name, type glyph in the empty tile.
D1-6 / U1-10 fixed — Library detail meta run gets "[Monitor] On {device}"; its button reads "Download to this device" for a remote asset.
U1-5 fixed — missing remote row status = remoteReason.missing.
U1-7 fixed — second line "Run lop network update {device} on this device, or update Local Operator on {device}, then retry." Verb verified in core: local_operator/network/cli.py (lop network update <peer>, :9781). Test: session-files-hook peer_update case.
U1-8 fixed — saveAssetCopy shows the loading toast for a remote asset; a 504 throws RemoteAssetUnreachableError carrying the shared offline sentence (preview Open/Download, asset menu).
Q1-4 fixed — a 409 waveform is painted flat but not cached; a remount re-asks. 404 stays cached. Tests: attachment-url "a 409 waveform is flat but not kept" (fails on a4462e7) and "a 404 … still kept".
R1-4 fixed — > 32 MiB: Show in canvas disabled with "Too large to open from {device}. Download it to this device instead."; canvas open says so instead of fetching; Download stays. Test: remote-files "readable over 32 MiB …".
R1-6 fixed — localPathOf(asset) (null when asset.remote) gates the drawer's openFile / showItemInFolder / viewer open / isPathBacked / file-watch path.
R1-3 fixed (with R1-2) — the persisted path is the re-fetchable identifier; blob: is no longer a pass-through (test "a blob: path is no longer a pass-through document").
I1-4..I1-7, I1-9, I1-10 fixed — auditor's exact strings (es/fr/vi/zh-CN scratchpad, fr ’, zh-CN no spaces, hi दूसरे डिवाइस, vi không lấy được, zh-CN 不公开). I1-8 (nit) also applied.
R1-7..R1-9 fixed — canvas comment rewritten for the identifier; finder-thumbnails comment points at finderOriginal's remote guard; assetDisplayName JSDoc moved back.

Also, found on the way (same I3-1 class, small): ViewerChrome/OpenInOsButton refuse remote-file: paths; classifyImageSource treats it as blob (no path actions); remote videos start on the bytes path, not the backend's path route.

DEFERRED

  • I1-11 — deferred: auditor judged the concatenated label acceptable in all 7 locales.
  • Q1-6 — deferred: pre-existing (one cold-reload /files request before the catalogue marks the session remote).
  • Remote user-attachments' local path actions — deferred: pre-existing, reviewer-judged out of scope.
  • Library download > 32 MiB — deferred: same ceiling as local assets; waits on the core download route.

Not addressed (noted)

  • A remote xlsx opens in the spreadsheet viewer, which still looks editable; its save returns unavailable (identifier refused). Read-only workbook view is a follow-up if wanted.
  • A remote path sound in the Files panel and in the canvas audio viewer is press-to-load (no per-view mesh fetch), so it takes two presses (load, then play).

i18n

Edited pairs (round-2 delta audit, COMMANDS.md §C): es/hi/ru/ur/vi/zh-CN/fr ui.canvas; fr/ur/vi ui.chat.attachments. en: ui.canvas + ui.canvas.context (8 new keys, 2 changed, remoteUnreachableRetry removed). §A bound es/hi/ru/zh-CN ui.chat.attachments (byte-identical, the auditor's receipts) and §D recorded translator coder / anthropic/claude-opus-5-5 on them. ledger-check: 30 findings = the 10 edited pairs × 3, awaiting the round-2 auditor. No receipts written by me.

Targeted test evidence

Head 79ae38539282a0c4496aaa6f63481f967f83e207.

python3 ~/local-operator/scripts/run_bounded.py --timeout 600 --memory-mb 3000 -- node --test --test-concurrency=2 scripts/remote-files.test.mjs scripts/attachment-url.test.mjs scripts/session-files-hook.test.mjs scripts/file-blob-url.test.mjs scripts/library-card.test.mjs scripts/remote-sound-row.test.mjs
# tests 82  # pass 82  # fail 0   (5.2 s)

python3 ~/local-operator/scripts/run_bounded.py --timeout 900 --memory-mb 3000 -- node --test --test-concurrency=2 scripts/attachment-chrome-i18n.test.mjs scripts/canvas-attachment-locale.test.mjs scripts/canvas-file-freshness.test.mjs scripts/desktop-contract.test.mjs scripts/finder-files.test.mjs scripts/image-actions.test.mjs scripts/library-model.test.mjs scripts/supplement-file-actions.test.mjs scripts/waveform-model.test.mjs scripts/image-preview-decode.test.mjs scripts/chat-image-expand.test.mjs scripts/sound-player.test.mjs scripts/user-attachments-render.test.mjs scripts/link-actions.test.mjs scripts/library-taxonomy-i18n.test.mjs scripts/preview-signed-urls.test.mjs scripts/canvas-tab-close.test.mjs
# tests 307  # pass 307  # fail 0   (56.4 s)

Regression tests against a4462e721's src (tests from this head, `git stash -u -- src`):
remote-sound-row 2/3 FAIL (readFileBytes ['~/Music/song.mp3']) · file-blob-url 3/18 FAIL (remote-file cases) · library-card 1/7 FAIL (Q1-2) · attachment-url 2/25 FAIL (U1-1 pending, Q1-4)

tsc --noEmit -p tsconfig.app.json rc=0 (9.6 s) · -p tsconfig.node.json rc=0 · pre-push types leg passed
biome check (36 changed src files) clean; check-scripts-lint: 6 changed scripts lint-clean
i18n: generate --check, check-duplicates, check-strings, context-parity, check-added-strings, check-coverage: ok
ledger-check: FAILED, 30 findings (10 edited pairs × 3; round-2 audit pending)

pnpm test:affected names a tree-wide set (catalogue edits reach every t() consumer); that breadth is the merge-gate full suite's, not run here. Xvfb captures are QA round 2's.

@damianvtran

Copy link
Copy Markdown
Owner Author

Agent review — round 2

Reviewer: reviewer (lopdev, session 581b171b8866), served model anthropic/claude-opus-5-5, no fallback.
Scope: a4462e7..79ae385 (delta: b1df80525 code/tests/catalogues, 79ae38539 i18n ledger only). Judged against the round-1 contract (CONTEXT.md acceptance: "any action that genuinely can't work remotely is disabled or explained"; I3-1, owner paths never meet this disk) and the UI-LEDGER-R1 asks.

Verdict: 1 MAJOR open (R2-1). Everything else is MINOR/NIT.

Round-1 findings: verified on the head

id result evidence (read at 79ae385)
R1-1 fixed AudioDocumentPlayer checks doc.remote before it reaches FinderPathSound. An unreadable row shows its reason, a digest row goes through FinderArtifactSound, and a path row goes through FinderRemoteSound, which reads a remote-file: identifier over sessions.files.content. I traced every other reader. finderOriginal checks document.remote before readFileBytes (finder-thumbnails.ts:59), and it covers both the thumbnail queue (:105) and the lightbox (finder-files-view.tsx:363). Remote rows get RemoteFileActionsMenu, never the path-taking ImageActionsMenu. The canvas open turns a path row into an identifier (remoteCanvasDocument), and a digest row keeps artifact:<digest> as its path (session-files.ts:124). useFileBlobUrl parses the identifier before it would call the bridge. Video starts on bytes (video-preview.tsx:189). classifyImageSource treats the identifier as a blob source, so it gets no path actions. ViewerChrome and OpenInOsButton refuse it. The freshness bar is skipped for document.remote, and saveBuffer refuses the identifier before any probe (document-buffers.ts:484). In the Library, localPathOf gates openFile, showItemInFolder, isPathBacked/useAssetContent and the markdown probe, and the asset menu already had a !remote gate (:98, :188).
R1-2 fixed (see R2-2) No createObjectURL happens at open any more. The identifier is the shared cache key (use-file-blob-url.ts:220). Failures are never published, a reload-policy key is per holder and is released on unmount, and text/CSV get no blob at all. So no new code path makes the cache grow without bound. The "every holder shares one URL … create/revoke balance" test passes here.
R1-3 fixed The persisted document stores the identifier with content: "", so a reloaded viewer fetches again. Removing the blob: pass-through restores main's behaviour (83a18c6 has no blob: case in isDirectUrl).
R1-4 / R1-6 fixed Canvas open: remoteFitsWindow → info toast, and Download stays offered (canvas-file-viewer.tsx:~493). Library: localPathOf. The one gap left is the inline sound row (R2-3).

Delta review: what you asked me to probe

  • Download auto-resend loop: bounded. ATTACHMENT_RETRY_DELAYS_MS = [2000, 4000, 8000, 16000], so at most 5 POSTs over about 30 s. It re-sends only when the code is attachment_fetch_pending; any other 504 gets the shared offline sentence, and a non-504 gets a failure toast.
  • Toast lifecycle: every exit replaces the single remote-download:<key> loading toast: the catch, 200 with a receipt, a non-504, an exhausted pending and a plain 504. The unreadable path returns before the loading toast is shown. Library saveAssetCopy dismisses its loading toast in finally.
  • Crafted remote-file: identifiers: parsing yields only {sessionId, fileId}, and the only network use is desktopMedia({op: "sessions.files.content", …}). That goes to the user's own backend under the same per-session authority every other session read uses, so no cross-session reach beyond what the user already has. A crafted string cannot reach a local open, reveal or write: every one of those sinks refuses the prefix (list under R1-1). If a document carries the identifier without .remote, handleFileClick only stats it as an ordinary relative path, which answers "missing". It never resolves to an owner path. Nothing here gates.

Findings

R2-1 — MAJOR — a remote workbook is editable, and its saves fail silently (the U1-3 class, still open for xlsx).

  • The delta keeps a remote .xlsx in the live spreadsheet editor (canvas-content.tsx:69-83: "a workbook keeps the spreadsheet viewer, whose save refuses the identifier").
  • Code path:
    • Cells are editable: editable: true (spreadsheet-preview.tsx:1009).
    • Each edit triggers the debounced auto-save (:905-908), or Ctrl+S calls saveChanges(true) (:919).
    • saveBuffer → URL_PATH matches remote-file: → {status: "unavailable"} (document-buffers.ts:484).
    • spreadsheet-preview.tsx:861 then does if (outcome.status !== "written") return;, with no toast and no caption.
  • Result: the reader edits and saves, nothing lands, and nothing says so. That breaks the acceptance line "disabled or explained", and it is the same defect U1-3 was marked MAJOR for on text/markdown.
  • Fix (small): for document.remote, set editable: !document.remote on the column defs, skip saveChanges, and show the existing remoteReadOnly caption. Alternatively, at minimum, show remoteReadOnly as an info toast on unavailable.
  • Regression test: render a remote workbook and assert the cells are not editable, or that an unavailable save shows the sentence.

R2-2 — MINOR — a holder that unmounts mid-fetch releases another holder's reference (inherited pattern, now on an always-shared key).

  • use-file-blob-url.ts:303 releases unconditionally in cleanup, even when this holder never retained. Remote keys are always shared (:220).
  • Scenario: holder A lands its fetch and retains (refs 1). Holder B on the same identifier unmounts before its own fetch lands. B's release drops refs to 0 and revokes the URL A is still rendering.
  • Repro: I appended a test to a scratch copy of scripts/file-blob-url.test.mjs that mounts two holders, resolves A, then unmounts B. Result: not ok … expected: true actual: false (A's URL is no longer live).
  • Control: the same race on a local mtime-keyed path fails the same way. So the pattern is pre-existing, and that is why this is not MAJOR. Real reach needs two concurrent holders of one row, e.g. a Files-row sound plus its canvas tab.
  • Fix: in both branches, use let retained = false, set it where retain succeeds, and in cleanup call if (retained) release(key).

R2-3 — MINOR — the inline remote sound has no size gate (R1-4 gap).

  • FinderRemoteSound (audio-document-player.tsx:204) offers Play for any readable row. A sound over 32 MiB is refused by the relay and shows the generic remoteReadFailed, not "Too large to open from {device}…".
  • Fix: if (!remoteFitsWindow(remote)) render remoteTooLargeText(remote) instead of the Play button.

R2-4 — MINOR — two presses for a remote path sound (coder's not-addressed item 2).

  • Judged acceptable against the acceptance criteria. Q1-2 requires no fetch on view, so the first press is the explicit play. It is friction, not a defect.
  • Suggested: start playback once the armed load lands. SoundPlayer has no autoplay prop, so this would be an imperative play() on first ready. Defer if you prefer.

R2-5 — NIT — library-model.ts:208-209: the moved assetDisplayName JSDoc is now separated from its function by a blank line. Drop the blank line.

Coder's not-addressed items

  1. Remote xlsx looks editable and its save is refused: not acceptable as is. The refusal is silent (code path above), so this becomes R2-1 MAJOR.
  2. Two presses for a remote sound: acceptable, see R2-4 (MINOR, may be deferred).

Tests run (the delta's own files only, bounded)

run_bounded --timeout 300 --memory-mb 2000 -- node --test --test-concurrency=1 scripts/remote-files.test.mjs scripts/attachment-url.test.mjs scripts/session-files-hook.test.mjs scripts/file-blob-url.test.mjs scripts/library-card.test.mjs scripts/remote-sound-row.test.mjs
# tests 82  # pass 82  # fail 0   (9.3 s)
R2-2 repro (scratch copy of file-blob-url.test.mjs + 2 appended tests): remote race FAIL, local mtime control FAIL (same pattern)

I did not re-run the "fails on a4462e7 src" counts. They are the coder's, from the remediation reply.
CI at review time: only Version Bump Guard was reported, and it was QUEUED. I read it once and did not wait on it.

Out of scope — pre-existing: a remote workbook's base64 content is persisted to the canvas store's localStorage. Local workbooks are persisted the same way.

@damianvtran

Copy link
Copy Markdown
Owner Author

i18n audit — round 2

Reviewer: reviewer (i18n auditor), session 03cedf51c7f4, receipt instances 03cedf51c7f4-1880r2-<locale>. Served model: anthropic/claude-opus-5-5, no fallback. I am independent of the translator (the remediation coder) and of the round-1 auditor (1c4ab04dbe3e).
Scope: a4462e7..79ae385 (head 79ae38539282a0c4496aaa6f63481f967f83e207). I re-audited the 10 edited pairs: ui.canvas es, fr, hi, ru, ur, vi, zh-CN and ui.chat.attachments fr, ur, vi. That covers every value changed in the delta: the I1 fixes, the 8 added keys, the re-worded remoteUnreachable and remoteDownloaded, and the removal of remoteUnreachableRetry.

Method. For each changed value I read the en value with its .context.json note, the target before and after, and the call sites. I checked the glossary and style guide (core ad910f5d) and compared terms against each locale catalogue. Every changed value was parsed and rendered with intl-messageformat:

  • 0 parse errors.
  • Placeholder sets and key sets equal en.
  • No bidi controls.
  • ur was also rendered through src/i18n/bidi.ts and fribidi --rtl.

Read-only gates at head: generate --check, check-duplicates and context-parity are ok. ledger-check reports 30 findings: the 10 pairs × 3, which this round's receipts resolve.

Verdict: clean. No BLOCKER or MAJOR. 2 MINOR + 1 NIT are counted in receipts, and 2 MINOR are source-level and not counted. All are deferred; none needs a new commit.

I1 verification

Every requested string is byte-exact at head:

  • I1-1 · ur: remoteDownload, remoteReadFailed, remoteDeviceFallback دوسری ڈیوائس, and owner_unreachable. No آلہ/آلے remains.
  • I1-2 · ru: сессия / рабочем пространстве / скретчпад.
  • I1-3 · hi: कार्यक्षेत्र.
  • I1-4 · es/fr/vi/zh-CN: each now matches its pathCopyNote term (I re-read all four).
  • I1-5 · fr: ’ applied; no straight ' in any changed fr value.
  • I1-6 · zh-CN: no CJK–Latin spaces.
  • I1-7 · hi: दूसरे डिवाइस. It is still correct for the new use, before पर in remotePeerUpdateHow.
  • I1-8 · hi, ur.
  • I1-9 · vi.
  • I1-10 · zh-CN: {device}不公开此文件, which correctly replaces the I1-6 value for that key.

I1-1/I1-5 on remoteUnreachable/remoteUnreachableRetry were overtaken by the source change (U1-6). The new values use ڈیوائس, and the ur {device} جواب نہیں دے رہی matches the catalogue's ui.mesh.mesh_drop.no_answer.

Findings

I2-1 · MINOR · zh-CN (remoteFetching, remoteDownloading). The zh-CN guide says "Ellipsis is …… (two marks)", and the catalogue has 241 values with …… vs 25 with a single mark. Corrected: 正在从{device}获取…… · 正在从{device}下载{name}……. Deferred.

I2-2 · NIT · hi (remotePeerUpdateHow). फिर से ("again") drops the source's "then". The catalogue's "…, then try again" is फिर (दोबारा) कोशिश करें. Corrected: इस डिवाइस पर lop network update {device} चलाएँ, या {device} पर Local Operator अपडेट करें, फिर दोबारा कोशिश करें। Deferred.

I2-3 · MINOR · source/code, not counted. In remote-files.ts:306-317, when page.device_name is empty, remoteDeviceFallback is spliced into the new remotePeerUpdateHow. That renders a command nobody can run, in every locale. For example, en gives Run lop network update the other device on this device…, and ru is also ungrammatical (на устройстве другое устройство). Fix: append the How line only when device_name is set.

I2-4 · MINOR · source/code, not counted · ur RTL. {device} is isolated on its own, so lop network update {device} paints as two LTR islands in RTL order. I passed the real pipeline output (isolateArguments + isolateSigils) through fribidi --rtl, and it shows …MacBook-Pro lop network update ﺮﭘ ﺲﺋﺍﻮﯾﮈ ﺱﺍ visually. The logical and copy order is correct, and an RTL reader still meets the command first. The string alone can't fix this, because the guide bans manual bidi controls. Fix: pass the whole command as one {command} argument, so it becomes one FSI island.

Out of scope for i18n: check-strings run against the current origin/main reports allowance growth in image-group-lightbox.tsx and sound-batch.tsx. That is base drift: main paid those entries down after the merge-base. It is not in this delta, and CI's merge-base run decides it.

Receipts and binding

Receipts, one per locale, are in /home/ec2-user/.local-operator/sessions/03cedf51c7f4/scratchpad/i18n-1880-r2/receipts/:

receipt sha256 ui.canvas ui.chat.attachments
03cedf51c7f4-1880r2-es.json 5ff781b2… clean —
03cedf51c7f4-1880r2-fr.json 1fbb029a… clean clean
03cedf51c7f4-1880r2-hi.json b7b20fe4… clean-with-findings (1 nit) —
03cedf51c7f4-1880r2-ru.json 5c6b3d9f… clean —
03cedf51c7f4-1880r2-ur.json 9abe4e4d… clean clean
03cedf51c7f4-1880r2-vi.json 59f9b6c1… clean clean
03cedf51c7f4-1880r2-zh-cn.json 48047a35… clean-with-findings (2 minor) —

The exact bind lines and translator-provenance lines are in …/i18n-1880-r2/COMMANDS.md (bind.sh, translator.sh). I validated them on a scratch copy of the ledger, receipts and catalogues:

  • All 10 bind lines were accepted (status audited, earlier audits superseded), and so were all 10 --update-translator lines.
  • ledger-check on the copy then reported 0 findings out of 1113 entries.
  • Negative control: a one-byte edit to es/ui.canvas makes its line refuse (does not name the target bytes on disk). A wrong --result clean on hi also refuses. In both cases the ledger stayed byte-identical.

Bind against the bytes at 79ae385. Applying I2-1/I2-2 would change those bytes and need another delta audit, so they stay deferred.

The worktree was not written to (git status clean).

@damianvtran

Copy link
Copy Markdown
Owner Author

QA report — round 2

Verdict: PASS. All round-1 FAIL rows (Q1-1, Q1-2) and every MUST FIX row I could observe now pass on the built app at 79ae38539. Round 2 found no new BLOCKER or MAJOR. There are three non-gating findings (two MINOR, one NIT) and one pre-existing defect, which a same-head control confirms is old behaviour.

Context

  • Tester: qa-tester (lopdev), independent. Served model: anthropic/claude-opus-5-5, no fallback.
  • Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ 79ae38539282a0c4496aaa6f63481f967f83e207 · Round: 2 · Scope: a4462e721..79ae38539.
    • The worktree was used read-only. HEAD was 79ae385 and src/i18n were clean before and after every build and run.
    • The PR head on GitHub is still 79ae385.
  • Targeted tests:
    • Command: node --test scripts/{remote-files,attachment-url,session-files-hook,file-blob-url,library-card,remote-sound-row}.test.mjs, under run_bounded.
    • Result: # tests 82 # pass 82 # fail 0 in 5.6 s.

Build provenance: AFTER is 79ae385.

  • Worktree src/i18n were clean at 16:48:15Z and again at 16:49:03Z.
  • Build: electron-vite build --outDir dist/rfp/after-r2/out, 47.3 s, rc 0.
  • Build env: dummy VITE_{GOOGLE,MICROSOFT}_* values, as the capture rig's RIG.md describes.
  • I did not rebuild BEFORE. This round's controls are same-head local-session rows (see below).

Rig

  • The same headless Electron 45.1.0 + Xvfb rig as round 1, with isolated HOME/config, CMUX_*/XPC_FLAGS stripped and synthetic ids. run_bounded was 3000 MiB per run. There were three runs, and each was reaped (kill -0 fails afterwards).
  • Fixture additions in the QA copy (none of this ships):
    • a path-backed remote song.wav (R1-1);
    • a real budget.xlsx;
    • a 40 MiB big-notes.md row (R1-4);
    • per-key 504 codes (attachment_fetch_pending vs attachment_owner_unreachable);
    • a local session with a path-backed local-song.wav, used as a same-head control.
  • Main-process IPC logger. Every ipcMain invoke handler (131) was wrapped through the --inspect port. shell.showItemInFolder, openPath and openExternal were recorded instead of run.
    • Negative control: a deliberate window.api.readFileBytes('/etc/hostname') was logged as read-file-bytes /etc/hostname, so a zero in this report is a real zero.
  • Decoys. The viewer's own HOME held ~/.local-operator/sessions/remote/scratchpad/{song.wav,notes.md} at the owner's display paths. notes.md contained "LOCAL decoy notes". Neither was ever read or shown.
  • Disclosure. Run 2 (about 2.5 min) started while another session's temporary-chats Electron was live. My launch script's guard did not match its process name. Available memory was 6.8 GB, and nothing was OOM-killed.

Frames (64 PNGs, light + dark): https://github.com/damianvtran/local-operator-ui/tree/evidence/1880-qa-r2/frames

In the table, request lines are fixture hit-log entries (ms after action · method path → status). "IPC" means the main-process logger.

Matrix (AFTER = 79ae38539)

# Ledger Surface Action Actual Result
N1 R1-1 Files row, remote path sound song.wav Idle, then press Play from lop-remote-node-2, then Play Idle: no request. Press 1: +10 · GET …/files/000000000000000e/content → 200 and IPC read-file-bytes ×0. Press 2: play() on a blob: holding 66 194 B audio/wav (the content bytes), which runs 1.5 s to 0:01/0:01. The decoy at the owner's path on this disk was never read. PASS
N2 R1-1 Canvas, remote path sound Row menu → Show in canvas → Play from → Play 1 content GET → 200, read-file-bytes ×0, and it plays. After a prior row play the canvas can say "This sound could not be played" (Q2-4, pre-existing). PASS
N3 R1-2 Canvas open/close ×10, remote doc notes.md Show in canvas → close tab, 10 times, then GC 10 content GETs. 0 blobs created for the doc. The 10 createObjectURL calls were all audio/wav from the tone row remounting as the panel returned, and 10 were revoked; the one left live is the mounted tone row. JS heap after GC went 48.8 → 50.8 MB, and the largest renderer RSS went 325.2 → 332.5 MB. PASS
N4 R1-2 Same, song.wav with load and play each cycle 10 cycles 20 creates and 20 revokes (10 song + 10 tone remount), live = the mounted tone row only. Heap 51.9 MB, renderer 336.4 MB. PASS
N5 R1-2 Same, budget.xlsx 10 cycles 0 xlsx blobs (it uses base64 content). 9 content GETs for 10 opens. Heap 56.8 MB, renderer 345.6 MB, app tree 1441.6 MB. IPC read-file-bytes ×0 across all 30 cycles. PASS
N6 R1-3 App reload with 4 remote canvas tabs CDP Page.reload with notes.md, budget.xlsx, render-2.png, song.wav open All 4 tabs were restored. notes.md re-fetched (content → 200) and rendered with its read-only caption. render-2.png painted. song.wav was idle at "Play from…". budget.xlsx kept its in-memory buffer, matching the close toast's "kept until the app quits". There were no blob: paths and 0 read-file-bytes. PASS
N7 Q1-1 Files header, remote session List the header buttons Documents view, files 16, Variables view, New file, Open file from disk, Close canvas, Mentioned, Filter has no Open folder. The count line reads 16 files · [Monitor] On lop-remote-node-2. The shell log was empty. Control: a local session's header on the same build shows Open folder. PASS (round-1 M21 FAIL fixed)
N8 Q1-2 /library Navigate there and idle 26 s, including two theme captures 1 list request and 4 thumbnails (la_…3 → 404 glyph). 0 content/waveform requests. The first press on the remote tone's Play from lop-remote-node-2 sends +5 · …/la_0000000000000004/content → 200 and …/waveform → 404 (the fixture has no library waveform). The control then becomes Play remote-tone.wav. PASS (round-1 M22 FAIL fixed)
N9 U1-1/D1-2/D1-3 Transcript image tile pending failNext(img2, 99) → open the session The tile keeps its 117×78 box with a centred spinner. "Fetching from lop-remote-node-2…" appears only as aria-label/title (Q2-2). PASS / Q2-2
N10 U1-1 Transcript retryable → Retry Wait for the backoff → press The tile shows a ↻ glyph with a visible Retry caption. Its name is "Image 2 lop-remote-node-2 isn't answering. Try again when it's online. Retry". The press shows the pending spinner box, not an empty box (+11 · 504, +2015 · 504). After failNext(…,0) and Retry, all 5 tiles decode. PASS
N11 U1-1 Transcript audio and video pending failNext(tone/mp4, 99) Sunken wells read "Fetching from lop-remote-node-2…" next to the spinner, in both themes. PASS
N12 U1-1/D1-2 Canvas video and image pending → retryable → recovered failNext(mp4/img4, 99) → Show in canvas Pending reads "Fetching from lop-remote-node-2…" (no "Opening…"). Retryable reads "lop-remote-node-2 isn't answering. Try again when it's online. · clip.mp4 · [Retry]". After the reset, <video> reaches readyState 4, 3.2 s, 320 px wide. PASS
N13 U1-2 Download, pending ×3 failNext(file04, 3, fetch_pending) → Download POSTs 504, 504, 504, 200 at intervals of +2025, +4003, +8002 ms, with no re-click. The loading toast "Downloading render-4.png from lop-remote-node-2…" is replaced in place by "Downloaded render-4.png to downloads · Open file · Open folder · Copy file path". The landed sha256 equals the source. PASS
N14 U1-9 Success toast duration Sample at 200 ms Visible from 6.53 s to 14.66 s, which is 8.1 s. PASS
N15 U1-2/U1-6 Download, owner offline failNext(file02, 1, owner_unreachable) 1 POST → 504, with no re-POST. Toast: "lop-remote-node-2 isn't answering. Try again when it's online." PASS
N16 U1-2 Download, pending exhausted failNext(file01, 9, fetch_pending) 5 POSTs (+0, +2.1, +6.2, +14.2, +30.2 s), then "Still fetching from lop-remote-node-2. Try again in a moment." PASS
N17 Q1-5 Download onto an identical file Reload, then download render-5 / render-4 again 1 POST → 200 created:false. Toast: "render-5.png is already in downloads on this device · Open file · Open folder · Copy file path". No new file was written. Within one app session the row offers Open file instead of Download, so the case needs a reload to reach. PASS
N18 M15 (neighbour) Landed actions Row → Open folder / Open file showItemInFolder and openPath were each called with <config>/downloads/render-5.png, the landed path. PASS
N19 U1-3/Q1-3 Canvas, remote notes.md Show in canvas, then click and type "XYZ" Caption "From lop-remote-node-2 · read-only". No disk freshness strip (/disk/ matched nothing). CodeMirror contenteditable=false aria-readonly=true, and the text was unchanged after typing. The content came over the route; the decoy was not shown. PASS
N20 not addressed Canvas, remote budget.xlsx Double-click a cell, type 99, Enter, Ctrl+S, close the tab The edit is accepted and Ctrl+S gives no feedback: no toast, no write IPC, no POST. Only on close does the app say "Closed budget.xlsx. Your edits could not be saved, and are kept until the app quits — opening it again brings them back." The owner file is never touched. Q2-1 (MINOR)
N21 not addressed Two-press sound Files row, canvas, Library card Each surface needs Play from (load) and then Play. No surface fetches on view. Q2-3 (NIT)
N22 D1-1 Remote mark DOM and frames One [data-remote-file-mark] per panel, on the count line: lucide-monitor size-3 shrink-0 text-ink-dim inside text-meta text-ink-muted. No per-row glyphs. Library: one Monitor glyph on each of the 3 remote cards and none on the 2 local ones. PASS
N23 D1-4/D1-5/U1-4 Unreadable rows and menus .env, main.py, old.csv, dataset.tar, compacted.png Same entries as a readable row, with transfers [DIS] and Copy path enabled. The reason appears once as a label at the top (e.g. "lop-remote-node-2 keeps this file private"). Each row shows its reason in the status slot, and compacted.png has a type glyph in its tile. PASS
N24 U1-5 Missing row old.csv Status: "No longer on lop-remote-node-2". PASS
N25 R1-4 Remote doc over 32 MiB big-notes.md (40 MiB) menu "Show in canvas" is [DIS] with "Too large to open from lop-remote-node-2. Download it to this device instead."; Download is enabled. PASS
N26 U1-7 Older peer opts.peerUpdate=true "Update lop-remote-node-2 to see its files here." / "Run lop network update lop-remote-node-2 on this device, or update Local Operator on lop-remote-node-2, then retry." / Retry. lop network update [peer] exists (lop network update --help). PASS
N27 Q1-4 Waveform 409 not cached legacy=true → open → legacy=false → switch away and back waveform 409 + bytes 409, then on remount waveform 200 + bytes 200. PASS
N28 D1-6/U1-10/R1-6 Library remote detail Open remote-render-2 Meta reads "… · [Monitor] On lop-remote-node-2", with buttons Open · Download to this device. The asset menu has no Open file or Show in folder entries. The shell log was empty. PASS
N29 U1-8 Library remote Download Press Download to this device Loading toast "Downloading remote-render-2.png from lop-remote-node-2…", then "Saved a copy"; content?download=1 → 200. PASS (offline untested)
N30 beyond ledger Canvas viewer menus, remote image and video ⋯ Image: Save as…, Copy image. Video: Save as…. Neither offers Open file, Open folder or Open in default app. PASS
N31 M7 (neighbour) No request loop 60 s idle with the transcript fold, Files panel and 4 remote canvas tabs open (fresh launch) 34 background polls (sessions 12, asks 10, health 6, events 2, team-bridge 2, capabilities 2). 0 /files, /attachments, /content or /waveform. PASS
— R1-5 Regression tests Targeted run 82/82. PASS
— R1-7..R1-9, I1-1..I1-10 Comments and translations — Not a QA surface (I1 belongs to the i18n audit stream). The English copy above was read on screen. n/a
M1, M5, M8, M11, M16, M19, M24–M26 — Round-1 rows that the delta does not reach — — retained (a4462e7)
M2–M4, M6, M7, M10, M12–M15, M17, M18, M20–M23, M27 — Superseded by N-rows above — — re-run

Findings

Q2-1 — MINOR — A remote .xlsx accepts edits, and the refused save is only explained when the tab closes

  • Repro (N20): Files → budget.xlsx → Show in canvas. Double-click 42 and type 99, then Enter, then Ctrl+S.
    • The cell shows 99. There is no toast, dirty mark or write. The owner file never changes, and there is no IPC write or POST.
    • Closing the tab shows "Your edits could not be saved, and are kept until the app quits".
    • Frames: edited, close toast.
  • Why only MINOR: nothing is lost or overwritten, and the refusal is explained, just late.
    • It is the same class as U1-3. The acceptance reads "disabled or explained", and explaining at close meets it in a weak form.
  • Suggested fix:
    • Give the workbook viewer the same "From · read-only" caption and a read-only grid (editable:false), as markdown now has.
    • Alternatively, defer it in the thread as the coder proposed.

Q2-2 — MINOR (design to judge) — The compact pending tile shows only a spinner

  • What I saw (N9): "Fetching from …" is the tile's aria-label/title only. The 117×78 transcript tile shows a spinner without text. The wells (audio, video) and canvas do show the sentence.
  • The ledger's U1-1 asked for "a centred Spinner plus 'Fetching from …'". The coder's comment says there is no room in the tile.
  • Frame: zoom.
  • Whether this deviation meets D1-2 is the design stream's call. It does not gate on QA.

Q2-3 — NIT — Remote path sounds take two presses (N21; the coder already listed this)

  • Load, then Play, on the Files row, the canvas and the Library card. This is the deliberate trade for "no per-view mesh fetch".
  • Suggested fix: start playback automatically once the bytes land after the first press. The fetch would still only happen on the press.

Q2-4 — out of scope, pre-existing — After a row play, the same sound in a canvas tab can fail with "This sound could not be played"

  • Repro:
    1. Files row: Play from → Play, and let it end.
    2. Show in canvas, then wait about 30 s.
    3. Play from → Play.
    • Result: MediaError 2 and "This sound could not be played" (frame). With a gap of about 6 s it happens to play from the element's buffer.
  • Cause:
    • The row and the canvas share ownerId (sound:remote-file:…). audio-engine-store.toggle treats ended as resume() on the old element src, which is the row's blob: that was revoked when the row unmounted. The canvas's freshly fetched URL is never used. __qaPlay shows play() on the revoked …a4c3131c118e while the new …a9ffe3cdbb66 sits unused.
  • Why it is pre-existing:
    • Same-head control: a LOCAL session's local-song.wav (FinderPathSound, ownerId sound:path:…) reproduces it identically (frame).
    • Base 83a18c632 has the same ownerId, the per-holder cacheKey and the ended → resume path.
    • The new remote player inherits this behaviour; it did not introduce it.
  • Suggested follow-up (not this PR): toggle should call play() when url !== state.url.

Gaps I could not cover

  • Library offline sentence (U1-8 504). The fixture's library route has no 504 gate. RemoteAssetUnreachableError is covered only by the coder's unit tests.
  • Where Library "Saved a copy" lands. It uses the browser-anchor download path (pre-existing), and nothing was observable headless.
  • Real core and mesh, and RAM for large assets. As in round 1, the fixture stands in for the core wire, and the assets are under 120 KB.
  • Save as… OS dialog and clipboard read-back. Not driven headless.

@damianvtran

Copy link
Copy Markdown
Owner Author

Design review — round 2

Reviewer: designer (lopdev). Served model anthropic/claude-opus-5-5, no fallback.
Scope: a4462e721..79ae38539 (head 79ae38539282a0c4496aaa6f63481f967f83e207). I used the worktree read-only and ran no captures.
Evidence: QA round 2's frames at this head, light and dark, 1380×872 (published). Round-1 frames are the before. Contrast and geometry come from full-resolution PIL reads: WCAG relative luminance, ground = the modal pixel in the box, ink = the pixel furthest from it. "Dark / light" pairs are in that order.

Verdict: TERMINAL. All of D1-1..D1-8 are resolved. No BLOCKER or MAJOR is open. Two new MINORs and one NIT are in the delta, and none of them gates.

Round-1 findings

id was now evidence
D1-1 MAJOR resolved The count line reads 16 files · [Monitor] On lop-remote-node-2 (*-b-files-panel-top). The glyph is 12 px and is drawn as a screen. The count and glyph measure 5.48 / 6.24:1 and the device label 7.55 / 8.78:1. It is text-meta, it sits outside the sticky headers, and no row carries a mark (QA N22 DOM count). The local control (after-dark-b-files-local-session-header-control) reads 1 file with no mark, and Open folder is still there. The Library card mark is now Monitor, 12 px wide, at 6.07 / 5.82:1, the same ink as the origin glyph above it, and it no longer looks like MessagesSquare.
D1-2 MINOR resolved The Retry tile is 117 px wide (x 284–401 light), the same box as its loaded siblings. The glyph sits above a Retry caption. Caption 9.64 / 7.18:1, glyph 6.99 / 5.02:1. The border-control edge still marks it as a control (*-a-transcript-retry-tile-zoom, after-light-f-canvas-video-retryable).
D1-3 MINOR resolved The pending tile spans x 409–526 (117 px) next to a 116 px sibling, with the shared Spinner centred at x 467.5 against a tile centre of 467.5 (after-dark-a-transcript-pending-tiles). So the slot keeps its box and nothing collapses when it resolves. The canvas reads "Fetching from lop-remote-node-2…" at 5.48 / 6.14:1 (*-f-canvas-video-pending). The audio and video wells show the full sentence at 9.64 / 7.18:1: the text run is 211 px, the same as the canvas copy, so it is not truncated. See D2-2 for the video well's box.
D1-4 MINOR resolved The unreadable .env menu now has the readable row's entries in its order: Show in canvas · Download to this device · Copy path on lop-remote-node-2 · Save as…. Only Copy path is enabled. Disabled entries measure 7.55 / 8.78:1, up from 2.08 / 2.96:1 in round 1. They are still clearly secondary to the enabled entry (12.31 / 16.34:1). The reason appears once as a top DropdownMenuLabel at text-meta, 7.55 / 8.78:1, wrapping inside max-w-52. A neighbour, the over-32 MiB big-notes.md menu, has its one disabled entry at 7.55 / 8.78 and a per-entry reason at 5.48 / 6.14:1, so the reason is now dimmer than the label it explains. In round 1 it was the other way round.
D1-5 MINOR resolved (list rows); tile → D2-1 Each list row shows its reason in the status slot: .env "lop-remote-node-2 keeps this file private", dataset.tar "Too large to fetch…", old.csv "No longer on…", all at 6.07 / 5.73:1. The unreadable name is muted at 8.36 / 8.19:1, against 13.64 / 15.23:1 for readable names. The compacted.png well now shows a type glyph (10.01 / 6.94:1) instead of an empty box.
D1-6 MINOR resolved The detail meta run reads 195 B 96×64 [Monitor] On lop-remote-node-2 Not organized yet (*-e-library-remote-detail). The gaps between items are 9 / 8–9 / 10 px, the run's existing rhythm. The glyph-to-label gap is 6 px (gap-1 plus side bearings). The label shares the size's ink (8.19 / 8.36:1), and the glyph measures 5.82 / 6.07:1. The button reads "Download to this device", the same wording as the Files menu.
D1-7 NIT resolved The canvas retryable state reads "lop-remote-node-2 isn't answering. Try again when it's online." (12.31 / 16.34:1), and the offline toast uses the same sentence. The unnamed fallback key exists for when the device is not known.
D1-8 NIT resolved The success toast reads "Downloaded render-5.png to downloads", followed by Open file · Open folder · Copy file path (title 16.34:1, links 5.51:1, light). The lowercase downloads comes from the rig: folderOf() takes the path's basename, and QA's download folder is <config>/downloads (N18). On a real machine it reads "Downloads". The in-progress toast "Downloading render-4.png from lop-remote-node-2…" measures 12.31 / 16.34:1.

Ruling on Q2-2 (spinner-only small pending tile): meets D1-3, no change from design

The D1-3 spec for the 117×78 tile was to keep the box and centre the shared Spinner. I specified the sentence only for the canvas, and the wells now carry it too. A caption would not fit:

  • "Fetching from lop-remote-node-2…" is about 198 px at text-meta. I derived that from "On lop-remote-node-2" measuring 124 px for 20 characters on the count line, which is 6.2 px per character, times 32 characters.
  • That is 1.7× the tile's width. In the tile it would truncate to "Fetching fr…" or wrap to three lines. The unreadable tile in D2-1 shows how that kind of truncation reads.
  • The Retry tile gets a caption because "Retry" is a five-letter action label that fits.

The sentence stays in title and aria-label. Whether a text-only signal is needed for the flow is U1-1's call (UX), not a design gate.

New in the delta (non-gating)

D2-1 — MINOR — In tile layout, the unreadable reason truncates to the device name.

  • Frame: *-b-files-panel-top, compacted.png. The tile caption is "compact… lop-remot…".
  • Every remoteReason.* string starts with {device}, so the 117 px tile shows only the start of the device name. That still doesn't say why, which was the D1-5 gap.
  • The muted name and type glyph do help, and the full sentence is in the tooltip.
  • Suggestion: in tiled layout, drop the status text and let the muted name, the glyph and the tooltip carry it. Or use a device-free short form there ("Not shared", "Private", "Too large"). This is i18n's call, as noted in round 1. List rows are fine as they are.

D2-2 — MINOR — The pending video well is a 38 px pill that grows by about 204 px when the video resolves.

  • Frame: after-dark-a-transcript-audio-video-pending-wells. The well is 265×38. The resolved clip in round 1's after-dark-gh-audio-video-fold is 322×242.
  • With the owner backoff, this lasts seconds, and the jump pushes everything below it. The audio well is roughly the loaded player's height, so it's fine.
  • Suggestion: give a pending video well a reserved aspect-video box at the video slot's max width, with the same centred spinner and sentence. The images already work this way under D1-3.

D2-3 — NIT — The device name breaks at its hyphen when a line wraps.

  • The loading toast wraps to "…from lop-remote-node-" / "2…" (both themes).
  • The over-32 MiB menu reason wraps to "…from lop-remote-node-" / "2. Download it…" (*-c-menu-bignotesmd).
  • Suggestion: where the string is built in JSX, wrap {device} in whitespace-nowrap. Otherwise accept it.

Not judged / out of scope

  • Missing toast frames. after-dark-d-download-success-toast and both *-d-download-already-there-toast frames show no toast, so they were captured after it dismissed. I judged the success toast from the light frame, which uses the same component. I judged "is already in {folder} on this device" from QA N17's text and the catalogue, not from a frame. This is non-gating, because the copy is the round-1 Q1-5 wording.
  • Toast loading glyph, out of scope — pre-existing. It measures 3.14 / 2.67:1 and is the shared toast's default loader. The toast text carries the state.
  • Over-32 MiB rows have no Save as…. This is deliberate (a relay read can't exceed the window), and Download is offered. Not a design finding.
  • Already open for the next wave, not repeated here: R2-1 / Q2-1 (the remote xlsx accepts edits) and Q2-3 / R2-4 (two-press sound).

@damianvtran

Copy link
Copy Markdown
Owner Author

UX review — round 2

Verdict: TERMINAL. U1-1 through U1-10 are all fixed at 79ae38539. Round 2 found no new BLOCKER or MAJOR. There are two new MINOR findings and two NITs; none of them gates. Q2-3 is ruled on below.

Context

  • Reviewer: ux-reviewer (lopdev). Served model: anthropic/claude-opus-5-5, no fallback.
  • Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ 79ae38539282a0c4496aaa6f63481f967f83e207 · Round: 2 · Scope: a4462e721..79ae38539. I used it read-only, and the PR head on GitHub is still 79ae385.
  • Evidence base:
    • QA round 2's 64 light/dark frames of the built app at this head (evidence/1880-qa-r2) and its timings (N1–N31).
    • The delta's code paths, cited below.
    • Pixel reads on the menu frames for the muted-name check.
  • No live run of my own. This round's brief does not allow captures. Interaction claims about timing come from QA's measured rows, and claims about focus come from the component structure I cite. Because of that, the brief's rule (a new MAJOR needs a frame) caps U2-1 at MINOR.
  • Already open for the next wave, so not repeated here: R2-1 (remote xlsx is editable and saves are refused silently), Q2-2 (the compact pending tile shows only a spinner), and Q2-3/R2-4 (two-press sound; ruling below).

Round-1 findings

id r1 sev Round 2 Evidence
U1-1 MAJOR fixed The pending state now starts at the first 504 and stays through a Retry. QA N9/N10 measured the press going to the spinner box (+11 · 504, +2015 · 504), not to an empty box. The retry tile now has a visible Retry caption, and the audio and video wells say "Fetching from lop-remote-node-2…". Frames: retry tile → after the press, wells. Canvas image/video show "Fetching from…" and then a retryable state (N12). Residue: the tile's text appears only in its tooltip, which is Q2-2 and already open. Keyboard focus is lost on Retry, which is U2-2.
U1-2 MAJOR fixed The loading toast is replaced in place by the outcome. On a pending 504 the app re-POSTs on its own at +2/+4/+8 s and the file lands without a re-click (N13). Offline sends one POST and shows the shared sentence (N15). An exhausted pending ends with "Still fetching… Try again in a moment." (N16), which is the right wording because the owner did answer. Frames: loading, success (the dark "success" frame was captured after the toast had gone; the light one shows it), offline. "to downloads" in lower case is the fixture's <config>/downloads fallback. Against a real core the file lands in ~/Downloads and the toast reads "Downloads" (design §3).
U1-3 MAJOR fixed (md/html/code/csv) A remote text file now opens read-only with "From lop-remote-node-2 · read-only". CodeMirror is contenteditable=false, typing changes nothing, and the disk-freshness strip is gone (N19, frame). Markdown shows its source rather than rendered text, which matches the existing read-only attachment preview, so that is consistent. The xlsx residue is R2-1, already open. The failure states of this new surface are U2-1.
U1-4 MINOR fixed The reason now sits in the status slot, and the row button's text contains it, so a keyboard user gets it without hover. Unreadable names are muted: pixel reads give 194,188,175 for .env, main.py, old.csv and dataset.tar, against 241,238,230 for readable names. compacted.png has a type glyph. The menu opens with the reason, transfers are disabled, and Copy path is enabled (frame). Residue: long reasons are truncated, which is U2-3.
U1-5 MINOR fixed The missing row reads "No longer on lop-remote-node-2" (N24).
U1-6 MINOR fixed One sentence, "{device} isn't answering. Try again when it's online.", is used on the tile, in the canvas, on the sound line and in the toasts (N10/N12/N15). Residue: two newly added surfaces use it for every 504 (U2-1).
U1-7 MINOR fixed The second line names lop network update lop-remote-node-2 (frame), and the verb exists in core (N26). See U2-4 for a NIT.
U1-8 MINOR fixed The loading toast "Downloading remote-render-2.png from lop-remote-node-2…" was observed (N29). The offline 504 → shared sentence is verified from code and the coder's unit test only, because the fixture has no Library 504 gate (QA gap).
U1-9 NIT fixed The receipt toast was measured visible for 8.1 s (N14).
U1-10 NIT fixed The detail meta shows "[Monitor] On lop-remote-node-2", and the button reads "Download to this device" (frame).

Flows not moved by the delta (sticky sections, sort and filter, and row-menu focus return) are retained (a4462e7).

Ruling on Q2-3 (two-press remote sound)

Not acceptable as the end state. Fine to ship as non-gating MINOR now; fix it in the next wave beside R2-1.

  • The rule doesn't need the second press. "No bytes on view" forbids fetching because a row was shown. The first press is already the user's explicit request to fetch, so playing as soon as the bytes land breaks no rule.
  • The label promises something the press doesn't do. The control reads Play from lop-remote-node-2. After the press the user gets a stopped player at 0:00 / 0:00 (frame), and nothing plays.
  • A keyboard user is hit harder. The button they pressed unmounts (audio-document-player.tsx:209 setArmed → RemoteSoundWait → SoundPlayer), so focus drops to the document and they have to Tab back to find the new Play button. The Library card has the same armed-button swap.
  • Why it isn't MAJOR: for a pointer user the second control appears where the first one was, nothing is lost, and the no-fetch rule is honoured.
  • Fix: on an armed press, call play() once the blob is ready (Electron's default autoplayPolicy is no-user-gesture-required, and the press is a gesture anyway), and move focus to the player's play/pause control.

New findings (round 2)

U2-1 — MINOR — The new remote text preview and remote path sound treat every 504 as "offline", have no backoff and no Retry, and the text preview still says "Opening…"

  • Where it shows (all new in b1df80525):
    • Canvas → a remote notes.md while the owner is slow or asleep.
    • Files → song.wav → Play from, under the same conditions.
  • Code path:
    • fetchRemoteFileBlob throws AttachmentOwnerUnreachableError on any 504 (remote-file-transfer.tsx:80). It does the same for attachment_fetch_pending, which Download now tells apart (U1-2).
    • useFileBlobUrl's remote branch maps any 504 to the offline sentence (use-file-blob-url.ts:280).
    • Neither path retries. The failure state is a plain FileViewerState with no action (artifact-text-preview.tsx:152-160) or a bare <span> (audio-document-player.tsx:240-245).
    • The text preview's loading line is still ui.common.viewer.opening (:151), although D1-2 changed canvas wording to "Fetching from {device}…" and image and video now use it.
  • Effect:
    • A real core can hold a cold fetch for its 20 s wait and then answer fetch_pending while the owner is online. The user is told "lop-remote-node-2 isn't answering. Try again when it's online.", which is wrong.
    • There is nothing to press. They have to close and reopen the tab, or collapse and re-expand Sounds.
    • Canvas image and video in the same session have a Retry and a backoff (N12), so the experience is inconsistent within one session.
  • Why only MINOR: QA didn't drive a 504 on these two surfaces, and I may not capture one, so there is no frame. It is the same class as U1-1/U1-2, and a frame against a real core would justify raising it.
  • Fix:
    • Reuse ATTACHMENT_RETRY_DELAYS_MS with the pending state ("Fetching from {device}…").
    • Branch on the 504 body code the same way downloadRemoteFile does.
    • Give both failure states a Retry action.

U2-2 — MINOR — Pressing Retry on a transcript tile drops keyboard focus

  • Step: Tab to a retryable tile and press Enter.
  • Code path: source.retry puts the source into resolving.pending, and canonical-image.tsx:115 then renders PendingAttachment, a non-focusable <output>, in place of the button. Focus falls to <body>. When the backoff ends (about 30 s, QA M3), the Retry tile comes back without focus.
  • Expected: focus stays in place. The Files panel already does this for its own Retry (refocusRetry, finder-files-view.tsx). For example, keep the tile as a focusable element while it is pending (aria-busy), or restore focus to it when it settles.
  • Note: this existed at a4462e721 (the button became a bare box). The delta kept the swap, so I'm filing it as MINOR, not a regression.

U2-3 — NIT — Long unreadable reasons are cut, and the actionable part is cut first

  • main.py reads "Lives in lop-remote-node-2's workspace. Ask the session to copy it to…". On a tile, compacted.png reads only "lop-remot…".
  • The full text is in the tooltip (hover), in the row's accessible name, and in the toast on Enter, so you can recover.
  • Optional: use a shorter status form ("In lop-remote-node-2's workspace") and keep the advice in the menu label and tooltip.

U2-4 — NIT — The older-peer command is plain prose

  • lop network update lop-remote-node-2 runs inline in the sentence. It isn't set as code and can't be copied with one click.
  • Optional: use a mono span and a Copy affordance, as other command hints in the app do.

Noted, outside the delta (not filed): in the Files panel, a thumbnail that is pending or offline (render-2 in this frame) shows the same File glyph as an unreadable row, with the status "In session". The only change to finder-thumbnails.ts was a comment. This is a candidate for the same pending treatment as U1-1, in a later PR.

For the next wave (non-gating)

  • Q2-3 (ruled MINOR above) and U2-1 touch the same two remote read surfaces as R2-1's read-only work, so one coder pass covers all three.
  • Fold in U2-2 if it fits; U2-3 and U2-4 are optional.

- R2-1: a peer-held workbook opens read-only. No editable cell, no autosave,
  no Ctrl+S, and the "From <device> · read-only" caption the remote text
  viewer uses. Its save could never reach the owner and failed silently.
- R2-2: useFileBlobUrl releases only a reference this holder took. A holder
  that unmounted mid-fetch spent another holder's reference on the shared
  remote key and revoked the URL that holder was rendering.
- R2-3: the inline remote sound row refuses rows over the relay window with
  the existing "Too large to open from <device>" sentence, not a failing press.
- U2-1: the remote text viewer and the remote path sound now use the
  transcript's 504 backoff, show "Fetching from <device>…" while waiting,
  and offer Retry. Main forwards the content read's 504 code, so
  attachment_fetch_pending reads "still fetching" and only an unreachable
  owner is called offline.
- U2-2: Retry on a transcript tile or well keeps keyboard focus through the
  wait and back.
- D2-1: tiles no longer paint the truncated unreadable reason; it stays in
  the tooltip and the accessible name.
- D2-2: a pending transcript video reserves a 16:9 box at the player's
  240 px ceiling.
- D2-3: device names stay on one line in the download toast and menu reasons.
- I2-3: the "lop network update" line shows only with a real device name.
- R2-5: JSDoc blank line.

No catalogue bytes change; every string is an existing key.
@damianvtran

Copy link
Copy Markdown
Owner Author

Remediation — round 2

Implementer: coder (lopdev), served model anthropic/claude-opus-5-5, no fallback.
Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ a1bc2437a7ad448175554a37e476d0eb2768eb61 · Round: 3 · Scope: 79ae38539..a1bc2437a
Commits: 4f915a3b8 is the one code commit. a1bc2437a binds the i18n ledger and touches only i18n/ledger.json and i18n/receipts/03cedf51c7f4-1880r2-*.json.
Catalogue bytes: git diff 79ae38539..HEAD -- src/i18n/catalogues is empty. Every string uses an existing key.

Dispositions

id disposition where / regression test
R2-1 (=Q2-1) MAJOR fixed 4f915a3b8. A remote workbook is read-only: every column is editable: !readOnly, saveChanges returns early, there is no Ctrl+S listener (the key is no longer preventDefaulted), and the grid shows the "From {device} · read-only" caption in the remote text viewer's slot and classes. spreadsheet-preview.tsx. remote-viewers-round2.test.mjs uses the real viewer and the xlsx parse, and records the grid's props. "R2-1: a REMOTE workbook is read-only…" fails on 79ae385 and passes here. Its LOCAL control (editable, no caption) passes on both.
R2-2 MINOR fixed 4f915a3b8. Both branches set retained only when retain returns a URL, and cleanup releases only then. use-file-blob-url.ts. file-blob-url.test.mjs "R2-2: a holder that leaves mid-fetch…" uses the reviewer's scenario: A lands, B unmounts mid-fetch, A's URL stays live, and create/revoke balances. Fails on 79ae385.
R2-3 MINOR fixed 4f915a3b8. FinderRemoteSound checks remoteFitsWindow: over 32 MiB it shows remoteTooLargeText and offers no Play. audio-document-player.tsx. remote-sound-row.test.mjs "R2-3…" checks the sentence, that there is no Play control and that nothing is fetched. Fails on 79ae385.
U2-1 MINOR fixed 4f915a3b8. (a) Main forwards the content read's declared 504 code (refusal, the notification_sounds.upload pass-through, extended to sessions.files.content). (b) useFileBlobUrl's remote branch and fetchRemoteFileBlob(…, {delays}) retry on ATTACHMENT_RETRY_DELAYS_MS. (c) While waiting, the text viewer shows "Fetching from {device}…" instead of "Opening…", and the sound shows its existing wait. (d) At the end, attachment_fetch_pending reads "Still fetching from {device}. Try again in a moment." (existing remoteDownloadPending). Any other 504 reads the shared offline sentence. (e) Both surfaces offer Retry (ui.canvas.files.retry), which reads again. Action callers (Save as, Copy) keep a single attempt by default. desktop-media.ts, use-file-blob-url.ts, remote-file-transfer.tsx (RemoteFetchPendingError subclasses AttachmentOwnerUnreachableError, so existing retryable callers are unchanged), artifact-text-preview.tsx, audio-document-player.tsx. Tests failing on 79ae385: remote-viewers-round2 ×2 (text: pending copy, still-fetching + Retry reads again; offline + Retry), remote-sound-row "U2-1…" (5 attempts on the real 2/4/8/16 s schedule under mock timers, then still-fetching, then Retry plays), file-blob-url ×2, remote-files ×2, notification-sound-main "a peer file's content read forwards its 504 code".
U2-2 MINOR fixed 4f915a3b8. A Retry press sets retried, and the wait (tabIndex=-1, aria-busy) and the next Retry control take focus on mount (preventScroll). This covers image tiles and the audio and video wells. attachment-frame.tsx, canonical-image/-audio/-video.tsx. remote-viewers-round2 "U2-2…" uses the real CanonicalImage and resolver: it runs the backoff to exhaustion, focuses Retry, clicks, checks that focus is on the pending tile, exhausts again, and checks that focus is on the Retry tile. Fails on 79ae385.
D2-1 MINOR fixed 4f915a3b8. A tile no longer paints the unreadable reason. It stays in the tooltip, and an sr-only run keeps it in the button's name (U1-4). List rows are unchanged. finder-files-view.tsx. No dedicated test; the existing finder-reveal-scroll and canvas-attachment-locale tests pass.
D2-2 MINOR fixed 4f915a3b8. A pending transcript video shows aspect-video w-[427px] max-w-full, which is 16:9 at the player's max-h-[240px] ceiling, with the spinner and the sentence centred. attachment-frame.tsx (video prop), canonical-video.tsx. Not captured this round (no Xvfb); the geometry should be checked on a rendered frame.
D2-3 NIT fixed 4f915a3b8. keepDeviceWhole() wraps the device's run in whitespace-nowrap in the loading toast, the menu reason label and the disabled-entry sublabel. The translated sentence is not split or changed. remote-file-transfer.tsx, remote-file-actions-menu.tsx. remote-files.test.mjs's download test now checks the nowrap span. Fails on 79ae385.
I2-3 fixed 4f915a3b8. The "lop network update …" line is appended only when device_name is set. remote-files.ts. remote-files.test.mjs "I2-3…" fails on 79ae385.
R2-5 NIT fixed 4f915a3b8. The blank line is removed. library-model.ts
U2-3 NIT deferred — a shorter status form needs new copy, and catalogue bytes are frozen for the clean audit. Next copy pass.
U2-4 NIT deferred — the command sits inside the translated remotePeerUpdateHow sentence. Setting it in <code> means splitting the ICU string per locale, which also hits I2-4's bidi order, so it needs a re-audit. Next copy pass.
I2-1, I2-2 deferred — applying them would change the clean audited bytes. Next copy pass.
I2-4 deferred — the ur bidi order for the command needs an ICU argument change in every locale, so a re-audit. Next copy pass.
R2-4 / Q2-3 deferred — accepted by the reviewer and UX. No bytes may be fetched on view (Q1-2), so the first press is the explicit fetch.
Q2-2 no change — design round 2 resolved D1-3. The 117 px tile has no room for the sentence, which stays in its title/aria-label.
Q2-4 deferred — out of scope, pre-existing. It reproduces on a local session's path sound at the same head (QA's control).

i18n binding (a1bc2437a)

I ran the 10 section-A lines and then the 10 section-B lines from 03cedf51c7f4/…/i18n-1880-r2/COMMANDS.md one at a time with the repo's own node scripts/i18n/ledger-assemble.mjs. All 20 exited 0. I did not execute bind.sh or translator.sh. Before binding, the receipt sha256s matched COMMANDS.md.

before: ledger-check: FAILED — 30 finding(s) across 1113 ledger entr(ies)
after:  ledger-check: ok — 1113 entr(ies) complete, fresh and independently audited
generate --check: byte-identical · check-duplicates: ok (0 duplicate keys) · context-parity: ok

check-strings. Run locally against origin/main 5033f64, it reports 3 "allowance grew" ids, in image-group-lightbox.tsx and sound-batch.tsx.

  • They are main paying that debt (feat(i18n): key the generation card labels and sound-batch states (S16f) #1876 removed them), not this branch. This branch never edits i18n/literal-allowance.json or sound-batch.tsx.
  • In the merge tree (git merge-tree --write-tree HEAD origin/main, clean), both files are byte-identical to main's: blobs 51e9b8a… and 0f35a98….
  • This branch's lightbox hunk adds a RetryableAttachment element and no literal.
  • So I made no change and did not merge main. This is inferred from the merge tree; check-strings has not been run on it. CI's Language Coverage on the merge ref settles it.

Targeted test evidence

Head a1bc2437a7ad448175554a37e476d0eb2768eb61. Each file run alone under run_bounded.py --timeout 600 --memory-mb 3000 -- node --test scripts/<file>:

file pass fail run
scripts/remote-viewers-round2.test.mjs 5 0 rc=0 wall=2.7s
scripts/file-blob-url.test.mjs 22 0 rc=0 wall=1.5s
scripts/remote-sound-row.test.mjs 5 0 rc=0 wall=1.6s
scripts/remote-files.test.mjs 22 0 rc=0 wall=1.5s
scripts/notification-sound-main.test.mjs 8 0 rc=0 wall=3.8s
scripts/attachment-url.test.mjs 25 0 rc=0 wall=0.6s
scripts/chat-image-expand.test.mjs 45 0 rc=0 wall=9.1s
scripts/canvas-attachment-locale.test.mjs 1 0 rc=0 wall=2.4s
scripts/finder-reveal-scroll.test.mjs 7 0 rc=0 wall=1.2s
scripts/sound-player.test.mjs 12 0 rc=0 wall=2.4s
scripts/image-preview-decode.test.mjs 4 0 rc=0 wall=2.1s
scripts/library-model.test.mjs 14 0 rc=0 wall=1.6s
scripts/library-card.test.mjs 7 0 rc=0 wall=2.1s
scripts/session-files-hook.test.mjs 10 0 rc=0 wall=2.6s
scripts/canvas-file-freshness.test.mjs 40 0 rc=0 wall=1.8s
scripts/desktop-contract.test.mjs 56 0 rc=0 wall=44.9s

Against 79ae38539's src (this head's tests, git stash push -- src):

  • remote-viewers-round2: 4 of 5 FAIL. Only the local control passes.
  • file-blob-url: 3 of 22 FAIL.
  • remote-sound-row: 2 of 5 FAIL.
  • remote-files: 4 of 22 FAIL.
  • notification-sound-main: 1 of 8 FAIL.

Other gates:

  • tsc --noEmit passes on tsconfig.app.json and tsconfig.node.json (rc=0).
  • biome on the 20 changed files: 0 errors. The warnings are the test files' existing useTopLevelRegex pattern.
  • The pre-push gate (scripts lint, src lint, types) passed against origin/main 5033f64.

Not run here: pnpm test:affected and the full suite (CI's), and Xvfb captures.
For the round-3 streams: D2-1 and D2-2 need a rendered frame. U2-1 and U2-2 are covered in JSDOM, with the real resolver and timers.

@damianvtran

Copy link
Copy Markdown
Owner Author

Agent review — round 3

Reviewer: reviewer (lopdev), served model anthropic/claude-opus-5-5, no fallback.
Scope: 79ae385..a1bc243
Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ a1bc2437a7ad448175554a37e476d0eb2768eb61 · Round: 3 · Retest: R2-1 R2-2 R2-3 U2-1 U2-2 (+ D2-1/D2-2/D2-3/I2-3/R2-5 read)

What I reviewed: the delta round for R2-1 (MAJOR), plus the fold-ins and the new desktop-media.ts forwarding. I read the whole product diff of 4f915a3b8. For a1bc2437a I confirmed that git diff 79ae38539..a1bc2437a -- src/i18n/catalogues is empty; the i18n binding is the i18n stream's to review. Other rows are retained (79ae385).

Verification of prior findings

id verdict evidence
R2-1 MAJOR fixed spreadsheet-preview.tsx: readOnly = document.remote != null. Every column gets editable: !readOnly (L1021), and defaultColDef only sets resizable. The only path that dirties the buffer is onCellValueChanged → proposeBuffer/setHasUserChanges(true) (L972-982). AG Grid fires that only for an editable cell, so autosave (L910-919, gated on hasUserChanges) and the close-time flush (document-buffers.ts:672, gated on dirtyOf) have nothing to write. saveChanges also returns early on readOnly (L850). The Ctrl+S listener is not registered (L924). The repo has no other Ctrl+S, menu accelerator or toolbar saveBuffer caller for spreadsheets: grep -rn 'saveBuffer(' src/renderer shows only code-editor, wysiwyg and the owner's close flush. The caption reuses ui.canvas.files.remoteReadOnly. The test discriminates: 79ae385 hard-coded editable: true, which fails every(col => col.editable === false).
R2-2 fixed use-file-blob-url.ts: retained is set only from a non-null retain(key), in both the remote and the disk branch, and cleanup does if (retained) release(key). The cached branch retained before it returns, so its unconditional release is correct. publish keeps the incumbent, and the retain that follows always joins it, so a landed holder always counts itself.
R2-3 fixed FinderRemoteSound returns remoteTooLargeText before the Play button when !remoteFitsWindow(remote). Both useState calls sit above the early returns, so hook order is unchanged.
U2-1 fixed The remote branch of useFileBlobUrl and fetchRemoteFileBlob(…, {delays, signal}) retry 504s on ATTACHMENT_RETRY_DELAYS_MS, and unmount clears the timer (clearTimeout(wait) / signal.aborted). Only refusal.code === "attachment_fetch_pending" reads as "still fetching"; every other 504 keeps the offline sentence. Retry re-runs the read: the text view bumps attempt in its deps, and the sound row remounts with key={attempt}, which works because failures are never published. Retry is offered only on owner-unreachable. Core side checked: ~/local-operator/.worktrees/remote-files-parity/local_operator/server/routes/desktop_sessions.py:2232-2246 raises a 504 whose envelope(code=error.code) is attachment_fetch_pending or attachment_owner_unreachable.
U2-2 fixed (wait and back) retry() sets waitingFor before bumping cycle (use-attachment-url.ts:467-471), so the first frame after the press is PendingAttachment with focusOnMount, never the unfocusable reserved box. Exhaustion remounts RetryableAttachment with focusOnMount. useFocusOnMount runs once per mount with preventScroll. See MINOR-1 for the success path.
D2-1 / D2-2 / D2-3 / I2-3 / R2-5 read; no code defect Tile reason: sr-only on tiles only. Video wait: aspect-video w-[427px] max-w-full. Device name: keepDeviceWhole uses an indexOf on the already-translated sentence and leaves it unchanged when the name is not found. Peer-update line: printed only when device_name is set. Blank line removed. Design and UX own the rendered checks.

desktop-media.ts forwarding: conforms to precedent

The change only widens the op guard to notification_sounds.upload || sessions.files.content. Everything the renderer receives goes through the same shaping as the precedent:

  • refusal.code is the envelope's string code, slice(0, 64);
  • params go through scalarParams: at most 8 keys, scalar values only, strings ≤128;
  • the closed code arm still requires isDesktopRefusalCode.

The renderer only compares refusal.code to a constant (remoteReadFailure, fetchRemoteFileBlob). It never paints it, and it never reads params for this op. This adds no new category of backend text: detail, a backend sentence ≤512 chars, already reaches the renderer for every op (pre-existing). Test: notification-sound-main.test.mjs "a peer file's content read forwards its 504 code".

Findings

  • MINOR-1: U2-2 covers the wait and the Retry tile, but not success. When a retried tile then resolves, canonical-image.tsx:208 renders ImageAttachment with no focus hand-off, so keyboard focus falls to <body>. The text viewer's Retry (artifact-text-preview.tsx:139) and the sound row's Retry (audio-document-player.tsx:271) also lose focus on the next state. Fix: pass focusOnMount={retried} through to the ready tile's expand control. Non-gating; defer.
  • MINOR-2: desktop-media.ts:1226. sessions.files.content also forwards params (e.g. device), which no renderer reader uses. Fix: forward code only for this op, or shape-check the code (/^[a-z0-9_.]{1,64}$/). It matches the precedent and adds nothing beyond detail, so it does not gate.
  • NIT-1: The sound row's Retry is a bare <button className="text-ink hover:underline">, while the text viewer uses <Button variant="secondary" size="sm">, so the same action has two looks. Design is TERMINAL; record only.

out of scope — pre-existing: use-attachment-url.ts:450 still calls release(digest) unconditionally in cleanup, the same shape as R2-2. A row that unmounts during its backoff, after a sibling row on the same digest has landed and retained, spends that sibling's reference. This PR does not touch that file.

Tests (delta files only, run here at a1bc243, each run_bounded.py --timeout 300 --memory-mb 3000 -- node --test scripts/<f>)

file tests pass fail rc wall
remote-viewers-round2.test.mjs 5 5 0 0 2.7 s
file-blob-url.test.mjs 22 22 0 0 1.7 s
remote-sound-row.test.mjs 5 5 0 0 1.8 s
remote-files.test.mjs 22 22 0 0 1.9 s
notification-sound-main.test.mjs 8 8 0 0 3.8 s

I did not re-run the "fails on 79ae385" claims, because that would need a second checkout. For R2-1, R2-2 and R2-3 I derived from the diff that the old code fails each new assertion (hard-coded editable: true, unconditional release, no size gate).

Verdict: clean. No BLOCKER or MAJOR remains, and R2-1 is fixed. This round is terminal: MINOR-1, MINOR-2 and NIT-1 are deferred in this thread and need no new commit.

@damianvtran

Copy link
Copy Markdown
Owner Author

QA report — round 3

Verdict: PASS.

  • R2-1, the round-2 MAJOR, is fixed on the built app at a1bc2437a.
  • A same-input control on the 79ae38539 build still reproduces it.
  • Every other retest row passes too: R2-2, R2-3, U2-1, U2-2, D2-1, D2-2, D2-3, I2-3, and the 504-code forwarding.
  • There is no BLOCKER and no MAJOR.
  • There are two non-gating findings: Q3-1 (MINOR) and Q3-2 (NIT, for design to judge).

Context

  • Tester: qa-tester (lopdev), independent, session 4c0fc6cbadb2. Served model: anthropic/claude-opus-5-5, no fallback.
  • Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ a1bc2437a7ad448175554a37e476d0eb2768eb61 · Round: 3 · Scope: 79ae38539..a1bc2437a · Retest: R2-1 R2-2 R2-3 U2-1 U2-2 D2-1 D2-2 D2-3 I2-3
    • I used the worktree read-only.
    • HEAD was a1bc243 before and after every run, and src/i18n showed 0 porcelain lines both times.
    • The PR head on GitHub is a1bc243.
  • Build provenance (AFTER):
    • Built from a1bc243 with electron-vite build --outDir dist/rfp/after-r3/out, under run_bounded, using dummy VITE_* OAuth IDs. Wall time 42.0 s, rc 0.
    • Provenance lines were written at build start (18:13:51Z) and end (18:14:34Z): same SHA both times, and src/i18n clean both times.
    • The renderer URL during the runs was …/dist/rfp/after-r3/out/renderer/index.html.
  • CONTROL: round 2's existing dist/rfp/after-r2/out build of 79ae38539. It was not rebuilt, and I drove it with the same driver code and the same input.
  • Targeted tests (the delta's own files):
    • Command: node --test scripts/{file-blob-url,notification-sound-main,remote-files,remote-sound-row,remote-viewers-round2}.test.mjs
    • Result: # tests 62 # pass 62 # fail 0 in 5.1 s.
  • Rig:
    • I copied round 2's rig into my own scratchpad and fixed its hardcoded paths.
    • Electron 45.1.0 ran headless under Xvfb at 1380x872, with isolated HOME/config, CMUX_*/XPC_FLAGS stripped, and synthetic session IDs (c10ad…).
    • The main-process IPC logger and shell stubs from round 2 were in place.
    • Fixture additions (QA copy only):
      • big-song.wav: a path-backed remote sound listed at 40 MiB (R2-3).
      • opts.peerUpdateNoName: an older-peer page with device_name: null (I2-3).
  • Runs: there were two run_bounded runs: AFTER (1467 s) and CONTROL (80 s).
    • Each was started as a group leader and quit cleanly (drain {"stopped":1,"clean":true}).
    • kill -0 <pgid> fails for both, and no process remains in either group.
    • Disclosure: a core QA pytest storm loop (qa3876 … base-storm-NN, about 90 MB RSS per test) restarted between my "node clear" check and the AFTER launch, so the two overlapped. About 7 GB was available and nothing was OOM-killed.

Frames: 44 PNGs in light and dark, on orphan branch evidence/1880-qa-r3 (commit 07112a139). Raw link pattern: https://raw.githubusercontent.com/damianvtran/local-operator-ui/07112a13924dfde213262f1a0681f8a5e149300b/frames/<name>.png

t+ times are fixture hit-log offsets from the action that triggered them.

Matrix (AFTER = a1bc2437a; CONTROL = 79ae38539)

# Ledger Surface Action Actual Result
Q1 R2-1 Canvas, remote budget.xlsx Row menu → Show in canvas. Then: double-click the 42 cell, real key events 9,9, Enter (plus, in an earlier pass, single-click + key 8 and F2), Ctrl+S, close the tab. Open: 1 GET …/files/000000000000000f/content → 200. Caption reads "From lop-remote-node-2 · read-only". The root has data-spreadsheet-readonly="true".
Typing: double-click opens no editor (no .ag-cell-inline-editing/.ag-cell-editor). Single-click + 8 and F2 opened none either. The cells stay Relay 42 Storage 17, and with real key events the composer stays ''.
Ctrl+S: no toast. Main-process IPC after Ctrl+S held only background polls (sessions.list, asks.list, mcp.list, backend-status): no write and no POST.
Close: no toast within 2.5 s. The toast recorder's negative control did catch the "Too large…" toast on the same page.
CONTROL 79ae, same driver code: the editor opens and the cell becomes 99. Ctrl+S gives no feedback. Close shows "Closed budget.xlsx. Your edits could not be saved, and are kept until the app quits — opening it again brings them back."
PASS (MAJOR fixed)
Q2 R2-2 Same remote sound in two holders (Files row → canvas tab) failNext(song, 3, fetch_pending). Press Play from on the row, which then waits in the backoff. Show in canvas, so the row unmounts mid-fetch. Press Play from in the canvas. Content GETs: +0 504, +2.0 504 (row), +5.6 504, +7.6 200 (canvas). The row's third attempt, due at +6 s, never went out: its unmount stopped asking. The blob log shows 1 create (the canvas's audio/wav) and 0 for the abandoned row, and the canvas URL stayed live. The concurrent two-holder case is not reachable in this UI (see Gaps), so I ran the coder's R2-2: a holder that leaves mid-fetch never releases another holder's reference test, which passes. PASS (real path: sequential handoff; concurrent case: unit test)
Q3 R2-3 Files row, big-song.wav (40 MiB, path-backed) Idle. Press the name. Open the row menu. Row: "41.9 MB · Too large to open from lop-remote-node-2. Download it to this device instead." The row has no Play from control and no <audio>.
Name press: a toast with the same sentence and 0 requests to …/0000000000000011/.
Menu: Show in canvas [DIS] with that reason. Download is enabled.
CONTROL 79ae: the row offers Play from lop-remote-node-2.
PASS
Q4 U2-1 Canvas, remote notes.md failNext(notes, 2, fetch_pending) → Show in canvas. "Fetching from lop-remote-node-2…" (not "Opening…"). GETs 504, +2.0 s 504, +4.0 s 200, then the content renders with the read-only caption. PASS
Q5 U2-1 Same failNext(notes, 9, fetch_pending) "Fetching from lop-remote-node-2…" for about 30 s over 5 GETs, at gaps of 2.0, 4.0, 8.0 and 16.0 s, all 504. Then: "Still fetching from lop-remote-node-2. Try again in a moment." · notes.md · [Retry] (data-remote-text-retry). PASS
Q6 U2-1 Same failNext(notes, 0) → keyboard Enter on Retry 1 GET → 200. "Fetching…" shows, then the content within 0.2 s. PASS
Q7 U2-1 Same failNext(notes, 9, attachment_owner_unreachable) The same 5-GET backoff, then "lop-remote-node-2 isn't answering. Try again when it's online." · notes.md · [Retry]. CONTROL 79ae under fetch_pending ×9: 1 GET, then at once the offline sentence with no Retry. PASS
Q8 U2-1 Files row, remote path sound song.wav failNext(song, 2) → Play from "Fetching from lop-remote-node-2…" (spinner), then the player (Play song.wav, 0:00). GETs at +0 504, +2.0 504, +6.0 200. PASS
Q9 U2-1 Same failNext(song, 9, fetch_pending) → Play from. Then failNext 0 → keyboard Enter on Retry → Play. 5 GETs (0, 2.0, 6.0, 14.0, 30.0 s), then "Still fetching from lop-remote-node-2. Try again in a moment. · Retry" (data-remote-sound-retry). Retry sends 1 GET → 200, and the player loads and plays to 0:01 / 0:01. PASS
Q10 U2-1 Same failNext(song, 9, attachment_owner_unreachable) 5 GETs, then "lop-remote-node-2 isn't answering. Try again when it's online. · Retry". PASS
Q11 U2-1 (main) desktop-media.ts forwards the 504 code for sessions.files.content Rows Q5/Q7/Q9/Q10 The same HTTP 504 renders different sentences depending on detail.code. The renderer sees that code only through refusal, which main now forwards for this op. CONTROL: fetch_pending was told "offline". PASS
Q12 U2-2 Transcript image tile (Image 2) failNext(img2, 99) → wait for Retry → .focus() the tile → Enter Focus moves BUTTON (Retry) → OUTPUT ("Fetching from lop-remote-node-2…", tabindex=-1, data-attachment-pending) → after 5 GETs, BUTTON ("Image 2 lop-remote-node-2 isn't answering…"). Focus is never on <body> during the swaps. PASS — see Q3-1 for the success case
Q13 U2-2 Transcript image tile and audio well, Retry that succeeds failNext(img2, 0) → Enter. Audio: failNext(tone, 1) → Enter. Image: 1 GET 200, the tile becomes Expand Image 2, and focus goes to BODY. Audio: BUTTON → OUTPUT → BODY once the player loads (504, +2.0 s 200). Q3-1 (MINOR)
Q14 D2-1 Files tiles band, unreadable compacted.png DOM + zoom frames The only visible text is "compact…" (the name), plus the type glyph. The reason sits in span.sr-only (1×1 px, clip-path: inset(50%)). The button's accessible text is "compacted.png / lop-remote-node-2 no longer shares this file", and the title also carries the reason. CONTROL 79ae: the reason is in a visible <bdi>. PASS
Q15 D2-2 Transcript video well (clip.mp4, a 4:3 clip) failNext(clip, 3) → open the fold. Sample the box and the next message on every rAF, with a PerformanceObserver('layout-shift'). Pending: 424×239 (16:9) with spinner and "Fetching from lop-remote-node-2…". After +14.1 s 200: <video> readyState 4 at 320×240. The next message's top moved 961 → 965 → 964 px (≤ 4 px). 0 layout-shift entries during the load; the only entry, 0.0216, came from opening the fold, before any bytes. PASS (≈0); width note in Q3-2
Q16 D2-3 Row menu reasons and disabled-action reason Open menus for .env, main.py, old.csv, dataset.tar, big-notes.md Each label wraps over 2–4 lines at 208/240 px, but lop-remote-node-2 sits in a span.whitespace-nowrap with 1 client rect every time. PASS
Q17 D2-3 Download loading toast failNext(render-4, 3, fetch_pending) → Download The toast wraps as "Downloading render-4.png from" / "lop-remote-node-2…". The name is a single rect (nowrap). POSTs: 504, 504, 504, 200 (retained N13 behaviour). PASS
Q18 I2-3 Files panel, older peer opts.peerUpdate=true, with device_name: null and then with the name No name: only "Update the other device to see its files here." There is no lop network update line. Named: "Update lop-remote-node-2 to see its files here." / "Run lop network update lop-remote-node-2 on this device, or update Local Operator on lop-remote-node-2, then retry." PASS
— R2-1…I2-3 Delta tests Targeted run 62/62 PASS
N1–N19, N21–N31 — Round-2 rows outside this delta's retest list — — retained (79ae385)
N20 — Old xlsx row — Superseded by Q1 —

Findings

Q3-1: MINOR. A Retry that succeeds drops keyboard focus to <body> (Q13)

  • What works: U2-2 holds focus through every swap between the Retry button and the pending state (Q12).
  • What doesn't: when the retried read lands, the pending <output> that holds focus unmounts, and nothing receives focus. Retry → success is the most common Retry outcome.
  • Where it happens:
    • transcript image tile: focus should go to Expand Image 2;
    • transcript audio well: focus should go to the player's Play button;
    • the same pattern on the neighbouring canvas surfaces: the text-preview Retry and the Files-row sound Retry also end on BODY.
  • Code path: useFocusOnMount is wired only into RetryableAttachment and PendingAttachment (attachment-frame.tsx). The ready branches of canonical-image.tsx, canonical-audio.tsx and canonical-video.tsx ignore retried.
  • Suggested fix: when retried is set, pass focusOnMount to the loaded tile's primary control (the expand button or the Play button).
  • Severity: a11y polish only. Nothing is lost, and it does not gate.

Q3-2: NIT, for design to judge. The pending video box is 16:9 at full width, but a 4:3 clip loads narrower (Q15)

  • The reserved box is 424×239 and the loaded 4:3 player is 320×240. Vertical movement is ≤ 4 px and CLS is 0, so D2-2's "nothing jumps" holds.
  • The well does narrow by 104 px on load. This is the trade the code comment names: the aspect is unknown until the bytes arrive.

Out of scope, pre-existing: Q2-4 again. During Q2, after an earlier row play, pressing Play in the canvas on song.wav showed "This sound could not be played". This is round 2's Q2-4: a shared ownerId, with ended → resume on the row's revoked blob:. It has the same cause, and round 2's same-head local-session control applies. The new URL from Q2 was live.

Rig notes

  • CDP Input.insertText is not a keystroke. With focus on the read-only grid cell, a probe committed text into the chat composer. The text went through the frame's editing selection, and the Enter that followed sent "99" and "77" to the fixture (404). Real keyDown events with text left the composer empty and the cell unchanged, so the R2-1 rows use real key events only. The two probe frames are kept locally and were not uploaded.
  • A timed-out Page.captureScreenshot with a clip left the renderer viewport at 520×494 until a window resize. Frames captured in that window were discarded and retaken. Zoom crops are made with nativeImage from full capturePage() frames.

Gaps I could not cover

  • R2-2 with two holders mounted at the same time.
    • The canvas mounts exactly one keyed CanvasContent (canvas/index.tsx:1011), and the Files panel unmounts when a document tab shows.
    • useFileBlobUrl for remote-file: is reached only from those surfaces (audio-document-player.tsx:256, image-preview.tsx, video-preview.tsx, pdf-preview.tsx).
    • So no UI path I found mounts two holders of one remote key at once. Covered by the delta's unit test (pass) plus the sequential real-path row Q2.
  • Real IME composition on the read-only grid. Not driven headless. See the rig note.
  • Real core and mesh. As in rounds 1–2, the loopback fixture stands in for the core wire.
  • Canvas-tab remote sound under a long 504 run. It is the same FinderRemoteSound component as the Files row (Q8–Q10). In the canvas I observed only pending → loaded (Q2).

@damianvtran damianvtran added the ready-to-merge Full suite requested on this head - re-arm with pnpm merge-gate <n> label Nov 12, 2026
@damianvtran

Copy link
Copy Markdown
Owner Author

UX review — round 3

Verdict: TERMINAL — no BLOCKER or MAJOR. R2-1 and U2-1 are fixed. U2-2 is fixed across Retry → pending → Retry, but not Retry → success: that remaining MINOR is QA's Q3-1, deferred — terminal-clean rule; no further remediation cycle. D2-1/D2-2/D2-3 pass the narrow UX retest below. No new U finding.

Reviewer: ux-reviewer (lopdev), actual served model openai/gpt-6.1-sol. My prior round used anthropic/claude-opus-5-5; this round made no Anthropic calls. QA's existing report is attributed evidence, not execution by this reviewer.

Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ a1bc2437a7ad448175554a37e476d0eb2768eb61 · Round: 3 · Scope: 79ae38539..a1bc2437a · Retest: D2-1 D2-2 D2-3 U2-1 U2-2 R2-1

Evidence and limits

Reviewed the remediation delta read-only, the manager's unchanged request/acceptance contract (remote preview parity, honest retryable errors, remote actions disabled or explained, no per-view Library byte fetch; no push/upload or auth expansion), and QA round 3. No capture, build, installation, code edit, or new browser/rig run. I inspected QA's light/dark rendered frames at this head; timing, focus transitions and DOM geometry below are QA's measured results, cross-checked against the delta, not my own clicks.

QA identifies its built renderer as dist/rfp/after-r3/out, SHA a1bc2437a, and uses the existing 79ae38539 build for same-input controls. Its delta tests report 62/62; I did not duplicate those. There is no attached browser here. Static-inspected only, unverified independently: full keyboard walk, responsive/200% zoom, reduced motion and computed contrast. The available new evidence is the standard 1380×872 fixture viewport in both themes; this is not a claim of fresh coverage on every personalization axis.

Narrow retest matrix

ID Verdict Evidence and user-facing result
R2-1 Fixed Both-theme readonly workbook frames show “From lop-remote-node-2 · read-only” above the grid. QA Q1: double-click + real 9,9, Enter, single-click + 8, and F2 create no editor; values remain 42/17; composer remains empty. Ctrl+S causes no write and closing has no unsaved-edit toast. Same-input control at 79ae edits to 99 and warns only at close. Source: spreadsheet-preview.tsx derives readOnly from document.remote, sets column editable: !readOnly, and blocks saving/its Ctrl+S listener. The caption makes the restriction clear before an edit attempt.
U2-1 Fixed QA Q4–Q11 now discriminate pending from offline through the real desktop-media response path. Text and sound show Fetching from lop-remote-node-2… while retrying on 2/4/8/16-second gaps. Exhausted pending shows Still fetching… Try again in a moment. plus Retry; unreachable shows the offline sentence plus Retry. Keyboard activation re-reads and recovers (text within 0.2 s in Q6; sound loads and plays in Q9). The 79ae text control instead immediately mislabels pending as offline without Retry. Frames: text pending, text pending exhausted, text offline, sound pending exhausted, sound offline. The code forwards refusal.code for sessions.files.content; the preview and blob hook branch on attachment_fetch_pending.
U2-2 Partially fixed; remaining MINOR deferred (Q3-1) QA Q12 measures focus BUTTON → OUTPUT (tabindex=-1) → BUTTON, never BODY, across Retry → waiting → another failure. The pending-focus frame visibly retains the focus outline. However Q13 measures BODY after image/audio success, also noted on the text/sound neighbors. Source agrees: focusOnMount is passed to the pending and retry branches, not the loaded image/audio/video primary control. This is not fully closed; carry Q3-1 without a duplicate U ID or another cycle.
D2-1 Pass The unreadable dark/light tile now shows the type glyph and filename, not a cut-off device fragment posing as an explanation. QA Q14 confirms the full reason remains in the button's accessible text and title; it is sr-only only for tiled rows. The list-row reason is unchanged. This preserves the keyboard/screen-reader explanation while removing the visual fragment.
D2-2 Pass The full pending frame shows a video-sized well with a spinner and fetching label. QA Q15 measures 424×239 pending, 320×240 for the loaded 4:3 fixture, following-message movement ≤4 px, and 0 load-time layout-shift entries. The pending 16:9 reservation prevents the old pill-to-player vertical jump. Width narrowing by 104 px is already Q3-2 (NIT), deferred — unknown aspect before bytes arrive; no new cycle. Some “loaded” stills have the player below the fold, so geometry/timing claims come from Q15, not that cropped still.
D2-3 Pass The loading toast wraps before the complete device name; menu text likewise keeps the name together. QA Q16/Q17 records one client rect for each nowrap device span across five menus and the toast. keepDeviceWhole adds markup without changing translated sentences.

Direct neighbor: older-peer named/no-name copy (QA Q18) now omits the command when no actual device name is known; it no longer suggests the invalid literal lop network update the other device. The named peer retains round 2's actionable hint. This is I2-3's fix, not a new UX finding.

Compatibility and retained evidence

Commands run here, actual output:

  • git rev-parse HEAD and gh pr view 1880 --json headRefOid --jq .headRefOid: both a1bc2437a7ad448175554a37e476d0eb2768eb61.
  • git diff 79ae38539..a1bc2437a -- src/i18n/catalogues: empty. A Python git show byte comparison of 1,437 catalogue files found 0 changed, diff 0 bytes.
  • Explicit git show byte comparisons: use-attachment-url.ts, use-session-files.ts, library-card.tsx, library-preview.tsx, library-download.ts, and canvas-content.tsx are IDENTICAL. The download request-loop-through-receipt/actions suffix is also byte-identical; its only changed UX before that loop is the nowrap loading-toast markup retested above. library-model.ts changes only a doc-comment blank line.
  • Changed-renderer added-line scan for TODO|FIXME|lorem ipsum|Something went wrong|An error occurred: 0 hits. scripts/copy_scan.py is not present in this repository; I did not claim to run it. Visible copy was inspected in the cited frames; unchanged catalogues retain the prior independent i18n audit. This pattern scan is not a substitute for a rendered dead-end/keyboard walk.
  • git diff --check 79ae38539..a1bc2437a: exit 0, no output.

Thus round-2 download/auto-land/receipt behavior (U1-2/U1-9), missing-row wording (U1-5), Library progress/detail behavior (U1-8/U1-10), and unaffected list/search/filter/sort interactions are retained (79ae385). Changed retry wording, focus, tile status and spreadsheet interactions are superseded by the rows above rather than blanket-retained.

The existing two-press-sound MINOR and other previously deferred MINOR/NIT items remain deferred; this round does not relabel them fixed. Q3-1 is likewise deferred, not a condition on this terminal verdict. No further UX remediation cycle requested.

@damianvtran

Copy link
Copy Markdown
Owner Author

Design review — round 3

Reviewer: designer (lopdev), served model openai/gpt-6.1-sol for this round. Prior round 2 was anthropic/claude-opus-5-5; no Anthropic/model calls were made in round 3 and no role/default pins were changed.
Scope: 79ae38539..a1bc2437a (head a1bc2437a7ad448175554a37e476d0eb2768eb61). Read-only worktree; no capture, build, code edits or test-suite runs.

Verdict: TERMINAL — no design BLOCKER or MAJOR. D2-1, D2-2 and D2-3 are resolved. The new spreadsheet read-only treatment and text/sound retry states pass visual review. The already-recorded focus-on-success MINOR stays deferred; no extra remediation cycle requested.

Evidence and measurement

I inspected QA's supplied r3-{dark,light}-*.png frames, excluding explore-*, from /home/ec2-user/.local-operator/sessions/4c0fc6cbadb2/scratchpad/r3/frames; published evidence. Real interaction results below are explicitly attributed to QA round 3, not claimed as my execution.

My measurable gate used Python/PIL on the supplied original 1380×872 frames: crop → modal ground colour → ink furthest from that ground in relative luminance → WCAG contrast. Ratios below are dark / light. The resized tile zooms produce resampling overshoot, so I cross-checked against the original full frames and used only those originals for reported ratios. Raster geometry is measured from pixel transitions; live DOM geometry/timing is attributed to QA.

Delta verdicts

Retest Verdict Rendered evidence and actual measurement
D2-1 — unreadable compact tile Resolved r3-*-b-files-unreadable-tile-zoom and the original r3-*-a-transcript-video-pending-16x9 show the type glyph and muted compact…, without the useless lop-remot… second fragment. Name contrast 8.36 / 8.19:1; glyph 10.01 / 6.94:1. QA Q14 confirms the reason remains in the button's accessible text and tooltip, with the separate reason span screen-reader-only. This implements the specific D2-1 option accepted in the ledger, without adding new copy.
D2-2 — pending video box Resolved r3-*-a-transcript-video-pending-16x9 shows a reserved video-sized well rather than the old 38px pill. Original-image outer raster bounds are x 284–707, y 309–546: 424×238px painted, consistent with QA's subpixel DOM measurement 424×239. The full sentence sits under the centred spinner and measures 9.64 / 7.18:1. QA Q15 sampled the actual pending→loaded transition: next-message movement ≤4px, 0 layout-shift entries during the load. That fixes the approximately 204px vertical jump from round 2. See the accepted width trade-off below.
D2-3 — nowrap device name Resolved r3-*-d-download-loading-toast-nowrap now wraps before the complete lop-remote-node-2…, not before its final 2. The same complete-name treatment is visible in r3-*-c-menu-bignotes-nowrap and r3-*-c-menu-env-nowrap. QA Q16–Q17 confirms each device name occupies one client rect. Toast copy 12.31 / 16.34:1; unreadable-menu reason and disabled labels 7.55 / 8.78:1, retaining the contrast fix from D1-4.
R2-1 — remote spreadsheet Visual pass; functional fix corroborated by QA r3-*-e-canvas-remote-xlsx-readonly[-after-typing] visibly says “From lop-remote-node-2 · read-only” above the grid; caption 7.55 / 8.78:1. The selected 42 cell has a selection outline, not an inline editor. QA Q1 used real keyboard events, double-click and F2: the value stays 42, no editor opens, Ctrl+S produces no write, and closing gives no unsaved-edit warning. The same driver against 79ae38539 changed it to 99. The misleading editable remote workbook is addressed.
U2-1 — text and path-sound pending/retry states Visual pass r3-*-f-canvas-text-{pending,still-fetching,offline} distinguish fetching, still-fetching and offline states; the latter two include the filename and a visible Retry button. Pending text 5.48 / 6.14:1; error/still-fetching title 12.31 / 16.34:1; Retry label 13.64 / 15.23:1. The matching r3-*-g-files-song-* rows retain the filename/size above their state sentence and a separate Retry action: reason 8.36 / 8.19:1, Retry 13.64 / 15.23:1. QA Q4–Q11 supplies the actual backoff, differentiated 504 outcomes and successful keyboard retry. The loaded text neighbour retains its read-only caption.
U2-2 — Retry focus Failure/pending handoff passes; success gap deferred r3-dark-a-transcript-image-retry-pressed-focus visibly preserves a focus ring around the pending tile; strongest ring ink 7.97:1 against its immediate outside ground. QA Q12 records BUTTON → OUTPUT → BUTTON, never body, for pending→failure. QA Q13 separately records focus returning to body when the retry succeeds. That is the already-open Q3-1 MINOR, not a fresh design finding and not a reason for another cycle.

Q3-2 width trade-off: accepted/non-gating. The fixture is a 4:3 clip, not the 16:9 assumption in the round-2 ledger. QA measures the loaded player at 320×240 versus the reserved 424×239, so it narrows 104px while keeping its height. The supplied loaded frames are scrolled differently and do not independently prove transition timing; I rely on QA's rAF/CLS measurement for that claim. Keeping a fixed 16:9 reserve before the intrinsic aspect is known is reasonable here. The visible horizontal narrowing is accepted as a deferred NIT; do not stretch the actual clip or reopen remediation for it.

Retained round-2 sign-off and limits

  • Retained (79ae38539): D1-1..D1-8 outside the explicitly retested neighbours. I inspected the delta: the remote mark, Library components, shared styles, canvas video preview and FileViewerState have 0 diff bytes. The image/audio loaded visual branches are unchanged; their changes add Retry/pending focus handling. The finder delta only hides the small tile's redundant reason; the menu/toast visual delta only wraps the device substring. Therefore these are source-compatible retentions, not an unexamined re-pin.
  • Catalogue compatibility verified: git diff 79ae38539..a1bc2437a -- src/i18n/catalogues is 0 bytes. Both catalogue tree IDs are dbe07f88766f80a78143bbe137c6c4333db05617. The round-2 copy/audit bytes are unchanged.
  • The measured changed text uses three opaque ink colours per theme: dark #A6A091, #C2BCAF, #F1EEE6; light #656056, #4E4940, #211E18. Changed caption/state copy reuses the existing two size tokens, text-meta (0.75rem) and text-body-sm (0.8125rem); no typography/palette source changed. These are the measured text regions/token definitions, not an invented whole-screen font census.
  • Browser/console tools are unavailable here and capture was explicitly forbidden. Unverified live in this review: console/network execution, computed DOM target sizes, complete tab order, other viewport widths/200% zoom, alternate palettes and reduced-motion behaviour. This round is supplied-frame review plus attributed QA interaction evidence, not new live coverage. No claim is made that a single standard viewport proves those other axes.
  • The synthetic 99/77 bubbles in some frames are QA's documented CDP probe artefact, not an app finding. No duplicate xlsx, sound two-press or focus-on-success finding is opened.

Final disposition: TERMINAL. No new D-finding and no additional design cycle required.

@damianvtran

Copy link
Copy Markdown
Owner Author

CI remediation — shard 3

Current head a11513c4542fef332f7077b507351b2bae6a1440. Delta from a1bc2437a7ad448175554a37e476d0eb2768eb61: one added test-fixture export in scripts/transcript-copy-affordances.test.mjs only. All product source, catalogue/i18n bytes and previous review surfaces remain byte-identical; previous matrix is retained on that compatibility basis, not rerun.

Full-gate run 43283003584, job 131441139340, artifact desktop-suite-log-43283003584-1-shard-3 (ID 13980210049), tmp/desktop-tests.log:337604–337638: esbuild reports No matching export in "fixture:toast" for import "showLoadingToast", imported by remote-file-transfer.tsx:24. Reproduced locally before the fix, rc=1; the file failed at module build before any test body. The fixture now supplies that existing production API using the same toast-id stub convention. No assertions or timeouts changed.

Isolated HOME/config, env -i (no inherited CMUX_* or XPC_FLAGS), no installs, no native/browser lane. Node-heavy slot checked idle before dispatch. Exact commands from the manager-owned worktree:

python3 ~/local-operator/scripts/run_bounded.py --timeout 180 -- node scripts/run-desktop-tests.mjs scripts/transcript-copy-affordances.test.mjs
# before: rc=1, missing showLoadingToast export, 0 pass / 1 failed file, wall=3.9s
# after: rc=0, 28 pass / 0 fail / 0 skip, wall=5.5s
python3 ~/local-operator/scripts/run_bounded.py --timeout 120 -- node_modules/.bin/biome check scripts/transcript-copy-affordances.test.mjs
# rc=0, 0 errors; 2 pre-existing useTopLevelRegex warnings, verified in a1bc2437
python3 ~/local-operator/scripts/run_bounded.py --timeout 180 -- node scripts/pre-push-gate.mjs --since a1bc2437a7ad448175554a37e476d0eb2768eb61
# rc=0, exact one-file scripts lint passed; no TS delta => types leg skipped

Push disclosure: used supported PREPUSH_BYPASS with an explicit reason after the exact-delta gate above, avoiding rerunning the broad branch gate outside this repair's bounded lease. This does not claim a full pre-push gate pass at the new head; required CI remains authoritative. No full-suite local run, workflow rerun, merge or release dispatched. Manager owns delta review and final full gate.

Actual served model: openai/gpt-6.1-sol; no fallback used.

@damianvtran

Copy link
Copy Markdown
Owner Author

Agent review — CI delta

Reviewer: reviewer (lopdev), served model openai/gpt-6.1-sol for this CI-delta review. The earlier round 3 used anthropic/claude-opus-5-5; no fallback used in this delta.
Scope: a1bc243..a11513c
Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ a11513c4542fef332f7077b507351b2bae6a1440

Exact delta reviewed: one added line in scripts/transcript-copy-affordances.test.mjs:174: export const showLoadingToast = () => "toast";. No assertions, timeouts, product code or i18n changes. GitHub's pushed head matches the briefed SHA.

Fixture semantics: the existing fixture replaces @shared/utils/toast-manager, not a product download/transfer implementation. The real showLoadingToast(message: ReactNode, options?: ExternalToast): string | number returns a toast ID; this stub accepts the call's arguments under normal JavaScript semantics and returns a valid string ID, consistently with the adjacent toast stubs. It restores the missing named export used by the transitive remote-file-transfer.tsx import; it does not skip a test, catch/suppress the import error, replace the transfer path, or weaken an assertion. Toast rendering is not this copy-affordance suite's subject.

Independent targeted execution: node-heavy slot was idle. Ran only:
env -i HOME=<fresh scratch root> LOCAL_OPERATOR_CONFIG_DIR=<same root>/.local-operator PATH="$PATH" TERM=xterm-256color python3 /home/ec2-user/local-operator/scripts/run_bounded.py --timeout 300 --memory-mb 3000 -- node --test scripts/transcript-copy-affordances.test.mjs

Actual output: 28 tests, 28 pass, 0 fail, 0 skipped, rc=0, bounded wall=5.5s. No inherited CMUX_* or XPC_FLAGS. Runner swept its process group; a post-run process query found no matching test/bundle process, and git status --short remained empty. No broad suite, browser, native lane or product QA rerun.

Retention proof, derived with git rev-parse <commit>:<tree>:

  • a1bc2437a:src = a11513c45:src = 7166164e876382f9e71e0279ac34f8f25ea51e03.
  • a1bc2437a:src/i18n/catalogues = a11513c45:src/i18n/catalogues = dbe07f88766f80a78143bbe137c6c4333db05617.
  • The complete commit diff contains only the fixture line above. Prior product code review, QA, design, UX and i18n evidence are retained; the previous deferred ledger stays frozen. Required CI at the new head remains the manager's merge gate, not claimed green by this targeted run.

Findings: none in this delta.

Verdict: clean — terminal.

@damianvtran damianvtran added ready-to-merge Full suite requested on this head - re-arm with pnpm merge-gate <n> and removed ready-to-merge Full suite requested on this head - re-arm with pnpm merge-gate <n> labels Nov 12, 2026
@damianvtran

Copy link
Copy Markdown
Owner Author

Window ownership, measured at main 5033f642352 — no UI chore(release) lock is open, and I am not the owner.

I have not owned a UI window since v0.54.3; the 0.54.5 window belonged to 549e1b1d019e and its lock #1879 is merged into that tag. Current state:

open chore(release) PRs: none
tags:   v0.54.5  v0.54.4  v0.54.3
since v0.54.5:  5033f642352  (#1876, generation-card labels)   <- the only merge
version on main: 0.54.5        <- 0.54.6 unspent
release states: v0.54.5 pre=true assets=21  |  v0.54.4 pre=false assets=21

So #1880 will ride a NEW window, opened after this PR merges. I have asked the i18n manager to take the lock, since #1876 is the only merge since the tag. When this merges, send the merge SHA and the Release: line verbatim to whoever opens the chore(release) PR — a PR with no Release: line contributes no sentence to the notes.

One state note worth carrying: v0.54.5 has its 21 assets attached but is still prerelease=true, so /releases/latest is currently v0.54.4. Assets-attached is not promoted; please do not describe 0.54.5 as shipped in anything written before it promotes.

@damianvtran

Copy link
Copy Markdown
Owner Author

CI remediation — remaining shard failures

Model: openai/gpt-6.1-sol; no fallback used.
Scope: a11513c4542fef332f7077b507351b2bae6a1440..2d765b8c21b8b534c356b30010af9b71f80336d5.
Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity.

Both late predecessor failures from run 43283003584 are deterministic instances of the same missing toast-fixture export as shard 3, but neither was fixed by a11513c, which touched only transcript-copy-affordances.test.mjs:

Shard / job TAP artifact Exact failure / disposition
0 / 131441139336 13980296867, tmp/desktop-tests.log:21382–21417 scripts/response-copy.test.mjs: esbuild cannot import showLoadingToast from fixture:toast, required by remote-file-transfer.tsx:24. Reproduced at a11513c; repaired its fixture export.
1 / 131441139537 13981365469, tmp/desktop-tests.log:27107–27142 scripts/speech-user-copy.test.mjs: identical missing-export build failure. Reproduced at a11513c; repaired its separate fixture export.

Export-only controls passed all assertions (4 and 3), but retained both processes beyond the 180-second diagnostic bound (rc=124). A read-only async-hooks probe on response-copy traced referenced 300000-ms timers to Query.scheduleGc / Query.removeObserver, not an assertion failure or runner-resource event. Both rigs owned anonymous QueryClients with no explicit cache cleanup. This commit retains each client, clears its cache after root unmount, asserts the cache is empty, and closes its jsdom window — the same teardown convention already used by transcript-copy-affordances.test.mjs. No forced exit, altered GC duration, increased cap, skipped test or weakened assertion.

Each test ran serially, node-heavy slot checked idle before every dispatch; env -i with isolated HOME/config and no inherited CMUX_* / XPC_FLAGS:

python3 ~/local-operator/scripts/run_bounded.py --timeout 180 -- node scripts/run-desktop-tests.mjs scripts/response-copy.test.mjs
# a11513c before: rc=1, missing export, wall=1.8s
# final head: rc=0, 4 passed / 0 failed / 0 skipped, wall=3.3s
python3 ~/local-operator/scripts/run_bounded.py --timeout 180 -- node scripts/run-desktop-tests.mjs scripts/speech-user-copy.test.mjs
# a11513c before: rc=1, missing export, wall=1.8s
# final head: rc=0, 3 passed / 0 failed / 0 skipped, wall=3.1s
python3 ~/local-operator/scripts/run_bounded.py --timeout 180 -- node scripts/pre-push-gate.mjs --since a11513c4542fef332f7077b507351b2bae6a1440
# rc=0, both changed scripts lint-clean; types skipped because no TS delta

Additional diagnostic runs with --import <session-scratch>/ci/timer-probe.mjs finished naturally on both final fixtures with TIMER_PROBE [] (zero live referenced timers). The uncommitted probe does not cancel, unref or modify timers; final uninstrumented commands above independently confirm natural completion.

Only the two named test files changed (18 additions, 10 deletions). git diff --exit-code a1bc2437a7ad448175554a37e476d0eb2768eb61..HEAD -- src i18n is empty. Product source and catalogue/i18n bytes remain identical to the reviewed head. Previous unaffected streams are retained on that byte compatibility; manager owns test-only delta review and the final gate. No native/browser lane, installs, full local suite, workflow cancellation/rerun/re-arm, merge or release dispatched.

Push disclosure: supported PREPUSH_BYPASS used with the explicit bounded test-only repair reason after the exact-delta pre-push gate passed. Broad branch pre-push gates were not rerun; required CI remains authoritative.

@damianvtran

Copy link
Copy Markdown
Owner Author

Agent review — CI delta 2

Reviewer: reviewer (lopdev), served model openai/gpt-6.1-sol, no fallback in this delta.
Scope: a11513c..2d765b8
Worktree: /home/ec2-user/local-operator-ui/.worktrees/remote-files-parity @ 2d765b8c21b8b534c356b30010af9b71f80336d5

Code verdict: clean. Execution status: BLOCKED; independent test-pass evidence is not claimed.

Reviewed the entire two-file delta: scripts/response-copy.test.mjs and scripts/speech-user-copy.test.mjs. Each adds the missing showLoadingToast export using the existing string-toast-ID stub convention, retains its previously anonymous QueryClient, and extends teardown in this order: awaited React root unmount → client.clear() → empty query-cache assertion → jsdom window close. The client defaults remain retry: false; no copy assertion, test case, timeout or production implementation is weakened/replaced. The existing tests invoke teardown after their behavioral assertions; the new clearing therefore does not erase state before those assertions. The next fixture mount reinstalls its own window/document globals. Global restoration on failed assertions was already absent and is not introduced by this delta.

Checked the installed TanStack query-core 5.73.3 implementation under the shared dependency tree: QueryClient.clear() clears its query/mutation caches; QueryCache.clear() removes each query; removal calls query.destroy(); that calls Removable.destroy() → clearGcTimeout(), and cancels the query retryer. Thus this is real resource cleanup of the fixture-owned cache after observer unmount, not shortened GC, timer unref, forced process exit, or ignored failures. Both added empty-cache assertions can fail normally.

Retention proof, derived here: git diff a1bc2437a..2d765b8c2 -- src i18n is empty. Git tree IDs at both revisions are:

  • src: 7166164e876382f9e71e0279ac34f8f25ea51e03
  • src/i18n/catalogues: dbe07f88766f80a78143bbe137c6c4333db05617
  • i18n: 8b12bb21edc7c01a2a3b1d80b866ef6515c6e1c2

GitHub's pushed head matches the brief. git diff --check passes and the manager-owned worktree is clean. Prior product review, QA, design, UX and i18n streams are retained; the prior deferred ledger stays frozen. No product/native/browser/full-suite rerun was performed.

Requested targeted execution: attempted to dispatch only the two files serially through run_bounded, with fresh scratch HOME/config and env -i. The tool refused the entire command before execution: BLOCKED by guardrail dangerous-delete (review); it identified the repository target as containing an opaque variable/substitution. Nothing in that invocation ran. The refusal is final for that action, so I did not re-spell or reroute it. Read-only source checks completed separately. A post-check found no matching test processes of mine.

Coder's independent evidence is attributed, not represented as my execution: CI remediation comment reports response-copy 4 pass and speech-user-copy 3 pass, both rc=0. I opened that comment; I did not independently reproduce those results. Manager has been notified of the execution blocker. Required final CI remains authoritative and this comment does not claim that gate green.

Findings: none in the code delta. Code review is clean/terminal; the requested reviewer rerun remains BLOCKED pending an approved execution path, not a product remediation or new QA/design/UX round.

@damianvtran damianvtran added ready-to-merge Full suite requested on this head - re-arm with pnpm merge-gate <n> and removed ready-to-merge Full suite requested on this head - re-arm with pnpm merge-gate <n> labels Nov 12, 2026
@damianvtran
damianvtran merged commit 53601db into main Nov 13, 2026
24 checks passed
@damianvtran
damianvtran deleted the feat/remote-files-parity branch November 13, 2026 01:37
@damianvtran

Copy link
Copy Markdown
Owner Author

Merge disposition

Merged reviewed head 2d765b8c21b8b534c356b30010af9b71f80336d5 as 53601dbe151c4d3d7994be135cd5ff3e78254a5b after live verification that all required checks were green: Bytecode Build Check, CI Gate, and Merge gate (full suite), plus all five Desktop Tests shards and classified fast checks.

Normal merge was refused by base-branch approval policy, so this owner-authored PR completed with disclosed --admin on the standing independent agent-review path. No human clicked approve: the owner account cannot approve its own PR. Independent code/QA/design/UX/i18n evidence and the CI-delta reviews are recorded above. The last two-test fixture delta had static independent review CLEAN but independent execution BLOCKED; coder 4+3 passes and now-passing hosted full-suite evidence remain distinct.

Release routing only becomes a real handoff after this verified merge. UI window owner: session 3ef6295a5545, lock #1886. No release/publication or real-Mac rendered proof is claimed here; Linux built-app proof is pre-merge evidence only.

Release: patch — Preview and download remote-session files in the transcript, Files panel, Canvas and Library, with clear remote and retry states.

Manager served model: openai/gpt-6.1-sol; prior implementation/early rounds used anthropic/claude-opus-5-5 and are attributed in their comments. No global or role model defaults changed.

bbqben added a commit that referenced this pull request Nov 13, 2026
Folds #1880 and #1800 (ba0a14e). The only conflicts were the two GENERATED
condense parity files: both sides moved sourceHash (main via the music job
pin, this branch via src/shared/desktop-session-contract.ts). Took main's copy
and regenerated with scripts/generate-condense-parity.mjs; against origin/main
the fixture differs by the sourceHash line only. i18n/ledger.json and
src/i18n/keys.gen.ts merged cleanly (ledger-check ok, 1127 entries;
generate --check clean).
damianvtran added a commit that referenced this pull request Nov 13, 2026
Base convergence 5: brings #1800 async-music and #1880 remote-files-parity. Conflict in canonical-transcript.tsx resolved by keeping #1828's removal of the inline profile consent/revert block and #1800's musicView hook; condense parity fixtures regenerated.
damianvtran added a commit that referenced this pull request Nov 13, 2026
Convergence for DIRTY #1874 (main 5033f64 -> ba0a14e: #1800 async-music, #1880 remote-files-parity, #1840, #1877).

- chat-content.tsx: the transcript slot takes main's shape, i.e. CanonicalTranscript wrapped in #1800's MusicJobsLiveContext.Provider with the sibling JobCancelPopover. This PR's missingTemporary prop (temporaryEnabled && canonical.view.temporaryDeleted === true) is carried onto that same element after `missing={gone}`. Whitespace-normalised, the two sides differ only in those lines. Imports auto-merged as a union (MakeTemporaryDialog plus JobCancelPopover/useJobCancel).
- condense.parity{,.manifest}.json: main's versions, regenerated with scripts/generate-condense-parity.mjs on the merged source. Cases are byte-identical to main's (700); only sourceHash/sha256 move.
- Auto-merged files changed on both sides, in disjoint additive hunks: use-canonical-session.ts (#1800's settled-job read trigger vs this PR's temporaryDeleted view), desktop-hooks.ts and desktop-contract.ts (feature/op unions), canonical-transcript.tsx, keys.gen.ts (regenerated, byte-identical) and i18n/ledger.json (disjoint row union, with no row changed on both sides).
damianvtran added a commit that referenced this pull request Nov 13, 2026
One number for the window merged since v0.54.5: #1876, #1877, #1840, #1880 and #1800. Minor rather than patch because #1800 adds a step-function workflow (detached music jobs whose tracks are retained), which the whole-window bump must not under-count.

package.json only, one line, produced by scripts/apply-release-bump.mjs. No feature change, no dependency or lockfile movement.
damianvtran added a commit that referenced this pull request Nov 13, 2026
Second convergence on the same head: main moved to ba0a14e (#1880 remote
files 53601db, #1800 music), so the PR went DIRTY again. No feature byte
changed: all 94 non-overlap feature files are blob-identical to 353263c and all
200 non-overlap upstream files to ba0a14e.

Same conflict shape as the first convergence - three lines of generated pin, not
content. The music work adds TWO model sources (music-gen-tool.ts,
music-receipt-model.ts), so the parity fixture is regenerated with 38 sources (36
+ those 2) while the 700 decision cases and the latch stay identical to both
sides; the pin is written last. i18n/ledger.json again auto-merged on disjoint
rows: this PR's 7 ui.chat.canonical rows vs upstream's 14 new ui.canvas and
ui.chat.attachments rows (1113 -> 1127 entries, nothing touched twice), and
src/i18n/keys.gen.ts is byte-identical to a fresh generation.
@damianvtran

Copy link
Copy Markdown
Owner Author

Release window cut: tag v0.55.0 on 5e8d5a99c41086db787ce3e3171acdebfc4c1869 (peeled tag commit matches the bump merge), pre-release published by the owner session. This PR is listed in the notes: https://github.com/damianvtran/local-operator-ui/releases/tag/v0.55.0 . publish.yml run 43382466458 (queued at 06:59Z); promotion to latest and npm publish follow from that run - not yet confirmed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Full suite requested on this head - re-arm with pnpm merge-gate <n>

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant