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
5.7 KiB
id, title, theme, status, created, date_source, order
| id | title | theme | status | created | date_source | order |
|---|---|---|---|---|---|---|
| known-bugs/background-job-logs-appeared-inside-an-open | Background job logs appeared INSIDE an open file in the code editor | known-bugs | open | 2026-08-16 | stated | 1 |
Background job logs appeared INSIDE an open file in the code editor. Reported 2026-08-16 by the
owner, on the production host, running as a member with a bun dev background job streaming. The
editor had a file open; the runner's log output was rendered into that file's buffer. It was never
saved — the file on disk was intact, and the editor was not marked dirty as far as the report goes.
So this is a DISPLAY-layer mixing, not a write path: the bytes reached a buffer that only exists in
the browser.
Not reproduced, not diagnosed, and deliberately not guessed at further than this. What is worth recording is where the two streams could meet, because both were added in the same week and they are the only two things writing text into a panel:
useTaskDetailpollsGET /chat/tasks/:idevery 2s while a job runs and returns{kind:'log', text}.CodeSurfacerenders it — andCodeSurfaceis shared with the editor's own surfaces.- The editor keys open files by PATH (
useEditorState), while the task panel keys by TASK ID, and several panel-scoped stores in between are keyed bypanelIdthroughuseGlobal/usePanelMemory. A key collision between those namespaces would produce exactly this: one panel's text arriving in another's buffer, with no server involvement and nothing persisted.
Screenshot 2026-08-16 answers the first question and kills two theories. The tab carries a DIRTY
dot, and the log lines have Monaco line numbers continuing the file's own sequence (…180, then eight
lines of dotenv/POSTGRES_URL output, then 189 </Link>). So the MODEL was mutated — this is the
editor's content state, not its render, and the earlier "the file was okay" means it was simply never
saved. Note the splice is mid-file, not a replacement: JSX text and log text share line 180
(Forgot?--env-file=.env --watch src/server.tsx).
Ruled out by that: CodeSurface renders with shiki, not Monaco, so the two cannot share an editor
instance. And the app's only other Monaco mount (FileViewer/renderers/EditorRenderer.tsx) passes no
path, so its model URI is inmemory://model/N and cannot collide with the editor's file:///….
So the pollution arrives as TEXT through the editor's own content path — openFile, setContent, or
the end-of-turn refresh that re-reads every clean file. That last one is the prime suspect: it is the
only thing that writes a buffer without a keystroke, it runs exactly when a background job is
producing output, and it lands its result by path.
Narrowed 2026-08-16, and a trap is now set. reloadFile sets originalContent and clears
isDirty, so the end-of-turn refresh CANNOT produce the dirty dot in the screenshot — the refresh
path is out. Nothing in the app injects text into the page either: no execCommand('insertText'),
no synthetic keyboard events, no clipboard writes near the editor. That leaves Monaco's own change
event, which means it looks like a paste or a programmatic executeEdits.
docs/editor-buffer-corruption.md is the procedure — read that when someone reports this. A
trap is set: reportSuspiciousEdit in CodeEditor.tsx records any insertion over 200 characters
arriving while the editor is NOT focused, with the stack at that moment, into
localStorage['officer:editor-buffer-trap'] (last five). It persists rather than only logging
because this arrives as a report hours later, when the console is long gone — the question becomes
"paste this key" instead of "what did you see?". Delete all of it once the cause is known.
One thing to rule out first, because it is mine and it is recent: 2b70a570 gave <Editor> a real
path, so models are now keyed by file URI globally rather than being per-mount inmemory://. Two
panels open on the same file now share one model. That should be an improvement, but it is a change
in exactly the layer that turned out to be involved.
2026-08-25 — that suspicion was right, and the "ruled out" row above was wrong. The 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 in
@monaco-editor/react@4.7.0's source: model lookup is getModel(uri) || createModel(value) so the second
mount's value is ignored, 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. CodeEditor responds with setContent(path, value): the other
panel's buffer becomes this one's content and goes dirty with no keystroke. Unmount also disposes the
shared model (keepCurrentModel defaults false), and key={activeFile.path} means that happens on every
tab switch, not just on panel close.
That reproduces the symptom's exact shape — dirty tab, content nobody typed, disk clean, fixed by
reopening. It does not explain how job output becomes one of those buffers, and nothing in the app
writes job output into useEditorState. So the trap stays armed; it is still the only thing that can name
where the text came from. Full write-up in docs/editor-buffer-corruption.md.
Worth noting because it is the reason to look at all: an editor that can show content the file does not contain is a correctness problem even when it never writes — the next person's instinct will be to save the file to "fix" it.