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
106 lines
8.1 KiB
Markdown
106 lines
8.1 KiB
Markdown
# 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 `value`s.** 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:
|
|
|
|
```js
|
|
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.
|