diff options
| author | haoyuren <13851610112@163.com> | 2026-09-04 22:26:49 -0500 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-09-04 22:26:49 -0500 |
| commit | 34f22876ab9e15e9f14300a8c21cd7109800d1ab (patch) | |
| tree | f9599f631c38773666c747f41e97752cfafc8e19 /src | |
| parent | 1251d9208caadce626fd0706dfd34ba3e022fb2a (diff) | |
Main document: the app never called Overleaf's official settings endpoint,
so "Set as main document" only changed renderer state and agents had no way
to switch at all — compiles kept using the old root. Now:
- Right-click Set as main and the new set_main_file MCP tool both POST
/project/:id/settings {rootDocId} (web parity, persists project-wide)
- Handle the rootDocUpdated broadcast: update the project snapshot, the
renderer state, and rewrite CLAUDE.md/AGENTS.md — the guide's stale
"Main file" line was teaching agents to compile the old root via
main_file overrides
- Compile root resolution uses the sync bridge's live maps, so docs
created mid-session resolve correctly (agent guide too)
MCP server location: Codex stores its MCP registration globally, and the
per-project path went stale when the project temp dir was cleaned up
("connection closed" on handshake). The bundled server now installs to
~/.lattex/lattex-mcp.mjs (project-independent, refreshed on connect; the
esbuild bundle since the raw source can't resolve node_modules from there)
and resolves the project at runtime: $LATTEX_PROJECT_DIR, cwd + ancestors,
legacy script location, then single live project in tmpdir. Register once,
works for every project.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/fileSyncBridge.ts | 11 | ||||
| -rw-r--r-- | src/main/index.ts | 399 | ||||
| -rw-r--r-- | src/mcp/lattex.mjs | 101 | ||||
| -rw-r--r-- | src/preload/index.ts | 7 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 12 | ||||
| -rw-r--r-- | src/renderer/src/components/FileTree.tsx | 23 |
6 files changed, 398 insertions, 155 deletions
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts index 08bf69e..7de04d7 100644 --- a/src/main/fileSyncBridge.ts +++ b/src/main/fileSyncBridge.ts @@ -1785,6 +1785,17 @@ export class FileSyncBridge { return Object.entries(this.fileRefPathMap).map(([id, path]) => ({ id, path })) } + /** Live docId → relPath lookup — unlike the connect-time project snapshot, + * this includes docs created mid-session */ + getDocPath(docId: string): string | undefined { + return this.docPathMap[docId] + } + + /** Live relPath → docId lookup (see getDocPath) */ + getDocIdForPath(relPath: string): string | undefined { + return this.pathDocMap[relPath] + } + /** Check if a doc's content is known */ hasDoc(relPath: string): boolean { return this.lastKnownContent.has(relPath) diff --git a/src/main/index.ts b/src/main/index.ts index 986e29b..c85a1b2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -43,6 +43,10 @@ interface ProjectSession { mcpCompileRequestPath: string | null mcpCompileActive: boolean compileInProgress: Promise<{ success: boolean; log: string; pdfPath: string }> | null + // Cached once at connect, used whenever the agent guide is (re)written + mcpServerPath: string + currentUserName: string + ownerName: string } const sessions = new Map<number, ProjectSession>() // webContents.id → session @@ -90,20 +94,44 @@ async function writeMcpState(s: ProjectSession): Promise<void> { } catch { /* ignore */ } } -async function prepareMcpServerPath(tmpDir: string): Promise<string> { - const sourcePath = app.isPackaged - ? join(app.getAppPath() + '.unpacked', 'out', 'mcp', 'lattex.mjs') - : join(__dirname, '..', '..', 'src', 'mcp', 'lattex.mjs') - - if (!app.isPackaged) return sourcePath +async function prepareMcpServerPath(): Promise<string> { + // Copy to a stable, project-INDEPENDENT path. Agents like Codex store the + // registered command globally — a per-project path goes stale the moment + // the project's temp dir is cleaned up ("connection closed" on handshake). + // The server resolves the actual project from its runtime cwd instead. + // (Also avoids persisting unsigned-macOS App Translocation paths.) + // + // The copy must be the esbuild BUNDLE (dependencies inlined) — the raw + // source imports @modelcontextprotocol/sdk, which is unresolvable from + // ~/.lattex where there is no node_modules. + const stableDir = join(app.getPath('home'), '.lattex') + await mkdirAsync(stableDir, { recursive: true }) + const serverPath = join(stableDir, 'lattex-mcp.mjs') + + if (app.isPackaged) { + await copyFile(join(app.getAppPath() + '.unpacked', 'out', 'mcp', 'lattex.mjs'), serverPath) + return serverPath + } - // Unsigned macOS apps can be launched from an App Translocation path. That - // path is not stable enough to persist in .mcp.json, so copy the bundled MCP - // server into the live project directory and point Claude at the copy. - const mcpDir = join(tmpDir, '.lattex') - await mkdirAsync(mcpDir, { recursive: true }) - const serverPath = join(mcpDir, 'lattex-mcp.mjs') - await copyFile(sourcePath, serverPath) + // Dev: `npm run dev` doesn't produce the bundle — build it if missing or + // older than the source. + const repoRoot = join(__dirname, '..', '..') + const bundled = join(repoRoot, 'out', 'mcp', 'lattex.mjs') + const source = join(repoRoot, 'src', 'mcp', 'lattex.mjs') + const [bStat, sStat] = await Promise.all([ + stat(bundled).catch(() => null), + stat(source).catch(() => null) + ]) + if (!bStat || (sStat && sStat.mtimeMs > bStat.mtimeMs)) { + await new Promise<void>((resolve) => { + const proc = spawn('npx', [ + 'esbuild', source, '--bundle', '--platform=node', '--format=esm', `--outfile=${bundled}` + ], { cwd: repoRoot }) + proc.on('close', () => resolve()) + proc.on('error', () => resolve()) + }) + } + await copyFile(bundled, serverPath) return serverPath } @@ -160,6 +188,142 @@ function writeMcpOnlineUsers(s: ProjectSession): void { }, 500) } +/** + * Write .claude/CLAUDE.md + AGENTS.md for this session. Called at connect AND + * whenever the main document changes ('rootDocUpdated') — a stale "Main file" + * line makes agents "helpfully" compile the old root via main_file overrides. + * Uses the bridge's live maps so mid-session docs are included. + */ +async function writeAgentGuides(s: ProjectSession): Promise<void> { + const project = s.sock.projectData?.project + if (!project || !s.mcpStateDir) return + + let rootDocPath = s.fileSyncBridge?.getDocPath(project.rootDoc_id) || '' + let texFiles: string[] + if (s.fileSyncBridge) { + texFiles = s.fileSyncBridge.getAllDocContents().map((d) => d.path).filter((p) => p.endsWith('.tex')).sort() + } else { + const { docPathMap } = walkRootFolder(project.rootFolder) + if (!rootDocPath) rootDocPath = docPathMap[project.rootDoc_id] || '' + texFiles = Object.values(docPathMap).filter((p) => p.endsWith('.tex')).sort() + } + if (!rootDocPath) rootDocPath = 'main.tex' + const fileListStr = texFiles.map((p) => `- \`${p}\``).join('\n') + + // One guide, two consumers: .claude/CLAUDE.md (Claude Code's native + // location) and AGENTS.md at the project root (the cross-tool standard + // read by Codex, Cursor, Gemini CLI, etc.). AGENTS.md is excluded from + // Overleaf sync alongside CLAUDE.md/.mcp.json. + const agentGuide = `# ${project.name} — Overleaf Project + +> **IMPORTANT — MANDATORY FIRST STEPS (do this EVERY conversation before ANY edits):** +> +> 1. **Read \`${rootDocPath}\`** to discover the paper structure — identify every \\\\input{} and \\\\include{} file. +> 2. **Read EVERY file** found in step 1, one by one. This means reading the full content of each .tex file listed below. Do NOT skip any file. Do NOT skim. You need to understand the paper's argument, notation, macro usage, and conventions before touching anything. +> 3. **Run \`get_comments\`** to check for reviewer comments, TODOs, or ongoing discussions. +> 4. Only AFTER completing steps 1–3 may you proceed with the user's request. +> +> This is a live Overleaf project — your edits appear to collaborators in real-time. Careless changes to a document you haven't fully read WILL break things and waste collaborators' time. + +This is a LaTeX project synced from Overleaf via LatteX. All files here are **bidirectionally synced** — your edits appear on Overleaf in real-time, and vice versa. +${s.currentUserName ? `\n**You are logged in as: ${s.currentUserName}** — this is the name that appears on comments and edits. The project owner is ${s.ownerName}.` : `\n**Project owner**: ${s.ownerName}`} + +## Project Structure + +- **Main file**: \`${rootDocPath}\` (the root document for compilation — this file is rewritten automatically when the main document changes; use \`set_main_file\` to change it, do NOT pass a stale \`main_file\` to compile_latex) +${fileListStr ? `- **TeX files**:\n${fileListStr}` : ''} + +## Rules + +- **NEVER edit without reading first.** You must understand what you are changing. Read the relevant file(s) fully before making any modification. +- **Match existing conventions.** Follow the notation, formatting, macro usage, and sectioning style already established in the document. Do NOT impose your own style. +- **Do NOT reorganize, rename labels, or refactor macros** unless explicitly asked. +- **Make targeted edits only.** Modify the specific parts that need changing. Do not rewrite surrounding paragraphs for style. +- **One logical change at a time.** Do not mix unrelated edits in a single pass. +- **Compile after changes.** Use \`compile_latex\` after every edit. If compilation fails, use \`get_compile_errors\` and fix immediately before proceeding. +- **Respond to comments.** When you address a comment, use \`reply_to_comment\` to explain what you changed, then \`resolve_comment\`. Never delete others' comments. + +## MCP Tools + +You have MCP tools to interact with Overleaf. Use them proactively. + +### Comments +- **get_comments**: Read comments. Pass \`file\` to filter, \`include_resolved\` for all. +- **resolve_comment**: Resolve a comment by \`thread_id\`. +- **reopen_comment**: Reopen a resolved comment. +- **reply_to_comment**: Reply to a comment thread. +- **delete_comment**: Permanently delete a comment thread. + +### Chat +- **get_chat_messages**: Read project chat history. +- **send_chat_message**: Send a message to project chat. + +### Project +- **list_project_files**: List all files with sizes. +- **get_online_users**: See who is currently online in this project. + +### Compilation +- **compile_latex**: Trigger LaTeX compilation on Overleaf server. Returns status + error summary. Compiles the project's main document; only pass \`main_file\` for a deliberate one-off compile of a different root. +- **set_main_file**: Permanently change the project's main document (root .tex file) — persists on Overleaf, same as Menu → Main document in the web editor. +- **get_compile_errors**: Get parsed errors from last compile (file, line, message). +- **get_compile_warnings**: Get parsed warnings from last compile. +- **get_compile_log**: Get full raw log. Pass \`tail: N\` for last N lines only. + +### PDF +- **read_compiled_pdf**: Get the path to the compiled PDF. After calling this, use your **Read** tool on the returned path to visually inspect the PDF. Use the \`pages\` parameter (e.g. \`"1-3"\`) to read specific pages. This lets you verify formatting, figures, tables, and layout. + +### Bibliography +- **search_citation**: Search academic papers by title, topic, or author (Semantic Scholar). Returns matching papers with ready-to-use BibTeX entries that can be pasted directly into a \`.bib\` file. **Note:** Without a Semantic Scholar API key configured in LatteX settings, requests will likely be rate-limited (HTTP 429). With a key, the rate limit is 1 request/second. +- **search_openalex**: Search scholarly works via OpenAlex (broader/faster-moving coverage, citation counts, venues). **Citation policy:** OpenAlex metadata lags, so BibTeX is cross-checked against Semantic Scholar — only entries marked "Semantic Scholar ✓" are authoritative. Entries marked "OpenAlex only ⚠" must be verified with a web search (publisher page / arXiv) before citing; very recent papers may be missing from both indexes. + +### Workflows + +#### Comment Workflow +1. Use \`get_comments\` to see what reviewers have flagged +2. Read the relevant sections to understand context +3. Edit the .tex files to address the feedback +4. Use \`reply_to_comment\` to explain what you changed +5. Use \`resolve_comment\` to mark it as done + +#### Compile-Debug Workflow +1. Edit .tex files +2. Use \`compile_latex\` to compile +3. If errors: use \`get_compile_errors\` for details, fix them, recompile +4. If warnings: use \`get_compile_warnings\` to review +5. To check visual output: use \`read_compiled_pdf\`, then Read the returned path with \`pages: "1-3"\` + +#### Bibliography Workflow +1. Use \`search_citation\` (Semantic Scholar) or \`search_openalex\` (broader coverage) to find references +2. If the entry is marked "OpenAlex only ⚠", verify it with a web search before using it +3. Copy the BibTeX entry into the \`.bib\` file +4. Use \`\\cite{key}\` in the \`.tex\` file +5. Compile to verify the citation renders correctly + +## Workspace + +The \`claude-workspace/\` directory is your private scratch space. It is **not synced to Overleaf** — use it freely for: +- **Notes and plans** — draft outlines, track TODOs, keep analysis notes +- **Experiments** — test LaTeX snippets, try alternative formulations, prototype figures +- **Scripts** — helper scripts for data processing, bibliography management, etc. + +**Important**: Always ask the user before running experiments or creating files in \`claude-workspace/\`. This directory persists across sessions for the same project. + +## Agent Setup (MCP) + +The tools above come from LatteX's MCP server (standard stdio MCP — works with any MCP-capable agent): + +- **Claude Code**: auto-configured. \`.mcp.json\` in this directory registers the \`lattex\` server and \`.claude/settings.json\` pre-approves its tools. Just run \`claude\`. +- **Codex CLI**: one-time setup (works for every LatteX project — the server detects the project from the directory you launch codex in): + \`\`\` + codex mcp add lattex -- node "${s.mcpServerPath}" + \`\`\` + If you previously registered a project-specific path, run \`codex mcp remove lattex\` first. Approve \`lattex\` tool calls when Codex prompts. +- **Any other MCP client**: stdio transport, command \`node "${s.mcpServerPath}"\` launched in the project directory (or set \`LATTEX_PROJECT_DIR\`). +` + await writeFile(join(s.mcpStateDir, '.claude', 'CLAUDE.md'), agentGuide) + await writeFile(join(s.mcpStateDir, 'AGENTS.md'), agentGuide) +} + function createListWindow(): void { const win = new BrowserWindow({ width: 1400, @@ -1224,7 +1388,10 @@ async function otConnectImpl(e: Electron.IpcMainInvokeEvent, projectId: string) commentContextRefreshTimer: null, mcpCompileRequestPath: null, mcpCompileActive: false, - compileInProgress: null + compileInProgress: null, + mcpServerPath: '', + currentUserName: '', + ownerName: '' } sessions.set(s.webContentsId, s) sessionsByProject.set(projectId, s) @@ -1283,6 +1450,17 @@ async function otConnectImpl(e: Electron.IpcMainInvokeEvent, projectId: string) s.mcpOnlineUsers.delete(clientId) writeMcpOnlineUsers(s) } + } else if (name === 'rootDocUpdated') { + // Main document changed (by us via /settings, or by a collaborator) — + // keep the project snapshot current so compiles use the new root + const newRootDocId = args[0] as string + if (sock.projectData && newRootDocId) { + sock.projectData.project.rootDoc_id = newRootDocId + } + sessionSend(s, 'project:rootDocUpdated', newRootDocId) + // Rewrite CLAUDE.md/AGENTS.md — a stale "Main file" line makes agents + // compile the old root via main_file overrides + writeAgentGuides(s).catch(() => {}) } else if (name === 'new-chat-message') { sessionSend(s, 'chat:newMessage', args[0]) } else if ( @@ -1343,7 +1521,8 @@ async function otConnectImpl(e: Electron.IpcMainInvokeEvent, projectId: string) // temp dir so .mcp.json never contains a stale App Translocation path. let mcpServerPath = '' try { - mcpServerPath = await prepareMcpServerPath(tmpDir) + mcpServerPath = await prepareMcpServerPath() + s.mcpServerPath = mcpServerPath await writeFile(join(tmpDir, '.mcp.json'), JSON.stringify({ mcpServers: { lattex: { @@ -1363,132 +1542,18 @@ async function otConnectImpl(e: Electron.IpcMainInvokeEvent, projectId: string) mkdirAsync(join(tmpDir, 'claude-workspace'), { recursive: true }).catch(() => {}) // Write .claude/ dir with CLAUDE.md + settings (dotfile dir = excluded from sync) mkdirAsync(join(tmpDir, '.claude'), { recursive: true }).then(async () => { - const rootDocPath = docPathMap[projectResult.project.rootDoc_id] || 'main.tex' - const texFiles = Object.values(docPathMap).filter((p: string) => p.endsWith('.tex')) - const fileListStr = texFiles.map((p: string) => `- \`${p}\``).join('\n') - - // Fetch current user's name for CLAUDE.md - let currentUserName = '' + // Fetch current user's name once for the agent guide try { const userResult = await overleafFetch('/user/settings') if (userResult.ok && userResult.data) { const u = userResult.data as { first_name?: string; last_name?: string; email?: string } - currentUserName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email || '' + s.currentUserName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email || '' } } catch { /* non-fatal */ } - const ownerName = [projectResult.project.owner.first_name, projectResult.project.owner.last_name].filter(Boolean).join(' ') + s.ownerName = [projectResult.project.owner.first_name, projectResult.project.owner.last_name].filter(Boolean).join(' ') - // One guide, two consumers: .claude/CLAUDE.md (Claude Code's native - // location) and AGENTS.md at the project root (the cross-tool standard - // read by Codex, Cursor, Gemini CLI, etc.). AGENTS.md is excluded from - // Overleaf sync alongside CLAUDE.md/.mcp.json. - const agentGuide = `# ${projectResult.project.name} — Overleaf Project + await writeAgentGuides(s) -> **IMPORTANT — MANDATORY FIRST STEPS (do this EVERY conversation before ANY edits):** -> -> 1. **Read \`${rootDocPath}\`** to discover the paper structure — identify every \\\\input{} and \\\\include{} file. -> 2. **Read EVERY file** found in step 1, one by one. This means reading the full content of each .tex file listed below. Do NOT skip any file. Do NOT skim. You need to understand the paper's argument, notation, macro usage, and conventions before touching anything. -> 3. **Run \`get_comments\`** to check for reviewer comments, TODOs, or ongoing discussions. -> 4. Only AFTER completing steps 1–3 may you proceed with the user's request. -> -> This is a live Overleaf project — your edits appear to collaborators in real-time. Careless changes to a document you haven't fully read WILL break things and waste collaborators' time. - -This is a LaTeX project synced from Overleaf via LatteX. All files here are **bidirectionally synced** — your edits appear on Overleaf in real-time, and vice versa. -${currentUserName ? `\n**You are logged in as: ${currentUserName}** — this is the name that appears on comments and edits. The project owner is ${ownerName}.` : `\n**Project owner**: ${ownerName}`} - -## Project Structure - -- **Main file**: \`${rootDocPath}\` (this is the root document for compilation) -${fileListStr ? `- **TeX files**:\n${fileListStr}` : ''} - -## Rules - -- **NEVER edit without reading first.** You must understand what you are changing. Read the relevant file(s) fully before making any modification. -- **Match existing conventions.** Follow the notation, formatting, macro usage, and sectioning style already established in the document. Do NOT impose your own style. -- **Do NOT reorganize, rename labels, or refactor macros** unless explicitly asked. -- **Make targeted edits only.** Modify the specific parts that need changing. Do not rewrite surrounding paragraphs for style. -- **One logical change at a time.** Do not mix unrelated edits in a single pass. -- **Compile after changes.** Use \`compile_latex\` after every edit. If compilation fails, use \`get_compile_errors\` and fix immediately before proceeding. -- **Respond to comments.** When you address a comment, use \`reply_to_comment\` to explain what you changed, then \`resolve_comment\`. Never delete others' comments. - -## MCP Tools - -You have MCP tools to interact with Overleaf. Use them proactively. - -### Comments -- **get_comments**: Read comments. Pass \`file\` to filter, \`include_resolved\` for all. -- **resolve_comment**: Resolve a comment by \`thread_id\`. -- **reopen_comment**: Reopen a resolved comment. -- **reply_to_comment**: Reply to a comment thread. -- **delete_comment**: Permanently delete a comment thread. - -### Chat -- **get_chat_messages**: Read project chat history. -- **send_chat_message**: Send a message to project chat. - -### Project -- **list_project_files**: List all files with sizes. -- **get_online_users**: See who is currently online in this project. - -### Compilation -- **compile_latex**: Trigger LaTeX compilation on Overleaf server. Returns status + error summary. -- **get_compile_errors**: Get parsed errors from last compile (file, line, message). -- **get_compile_warnings**: Get parsed warnings from last compile. -- **get_compile_log**: Get full raw log. Pass \`tail: N\` for last N lines only. - -### PDF -- **read_compiled_pdf**: Get the path to the compiled PDF. After calling this, use your **Read** tool on the returned path to visually inspect the PDF. Use the \`pages\` parameter (e.g. \`"1-3"\`) to read specific pages. This lets you verify formatting, figures, tables, and layout. - -### Bibliography -- **search_citation**: Search academic papers by title, topic, or author (Semantic Scholar). Returns matching papers with ready-to-use BibTeX entries that can be pasted directly into a \`.bib\` file. **Note:** Without a Semantic Scholar API key configured in LatteX settings, requests will likely be rate-limited (HTTP 429). With a key, the rate limit is 1 request/second. -- **search_openalex**: Search scholarly works via OpenAlex (broader/faster-moving coverage, citation counts, venues). **Citation policy:** OpenAlex metadata lags, so BibTeX is cross-checked against Semantic Scholar — only entries marked "Semantic Scholar ✓" are authoritative. Entries marked "OpenAlex only ⚠" must be verified with a web search (publisher page / arXiv) before citing; very recent papers may be missing from both indexes. - -### Workflows - -#### Comment Workflow -1. Use \`get_comments\` to see what reviewers have flagged -2. Read the relevant sections to understand context -3. Edit the .tex files to address the feedback -4. Use \`reply_to_comment\` to explain what you changed -5. Use \`resolve_comment\` to mark it as done - -#### Compile-Debug Workflow -1. Edit .tex files -2. Use \`compile_latex\` to compile -3. If errors: use \`get_compile_errors\` for details, fix them, recompile -4. If warnings: use \`get_compile_warnings\` to review -5. To check visual output: use \`read_compiled_pdf\`, then Read the returned path with \`pages: "1-3"\` - -#### Bibliography Workflow -1. Use \`search_citation\` (Semantic Scholar) or \`search_openalex\` (broader coverage) to find references -2. If the entry is marked "OpenAlex only ⚠", verify it with a web search before using it -3. Copy the BibTeX entry into the \`.bib\` file -4. Use \`\\cite{key}\` in the \`.tex\` file -5. Compile to verify the citation renders correctly - -## Workspace - -The \`claude-workspace/\` directory is your private scratch space. It is **not synced to Overleaf** — use it freely for: -- **Notes and plans** — draft outlines, track TODOs, keep analysis notes -- **Experiments** — test LaTeX snippets, try alternative formulations, prototype figures -- **Scripts** — helper scripts for data processing, bibliography management, etc. - -**Important**: Always ask the user before running experiments or creating files in \`claude-workspace/\`. This directory persists across sessions for the same project. - -## Agent Setup (MCP) - -The tools above come from LatteX's MCP server (standard stdio MCP — works with any MCP-capable agent): - -- **Claude Code**: auto-configured. \`.mcp.json\` in this directory registers the \`lattex\` server and \`.claude/settings.json\` pre-approves its tools. Just run \`claude\`. -- **Codex CLI**: register the server once for this project: - \`\`\` - codex mcp add lattex -- node "${mcpServerPath}" - \`\`\` - The path is project-specific — re-run this when switching projects. Approve \`lattex\` tool calls when Codex prompts. -- **Any other MCP client**: stdio transport, command \`node "${mcpServerPath}"\`. -` - await writeFile(join(tmpDir, '.claude', 'CLAUDE.md'), agentGuide) - await writeFile(join(tmpDir, 'AGENTS.md'), agentGuide) await writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: [ @@ -1502,6 +1567,7 @@ The tools above come from LatteX's MCP server (standard stdio MCP — works with 'mcp__lattex__list_project_files', 'mcp__lattex__get_online_users', 'mcp__lattex__compile_latex', + 'mcp__lattex__set_main_file', 'mcp__lattex__get_compile_errors', 'mcp__lattex__get_compile_warnings', 'mcp__lattex__get_compile_log', @@ -1929,6 +1995,24 @@ ipcMain.handle('overleaf:setProjectState', async (_e, projectId: string, action: return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) +// Set the project's main document — the official web endpoint +// (POST /project/:id/settings {rootDocId}), which persists project-wide and +// broadcasts 'rootDocUpdated' to all connected clients (including us). +ipcMain.handle('overleaf:setRootDoc', async (e, projectId: string, docId: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + const result = await overleafFetch(`/project/${projectId}/settings`, { + method: 'POST', + body: JSON.stringify({ rootDocId: docId }) + }) + if (result.ok) { + // Update the snapshot immediately — the socket echo also does this, but + // a compile fired right after shouldn't race it + const s = getSession(e) + if (s?.sock.projectData) s.sock.projectData.project.rootDoc_id = docId + } + return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } +}) + ipcMain.handle('overleaf:renameProject', async (_e, projectId: string, newName: string) => { if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } const result = await overleafFetch(`/project/${projectId}/rename`, { @@ -2192,11 +2276,16 @@ async function doServerCompileImpl(s: ProjectSession, rootDocId?: string): Promi const projectId = s.sock.projectData.project._id const effectiveRootDocId = rootDocId || s.sock.projectData.project.rootDoc_id || null - // Resolve rootResourcePath (file path of root doc) — matches Overleaf web client + // Resolve rootResourcePath (file path of root doc) — matches Overleaf web + // client. Prefer the bridge's live maps: the connect-time project snapshot + // doesn't know docs created mid-session. let rootResourcePath: string | undefined if (effectiveRootDocId) { - const { docPathMap } = walkRootFolder(s.sock.projectData.project.rootFolder) - rootResourcePath = docPathMap[effectiveRootDocId] + rootResourcePath = s.fileSyncBridge?.getDocPath(effectiveRootDocId) + if (!rootResourcePath) { + const { docPathMap } = walkRootFolder(s.sock.projectData.project.rootFolder) + rootResourcePath = docPathMap[effectiveRootDocId] + } } try { @@ -2355,15 +2444,43 @@ function startMcpCompileWatcher(s: ProjectSession, syncDir: string) { const reqData = JSON.parse(await readFile(requestPath, 'utf-8')) await unlink(requestPath).catch(() => {}) - console.log('[mcp-compile] compile request received:', reqData.requestId) + console.log('[mcp-compile] request received:', reqData.requestId, reqData.action || 'compile') + + // Resolve a relPath to a docId via the bridge's LIVE maps first — + // s.mcpPathDocMap shares the same object, but be explicit and also + // tolerate a leading "./" + const resolveDoc = (relPath?: string): string | undefined => { + if (!relPath) return undefined + const clean = relPath.replace(/^\.\//, '') + return s.fileSyncBridge?.getDocIdForPath(clean) ?? s.mcpPathDocMap[clean] + } + + // set-main-file action: persist the project's main document via the + // official settings endpoint (no compile) + if (reqData.action === 'setMainFile') { + const docId = resolveDoc(reqData.mainFile) + let outcome: { success: boolean; error?: string } + if (!docId) { + outcome = { success: false, error: `No document found at path: ${reqData.mainFile}` } + } else { + const res = await overleafFetch(`/project/${s.projectId}/settings`, { + method: 'POST', + body: JSON.stringify({ rootDocId: docId }) + }) + if (res.ok && s.sock.projectData) s.sock.projectData.project.rootDoc_id = docId + outcome = res.ok ? { success: true } : { success: false, error: `HTTP ${res.status}` } + } + await writeFile(resultPath, JSON.stringify({ requestId: reqData.requestId, ...outcome })) + console.log('[mcp-compile] setMainFile result:', JSON.stringify(outcome)) + return + } // Notify renderer: compile started sessionSend(s, 'compile:mcpStarted', null) - // Resolve main_file to rootDocId if provided - let rootDocId: string | undefined - if (reqData.mainFile && s.mcpPathDocMap[reqData.mainFile]) { - rootDocId = s.mcpPathDocMap[reqData.mainFile] + const rootDocId = resolveDoc(reqData.mainFile) + if (reqData.mainFile && !rootDocId) { + console.log('[mcp-compile] WARNING: main_file not found, compiling project root:', reqData.mainFile) } const result = await doServerCompile(s, rootDocId) diff --git a/src/mcp/lattex.mjs b/src/mcp/lattex.mjs index 265ef09..8ea2b15 100644 --- a/src/mcp/lattex.mjs +++ b/src/mcp/lattex.mjs @@ -16,24 +16,51 @@ import { import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, unlinkSync } from 'fs' import { join, relative, dirname } from 'path' import { fileURLToPath } from 'url' +import { tmpdir } from 'os' import https from 'https' // ── Project directory resolution ────────────────────────────── // -// Claude Code launches MCP servers with the project as cwd, but other MCP -// clients (Codex reads global config) may not. The packaged server is copied -// into <project>/.lattex/lattex-mcp.mjs, so the script's own location also -// identifies the project. Resolution order: cwd, then script parent dir. +// The server binary lives at a stable, project-independent path +// (~/.lattex/lattex-mcp.mjs) so agents register it ONCE; the project is +// resolved at runtime. Claude Code spawns MCP servers with the project as +// cwd; other clients (Codex reads global config) may spawn from anywhere. +// Resolution order: +// 1. $LATTEX_PROJECT_DIR (explicit override) +// 2. cwd or any ancestor containing .lattex-mcp.json (agents cd around) +// 3. the script's parent dir (legacy per-project copy at <project>/.lattex/) +// 4. the single live LatteX project dir in the OS tmpdir, if exactly one function resolveProjectDir() { - const cwd = process.cwd() - if (existsSync(join(cwd, '.lattex-mcp.json'))) return cwd + const marker = '.lattex-mcp.json' + + const envDir = process.env.LATTEX_PROJECT_DIR + if (envDir && existsSync(join(envDir, marker))) return envDir + + let dir = process.cwd() + for (let i = 0; i < 12; i++) { + if (existsSync(join(dir, marker))) return dir + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + try { const scriptDir = dirname(fileURLToPath(import.meta.url)) const candidate = dirname(scriptDir) // <project>/.lattex/.. = <project> - if (existsSync(join(candidate, '.lattex-mcp.json'))) return candidate + if (existsSync(join(candidate, marker))) return candidate + } catch { /* fall through */ } + + try { + const tmp = tmpdir() + const candidates = readdirSync(tmp) + .filter((name) => name.startsWith('lattex-')) + .map((name) => join(tmp, name)) + .filter((p) => existsSync(join(p, marker))) + if (candidates.length === 1) return candidates[0] } catch { /* fall through */ } - return cwd + + return process.cwd() } const PROJECT_DIR = resolveProjectDir() @@ -474,12 +501,26 @@ const TOOLS = [ properties: { main_file: { type: 'string', - description: 'Optional main .tex file path (e.g. "main.tex"). Uses project default if omitted.' + description: 'Optional main .tex file path (e.g. "main.tex") to use as the root for THIS compile only. Uses the project main document if omitted. To change the main document permanently, use set_main_file.' } } } }, { + name: 'set_main_file', + description: 'Set the project\'s main document (the root .tex file used for compilation). Persists project-wide on Overleaf — same as Menu → Main document in the web editor — and applies to all future compiles.', + inputSchema: { + type: 'object', + properties: { + file: { + type: 'string', + description: 'Path of the .tex file to make the main document (e.g. "main.tex" or "sections/root.tex")' + } + }, + required: ['file'] + } + }, + { name: 'get_compile_errors', description: 'Get LaTeX errors from the last compilation. Returns parsed error messages with file paths and line numbers. Run compile_latex first.', inputSchema: { @@ -1010,6 +1051,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // ── Compilation ─────────────────────────────── + case 'set_main_file': { + const file = args?.file + if (!file) return textResult('Error: "file" is required (e.g. "main.tex").') + const cwd = PROJECT_DIR + const requestPath = join(cwd, '.lattex-compile-request') + const resultPath = join(cwd, '.lattex-compile-result') + try { unlinkSync(resultPath) } catch {} + + // Route through the LatteX main process — it has the live path→docId + // map (including docs created this session) and calls the official + // POST /project/:id/settings endpoint + const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + writeFileSync(requestPath, JSON.stringify({ + requestId, + action: 'setMainFile', + mainFile: file, + timestamp: Date.now() + })) + + const start = Date.now() + let result = null + while (Date.now() - start < 15000) { + await new Promise(r => setTimeout(r, 300)) + try { + if (existsSync(resultPath)) { + result = JSON.parse(readFileSync(resultPath, 'utf-8')) + unlinkSync(resultPath) + break + } + } catch {} + } + + if (!result) { + try { unlinkSync(requestPath) } catch {} + return textResult('Error: LatteX did not respond — is the project open in LatteX?') + } + if (result.success) { + return textResult(`Main document set to "${file}". All future compiles will use it as the root file.`) + } + return textResult(`Failed to set main document: ${result.error || 'unknown error'}`) + } + case 'compile_latex': { const mainFile = args?.main_file || null const cwd = PROJECT_DIR diff --git a/src/preload/index.ts b/src/preload/index.ts index 116e8e2..1b8ebce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -177,6 +177,13 @@ const api = { ipcRenderer.invoke('overleaf:removeProjectsFromTag', tagId, projectIds) as Promise<{ success: boolean; message?: string }>, overleafSetProjectState: (projectId: string, action: 'archive' | 'unarchive' | 'trash' | 'untrash' | 'delete' | 'leave') => ipcRenderer.invoke('overleaf:setProjectState', projectId, action) as Promise<{ success: boolean; message?: string }>, + overleafSetRootDoc: (projectId: string, docId: string) => + ipcRenderer.invoke('overleaf:setRootDoc', projectId, docId) as Promise<{ success: boolean; message?: string }>, + onProjectRootDocUpdated: (cb: (docId: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, docId: string) => cb(docId) + ipcRenderer.on('project:rootDocUpdated', handler) + return () => { ipcRenderer.removeListener('project:rootDocUpdated', handler) } + }, overleafRenameProject: (projectId: string, newName: string) => ipcRenderer.invoke('overleaf:renameProject', projectId, newName) as Promise<{ success: boolean; message?: string }>, overleafCloneProject: (projectId: string, projectName: string, tags?: string[]) => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f3f9f79..0f48a3c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -309,6 +309,17 @@ export default function App() { setStatusMessage('Overleaf session expired — please sign in again') }) + // Main document changed (by us or a collaborator) — track it + const unsubRootDoc = window.api.onProjectRootDocUpdated?.((docId) => { + const store = useAppStore.getState() + store.setMainDocument(docId) + if (store.overleafProject) { + store.setOverleafProject({ ...store.overleafProject, rootDocId: docId }) + } + const name = store.docPathMap[docId]?.split('/').pop() + if (name) setStatusMessage(`Main document: ${name}`) + }) + // Keep the file tree in sync with Overleaf project-entity socket events. const unsubEntityCreated = window.api.onSyncEntityCreated((data) => { applyEntityCreated(data) @@ -427,6 +438,7 @@ export default function App() { unsubCursorDisconnected() unsubFileStatus?.() unsubAuthExpired?.() + unsubRootDoc?.() remoteCursors.clear() stopAutocompleteSync() } diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index 5486f20..f53ae2f 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -212,14 +212,27 @@ export default function FileTree() { // ── Project view actions (Overleaf entities) ── - const handleSetMainDoc = () => { + const handleSetMainDoc = async () => { if (!ctxMenu) return const node = ctxMenu.node - if (node.docId) { - useAppStore.getState().setMainDocument(node.docId) - useAppStore.getState().setStatusMessage(`Main document set to ${node.name}`) - } closeMenu() + if (!node.docId) return + const store = useAppStore.getState() + // Optimistic local update; the official settings endpoint persists it + // project-wide (and broadcasts rootDocUpdated to collaborators) + store.setMainDocument(node.docId) + const projectId = store.overleafProjectId + if (!projectId) return + const r = await window.api.overleafSetRootDoc(projectId, node.docId) + if (r.success) { + const st = useAppStore.getState() + if (st.overleafProject) { + st.setOverleafProject({ ...st.overleafProject, rootDocId: node.docId }) + } + st.setStatusMessage(`Main document set to ${node.name}`) + } else { + useAppStore.getState().setStatusMessage(`Failed to set main document (${r.message || 'error'})`) + } } const handleCopyPath = () => { |
