The code editor held file contents per panel while Monaco keyed the model by
file URI globally, so two panels on one file had two buffers and one model.
@monaco-editor/react resolves a model as getModel(uri) || createModel(value),
so the second panel adopted the first one's model and its own value was
ignored; the value-sync effect then overwrote the shared model with
executeEdits, and the flag suppressing onChange around that write is
per-instance — so the OTHER panel's onChange fired, and the editor answers that
with setContent. One panel's buffer silently became the other's and the tab
went dirty with no keystroke.
useEditorState now keys buffers by path in one shared store and keeps only the
tab set and active path per panel. openFile discards the content it is handed
when the file is already open elsewhere, mirroring what Monaco does with the
model — that is what removes the divergence, because there is then no value for
the sync effect to reconcile. Buffers are refcounted by holders and dropped
when the last panel closes, so closing a tab cannot discard another panel's
unsaved work; closing the last one still discards, as before.
keepCurrentModel is the other half and was needed under any fix: the default
disposes the shared model on unmount, and key={activeFile.path} makes that
every tab switch rather than only a panel close.
Writes go through queryClient.setQueryData rather than useGlobal's setter,
which applies a functional update to the data captured in render — invisible
with one writer, wrong with one per panel.
Nine tests, and the editor had none before. The known-bugs card stays OPEN: this
removes a mechanism that produces the reported shape, not the mystery of where
the log text came from. The trap stays armed, and it can now fire legitimately
when two panels share a file, so the stack matters more than the count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
6.9 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.
Mechanism removed 2026-08-25. useEditorState now holds one buffer per file, shared by every panel
that has it open, and keeps only the tab set and active path per panel — the state now matches the model
Monaco was already sharing. openFile deliberately discards the content it is handed when the file is
open elsewhere, which is what removes the divergence: with both panels reading one buffer there is no
value for the sync effect to reconcile, so the executeEdits never runs and the cross-panel onChange
never fires. A buffer is refcounted by holders and dropped when the last panel closes it, so closing a
tab in one panel cannot discard another's unsaved work. <Editor keepCurrentModel> stops the shared model
being disposed on every tab switch. Nine tests in useEditorState.test.ts.
Left open on purpose. This removes a mechanism that produces the reported SHAPE; it does not explain
where the log text came from, and that question is the actual bug. The trap stays armed and
docs/editor-buffer-corruption.md stays. One new wrinkle recorded there: two panels on one file can now
fire the trap legitimately on a large paste, so the stack matters more than the count.
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.