diff --git a/TODO.md b/TODO.md index 98892444..a4b6d73e 100644 --- a/TODO.md +++ b/TODO.md @@ -422,6 +422,17 @@ outside the example's new README: 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`. + + `reportSuspiciousEdit` in CodeEditor.tsx logs any insertion over 200 characters that arrives while + the editor is NOT focused, with a stack trace taken at that moment. That is the missing evidence: + whoever calls it will be named in the trace. Delete it once the cause is known — it is a trap, not a + feature. + One thing to rule out first, because it is mine and it is recent: `2b70a570` gave `` 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 diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx index c404b0a3..b13e955f 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx +++ b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx @@ -28,6 +28,41 @@ type CodeEditorViewProps = { panelId?: string; }; +/** + * Shout when the buffer changes in a way a person could not have typed. + * + * Background-job output has twice appeared spliced into an open file — the model mutated, the tab marked + * dirty, the file on disk untouched. Two explanations are already ruled out: the end-of-turn refresh + * cannot do it (`reloadFile` clears `isDirty`), and nothing in the app types into the page. That leaves + * Monaco's own change event, which means it looks like a paste or a programmatic edit — and neither can + * be identified from a screenshot after the fact. + * + * So this does not prevent anything. It records the one thing missing: what arrived, how big it was, and + * whether the editor even had focus at the time. A large insertion into an UNFOCUSED editor is not a + * user, and the stack trace attached to it names whatever called it. + * + * Delete this once the cause is known. It is a trap, not a feature. + */ +const SUSPICIOUS_INSERT = 200; + +function reportSuspiciousEdit( + editor: MonacoEditor.IStandaloneCodeEditor | null, + file: { path: string; content: string }, + next: string, +): void { + const grew = next.length - file.content.length; + if (grew < SUSPICIOUS_INSERT) return; + if (editor?.hasTextFocus()) return; + + // eslint-disable-next-line no-console + console.warn( + `[editor] ${grew} characters appeared in ${file.path} while the editor was NOT focused. ` + + `This is the background-job-output bug (TODO.md → Known bugs). First 120 chars:\n` + + next.slice(0, 120), + new Error('stack at the moment of the edit'), + ); +} + export const CodeEditorView = ({ className, theme = 'vs-dark', @@ -294,7 +329,10 @@ export const CodeEditorView = ({ theme={theme} language={getLanguage(activeFile.name)} value={activeFile.content} - onChange={(value) => setContent(activeFile.path, value ?? '')} + onChange={(value) => { + reportSuspiciousEdit(editorRef.current, activeFile, value ?? ''); + setContent(activeFile.path, value ?? ''); + }} beforeMount={configureMonacoTypescript} onMount={handleEditorMount} options={{