Skip to content

feat(desktop): add LaTeX paper writing and PDF preview - #3850

Merged
nonoqing merged 2 commits into
openBitFun:mainfrom
nonoqing:codex/latex-paper-writing
Nov 10, 2026
Merged

nonoqing merged 2 commits into
openBitFun:mainfrom
nonoqing:codex/latex-paper-writing

Conversation

@nonoqing

@nonoqing nonoqing commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add Paper Writing under Other agents. When a user opens the main .tex file, the editor shows LaTeX source on the left and the typeset PDF on the right. Users can save and compile once or opt in to recompiling after edits. If a build fails, the last successful PDF stays visible and the compiler log is available beside it.

Type and Areas

Feature. Desktop/Tauri, editor, built-in agents, remote capability contracts, and i18n.

Motivation / Impact

Paper authors can revise their source and check the result in the same workspace. Preview uses the workspace host's existing latexmk and pdfLaTeX or XeLaTeX installation. Build output goes to a temporary directory, with shell escape and latexmk configuration scripts disabled. Peer clients enable preview only when the host advertises support.

Verification

AI-assisted; lightly tested (focused automated checks and a local preview exercise).

  • Passed 113 Web UI tests across the LaTeX editor, PDF viewer, code editor, LaTeX API, peer connection manager, and chat mode suites. Web UI and Mobile Web passed tsc --noEmit.
  • Passed Rust tests for the prompt catalog, Paper Writing availability, remote operation registry, and Remote Connect agent mapping. Passed pnpm run capabilities:check, capabilities:test, check:core-boundaries, i18n:audit, check:repo-hygiene, and the appearance and color audits.
  • Before updating to the latest upstream base, cargo test --locked -p openbitfun-desktop --no-default-features --lib latex_api::tests -- --include-ignored passed with real pdfLaTeX and XeLaTeX, covering a multi-file paper with BibTeX and compiler errors. The POSIX command was exercised in a local shell. In an isolated browser fixture backed by real TeX, changing source refreshed the PDF and a compile error preserved the last preview. The compiler and editor source files were carried over unchanged.
  • The aggregate pnpm run check:web stops at an existing typography violation in Dialog.module.css:77. The same violation reproduces against that file on upstream main. The individual type and appearance checks above passed.
  • Live SSH, Docker, WSL, Peer Device Mode, Remote Connect and Detached Dispatch were not exercised end to end.

Reviewer Notes

With latexmk installed, choose Other agents → Paper Writing, open a valid .tex file, and select Save & compile. Enable Auto compile, edit the title, and check that it updates in the PDF. Introduce an undefined command to check that the error is shown and the previous PDF remains visible.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable.

@Raing5Days

Copy link
Copy Markdown
Contributor

Thanks for the LaTeX work. Reviewing it against main I hit three problems: two break existing contract tests, one silently drops a save. The commit below sits on top of 5c6200e18 and fixes exactly those; git am reproduces it byte for byte.

1. The new mode breaks the builtin agent catalog contract

agent_registry_contracts.rs asserts the exact ordered definition list, so adding PaperWriting fails it:

left:  [... "DeepResearch", "PaperWriting", "Ultimate" ...]
right: [... "DeepResearch", "Ultimate" ...]
test result: FAILED. 0 passed; 1 failed

2. PaperWritingAgent misses the thread-goal lifecycle

builtin primary mode PaperWriting is missing get_goal

every_builtin_standard_mode_defaults_to_the_thread_goal_lifecycle requires get_goal / create_goal / update_goal on every standard primary mode (Minimal and Ultimate excepted), and the Desktop composer gates the goal affordance on hasCompleteThreadGoalTools(), so the missing tools also hide the Goal action for this mode.

3. The saveFileContent coalescing drops a save request

The in-flight dedupe added to the shared CodeEditor discards a save that arrives while a write is running, so await document.save() in LatexEditor can finish with an older buffer on disk and then report saveFailed. The patch re-runs the write when a request lands during one, and adds a CodeEditor regression test that fails on the current head (expected [ 'first edit' ] to deeply equal [ 'first edit', 'second edit' ]).

Verification on top of 5c6200e18

  • cargo test --locked -p openbitfun-agent-runtime --features agent-runtime --test agent_definition_contracts -> 90 passed (1 failed before)
  • cargo test --locked -p openbitfun-core --features agent-runtime --lib -- agentic::agents::registry::tests -> 55 passed (1 failed before)
  • pnpm --dir src/web-ui run type-check -> exit 0
  • vitest CodeEditor / LatexEditor / PdfViewer / EditorDocument / LatexAPI -> 44 passed
  • pnpm run fmt:rs applied

Patch

Save the block below as 0001-fix-latex-register-Paper-Writing-with-the-shared-age.patch and run git am 0001-fix-latex-register-Paper-Writing-with-the-shared-age.patch. It touches 4 files (+51/-6) and nothing else.

From 2e469b210e42479edc2cbd0e9b585562ee9fd5eb Mon Sep 17 00:00:00 2001
From: Raing5Days <Raing5Days@users.noreply.github.com>
Date: Mon, 9 Nov 2026 23:15:12 +0800
Subject: [PATCH] fix(latex): register Paper Writing with the shared agent
 contracts

Follow-up fixes for the Paper Writing / LaTeX preview change.

- Add the thread-goal tool lifecycle (get_goal, create_goal, update_goal) to
  the Paper Writing agent. Every other standard primary mode carries it, the
  Web UI gates its goal affordance on the complete set, and without it
  `every_builtin_standard_mode_defaults_to_the_thread_goal_lifecycle` fails.
- Record PaperWriting in the builtin agent catalog contract, which asserts the
  exact ordered definition list; the new mode broke
  `builtin_agent_definition_catalog_preserves_order_categories_models_and_visibility`
  and the swarm parent list that enumerates every top-level mode.
