summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/main/fileSyncBridge.ts12
-rw-r--r--src/main/index.ts98
-rw-r--r--src/mcp/lattex.mjs24
-rw-r--r--src/renderer/src/App.css64
-rw-r--r--src/renderer/src/components/Toolbar.tsx40
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