diff options
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | src/main/fileSyncBridge.ts | 12 | ||||
| -rw-r--r-- | src/main/index.ts | 98 | ||||
| -rw-r--r-- | src/mcp/lattex.mjs | 24 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 64 | ||||
| -rw-r--r-- | src/renderer/src/components/Toolbar.tsx | 40 |
6 files changed, 208 insertions, 32 deletions
diff --git a/package.json b/package.json index 9fecdd4..b82bd04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lattex", - "version": "0.3.3", + "version": "0.3.4", "description": "LaTeX editor with real-time Overleaf sync", "license": "AGPL-3.0", "author": "Yuren Hao", diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts index f17808c..296ed12 100644 --- a/src/main/fileSyncBridge.ts +++ b/src/main/fileSyncBridge.ts @@ -163,7 +163,8 @@ export class FileSyncBridge { ignored: [ /(^|[/\\])\../, // dotfiles /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/, // LaTeX output files - /(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/ // App-generated config files + /(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/, // App-generated config files + /(?:^|[/\\])claude-workspace(?:[/\\]|$)/ // Claude Code scratch space (not synced) ] }) @@ -530,9 +531,10 @@ export class FileSyncBridge { private onFileChanged(relPath: string): void { if (this.stopped) return - // Skip app-generated config files that should not be synced to Overleaf + // Skip app-generated config files and scratch space that should not be synced const basename = relPath.split('/').pop() || relPath if (basename === 'CLAUDE.md' || basename === '.mcp.json') return + if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') return // Layer 1: Skip if bridge is currently writing this file if (this.writesInProgress.has(relPath)) { @@ -950,10 +952,11 @@ export class FileSyncBridge { for (const relPath of allFiles) { if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) continue - // Skip LaTeX output files and app-generated config files + // Skip LaTeX output files, app-generated config files, and scratch space if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex|synctex)/.test(relPath)) continue if (/(^|[/\\])\./.test(relPath)) continue if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) continue + if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') continue bridgeLog(`[FileSyncBridge] orphaned file found: ${relPath}`) this.onNewLocalFile(relPath) @@ -970,10 +973,11 @@ export class FileSyncBridge { if (this.stopped) return if (this.writesInProgress.has(relPath)) return - // Skip LaTeX output files, dotfiles, and app-generated config files (same as chokidar ignored) + // Skip LaTeX output files, dotfiles, app-generated config files, and scratch space if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/.test(relPath)) return if (/(^|[/\\])\./.test(relPath)) return if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) return + if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') return // Debounce 1s to let the tool finish writing const key = 'new:' + relPath diff --git a/src/main/index.ts b/src/main/index.ts index 5d29897..43de330 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,8 @@ let mcpStateDir = '' // syncDir for .lattex-mcp.json let mcpProjectId = '' let mcpCommentContexts: Record<string, { file: string; text: string; pos: number }> = {} let mcpPathDocMap: Record<string, string> = {} // relPath → docId for MCP +const mcpOnlineUsers = new Map<string, { name: string; email?: string }>() +let mcpOnlineUsersWriteTimer: ReturnType<typeof setTimeout> | null = null async function writeMcpState(): Promise<void> { if (!mcpStateDir || !mcpProjectId) return @@ -38,6 +40,15 @@ async function writeMcpState(): Promise<void> { } catch { /* ignore */ } } +function writeMcpOnlineUsers(): void { + if (!mcpStateDir) return + if (mcpOnlineUsersWriteTimer) clearTimeout(mcpOnlineUsersWriteTimer) + mcpOnlineUsersWriteTimer = setTimeout(() => { + const users = Array.from(mcpOnlineUsers.entries()).map(([id, u]) => ({ id, ...u })) + writeFile(join(mcpStateDir, '.lattex-online-users.json'), JSON.stringify(users)).catch(() => {}) + }, 500) +} + function createWindow(): void { mainWindow = new BrowserWindow({ width: 1400, @@ -714,12 +725,23 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { }) }) - // Relay collaborator cursor updates to renderer + // Relay collaborator cursor updates to renderer + track for MCP overleafSock.on('serverEvent', (name: string, args: unknown[]) => { if (name === 'clientTracking.clientUpdated') { sendToRenderer('cursor:remoteUpdate', args[0]) + // Track online user for MCP + const u = args[0] as { id: string; user_id?: string; name?: string; email?: string } + if (u.id) { + mcpOnlineUsers.set(u.id, { name: u.name || u.email?.split('@')[0] || 'User', email: u.email }) + writeMcpOnlineUsers() + } } else if (name === 'clientTracking.clientDisconnected') { sendToRenderer('cursor:remoteDisconnected', args[0]) + const clientId = args[0] as string + if (clientId) { + mcpOnlineUsers.delete(clientId) + writeMcpOnlineUsers() + } } else if (name === 'new-chat-message') { sendToRenderer('chat:newMessage', args[0]) } else if ( @@ -769,40 +791,52 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { }, null, 2)).catch(() => {}) // Clean up old root-level CLAUDE.md (was incorrectly placed there before) require('fs').unlink(join(tmpDir, 'CLAUDE.md'), () => {}) + // Create claude-workspace/ for Claude Code scratch space (not synced to Overleaf) + 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 = '' + 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 || '' + } + } catch { /* non-fatal */ } + const ownerName = [projectResult.project.owner.first_name, projectResult.project.owner.last_name].filter(Boolean).join(' ') + await writeFile(join(tmpDir, '.claude', 'CLAUDE.md'), `# ${projectResult.project.name} — Overleaf Project +> **IMPORTANT — MANDATORY FIRST STEPS (do this EVERY conversation before ANY edits):** +> +> 1. **Read \`${rootDocPath}\`** and ALL files it \\\\input/\\\\include to understand the full paper structure, notation, and conventions. You MUST NOT skip this step or make any edits before completing it. +> 2. **Run \`get_comments\`** to check for reviewer comments, TODOs, or ongoing discussions. +> 3. Only AFTER completing steps 1–2 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 read will break things. + 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}` : ''} -## Guidelines +## Rules -### Before Starting -- **Read the full document first.** Before making any changes, read through \`${rootDocPath}\` and its \\\\input/\\\\include files to understand the paper's structure, notation, and conventions. -- **Check comments.** Use \`get_comments\` to see if there are reviewer comments or TODOs that inform what needs to be done. - -### Writing Style -- **Match existing conventions.** Follow the notation, formatting, macro usage, and sectioning style already established in the document. -- **Don't reorganize without asking.** Preserve the existing structure — don't move sections, rename labels, or refactor macros unless explicitly asked. -- **Preserve \\\\label names.** Changing labels breaks cross-references across files. Only rename if asked. - -### Editing -- **Make targeted edits.** Modify only the parts that need changing. Don't rewrite surrounding paragraphs for style. -- **Compile after changes.** Use \`compile_latex\` to verify your edits don't introduce errors. If compilation fails, use \`get_compile_errors\` and fix immediately. -- **One logical change at a time.** Don't mix unrelated edits in a single pass. - -### Collaboration -- **Respond to comments.** When you address a comment, use \`reply_to_comment\` to explain what you changed, then \`resolve_comment\`. -- **Don't delete others' comments.** Only resolve them after addressing the feedback. +- **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 @@ -821,6 +855,7 @@ You have MCP tools to interact with Overleaf. Use them proactively. ### 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. @@ -842,6 +877,15 @@ You have MCP tools to interact with Overleaf. Use them proactively. 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 + +## 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. `) await writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ permissions: { @@ -854,6 +898,7 @@ You have MCP tools to interact with Overleaf. Use them proactively. 'mcp__lattex__get_chat_messages', 'mcp__lattex__send_chat_message', 'mcp__lattex__list_project_files', + 'mcp__lattex__get_online_users', 'mcp__lattex__compile_latex', 'mcp__lattex__get_compile_errors', 'mcp__lattex__get_compile_warnings', @@ -933,10 +978,12 @@ ipcMain.handle('ot:disconnect', async () => { stopMcpCompileWatcher() if (mcpStateDir) { unlink(join(mcpStateDir, '.lattex-mcp.json')).catch(() => {}) + unlink(join(mcpStateDir, '.lattex-online-users.json')).catch(() => {}) } mcpStateDir = '' mcpProjectId = '' mcpCommentContexts = {} + mcpOnlineUsers.clear() await fileSyncBridge?.stop() fileSyncBridge = null @@ -1039,7 +1086,18 @@ ipcMain.handle('cursor:update', async (_e, docId: string, row: number, column: n ipcMain.handle('cursor:getConnectedUsers', async () => { if (!overleafSock) return [] try { - return await overleafSock.getConnectedUsers() + const users = await overleafSock.getConnectedUsers() + // Seed MCP online users map + mcpOnlineUsers.clear() + for (const raw of users) { + const u = raw as { client_id?: string; first_name?: string; last_name?: string; email?: string } + if (u.client_id) { + const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email?.split('@')[0] || 'User' + mcpOnlineUsers.set(u.client_id, { name, email: u.email }) + } + } + writeMcpOnlineUsers() + return users } catch (e) { console.log('[cursor:getConnectedUsers] error:', e) return [] diff --git a/src/mcp/lattex.mjs b/src/mcp/lattex.mjs index 861ff40..cfac04a 100644 --- a/src/mcp/lattex.mjs +++ b/src/mcp/lattex.mjs @@ -486,6 +486,15 @@ const TOOLS = [ } } } + }, + // ── Online Users ── + { + name: 'get_online_users', + description: 'Get the list of users currently online in this Overleaf project.', + inputSchema: { + type: 'object', + properties: {} + } } ] @@ -861,6 +870,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { ) } + case 'get_online_users': { + const cwd = process.cwd() + const usersPath = join(cwd, '.lattex-online-users.json') + try { + const users = JSON.parse(readFileSync(usersPath, 'utf-8')) + if (!Array.isArray(users) || users.length === 0) { + return textResult('No other users currently online.') + } + const lines = users.map(u => `- ${u.name}${u.email ? ` (${u.email})` : ''}`) + return textResult(`${users.length} user(s) online:\n${lines.join('\n')}`) + } catch { + return textResult('No other users currently online.') + } + } + default: return errorResult(`Unknown tool: ${name}`) } diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index 7f7b24c..906bf36 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -2229,6 +2229,10 @@ html, body, #root { /* ── Toolbar Users Count ─────────────────────────────────────── */ +.toolbar-users-wrap { + position: relative; +} + .toolbar-users { font-size: 11px; color: var(--text-muted); @@ -2236,6 +2240,66 @@ html, body, #root { padding: 2px 8px; border-radius: 10px; font-weight: 500; + border: none; + cursor: pointer; + font-family: var(--font-sans); +} + +.toolbar-users:hover { + background: var(--bg-active); +} + +.users-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 0; + min-width: 180px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.users-popover-title { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 12px 6px; + border-bottom: 1px solid var(--border); + margin-bottom: 2px; +} + +.users-popover-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + font-size: 12px; + color: var(--text-primary); +} + +.users-popover-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.users-popover-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.users-popover-empty { + padding: 5px 12px; + font-size: 11px; + color: var(--text-muted); + font-style: italic; } /* ── Chat Panel ──────────────────────────────────────────────── */ diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 0cee7e1..b89385a 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react' import { useAppStore } from '../stores/appStore' +import { remoteCursors } from '../App' interface ToolbarProps { onCompile: () => void @@ -19,19 +20,24 @@ export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarPr } = useAppStore() const [showCompileMenu, setShowCompileMenu] = useState(false) + const [showUsersPopover, setShowUsersPopover] = useState(false) const menuRef = useRef<HTMLDivElement>(null) + const usersRef = useRef<HTMLDivElement>(null) - // Close menu on outside click + // Close menus on outside click useEffect(() => { - if (!showCompileMenu) return + if (!showCompileMenu && !showUsersPopover) return const handler = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + if (showCompileMenu && menuRef.current && !menuRef.current.contains(e.target as Node)) { setShowCompileMenu(false) } + if (showUsersPopover && usersRef.current && !usersRef.current.contains(e.target as Node)) { + setShowUsersPopover(false) + } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) - }, [showCompileMenu]) + }, [showCompileMenu, showUsersPopover]) const projectName = overleafProject?.name || 'Project' @@ -91,9 +97,29 @@ export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarPr </div> <div className="toolbar-right"> {onlineUsersCount > 0 && ( - <span className="toolbar-users" title={`${onlineUsersCount} user${onlineUsersCount > 1 ? 's' : ''} online`}> - {onlineUsersCount} - </span> + <div className="toolbar-users-wrap" ref={usersRef}> + <button + className="toolbar-users" + onClick={() => setShowUsersPopover(!showUsersPopover)} + title={`${onlineUsersCount} user${onlineUsersCount > 1 ? 's' : ''} online`} + > + {onlineUsersCount} online + </button> + {showUsersPopover && ( + <div className="users-popover"> + <div className="users-popover-title">Online Users</div> + {Array.from(remoteCursors.values()).map((u) => ( + <div key={u.userId} className="users-popover-item"> + <span className="users-popover-dot" style={{ background: u.color }} /> + <span className="users-popover-name">{u.name}</span> + </div> + ))} + {remoteCursors.size === 0 && ( + <div className="users-popover-empty">No cursor data yet</div> + )} + </div> + )} + </div> )} <button className={`toolbar-btn ${showChat ? 'active' : ''}`} onClick={toggleChat} title="Toggle chat"> Chat |