- Keep a save request that arrives while a write is in flight instead of
  dropping it. The shared CodeEditor now re-runs the write once the in-flight
  one settles, so `await document.save()` (the "save then compile" path) always
  ends with the newest buffer on disk. Covered by a new CodeEditor test.
---
 .../agents/definitions/modes/paper_writing.rs |  6 ++++
 .../agent_registry_contracts.rs               |  2 ++
 .../editor/components/CodeEditor.test.tsx     | 18 +++++++++++
 .../tools/editor/components/CodeEditor.tsx    | 31 +++++++++++++++----
 4 files changed, 51 insertions(+), 6 deletions(-)

diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/paper_writing.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/paper_writing.rs
index a7d90d192..032f6c0fc 100644
--- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/paper_writing.rs
+++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/paper_writing.rs
@@ -38,6 +38,12 @@ impl Agent for PaperWritingAgent {
             "ExecControl",
             "WriteStdin",
             "AskUserQuestion",
+            // Every standard primary mode defaults to the thread-goal
+            // lifecycle, and the Desktop composer gates its goal affordance on
+            // the complete set.
+            "get_goal",
+            "create_goal",
+            "update_goal",
         ]
         .into_iter()
         .map(String::from)
diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs
index 89f73f564..8bb16e084 100644
--- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs
+++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs
@@ -225,6 +225,7 @@ fn builtin_agent_definition_catalog_preserves_order_categories_models_and_visibi
             "Creative",
             "Claw",
             "DeepResearch",
+            "PaperWriting",
             "Ultimate",
             "SwarmPlanner",
             "SwarmWorker",
@@ -329,6 +330,7 @@ fn builtin_agent_definition_catalog_preserves_order_categories_models_and_visibi
             Some("Creative"),
             Some("Claw"),
             Some("DeepResearch"),
+            Some("PaperWriting"),
             Some("SwarmWorker"),
             Some("SwarmReviewer"),
         ] {
diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.test.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.test.tsx
index a2a7acfca..a55b4ceeb 100644
--- a/src/web-ui/src/tools/editor/components/CodeEditor.test.tsx
+++ b/src/web-ui/src/tools/editor/components/CodeEditor.test.tsx
@@ -299,6 +299,24 @@ describe('CodeEditor disk synchronization', () => {
     expect(model().getValue()).toBe('unsaved edit');
   });
 
+  // "Save then compile" awaits document.save(), so a save request that lands
+  // while a write is in flight must still end with the newest buffer on disk
+  // instead of being dropped as a duplicate of the earlier write.
+  it('re-runs a save requested during an in-flight write and settles the newest edit', async () => {
+    await render();
+    const pendingWrite = deferred<void>();
+    mocks.write.mockReturnValueOnce(pendingWrite.promise);
+    await act(async () => model().setValue('first edit'));
+    let firstSave: Promise<unknown> | undefined;
+    await act(async () => { firstSave = session.save?.(); });
+    await act(async () => model().setValue('second edit'));
+    const secondSave = session.save?.();
+    await act(async () => pendingWrite.resolve(undefined));
+    await act(async () => { await Promise.all([firstSave, secondSave]); });
+    expect(mocks.write.mock.calls.map(call => call[2])).toEqual(['first edit', 'second edit']);
+    expect(session.snapshot).toMatchObject({ content: 'second edit', savedContent: 'second edit', isDirty: false });
+  });
+
   it.each(['success', 'failure'])('keeps edits dirty after a delayed metadata %s', async outcome => {
     await render();
     const pendingMetadata = deferred<typeof fileMetadata>();
diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx
index d7cd26643..33efa75d2 100644
--- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx
+++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx
@@ -304,6 +304,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
   const filePathRef = useRef<string>(filePath);
   const saveFileContentRef = useRef<() => Promise<void>>();
   const saveInFlightRef = useRef<Promise<void>>();
+  const saveQueuedRef = useRef(false);
   const latestEditorConfigRef = useRef<Partial<EditorConfigType> | null>(null);
   const delayedFontApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const userLanguageOverrideRef = useRef(false);
@@ -1745,13 +1746,31 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
     workspacePath,
   ]);
 
-  const saveFileContent = useCallback(() => {
-    if (!saveInFlightRef.current) {
-      saveInFlightRef.current = persistFileContent().finally(() => {
-        saveInFlightRef.current = undefined;
-      });
+  // Concurrent save requests share one write, but a request that arrives while
+  // that write is in flight must not be dropped: it can only capture newer
+  // buffer content, so it re-runs once the current write settles. Callers that
+  // await a save (for example "save then compile") therefore always observe
+  // the latest buffer on disk.
+  const saveFileContent = useCallback(async (): Promise<void> => {
+    const inFlight = saveInFlightRef.current;
+    if (inFlight) {
+      saveQueuedRef.current = true;
+      await inFlight;
+      return;
     }
-    return saveInFlightRef.current;
+
+    const started = (async (): Promise<void> => {
+      try {
+        do {
+          saveQueuedRef.current = false;
+          await persistFileContent();
+        } while (saveQueuedRef.current && hasChangesRef.current);
+      } finally {
+        saveInFlightRef.current = undefined;
+      }
+    })();
+    saveInFlightRef.current = started;
+    await started;
   }, [persistFileContent]);
   
   useEffect(() => {
-- 
2.53.0.windows.2

Keep PaperWriting in the built-in catalog contract and give it the
standard thread-goal tools. Queue saves arriving during another write
and retain the requesting document so switching files cannot send edits
to the wrong path.

Co-authored-by: Raing5Days <Raing5Days@users.noreply.github.com>
@nonoqing
nonoqing merged commit 16a3438 into openBitFun:main Nov 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants