Files
platform/docs/editor-buffer-corruption.md
T
pastilhasandClaude Opus 5 f16aa87d8b the code editor collides with itself: two panels, one model, two buffers
The "two Monaco editors sharing a model" elimination asked which OTHER
component mounts Monaco and never asked whether the code editor collides with
itself. It does — the panel is addable to a dashboard twice, and useEditorState
keeps files in a plain useState, so two panels hold two independent buffers for
one globally-keyed model.

Verified against @monaco-editor/react@4.7.0's built source rather than inferred.
Model lookup is getModel(uri) || createModel(value), so the second mount adopts
the first panel's buffer and ignores its own freshly-read value. The value-sync
effect then overwrites the shared model with executeEdits. And the flag that
suppresses onChange during that write is per-instance, so one panel's
programmatic write fires the OTHER panel's onChange — which CodeEditor answers
with setContent(path, value). The other panel's buffer becomes this one's
content and goes dirty with no keystroke: dirty tab, content nobody typed, disk
untouched, fixed by reopening. Exactly the reported shape.

Unmount also disposes the shared model (keepCurrentModel defaults false), and
key={activeFile.path} means that fires on every tab switch, not only on panel
close — so one panel can dispose a model another is still displaying.

Not yet a closed case: nothing in the app writes job output into
useEditorState, so this produces the right shape but does not explain where the
log text comes from. No fix shipped — the two candidates (per-panel model URIs
vs sharing the state to match the shared model) differ in whether Monaco's
TypeScript resolution keeps working, and that is the owner's call. Trap stays
armed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 07:16:22 +00:00

8.1 KiB

Background-job output appears inside an open file

An open bug, not reproducible on demand, with a trap already set for it. If someone has just reported this, skip to "When it is reported".

Status as of 2026-08-16: cause unknown, two candidates eliminated, instrumentation shipped.

What it looks like

A file is open in the code editor while a background job is producing output — a dev server, a watcher. The output appears inside the file's buffer, spliced into the middle of it. Observed once with a bun dev job: the JSX of SigninScreen.tsx and eight lines of dotenv/POSTGRES_URL output shared the buffer, with Monaco's line numbers continuing straight through (…180, the log lines, then 189 </Link>).

Two details from that report do most of the diagnostic work:

  • The tab was marked dirty. So the model really was mutated; this is not a rendering artefact.
  • The file on disk was untouched. The corruption is in the browser and was never saved. Reopening the tab fixes it.

What has been ruled out, and why

Do not re-investigate these without new evidence.

Candidate Why it is out
The end-of-turn refresh re-reading the file reloadFile sets originalContent and isDirty: false. It cannot produce a dirty tab.
A shared component between the task panel and the editor CodeSurface (the task log) renders with shiki. It is not Monaco and holds no editor model.
Two Monaco editors sharing a model — WRONG, see below This asked which OTHER component mounts Monaco and answered EditorRenderer (still true: no path, so inmemory://model/N, no collision). It never asked whether the code editor collides with ITSELF. It does — the panel is addable to a dashboard twice.
Something typing into the page No execCommand('insertText'), no synthesised KeyboardEvent, no clipboard writes anywhere near the editor.

That leaves Monaco's own onChange — the model was edited the way a paste or a programmatic executeEdits edits it. Which is why this needs to be caught in the act rather than reasoned about.

Two code-editor panels share one model and two buffers (2026-08-25)

The suspicion recorded here — 2b70a570 giving <Editor> a real path, so models are keyed by file URI globally rather than per mount — is correct, and it is worse than "should be an improvement". Verified by reading @monaco-editor/react@4.7.0's built source, not inferred:

  • Model lookup is getModel(Uri.parse(path)) || createModel(value, …). An existing model for that path wins and the second mount's value is ignored at creation. So the second panel to open a file adopts the first panel's buffer.
  • The value-sync effect then overwrites it, because value !== editor.getValue(): executeEdits('', [{ range: getFullModelRange(), text: value }]).
  • The guard that suppresses onChange during that write is per-INSTANCE. The package sets an internal flag around its own executeEdits and checks it inside onDidChangeModelContent. Panel B's flag is not panel A's. So B's programmatic write fires A's onChange with B's content.
  • Unmount disposes the shared model. keepCurrentModel defaults to false and we never set it, so the cleanup calls editor.getModel()?.dispose(). And CodeEditor mounts <Editor key={activeFile.path}>, so this runs on every tab switch, not only on panel close — switching tabs in one panel disposes the model another panel is still displaying.

The other half is ours: useEditorState holds files in a plain useState, so it is per panel instance. Its own comment says so — "this app also mounts as a dashboard panel, and a dashboard can hold two of them". One shared model, two independent content values, and an onChange that crosses between them. CodeEditor's handler does setContent(activeFile.path, value), so panel A's buffer silently becomes panel B's content and isDirty flips with no keystroke — dirty tab, content the user never typed, disk untouched, fixed by reopening. That is the reported symptom's exact shape.

What this does NOT yet explain is how JOB OUTPUT becomes one of those values. Nothing in the app writes job output into useEditorState — openFile/setContent/reloadFile are called only from CodeEditor.tsx, and the task log renders through shiki. So this is a real defect that produces the right shape, and it is not proven to be the reported instance. The trap is still the thing that names the text's source. Do not delete it on the strength of this section.

When it is reported

Ask for one thing. In the browser's console, on the machine where it happened:

JSON.parse(localStorage.getItem('officer:editor-buffer-trap'));

That returns up to the last five occurrences, each with:

Field What it tells you
stack The answer. The call stack at the moment of the edit — whatever inserted the text is named in it.
inserted The first 200 characters that arrived. Confirms it is job output rather than a real paste.
charactersAdded How much arrived at once.
path Which file was open.
at Lines it up against the job's own logs.
editorWasFocused Always false — the trap only fires when it is.

It is written by reportSuspiciousEdit in apps/CodeEditor/CodeEditor.tsx, which fires on any insertion over 200 characters arriving while the editor does not have focus. Nobody types 200 characters into an editor they are not focused on.

If the key is empty but the corruption happened, that is itself informative: either the editor was focused (so it plausibly is a paste, and the question becomes what pasted it), or the insertion arrived in chunks under 200 characters — in which case lower SUSPICIOUS_INSERT and wait for it again.

Why a trap rather than a fix

Because every fix available without knowing the cause is a guess that would look like a solution. A guard that refuses suspicious writes would hide the symptom on the one screen where it is visible, and the same mechanism would go on doing whatever else it does. The corruption is never saved and reopening the tab clears it, so the cost of waiting is low and the cost of a wrong fix is a bug that stops being reportable.

When it is solved

Delete all of it: reportSuspiciousEdit, EDITOR_TRAP_KEY, its call in the onChange handler, the entry in TODO.md → Known bugs, and this file. It is instrumentation for one bug, and instrumentation nobody remembers the reason for is worse than none.