Merge pull request #3 from delibae/demo

Demo
This commit is contained in:
Hanjin Bae 2026-02-27 14:43:53 +09:00 • committed by GitHub
commit ebc19be429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 11718 additions and 3947 deletions

160
.github/workflows/build-desktop.yml vendored Normal file
View file

@ -0,0 +1,160 @@
name: Build Desktop
on:
workflow_dispatch:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: windows-latest
target: x86_64-pc-windows-msvc
label: windows
- platform: macos-latest
target: aarch64-apple-darwin
label: macos-arm64
- platform: macos-13
target: x86_64-apple-darwin
label: macos-x64
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
label: linux-x64
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Linux dependencies
if: matrix.label == 'linux-x64'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Build and release
if: startsWith(github.ref, 'refs/tags/v')
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/desktop
tauriScript: pnpm tauri
args: --target ${{ matrix.target }}
tagName: ${{ github.ref_name }}
releaseName: "ClaudePrism ${{ github.ref_name }}"
releaseBody: "Release is being built..."
releaseDraft: false
prerelease: false
- name: Build only (no release)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/desktop
tauriScript: pnpm tauri
args: --target ${{ matrix.target }}
- name: Upload artifacts (manual builds)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.label }}
path: |
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.msi
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.dmg
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.app.tar.gz*
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage*
if-no-files-found: warn
update-release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog and update release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF_NAME}"
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | sed -n '2p' || true)
# Build changelog
if [ -n "$PREV_TAG" ]; then
CHANGES=$(git log --pretty=format:"- %s" "$PREV_TAG".."$TAG" --no-merges 2>/dev/null || echo "- Initial release")
COMPARE="[Full Changelog](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
else
CHANGES="- Initial release"
COMPARE=""
fi
cat > release-body.md << EOF
<p align="center">
<img src="https://raw.githubusercontent.com/${{ github.repository }}/main/apps/desktop/src-tauri/icons/icon.png" width="128" height="128" />
</p>
## ClaudePrism ${TAG}
### What's Changed
${CHANGES}
### Downloads
- **Windows**: \`.msi\` (installer) or \`.exe\` (NSIS setup)
- **macOS**: \`.dmg\` (Apple Silicon: \`aarch64\`, Intel: \`x64\`)
- **Linux**: \`.AppImage\` (universal) or \`.deb\` (Debian/Ubuntu)
### Installation
- **Windows**: Run the \`.msi\` installer or \`.exe\` setup
- **macOS**: Open the \`.dmg\` and drag ClaudePrism to Applications
- **Linux**: \`chmod +x\` the \`.AppImage\` and run it, or \`sudo dpkg -i\` the \`.deb\`
${COMPARE}
EOF
gh release edit "$TAG" \
--repo "${{ github.repository }}" \
--notes-file release-body.md

50
.gitignore vendored
View file

@ -35,3 +35,53 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
*.acn
*.acr
*.alg
*.aux
*.bak
*.bbl
*.bcf
*.blg
*.brf
*.bst
*.dvi
*.fdb_latexmk
*.fls
*.glg
*.glo
*.gls
*.idx
*.ilg
*.ind
*.ist
*.lof
*.log
*.lol
*.lot
*.maf
*.mtc
*.mtc1
*.nav
*.nlo
*.nls
*.out
*.pdf
*.pyg
*.run.xml
*.snm
*.synctex.gz
*.tex.backup
*.tex~
*.thm
*.toc
*.vrb
*.xdy
*.xml
*blx.bib
.bak
.mtc
build/
reference_pr
reference_pr_claude

47
SHARED_TASK_NOTES.md Normal file
View file

@ -0,0 +1,47 @@
# Template Gallery — Shared Task Notes
## What's Done (Phase 1 + Phase 2 start)
### Template Data Architecture (Phase 1)
- `src/lib/template-registry.ts` — Template schema with rich metadata: categories, subcategories, tags, packages, accent colors, bibliography flag. 12 templates total (was 8). New: IEEE, ACM, Technical Report, Newsletter. Registry API: `getAllTemplates()`, `getTemplateById()`, `searchTemplates()`, `getTemplatesByCategory()`.
- `src/stores/template-store.ts` — Zustand store for gallery state: search query, category filter, template selection, preview state. `computeFiltered()` handles combined search+category filtering.
### Visual Gallery UI (Phase 2 start)
- `src/components/template-gallery/` — New component directory:
- `template-gallery.tsx` — Main gallery: search bar (⌘K), category sidebar + grid layout, grouped view when no filter active, empty state
- `template-card.tsx` — Cards with CSS-based document thumbnail placeholders (9 distinct layouts: paper, slides, poster, CV, letter, book, report, newsletter, blank). Hover reveals "Preview" button
- `category-sidebar.tsx` — Category navigation with template counts
- `template-preview.tsx` — Dialog showing LaTeX source, package list, tags, metadata, "Use Template" CTA
- `index.ts` — Barrel export
### Wizard Refactor
- `project-wizard.tsx` — Now uses `TemplateGallery` for step 1 instead of old inline `TemplateGrid`. Step 2 (project details) unchanged. All project creation logic preserved. Uses `template.hasBibliography` instead of hardcoded ID check.
## Next Priorities
### Phase 2 completion
- **Keyboard navigation** — Arrow keys to navigate grid, Enter to select, Tab between sections
- **Animations** — Consider adding `framer-motion` (not currently installed) for card entrance animations and page transitions
- **Responsive grid** — Test and tune grid breakpoints for various window sizes
### Phase 3: Template Preview System
- **PDF thumbnail generation** — Compile templates to PDF, render first page as image using pdfjs-dist (already a dependency). Store as cached images. Replace CSS placeholders with actual rendered thumbnails
- **Live preview in modal** — Show rendered PDF in the preview dialog instead of just source code
- **Multi-page preview** — For templates like presentations/books
- **Side-by-side compare** — Allow comparing two templates
### Phase 4: Package Integration
- **Package browser** — Curated CTAN package list
- **Package adding** — Let users add packages before project creation
- **Compatibility checker** — Warn about conflicting packages
### Phase 5: More Templates
- **Springer, APA, MLA, Chicago** style templates
- **User template library** — Save customized templates
- **Template import** — From .tex files or Overleaf
## Technical Notes
- No `framer-motion` installed — animations use Tailwind CSS `tw-animate-css`. Install if needed for gallery transitions
- `line-clamp-2` used in template-card.tsx — may need `@tailwindcss/line-clamp` if not natively supported (TW4 may include it)
- Template IDs changed: `paper` → `paper-standard`, `cv` → `cv-modern`, etc. Old IDs no longer exist
- `react-pdf` and `pdfjs-dist` already installed — ready for thumbnail generation work

View file

@ -1,21 +0,0 @@
{
"name": "@claude-prism/desktop-sidecar",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.6.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"typescript": "^5.7.0"
}
}

View file

@ -1,428 +0,0 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { mkdir, rm, writeFile, readFile, access, cp, realpath } from "node:fs/promises";
import { join, resolve, basename } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
const MAX_CONCURRENT = 3;
const COMPILE_TIMEOUT_MS = 30000;
let activeCompilations = 0;
// Track last build dir per project for synctex inverse search
const lastBuildDirs = new Map<string, { workDir: string; mainFileName: string }>();
function sanitizePath(workDir: string, filePath: string): string | null {
if (filePath.includes("..")) return null;
const normalized = resolve(workDir, filePath);
if (!normalized.startsWith(`${workDir}/`) && normalized !== workDir) {
return null;
}
return normalized;
}
interface Resource {
path?: string;
content?: string;
file?: string;
main?: boolean;
}
interface CompileRequest {
compiler?: string;
resources?: Resource[];
projectDir?: string;
mainFile?: string;
}
interface CompileError {
error: string;
details?: string;
log_files?: Record<string, string>;
}
const runWithTimeout = (
cmd: string[],
cwd: string,
): Promise<{ exitCode: number; timedOut: boolean; stdout: string; stderr: string }> => {
return new Promise((resolve) => {
const [command, ...args] = cmd;
const proc = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
const timeout = setTimeout(() => {
timedOut = true;
proc.kill();
}, COMPILE_TIMEOUT_MS);
proc.on("close", (code) => {
clearTimeout(timeout);
resolve({ exitCode: code ?? 1, timedOut, stdout, stderr });
});
proc.on("error", (err) => {
clearTimeout(timeout);
resolve({ exitCode: 1, timedOut: false, stdout, stderr: stderr || err.message });
});
});
};
export const compileRoutes = new Hono();
compileRoutes.use("/builds/*", bodyLimit({ maxSize: 10 * 1024 * 1024 }));
compileRoutes.get("/", (c) => {
return c.json({ status: "ok", service: "claude-prism-sidecar" });
});
compileRoutes.post("/builds/sync", async (c) => {
if (activeCompilations >= MAX_CONCURRENT) {
return c.json(
{ error: "Server busy, try again later" } satisfies CompileError,
503,
);
}
const body = await c.req.json<CompileRequest>();
const { compiler = "pdflatex", resources, projectDir, mainFile } = body;
// Mode 1: Project directory mode (desktop app)
if (projectDir) {
return handleProjectDirCompile(c, compiler, projectDir, mainFile || "document.tex");
}
// Mode 2: Resource upload mode (legacy/web compatibility)
if (!resources || resources.length === 0) {
return c.json(
{ error: "No resources or projectDir provided" } satisfies CompileError,
400,
);
}
return handleResourceCompile(c, compiler, resources);
});
async function handleProjectDirCompile(
c: any,
compiler: string,
projectDir: string,
mainFile: string,
) {
// Use realpath to resolve symlinks (macOS: /var → /private/var)
// so the stored workDir matches paths returned by synctex
const rawWorkDir = join(tmpdir(), `latex-${randomUUID()}`);
await mkdir(rawWorkDir, { recursive: true });
const workDir = await realpath(rawWorkDir);
// Clean up previous build dir for this project
const prevBuild = lastBuildDirs.get(projectDir);
if (prevBuild) {
await rm(prevBuild.workDir, { recursive: true, force: true }).catch(() => {});
lastBuildDirs.delete(projectDir);
}
// pdflatex outputs to CWD using basename (e.g. "chapters/doc.tex" → "doc.pdf")
const mainFileName = basename(mainFile).replace(/\.tex$/, "");
activeCompilations++;
try {
// Copy project to temp dir to avoid polluting with aux files
await cp(projectDir, workDir, { recursive: true });
const compilerCmd =
compiler === "xelatex"
? "xelatex"
: compiler === "lualatex"
? "lualatex"
: "pdflatex";
// Check for .bib files
const { readdir } = await import("node:fs/promises");
const files = await readdir(workDir, { recursive: true });
const hasBib = files.some((f: string) => f.endsWith(".bib"));
const latexCmd = [compilerCmd, "-interaction=nonstopmode", "-synctex=1", mainFile];
let lastResult = { exitCode: 0, timedOut: false, stdout: "", stderr: "" };
if (hasBib) {
lastResult = await runWithTimeout(latexCmd, workDir);
if (lastResult.timedOut) {
return c.json({ error: "Compilation timed out" } satisfies CompileError, 500);
}
const auxPath = join(workDir, `${mainFileName}.aux`);
const auxExists = await access(auxPath).then(() => true).catch(() => false);
if (auxExists) {
lastResult = await runWithTimeout(["bibtex", mainFileName], workDir);
if (lastResult.timedOut) {
return c.json({ error: "BibTeX timed out" } satisfies CompileError, 500);
}
}
for (let i = 0; i < 2; i++) {
lastResult = await runWithTimeout(latexCmd, workDir);
if (lastResult.timedOut) {
return c.json({ error: "Compilation timed out" } satisfies CompileError, 500);
}
}
} else {
lastResult = await runWithTimeout(latexCmd, workDir);
if (lastResult.timedOut) {
return c.json({ error: "Compilation timed out" } satisfies CompileError, 500);
}
}
let pdfPath = join(workDir, `${mainFileName}.pdf`);
const logPath = join(workDir, `${mainFileName}.log`);
let logContent = "";
try {
logContent = await readFile(logPath, "utf-8");
} catch {}
// If "No pages of output", retry with \null injected to force an empty page
const pdfExists = await access(pdfPath).then(() => true).catch(() => false);
if (!pdfExists && logContent.includes("No pages of output")) {
const retryCmd = [
compilerCmd,
"-interaction=nonstopmode",
`-jobname=${mainFileName}`,
`\\AtEndDocument{\\null}\\input{${mainFile}}`,
];
lastResult = await runWithTimeout(retryCmd, workDir);
try {
logContent = await readFile(logPath, "utf-8");
} catch {}
}
try {
const pdfBuffer = await readFile(pdfPath);
return new Response(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename=${mainFileName}.pdf`,
},
});
} catch {
const details = extractErrorLines(logContent)
|| lastResult.stderr
|| lastResult.stdout.slice(-500)
|| "No PDF produced. Check that pdflatex is installed.";
return c.json(
{
error: "Compilation failed",
details,
} satisfies CompileError,
500,
);
}
} finally {
activeCompilations--;
// Keep workDir alive for synctex inverse search
lastBuildDirs.set(projectDir, { workDir, mainFileName });
}
}
function extractErrorLines(log: string): string {
if (!log) return "";
const lines = log.split("\n");
// Check for "No pages of output" (comment-only or empty document body)
if (lines.some((l) => l.includes("No pages of output"))) {
return "No pages of output. Add visible content to the document body.";
}
const errorLines = lines.filter(
(l) => l.startsWith("!") || l.includes("Error:") || l.includes("error:"),
);
return errorLines.slice(0, 10).join("\n") || log.slice(-500);
}
async function handleResourceCompile(
c: any,
compiler: string,
resources: Resource[],
) {
const mainResource = resources.find((r) => r.main) || resources[0];
const mainPath = mainResource.path || "main.tex";
const mainFileName = mainPath.replace(/\.tex$/, "");
const workDir = join(tmpdir(), `latex-${randomUUID()}`);
await mkdir(workDir, { recursive: true });
activeCompilations++;
try {
const hasBib = resources.some((r) => r.path?.endsWith(".bib"));
for (const resource of resources) {
const filePath =
resource.path || (resource.main ? "main.tex" : `file-${randomUUID()}`);
const fullPath = sanitizePath(workDir, filePath);
if (!fullPath) {
return c.json({ error: "Invalid path" } satisfies CompileError, 400);
}
const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/"));
if (parentDir && parentDir !== workDir) {
await mkdir(parentDir, { recursive: true });
}
if (resource.file) {
const buffer = Buffer.from(resource.file, "base64");
await writeFile(fullPath, buffer);
} else if (resource.content) {
await writeFile(fullPath, resource.content, "utf-8");
}
}
const compilerCmd =
compiler === "xelatex"
? "xelatex"
: compiler === "lualatex"
? "lualatex"
: "pdflatex";
const latexCmd = [compilerCmd, "-interaction=nonstopmode", mainPath];
if (hasBib) {
let result = await runWithTimeout(latexCmd, workDir);
if (result.timedOut) {
return c.json({ error: "Compilation timed out" } satisfies CompileError, 500);
}
const auxPath = join(workDir, `${mainFileName}.aux`);
const auxExists = await access(auxPath).then(() => true).catch(() => false);
if (auxExists) {
result = await runWithTimeout(["bibtex", mainFileName], workDir);
if (result.timedOut) {
return c.json({ error: "BibTeX timed out" } satisfies CompileError, 500);
}
}
for (let i = 0; i < 2; i++) {
result = await runWithTimeout(latexCmd, workDir);
if (result.timedOut) {
return c.json({ error: "Compilation timed out" } satisfies CompileError, 500);
}
}
} else {
for (let i = 0; i < 2; i++) {
const result = await runWithTimeout(latexCmd, workDir);
if (result.timedOut) {
return c.json({ error: "Compilation timed out" } satisfies CompileError, 500);
}
}
}
const pdfPath = join(workDir, `${mainFileName}.pdf`);
const logPath = join(workDir, `${mainFileName}.log`);
let logContent = "";
try {
logContent = await readFile(logPath, "utf-8");
} catch {}
try {
const pdfBuffer = await readFile(pdfPath);
return new Response(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename=${mainFileName}.pdf`,
},
});
} catch {
return c.json(
{
error: "Compilation failed",
details: extractErrorLines(logContent),
log_files: { "__main_document__.log": logContent },
} satisfies CompileError,
500,
);
}
} finally {
activeCompilations--;
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
// SyncTeX inverse search: PDF coordinates → source file + line
// Reference: Overleaf CLSI SynctexOutputParser + CompileManager
compileRoutes.post("/synctex/edit", async (c) => {
const { projectDir, page, x, y } = await c.req.json<{
projectDir: string;
page: number;
x: number;
y: number;
}>();
const build = lastBuildDirs.get(projectDir);
if (!build) {
return c.json({ error: "No build found for this project" }, 404);
}
// Verify that synctex data was generated
const synctexGz = join(build.workDir, `${build.mainFileName}.synctex.gz`);
const synctexPlain = join(build.workDir, `${build.mainFileName}.synctex`);
const hasSynctex = await access(synctexGz).then(() => true).catch(() =>
access(synctexPlain).then(() => true).catch(() => false)
);
if (!hasSynctex) {
return c.json({ error: "No synctex data found. Recompile with synctex enabled." }, 404);
}
const pdfFile = `${build.mainFileName}.pdf`;
const result = await runWithTimeout(
["synctex", "edit", "-o", `${page}:${x}:${y}:${pdfFile}`],
build.workDir,
);
if (result.exitCode !== 0) {
console.error("[synctex] edit failed:", result.stderr, result.stdout);
return c.json({ error: "synctex failed", details: result.stderr || result.stdout }, 500);
}
// Parse synctex output (Overleaf-style):
// SyncTeX result begin
// Output:main.pdf
// Input:./main.tex
// Line:42
// Column:0
// ...
// SyncTeX result end
const outputLines = result.stdout.split("\n");
let file = "";
let line = 0;
let column = 0;
for (const l of outputLines) {
const trimmed = l.trim();
if (trimmed.startsWith("Input:")) file = trimmed.slice("Input:".length);
else if (trimmed.startsWith("Line:")) line = parseInt(trimmed.slice("Line:".length), 10);
else if (trimmed.startsWith("Column:")) column = Math.max(0, parseInt(trimmed.slice("Column:".length), 10));
}
if (!file || line === 0) {
console.error("[synctex] could not parse result:", result.stdout);
return c.json({ error: "Could not resolve source location", raw: result.stdout }, 404);
}
// Normalize file path to relative path within project:
// synctex may return "./main.tex", absolute "/tmp/.../main.tex", or just "main.tex"
let relativeFile = file;
if (relativeFile.startsWith(build.workDir + "/")) {
relativeFile = relativeFile.slice(build.workDir.length + 1);
}
if (relativeFile.startsWith("./")) {
relativeFile = relativeFile.slice(2);
}
return c.json({ file: relativeFile, line, column });
});

View file

@ -1,44 +0,0 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createServer } from "node:net";
import { compileRoutes } from "./compile.js";
const app = new Hono();
app.use("/*", cors());
// Mount routes (chat is now handled directly via Claude CLI in Rust)
app.route("/", compileRoutes);
const port = parseInt(process.env.PORT || "3001", 10);
// Check if port is already in use before starting
function isPortFree(p: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.once("listening", () => {
server.close();
resolve(true);
});
server.listen(p);
});
}
async function main() {
const free = await isPortFree(port);
if (!free) {
console.log(`Port ${port} already in use, sidecar likely already running`);
process.exit(0);
}
serve({
fetch: app.fetch,
port,
});
console.log(`ClaudePrism sidecar running on port ${port}`);
}
main();

View file

@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View file

@ -1,10 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node20",
outDir: "dist",
clean: true,
splitting: false,
});

View file

@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"generate-previews": "tsx scripts/generate-previews.ts"
},
"dependencies": {
"@codemirror/commands": "^6.10.1",
@ -34,13 +35,12 @@
"date-fns": "^4.1.0",
"katex": "^0.16.28",
"lucide-react": "^0.563.0",
"mupdf": "^1.27.0",
"next-themes": "^0.4.6",
"pdfjs-dist": "5.4.296",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^9.0.1",
"react-pdf": "^10.3.0",
"react-resizable-panels": "^3.0.6",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",

View file

@ -0,0 +1,10 @@
\documentclass[12pt]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\begin{document}
\null
% Start writing here.
\end{document}

View file

@ -0,0 +1,27 @@
\documentclass[12pt,a4paper]{book}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\usepackage{graphicx}
\usepackage[margin=1in]{geometry}
\usepackage{hyperref}
\title{Book Title}
\author{Author Name}
\date{\today}
\begin{document}
\frontmatter
\maketitle
\tableofcontents
\mainmatter
\chapter{First Chapter}
\chapter{Second Chapter}
\backmatter
\end{document}

View file

@ -0,0 +1,164 @@
\documentclass[11pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage[margin=0.65in]{geometry}
\usepackage{hyperref}
\usepackage{enumitem}
\usepackage{titlesec}
\usepackage{xcolor}
\usepackage{tabularx}
\usepackage{fontawesome5}
\usepackage{microtype}
\usepackage{multicol}
\definecolor{primary}{HTML}{2C3E50}
\definecolor{accent}{HTML}{2E86C1}
\definecolor{lightgray}{HTML}{95A5A6}
\definecolor{darktext}{HTML}{333333}
\hypersetup{
colorlinks=true,
linkcolor=accent,
urlcolor=accent
}
\titleformat{\section}{\large\bfseries\color{primary}}{}{0em}{}[{\color{accent}\titlerule[1.5pt]}]
\titlespacing{\section}{0pt}{14pt}{6pt}
\pagestyle{empty}
\setlength{\parindent}{0pt}
% Custom commands
\newcommand{\cventry}[4]{%
\textbf{#1} \hfill {\color{lightgray}#2}\\
{\color{accent}\textit{#3}} \hfill {\color{lightgray}\textit{#4}}\\[2pt]
}
\newcommand{\cvsep}{\vspace{6pt}}
\begin{document}
% ──── Header ────
\begin{center}
{\fontsize{28}{34}\selectfont\bfseries\color{primary} Dr.\\ Alexandra Chen}\\[8pt]
{\color{lightgray}\rule{8cm}{0.5pt}}\\[8pt]
{\small
\faIcon{envelope}\;\href{mailto:a.chen@stanford.edu}{a.chen@stanford.edu} \quad
\faIcon{phone}\;+1 (650) 555-0142 \quad
\faIcon{map-marker-alt}\;Stanford, CA\\[4pt]
\faIcon{globe}\;\href{https://alexchen.io}{alexchen.io} \quad
\faIcon{github}\;\href{https://github.com/alexchen}{github.com/alexchen} \quad
\faIcon{linkedin}\;\href{https://linkedin.com/in/alexchen}{linkedin.com/in/alexchen} \quad
\faIcon{graduation-cap}\;\href{https://scholar.google.com/citations?user=XXXXX}{Google Scholar}
}
\end{center}
\vspace{4pt}
% ──── Summary ────
\section{Summary}
Machine learning researcher with 8+ years of experience in natural language processing and large language models. Published 25+ papers at top venues (NeurIPS, ICML, ACL, EMNLP) with 3,800+ citations. Led research teams developing foundation models deployed to millions of users. Seeking faculty positions in computer science.
% ──── Education ────
\section{Education}
\cventry{Ph.D.\\ in Computer Science}{2016 -- 2021}{Stanford University}{Stanford, CA}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt]
\item Thesis: ``Efficient Transformers for Long-Range Sequence Modeling''
\item Advisor: Prof.\\ Christopher Manning \quad Committee: Profs.\\ Percy Liang, Dan Jurafsky
\item Stanford Graduate Fellowship (SGF) recipient
\end{itemize}
\cvsep
\cventry{M.S.\\ in Computer Science}{2014 -- 2016}{Carnegie Mellon University}{Pittsburgh, PA}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt]
\item Focus: Machine Learning and Language Technologies
\item GPA: 4.0/4.0 \quad Dean's List all semesters
\end{itemize}
\cvsep
\cventry{B.S.\\ in Mathematics and Computer Science}{2010 -- 2014}{MIT}{Cambridge, MA}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt]
\item Summa Cum Laude \quad Phi Beta Kappa
\item Undergraduate thesis on spectral graph theory (awarded departmental prize)
\end{itemize}
% ──── Experience ────
\section{Professional Experience}
\cventry{Senior Research Scientist}{2023 -- Present}{Google DeepMind}{Mountain View, CA}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt]
\item Lead a team of 8 researchers developing next-generation language models
\item Designed novel attention mechanisms reducing compute by 40\% at scale
\item Core contributor to Gemini model family; responsible for efficiency improvements
\item Mentored 4 PhD interns, all placed at top research labs or faculty positions
\end{itemize}
\cvsep
\cventry{Research Scientist}{2021 -- 2023}{Meta AI (FAIR)}{Menlo Park, CA}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt]
\item Developed sparse mixture-of-experts models for efficient inference
\item Led the retrieval-augmented generation (RAG) project for factual consistency
\item Published 8 papers; 2 received spotlight/oral designations at NeurIPS
\item Open-sourced model checkpoints downloaded 500K+ times
\end{itemize}
\cvsep
\cventry{Research Intern}{Summers 2018, 2019}{OpenAI}{San Francisco, CA}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt]
\item Contributed to GPT-2 training infrastructure and evaluation framework
\item Developed techniques for reducing hallucination in text generation
\end{itemize}
% ──── Selected Publications ────
\section{Selected Publications}
{\small
\begin{enumerate}[leftmargin=1.5em, itemsep=3pt]
\item \textbf{A.\\ Chen}, J.\\ Smith, R.\\ Patel. ``FlashLinear: Sub-Quadratic Attention via Structured State Spaces.'' \textit{NeurIPS 2024}. \textbf{(Oral, top 0.5\%)}
\item \textbf{A.\\ Chen}, M.\\ Johnson. ``Scaling Laws for Mixture-of-Experts Language Models.'' \textit{ICML 2024}.
\item L.\\ Wang, \textbf{A.\\ Chen}, K.\\ Lee. ``Retrieval-Augmented Generation with Learned Relevance.'' \textit{ACL 2023}. \textbf{(Best Paper Award)}
\item \textbf{A.\\ Chen}, T.\\ Brown, S.\\ Narang. ``Efficient Long-Context Transformers via Sliding Window Attention.'' \textit{NeurIPS 2022}. \textbf{(Spotlight)}
\item \textbf{A.\\ Chen}, C.\\ Manning. ``Linear Transformers with Gated Retention.'' \textit{ICLR 2021}.
\end{enumerate}
}
% ──── Awards ────
\section{Awards \& Honors}
\begin{multicols}{2}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=3pt]
\item ACL Best Paper Award, 2023
\item MIT Technology Review 35 Under 35, 2024
\item Stanford Graduate Fellowship, 2016--2021
\item NSF Graduate Research Fellowship, 2016
\item CRA Outstanding Undergraduate Award, 2014
\item Phi Beta Kappa, MIT Chapter, 2014
\end{itemize}
\end{multicols}
% ──── Skills ────
\section{Technical Skills}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\textbf{Languages:} & Python, C++, CUDA, Julia, Rust \\
\textbf{Frameworks:} & PyTorch, JAX, TensorFlow, Hugging Face, DeepSpeed, Megatron-LM \\
\textbf{Infrastructure:} & Kubernetes, Slurm, TPU Pods, multi-node GPU training (up to 2048 GPUs) \\
\textbf{Specialties:} & Large language models, efficient attention, distributed training, NLP \\
\end{tabularx}
% ──── Service ────
\section{Academic Service}
\begin{itemize}[leftmargin=1.5em, nosep, itemsep=3pt]
\item \textbf{Area Chair}: NeurIPS 2024, ICML 2024, ACL 2023
\item \textbf{Reviewer}: NeurIPS, ICML, ICLR, ACL, EMNLP, JMLR, TACL (100+ reviews total)
\item \textbf{Workshop Organizer}: ``Efficient NLP'' at EMNLP 2023; ``Scaling Laws'' at ICML 2024
\item \textbf{Invited Talks}: Google, Microsoft, Meta, Amazon, Apple, NVIDIA, 15+ universities
\end{itemize}
\end{document}

View file

@ -0,0 +1,109 @@
\documentclass[12pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage[margin=1in]{geometry}
\usepackage{hyperref}
\usepackage{xcolor}
\usepackage{enumitem}
\usepackage{parskip}
\usepackage{microtype}
\usepackage{fancyhdr}
\usepackage{lastpage}
\usepackage{graphicx}
\definecolor{headercolor}{HTML}{1B3A5C}
\definecolor{accentcolor}{HTML}{2E86C1}
\definecolor{rulecolor}{HTML}{BDC3C7}
\hypersetup{
colorlinks=true,
urlcolor=accentcolor
}
\pagestyle{fancy}
\fancyhf{}
\renewcommand{\headrulewidth}{0pt}
\fancyfoot[C]{\small\color{rulecolor}Page \thepage\\ of \pageref{LastPage}}
\setlength{\parskip}{10pt}
\setlength{\parindent}{0pt}
\begin{document}
% ──── Letterhead ────
\begin{center}
{\color{headercolor}\rule{\textwidth}{2pt}}\\[12pt]
{\fontsize{20}{24}\selectfont\bfseries\color{headercolor} Quantum Computing Research Laboratory}\\[4pt]
{\large Department of Physics --- Massachusetts Institute of Technology}\\[4pt]
{\small 77 Massachusetts Avenue, Building 26-251, Cambridge, MA 02139}\\[2pt]
{\small Tel: +1 (617) 253-4000 \quad|\quad Fax: +1 (617) 253-8000 \quad|\quad \href{mailto:qcrl@mit.edu}{qcrl@mit.edu}}\\[8pt]
{\color{headercolor}\rule{\textwidth}{0.5pt}}
\end{center}
\vspace{1cm}
% ──── Date and Reference ────
\begin{flushright}
\textbf{Date:} January 15, 2025\\
\textbf{Ref:} QCRL/2025/REC-0047
\end{flushright}
\vspace{0.5cm}
% ──── Recipient ────
Prof.\\ Sarah M.\\ Thompson\\
Chair, Department of Computer Science\\
University of California, Berkeley\\
Soda Hall, Room 593\\
Berkeley, CA 94720
\vspace{0.5cm}
\textbf{Re: Letter of Recommendation for Dr.\\ James Liu -- Assistant Professor Position}
\vspace{0.3cm}
Dear Professor Thompson,
I am writing to provide my strongest recommendation for Dr.\\ James Liu, who is applying for the tenure-track Assistant Professor position in Quantum Computing at the University of California, Berkeley. I have had the privilege of working closely with James for the past five years, first as his doctoral advisor at MIT and subsequently as a research collaborator, and I can state without reservation that he is among the most talented young researchers I have encountered in my thirty-year career.
James joined my research group in 2018 after completing his undergraduate degree in Physics and Mathematics at Caltech, where he graduated first in his class. From the outset, it was clear that James possessed an unusual combination of deep mathematical intuition, strong programming skills, and creative thinking. His doctoral research focused on developing novel quantum error correction codes for fault-tolerant quantum computation, a topic of immense theoretical and practical importance.
During his time in my lab, James made several groundbreaking contributions that have significantly advanced the field:
\begin{enumerate}[leftmargin=2em, itemsep=4pt]
\item \textbf{Topological Error Correction:} James developed a new family of topological quantum error correcting codes that achieve a 3$\times$ improvement in the error threshold compared to the standard surface code. This work, published in \textit{Nature Physics} (2021), has been cited over 200 times and has been experimentally validated by research groups at Google and IBM.
\item \textbf{Efficient Decoding Algorithms:} He designed a near-linear-time decoding algorithm for his topological codes that runs in real-time on classical hardware, overcoming one of the major practical obstacles to fault-tolerant quantum computing. This work appeared in \textit{Physical Review Letters} (2022) and received the APS Outstanding Doctoral Thesis Award.
\item \textbf{Noise-Adapted Codes:} Most recently, James pioneered an approach to constructing quantum error correcting codes that are optimized for the specific noise characteristics of a given quantum processor. This noise-aware approach, published in \textit{Science} (2024), represents a paradigm shift in how the community thinks about error correction.
\end{enumerate}
Beyond his technical accomplishments, James is an exceptional communicator and mentor. He has supervised three master's students and two undergraduate researchers, all of whom have gone on to pursue doctoral studies at leading institutions. His lectures in our graduate quantum information course consistently receive the highest evaluations, and he has a remarkable ability to make complex concepts accessible without sacrificing rigor.
James's research program is both visionary and practical. He has articulated a compelling five-year plan that bridges the gap between theoretical error correction and the engineering reality of near-term quantum devices. His proposed work on hardware-efficient codes and real-time decoding architectures addresses critical challenges that must be solved for quantum computing to achieve its transformative potential. I believe this research agenda will attract significant funding and top graduate students.
On a personal level, James is a person of exceptional integrity and collegiality. He is generous with his time, always willing to help colleagues, and has been instrumental in fostering a collaborative and inclusive research culture in our group. He actively participates in outreach activities, including organizing quantum computing workshops for underrepresented high school students in the Boston area.
In summary, I give Dr.\\ James Liu my highest and most enthusiastic recommendation. He has the intellectual depth, technical skill, creative vision, and personal qualities to become a leading figure in quantum computing and an outstanding faculty member. I am confident that he would be an exceptional addition to your department, and I strongly urge you to give his application the most serious consideration.
Please do not hesitate to contact me if you require any additional information. I am happy to discuss James's qualifications in further detail by phone or video call at your convenience.
\vspace{0.8cm}
Sincerely yours,
\vspace{1.2cm}
\textbf{Prof.\\ Richard A.\\ Yamamoto}\\
William A.\\ Coolidge Professor of Physics\\
Director, Quantum Computing Research Laboratory\\
Massachusetts Institute of Technology\\
\href{mailto:ryamamoto@mit.edu}{ryamamoto@mit.edu} \quad|\quad +1 (617) 253-4851
\vspace{1cm}
{\small\color{rulecolor}\textit{Enclosures: Curriculum Vitae of Dr.\\ James Liu; List of publications}}
\end{document}

View file

@ -0,0 +1,217 @@
\documentclass[10pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage[margin=0.6in]{geometry}
\usepackage{multicol}
\usepackage{graphicx}
\usepackage{xcolor}
\usepackage{titlesec}
\usepackage{fancyhdr}
\usepackage{tikz}
\usepackage{enumitem}
\usepackage{microtype}
\usepackage{tabularx}
\usepackage{booktabs}
\usepackage{hyperref}
% ──── Color Theme ────
\definecolor{primary}{HTML}{1B3A5C}
\definecolor{accent}{HTML}{E74C3C}
\definecolor{secondary}{HTML}{2E86C1}
\definecolor{lightbg}{HTML}{F8F9FA}
\definecolor{darktext}{HTML}{2C3E50}
\definecolor{rulecolor}{HTML}{BDC3C7}
\hypersetup{colorlinks=true,urlcolor=secondary,linkcolor=primary}
% ──── Section Styling ────
\titleformat{\section}{\Large\bfseries\color{primary}}{}{0em}{}[{\color{accent}\titlerule[1.5pt]}]
\titleformat{\subsection}{\large\bfseries\color{secondary}}{}{0em}{}
\titlespacing{\section}{0pt}{14pt}{6pt}
\titlespacing{\subsection}{0pt}{10pt}{4pt}
% ──── Header/Footer ────
\pagestyle{fancy}
\fancyhf{}
\renewcommand{\headrulewidth}{0pt}
\fancyfoot[L]{\small\color{rulecolor}\textit{CS Department Newsletter}}
\fancyfoot[C]{\small\color{rulecolor}\thepage}
\fancyfoot[R]{\small\color{rulecolor}\textit{Spring 2025}}
\setlength{\parindent}{0pt}
\setlength{\parskip}{6pt}
\setlength{\columnsep}{0.8cm}
\setlength{\columnseprule}{0.4pt}
\renewcommand{\columnseprulecolor}{\color{rulecolor}}
\begin{document}
% ════════════════════════════════════════════
% MASTHEAD
% ════════════════════════════════════════════
\begin{tikzpicture}[remember picture, overlay]
\fill[primary] ([yshift=0cm]current page.north west) rectangle ([yshift=-4.5cm, xshift=\paperwidth]current page.north west);
\end{tikzpicture}
\vspace*{-1.5cm}
\begin{center}
{\color{white}\fontsize{36}{44}\selectfont\bfseries The Computing Chronicle}\\[6pt]
{\color{white!80}\large Department of Computer Science --- Stanford University}\\[4pt]
{\color{accent}\rule{6cm}{2pt}}\\[4pt]
{\color{white!70}\small Volume 12, Issue 3 \quad|\quad Spring 2025 \quad|\quad Editor: Prof.\\ Maria Santos}
\end{center}
\vspace{0.8cm}
% ════════════════════════════════════════════
% HIGHLIGHTS BAR
% ════════════════════════════════════════════
\colorbox{lightbg}{%
\begin{minipage}{\dimexpr\textwidth-2\fboxsep}
\centering\small\color{darktext}
\textbf{IN THIS ISSUE:}\quad
New AI Research Lab Opening $\bullet$
Faculty Spotlight: Prof.\\ Kim $\bullet$
Student Hackathon Results $\bullet$
Alumni Interview $\bullet$
Upcoming Events
\end{minipage}}
\vspace{0.6cm}
% ════════════════════════════════════════════
% MAIN CONTENT -- Two Columns
% ════════════════════════════════════════════
\begin{multicols}{2}
% ──── Lead Story ────
\section{Department Launches Interdisciplinary AI Research Laboratory}
The Department of Computer Science officially opened the Stanford Interdisciplinary AI Laboratory (SIAL) on March 15, with a ribbon-cutting ceremony attended by university president Dr.\\ Jonathan Blake and industry partners from Google, Microsoft, and NVIDIA.
The \$45 million facility spans 25,000 square feet on the second floor of the newly renovated Gates Building and houses state-of-the-art computing infrastructure, including a cluster of 128 NVIDIA H100 GPUs and dedicated wet lab space for AI-driven biological research.
``This laboratory represents our commitment to developing AI technologies that address humanity's greatest challenges,'' said department chair Prof.\\ Robert Chen at the opening ceremony. ``By bringing together researchers from computer science, medicine, climate science, and the social sciences, we aim to create AI systems that are not only powerful but also responsible and beneficial.''
\textbf{Research Focus Areas:}
\begin{itemize}[leftmargin=1.2em, nosep, itemsep=2pt]
\item \textbf{AI for Healthcare}: Drug discovery, medical imaging, and personalized medicine
\item \textbf{Climate AI}: Weather prediction, carbon capture optimization, and sustainable energy
\item \textbf{Trustworthy AI}: Fairness, interpretability, robustness, and alignment
\item \textbf{Foundations}: Large language models, multimodal learning, and reasoning
\end{itemize}
The lab will host 12 faculty members, 40 graduate students, and 15 postdoctoral researchers. Industry partnerships include a \$10 million gift from Google DeepMind and a \$5 million equipment grant from NVIDIA.
% ──── Faculty Spotlight ────
\section{Faculty Spotlight: Prof.\\ Soo-Jin Kim}
\subsection{From Seoul to Stanford: A Journey in Computational Biology}
Prof.\\ Soo-Jin Kim joined the department in September 2024 after a distinguished postdoctoral fellowship at the Broad Institute of MIT and Harvard. Her research focuses on developing machine learning methods for understanding gene regulation and predicting the effects of genetic mutations on protein function.
``I was drawn to computer science because of its potential to transform biology,'' Kim said in a recent interview. ``We are generating biological data at an unprecedented rate, and computational methods are essential for making sense of it all.''
Since arriving at Stanford, Kim has already secured a \$1.2 million NIH R01 grant for her project on ``Deep Learning Models for Predicting Variant Effects in Non-Coding Regulatory Regions.'' Her group currently includes three PhD students and two postdocs.
\textbf{Selected recent publications:}
\begin{enumerate}[leftmargin=1.5em, nosep, itemsep=2pt]
\item Kim, S.-J. et al. ``RegulomeNet: Predicting regulatory variant effects with graph attention networks.'' \textit{Nature Methods}, 2024.
\item Kim, S.-J. and Park, H. ``Foundation models for genomics: A survey.'' \textit{Nature Reviews Genetics}, 2024.
\end{enumerate}
% ──── Student News ────
\section{Student Hackathon: Record Participation}
The 8th Annual CS Department Hackathon, held over the weekend of February 22--23, drew a record 280 participants organized into 64 teams. This year's theme, ``AI for Social Good,'' challenged students to build applications addressing real-world social challenges in 36 hours.
\subsection{Winning Projects}
\begin{description}[leftmargin=0em, style=nextline, itemsep=4pt]
\item[1st Place -- ``AccessiVision'' (Team Alpha)]
A real-time mobile app that uses computer vision to help visually impaired users navigate indoor spaces. The app identifies obstacles, reads signs, and provides audio directions. Built with PyTorch Mobile and ARCore.
\item[2nd Place -- ``CrisisMap'' (Team Resilience)]
A platform that aggregates social media posts during natural disasters to create real-time maps of affected areas, resource needs, and safe routes. Uses NLP to classify and geolocate posts.
\item[3rd Place -- ``FairLend'' (Team Justice)]
An auditing tool that detects and mitigates bias in lending algorithms. Provides interpretable reports showing how different demographic groups are affected by model decisions.
\end{description}
The winning teams received prizes including internship offers from sponsoring companies and travel grants to present at the ACM Student Research Competition.
\columnbreak
% ──── Alumni Interview ────
\section{Alumni Interview: Dr.\\ Marcus Chen (Ph.D.\\ '18)}
\textit{Dr.\\ Marcus Chen graduated from our department in 2018 and is now VP of Engineering at Anthropic, where he leads the infrastructure team responsible for training large language models.}
\textbf{Q: How did your time at Stanford prepare you for your current role?}
A: The rigorous theoretical foundation I received at Stanford has been invaluable. My work on distributed systems with Prof.\\ Ousterhout taught me how to think about large-scale infrastructure challenges. But equally important were the soft skills---collaborating with researchers from different backgrounds, communicating complex ideas clearly, and managing uncertainty.
\textbf{Q: What advice would you give to current students?}
A: Three things. First, invest in fundamentals---algorithms, systems, and math. Trends come and go, but fundamentals are forever. Second, work on something you genuinely care about; your best work will come from intrinsic motivation. Third, build things. The gap between theory and practice is where the most interesting problems live.
\textbf{Q: What is the most exciting development in AI right now?}
A: I am most excited about AI systems that can reason and plan, not just pattern-match. The progress in chain-of-thought reasoning and tool use suggests we are approaching systems that can genuinely assist with complex intellectual work. But we need to be thoughtful about safety and alignment---these are not afterthoughts but core engineering challenges.
% ──── Upcoming Events ────
\section{Upcoming Events}
\renewcommand{\arraystretch}{1.3}
\begin{tabularx}{\columnwidth}{@{}lX@{}}
\toprule
\textbf{Date} & \textbf{Event} \\
\midrule
Apr 3 & Distinguished Lecture: Prof.\\ Yann LeCun, ``Objective-Driven AI'' \\
Apr 10--11 & Spring Research Symposium \\
Apr 15 & PhD Admissions Visit Day \\
Apr 22 & Industry Panel: ``Careers in AI'' \\
May 1 & Thesis proposal deadline (Fall graduates) \\
May 8 & Department Awards Banquet \\
May 15 & Senior project presentations \\
Jun 15 & Commencement ceremony \\
\bottomrule
\end{tabularx}
% ──── Quick Stats ────
\section{Department by the Numbers}
\begin{center}
\begin{tikzpicture}[
statbox/.style={rectangle, draw=primary, fill=lightbg, rounded corners=4pt, minimum width=3.2cm, minimum height=1.4cm, align=center}
]
\node[statbox] at (0,0) {{\Large\bfseries\color{accent} 847}\\\\{\tiny Undergrad majors}};
\node[statbox] at (3.6,0) {{\Large\bfseries\color{accent} 312}\\\\{\tiny Graduate students}};
\node[statbox] at (0,-1.8) {{\Large\bfseries\color{accent} 68}\\\\{\tiny Faculty members}};
\node[statbox] at (3.6,-1.8) {{\Large\bfseries\color{accent} \$94M}\\\\{\tiny Research funding}};
\end{tikzpicture}
\end{center}
% ──── Announcements ────
\section{Announcements}
\begin{itemize}[leftmargin=1.2em, itemsep=4pt]
\item \textbf{New course}: CS 329A ``Foundation Models'' will be offered for the first time in Fall 2025, co-taught by Profs.\\ Liang and Hashimoto.
\item \textbf{Computing resources}: The department cluster has been upgraded with 64 additional A100 GPUs. Submit allocation requests via the department portal.
\item \textbf{Travel grants}: Applications for the Summer Conference Travel Fund are due May 1. Awards of up to \$2,500 are available.
\item \textbf{Wellness}: The department peer counseling program holds drop-in hours every Wednesday 3--5pm in Gates 182.
\end{itemize}
\vspace{0.5cm}
\begin{center}
{\color{rulecolor}\rule{0.8\columnwidth}{0.5pt}}\\[6pt]
{\small\color{darktext}
\textbf{The Computing Chronicle} is published quarterly.\\
Submit articles and announcements to \href{mailto:newsletter@cs.stanford.edu}{newsletter@cs.stanford.edu}\\
Back issues: \href{https://cs.stanford.edu/newsletter}{cs.stanford.edu/newsletter}}
\end{center}
\end{multicols}
\end{document}

View file

@ -0,0 +1,222 @@
\documentclass[sigconf,nonacm]{acmart}
\usepackage{booktabs}
\usepackage{lipsum}
\usepackage{listings}
\usepackage{xcolor}
\lstset{
basicstyle=\ttfamily\small,
keywordstyle=\color{blue!70!black}\bfseries,
commentstyle=\color{green!50!black}\itshape,
stringstyle=\color{red!60!black},
frame=single,
breaklines=true,
numbers=left,
numberstyle=\tiny\color{gray},
tabsize=2
}
\title{ConversaDB: A Context-Aware Query Engine for Conversational Database Interactions}
\author{Emily R. Nakamura}
\affiliation{
\institution{University of Washington}
\department{Paul G. Allen School of Computer Science}
\city{Seattle}
\state{WA}
\country{USA}
}
\email{enakamura@cs.washington.edu}
\author{Carlos A. Mendez}
\affiliation{
\institution{ETH Zurich}
\department{Department of Computer Science}
\city{Zurich}
\country{Switzerland}
}
\email{mendez@inf.ethz.ch}
\author{Priya S. Gupta}
\affiliation{
\institution{Microsoft Research}
\city{Redmond}
\state{WA}
\country{USA}
}
\email{priyag@microsoft.com}
\begin{document}
\begin{abstract}
Natural language interfaces to databases (NLIDBs) have long been a goal of the database and NLP communities. While recent advances in large language models (LLMs) have dramatically improved text-to-SQL accuracy, existing systems treat each query independently, ignoring the conversational context that is natural in real-world database interactions. We present ConversaDB, a context-aware query engine that maintains a multi-turn dialogue state to resolve ambiguities, handle co-references, and support follow-up queries. Our system introduces three key innovations: (1)~a dialogue state tracker that maintains a structured representation of the conversation history, including referenced tables, columns, and filter conditions; (2)~a context-aware SQL generation module that conditions on both the current utterance and the dialogue state; and (3)~an interactive disambiguation protocol that proactively seeks clarification when query intent is uncertain. We evaluate ConversaDB on the SParC and CoSQL benchmarks, achieving 71.4\% and 53.8\% question match accuracy respectively, representing improvements of 6.2\% and 8.1\% over the previous state of the art. A user study with 48 participants confirms that ConversaDB significantly reduces the number of interaction turns required to complete complex analytical tasks.
\end{abstract}
\keywords{natural language interfaces, text-to-SQL, conversational AI, database systems, dialogue state tracking}
\maketitle
\section{Introduction}
Databases store vast amounts of structured information, yet accessing this data requires proficiency in query languages such as SQL. Natural language interfaces to databases (NLIDBs) aim to bridge this gap by allowing users to express their information needs in plain language. Recent progress in large language models has led to dramatic improvements in single-turn text-to-SQL systems, with state-of-the-art models achieving over 80\% accuracy on the Spider benchmark~\cite{yu2018spider}.
However, real-world database interactions are inherently conversational. Users rarely formulate their complete information need in a single query. Instead, they engage in multi-turn dialogues, refining their queries, asking follow-up questions, and exploring the data incrementally. Consider the following example interaction:
\begin{enumerate}
\item \textit{``Show me sales by region for Q4 2024.''}
\item \textit{``What about the previous quarter?''}
\item \textit{``Sort those by revenue.''}
\item \textit{``Which region had the highest growth?''}
\end{enumerate}
Each utterance after the first requires understanding the conversational context to generate the correct SQL query. Utterance~2 requires resolving ``the previous quarter'' relative to Q4~2024. Utterance~3 refers to ``those,'' which co-references the results from utterance~2. These phenomena---temporal references, co-references, and ellipsis---are pervasive in natural dialogue but poorly handled by single-turn systems.
\paragraph{Contributions.} We make the following contributions:
\begin{itemize}
\item We introduce ConversaDB, a context-aware query engine that maintains structured dialogue state for multi-turn database interactions.
\item We propose a novel context-aware SQL generation architecture that combines dialogue state tracking with constrained decoding.
\item We design an interactive disambiguation protocol that reduces errors by proactively seeking clarification.
\item We achieve new state-of-the-art results on SParC and CoSQL benchmarks and validate our system through a comprehensive user study.
\end{itemize}
\section{Related Work}
\subsection{Text-to-SQL}
The text-to-SQL task has seen rapid progress in recent years. Early approaches used rule-based parsing and template filling~\cite{li2014constructing}. Neural approaches, beginning with Seq2SQL~\cite{zhong2017seq2sql}, framed the problem as sequence-to-sequence translation. More recent work leverages pre-trained language models, with systems like RESDSQL~\cite{li2023resdsql} achieving over 80\% exact match accuracy on Spider.
\subsection{Conversational Text-to-SQL}
SParC~\cite{yu2019sparc} and CoSQL~\cite{yu2019cosql} introduced benchmarks for context-dependent text-to-SQL. EditSQL~\cite{zhang2019editing} proposed editing the previous SQL query to handle follow-up questions. IGSQL~\cite{cai2020igsql} used an interaction graph to capture cross-turn dependencies. Despite these advances, handling complex co-references and temporal expressions remains challenging.
\section{System Architecture}
ConversaDB consists of three main components: (1)~a Dialogue State Tracker (DST), (2)~a Context-Aware SQL Generator (CASG), and (3)~an Interactive Disambiguation Module (IDM). Figure~1 illustrates the overall architecture.
\subsection{Dialogue State Tracker}
The DST maintains a structured representation $\mathcal{S}_t$ at each turn $t$ that captures the essential context from the conversation history:
\begin{equation}
\mathcal{S}_t = (\mathcal{T}_t, \mathcal{C}_t, \mathcal{F}_t, \mathcal{R}_t, Q_{t-1})
\end{equation}
\noindent where $\mathcal{T}_t$ is the set of referenced tables, $\mathcal{C}_t$ is the set of referenced columns, $\mathcal{F}_t$ represents active filter conditions, $\mathcal{R}_t$ captures the result schema from the previous query, and $Q_{t-1}$ is the most recent SQL query. At each turn, the DST updates the state based on the new utterance $u_t$:
\begin{equation}
\mathcal{S}_t = \text{DST}(\mathcal{S}_{t-1}, u_t, \mathcal{DB})
\end{equation}
\noindent where $\mathcal{DB}$ denotes the database schema.
\subsection{Context-Aware SQL Generator}
The CASG generates SQL queries conditioned on both the current utterance and the dialogue state. We encode the input as a linearized sequence:
\begin{equation}
x_t = [u_t; \text{serialize}(\mathcal{S}_t); \text{serialize}(\mathcal{DB})]
\end{equation}
The SQL query is generated autoregressively with schema-aware constrained decoding that ensures syntactic validity. We fine-tune a pre-trained T5-large model with the following objective:
\begin{equation}
\mathcal{L} = -\sum_{t=1}^{T} \sum_{j=1}^{|q_t|} \log P(q_t^j \mid q_t^{<j}, x_t; \theta)
\end{equation}
\subsection{Interactive Disambiguation}
When the model's confidence in the generated SQL falls below threshold $\tau$, the IDM generates a clarification question. We compute confidence as the geometric mean of token-level probabilities:
\begin{equation}
\text{conf}(q_t) = \left( \prod_{j=1}^{|q_t|} P(q_t^j \mid q_t^{<j}, x_t) \right)^{1/|q_t|}
\end{equation}
\section{Evaluation}
\subsection{Benchmark Results}
Table~\ref{tab:benchmark} shows results on SParC and CoSQL.
\begin{table}[t]
\caption{Question match accuracy (\%) on SParC and CoSQL development sets.}
\label{tab:benchmark}
\begin{tabular}{@{}lcc@{}}
\toprule
\textbf{Model} & \textbf{SParC} & \textbf{CoSQL} \\
\midrule
EditSQL (2019) & 47.2 & 31.4 \\
IGSQL (2020) & 50.7 & 36.8 \\
HIE-SQL (2022) & 60.1 & 42.3 \\
STAR (2023) & 65.2 & 45.7 \\
\midrule
\textbf{ConversaDB} & \textbf{71.4} & \textbf{53.8} \\
\bottomrule
\end{tabular}
\end{table}
\subsection{User Study}
We conducted a user study with 48 participants (24 SQL-proficient, 24 non-technical) who completed 8 analytical tasks using ConversaDB and a single-turn baseline. ConversaDB reduced the average number of interaction turns from 6.3 to 3.8 and increased task completion rate from 72\% to 91\%. Participants rated ConversaDB 4.2/5.0 on naturalness compared to 2.8/5.0 for the baseline.
\subsection{Ablation Study}
Table~\ref{tab:ablation} shows the contribution of each component.
\begin{table}[t]
\caption{Ablation study on SParC development set.}
\label{tab:ablation}
\begin{tabular}{@{}lc@{}}
\toprule
\textbf{Configuration} & \textbf{QM Acc. (\%)} \\
\midrule
Full ConversaDB & 71.4 \\
$-$ Dialogue State Tracker & 63.8 \\
$-$ Constrained Decoding & 67.1 \\
$-$ Interactive Disambiguation & 69.2 \\
$-$ Pre-training & 58.4 \\
\bottomrule
\end{tabular}
\end{table}
\section{Conclusion}
We presented ConversaDB, a context-aware query engine that brings conversational capabilities to natural language database interfaces. By maintaining structured dialogue state and employing context-aware SQL generation with interactive disambiguation, ConversaDB achieves state-of-the-art results on multi-turn text-to-SQL benchmarks. Our user study confirms that the conversational paradigm significantly improves the database interaction experience for both technical and non-technical users.
\section*{Acknowledgments}
This research was supported by NSF Award IIS-2238811 and a gift from Microsoft Research. We thank the anonymous reviewers for their constructive feedback.
\bibliographystyle{ACM-Reference-Format}
% \bibliography{references}
\begin{thebibliography}{10}
\bibitem{yu2018spider}
T.~Yu et~al., ``Spider: A large-scale human-labeled dataset for complex and cross-database semantic parsing and text-to-SQL task,'' in \textit{EMNLP}, 2018, pp.~3911--3921.
\bibitem{li2014constructing}
F.~Li and H.~V. Jagadish, ``Constructing an interactive natural language interface for relational databases,'' \textit{PVLDB}, vol.~8, no.~1, pp.~73--84, 2014.
\bibitem{zhong2017seq2sql}
V.~Zhong, C.~Xiong, and R.~Socher, ``Seq2SQL: Generating structured queries from natural language using reinforcement learning,'' \textit{arXiv:1709.00103}, 2017.
\bibitem{li2023resdsql}
H.~Li et~al., ``RESDSQL: Decoupling schema linking and skeleton parsing for text-to-SQL,'' in \textit{AAAI}, 2023.
\bibitem{yu2019sparc}
T.~Yu et~al., ``SParC: Cross-domain semantic parsing in context,'' in \textit{ACL}, 2019, pp.~4511--4523.
\bibitem{yu2019cosql}
T.~Yu et~al., ``CoSQL: A conversational text-to-SQL challenge towards cross-domain natural language interfaces to databases,'' in \textit{EMNLP}, 2019.
\bibitem{zhang2019editing}
R.~Zhang et~al., ``Editing-based SQL query generation for cross-domain context-dependent questions,'' in \textit{EMNLP}, 2019.
\bibitem{cai2020igsql}
Y.~Cai and B.~Wan, ``IGSQL: Database schema interaction graph based neural model for context-dependent text-to-SQL generation,'' in \textit{EMNLP}, 2020.
\end{thebibliography}
\end{document}

View file

@ -0,0 +1 @@
% empty bibliography

View file

@ -0,0 +1,187 @@
\documentclass[conference]{IEEEtran}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\usepackage{graphicx}
\usepackage{hyperref}
\usepackage{cite}
\usepackage{booktabs}
\usepackage{algorithmic}
\usepackage{algorithm}
\usepackage{microtype}
\usepackage{xcolor}
\usepackage{lipsum}
\title{FedSecure: A Privacy-Preserving Federated Learning Framework for Heterogeneous Edge Networks}
\author{
\IEEEauthorblockN{James R. Mitchell\IEEEauthorrefmark{1}, Sarah K. Park\IEEEauthorrefmark{2}, and David L. Fernandez\IEEEauthorrefmark{1}}
\IEEEauthorblockA{\IEEEauthorrefmark{1}Department of Electrical and Computer Engineering\\
Georgia Institute of Technology, Atlanta, GA 30332\\
Email: \{jmitchell, dfernandez\}@gatech.edu}
\IEEEauthorblockA{\IEEEauthorrefmark{2}Microsoft Research\\
Redmond, WA 98052\\
Email: sarahpark@microsoft.com}
}
\begin{document}
\maketitle
\begin{abstract}
Federated learning enables collaborative model training across distributed edge devices without centralizing raw data. However, existing frameworks face critical challenges in handling statistical heterogeneity across clients and ensuring robust privacy guarantees. In this paper, we present FedSecure, a novel federated learning framework that integrates differential privacy with adaptive aggregation strategies designed for heterogeneous edge networks. Our approach introduces a client-aware noise calibration mechanism that adjusts privacy budgets based on local data characteristics, achieving a favorable privacy-utility trade-off. We propose a hierarchical aggregation protocol that groups clients by data distribution similarity, reducing the impact of non-IID data on global model convergence. Extensive experiments on four benchmark datasets across diverse heterogeneity scenarios demonstrate that FedSecure improves accuracy by 8.3\% over FedAvg while maintaining $(\epsilon, \delta)$-differential privacy with $\epsilon = 1.0$. Our framework also reduces communication overhead by 40\% through gradient compression and selective participation.
\end{abstract}
\begin{IEEEkeywords}
Federated learning, differential privacy, edge computing, heterogeneous networks, distributed machine learning
\end{IEEEkeywords}
\section{Introduction}
The proliferation of edge devices has created unprecedented opportunities for distributed machine learning. Mobile phones, IoT sensors, and autonomous vehicles generate vast amounts of data that can be leveraged to train powerful predictive models. However, transmitting this data to a central server raises significant privacy concerns and may violate data protection regulations such as GDPR and CCPA~\cite{voigt2017gdpr}.
Federated learning (FL) has emerged as a promising paradigm to address these challenges by enabling model training directly on edge devices~\cite{mcmahan2017communication}. In the canonical federated learning setup, a central server coordinates the training process by distributing model parameters to participating clients, which perform local training on their private data and return model updates to the server for aggregation.
Despite significant progress, two fundamental challenges remain:
\begin{enumerate}
\item \textbf{Statistical heterogeneity}: Data distributions across clients are typically non-IID, leading to client drift and degraded global model performance~\cite{karimireddy2020scaffold}.
\item \textbf{Privacy guarantees}: While federated learning avoids direct data sharing, model updates can still leak sensitive information through inference attacks~\cite{nasr2019comprehensive}.
\end{enumerate}
In this paper, we propose FedSecure, a comprehensive framework that jointly addresses these challenges. Our key contributions include:
\begin{itemize}
\item A client-aware differential privacy mechanism that adaptively calibrates noise injection based on local data characteristics and sensitivity.
\item A hierarchical aggregation protocol that clusters clients by distribution similarity to mitigate non-IID effects.
\item A gradient compression scheme that reduces communication costs while preserving model quality under privacy constraints.
\item Comprehensive evaluation on CIFAR-10, CIFAR-100, FEMNIST, and Shakespeare datasets demonstrating state-of-the-art performance.
\end{itemize}
\section{Related Work}
\subsection{Federated Learning}
McMahan et al.~\cite{mcmahan2017communication} introduced FedAvg, the foundational algorithm for federated learning. Subsequent work has proposed improvements for handling non-IID data, including FedProx~\cite{li2020federated}, SCAFFOLD~\cite{karimireddy2020scaffold}, and FedNova~\cite{wang2020tackling}. These methods introduce regularization terms or variance reduction techniques to improve convergence under heterogeneous data distributions.
\subsection{Differential Privacy in FL}
Differential privacy (DP) provides formal privacy guarantees by adding calibrated noise to computations~\cite{dwork2014algorithmic}. Several works have integrated DP into federated learning, including user-level DP~\cite{mcmahan2018learning} and record-level DP approaches. The primary challenge lies in balancing the privacy budget with model utility, particularly in heterogeneous settings.
\section{Proposed Framework}
\subsection{System Model}
We consider a federated learning system with $N$ clients $\{C_1, C_2, \ldots, C_N\}$ coordinated by a central server $S$. Each client $C_i$ holds a local dataset $\mathcal{D}_i$ of size $n_i$, drawn from a potentially unique distribution $P_i$. The global objective is:
\begin{equation}
\min_{w} F(w) = \sum_{i=1}^{N} \frac{n_i}{n} F_i(w)
\end{equation}
where $F_i(w) = \mathbb{E}_{\xi \sim P_i}[f(w; \xi)]$ is the local objective and $n = \sum_{i=1}^{N} n_i$.
\subsection{Client-Aware Noise Calibration}
Standard DP mechanisms apply uniform noise across all clients, which is suboptimal when clients have varying data characteristics. We propose an adaptive noise calibration strategy that adjusts the noise scale $\sigma_i$ for each client:
\begin{equation}
\sigma_i = \frac{\Delta_i \cdot \sqrt{2 \ln(1.25/\delta)}}{\epsilon_i}
\end{equation}
where $\Delta_i$ is the sensitivity of client $i$'s gradient update and $\epsilon_i$ is the allocated privacy budget. We distribute the total privacy budget $\epsilon$ across clients using an optimization-based allocation that minimizes the expected aggregation error.
\subsection{Hierarchical Aggregation}
To address non-IID data, we introduce a two-level aggregation scheme. First, we compute the pairwise distribution distance between clients using the Wasserstein distance estimated from model updates. Clients are then clustered into $K$ groups $\{G_1, \ldots, G_K\}$ using spectral clustering. Aggregation proceeds in two phases: intra-group aggregation followed by inter-group aggregation.
\begin{algorithm}[t]
\caption{FedSecure Training Protocol}
\begin{algorithmic}[1]
\STATE \textbf{Input:} $N$ clients, rounds $T$, local epochs $E$, privacy budget $\epsilon$
\STATE Initialize global model $w_0$
\FOR{$t = 0$ to $T-1$}
\STATE Server selects subset $S_t \subseteq \{1,\ldots,N\}$
\STATE Server broadcasts $w_t$ to selected clients
\FOR{each client $i \in S_t$ \textbf{in parallel}}
\STATE $g_i \leftarrow$ LocalTrain($w_t, \mathcal{D}_i, E$)
\STATE $\hat{g}_i \leftarrow$ Clip($g_i$, $C$) + $\mathcal{N}(0, \sigma_i^2 I)$
\STATE Send $\hat{g}_i$ to server
\ENDFOR
\STATE Server clusters clients into groups $\{G_k\}$
\STATE $w_{t+1} \leftarrow$ HierarchicalAggregate($\{\hat{g}_i\}$, $\{G_k\}$)
\ENDFOR
\end{algorithmic}
\end{algorithm}
\section{Experimental Evaluation}
\subsection{Setup}
We evaluate FedSecure on four datasets: CIFAR-10, CIFAR-100, FEMNIST (62-class character recognition), and Shakespeare (next-character prediction). We simulate non-IID distributions using a Dirichlet allocation with concentration parameter $\alpha \in \{0.1, 0.5, 1.0\}$ across 100 clients. We use ResNet-18 for CIFAR experiments and an LSTM model for Shakespeare.
\subsection{Main Results}
Table~\ref{tab:main_results} presents the comparison of FedSecure with baseline methods under non-IID settings ($\alpha = 0.5$) with privacy guarantee $\epsilon = 1.0$.
\begin{table}[t]
\centering
\caption{Test accuracy (\%) comparison under non-IID setting ($\alpha=0.5$) with $\epsilon=1.0$ differential privacy.}
\label{tab:main_results}
\begin{tabular}{@{}lcccc@{}}
\toprule
\textbf{Method} & \textbf{CIFAR-10} & \textbf{CIFAR-100} & \textbf{FEMNIST} & \textbf{Shakespeare} \\
\midrule
FedAvg + DP & 61.2 & 33.8 & 72.4 & 45.1 \\
FedProx + DP & 63.5 & 35.2 & 74.1 & 46.8 \\
SCAFFOLD + DP & 65.1 & 37.4 & 75.3 & 47.9 \\
DP-FedAvg & 59.8 & 32.1 & 70.9 & 43.6 \\
\midrule
\textbf{FedSecure} & \textbf{69.5} & \textbf{41.7} & \textbf{78.6} & \textbf{51.2} \\
\bottomrule
\end{tabular}
\end{table}
\subsection{Communication Efficiency}
FedSecure achieves a 40\% reduction in communication overhead compared to FedAvg through the combination of gradient compression and selective client participation. To reach 65\% accuracy on CIFAR-10 with $\epsilon = 1.0$, FedSecure requires 120 communication rounds compared to 280 for FedAvg + DP and 195 for SCAFFOLD + DP.
\subsection{Privacy-Utility Trade-off}
We analyze the privacy-utility trade-off by varying $\epsilon$ from 0.1 to 10.0. FedSecure consistently outperforms baselines across all privacy levels. At stringent privacy ($\epsilon = 0.1$), FedSecure achieves 52.3\% accuracy on CIFAR-10 compared to 41.7\% for the best baseline, demonstrating the effectiveness of client-aware noise calibration.
\section{Conclusion}
We presented FedSecure, a privacy-preserving federated learning framework that addresses the dual challenges of statistical heterogeneity and privacy protection in edge networks. Through client-aware noise calibration and hierarchical aggregation, FedSecure achieves state-of-the-art performance across diverse datasets while providing formal differential privacy guarantees. Future work will explore extending FedSecure to cross-silo settings and incorporating secure aggregation protocols for enhanced protection against server-side attacks.
\section*{Acknowledgment}
This work was supported by NSF grants CNS-2143868 and CNS-2106891, and by a gift from Microsoft Research.
\begin{thebibliography}{10}
\bibitem{mcmahan2017communication}
B.~McMahan, E.~Moore, D.~Ramage, S.~Hampson, and B.~Aguera y Arcas, ``Communication-efficient learning of deep networks from decentralized data,'' in \textit{Proc. AISTATS}, pp.~1273--1282, 2017.
\bibitem{voigt2017gdpr}
P.~Voigt and A.~Von dem Bussche, ``The {EU} general data protection regulation ({GDPR}),'' \textit{Springer International Publishing}, vol.~10, pp.~10--5555, 2017.
\bibitem{karimireddy2020scaffold}
S.~P. Karimireddy, S.~Kale, M.~Mohri, S.~Reddi, S.~Stich, and A.~T. Suresh, ``{SCAFFOLD}: Stochastic controlled averaging for federated learning,'' in \textit{Proc. ICML}, pp.~5132--5143, 2020.
\bibitem{nasr2019comprehensive}
M.~Nasr, R.~Shokri, and A.~Houmansadr, ``Comprehensive privacy analysis of deep learning,'' in \textit{Proc. IEEE S\&P}, pp.~739--753, 2019.
\bibitem{li2020federated}
T.~Li, A.~K. Sahu, M.~Zaheer, M.~Sanjabi, A.~Talwalkar, and V.~Smith, ``Federated optimization in heterogeneous networks,'' in \textit{Proc. MLSys}, 2020.
\bibitem{wang2020tackling}
J.~Wang, Q.~Liu, H.~Liang, G.~Joshi, and H.~V. Poor, ``Tackling the objective inconsistency problem in heterogeneous federated optimization,'' in \textit{Proc. NeurIPS}, vol.~33, pp.~7611--7623, 2020.
\bibitem{dwork2014algorithmic}
C.~Dwork and A.~Roth, ``The algorithmic foundations of differential privacy,'' \textit{Foundations and Trends in Theoretical Computer Science}, vol.~9, no.~3--4, pp.~211--407, 2014.
\bibitem{mcmahan2018learning}
H.~B. McMahan, D.~Ramage, K.~Talwar, and L.~Zhang, ``Learning differentially private recurrent language models,'' in \textit{Proc. ICLR}, 2018.
\end{thebibliography}
\end{document}

View file

@ -0,0 +1 @@
% empty bibliography

View file

@ -0,0 +1,36 @@
\documentclass[12pt]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\usepackage{graphicx}
\usepackage[margin=1in]{geometry}
\usepackage{hyperref}
\usepackage{booktabs}
\usepackage{natbib}
\title{Title}
\author{Author Name}
\date{\today}
\begin{document}
\maketitle
\begin{abstract}
Your abstract here.
\end{abstract}
\section{Introduction}
\section{Related Work}
\section{Method}
\section{Results}
\section{Conclusion}
\bibliographystyle{plainnat}
\bibliography{references}
\end{document}

View file

@ -0,0 +1 @@
% empty bibliography

View file

@ -0,0 +1,247 @@
\documentclass[a0paper,portrait]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage{amsmath,amssymb}
\usepackage{graphicx}
\usepackage{multicol}
\usepackage[margin=2.5cm]{geometry}
\usepackage{xcolor}
\usepackage{tikz}
\usepackage{enumitem}
\usepackage{booktabs}
\usepackage{microtype}
\pagestyle{empty}
% ──── Color Theme ────
\definecolor{headerblue}{HTML}{1B3A5C}
\definecolor{accentblue}{HTML}{2E86C1}
\definecolor{lightbg}{HTML}{EBF5FB}
\definecolor{darktext}{HTML}{2C3E50}
\definecolor{sectioncolor}{HTML}{1A5276}
% ──── Section styling ────
\makeatletter
\renewcommand{\section}{\@startsection{section}{1}{0pt}%
{-1.5ex plus -0.5ex minus -0.2ex}{1ex plus 0.2ex}%
{\LARGE\bfseries\color{sectioncolor}}}
\makeatother
\renewcommand{\familydefault}{\sfdefault}
\begin{document}
% ════════════════════════════════════════════
% HEADER
% ════════════════════════════════════════════
\begin{tikzpicture}[remember picture, overlay]
\fill[headerblue] ([yshift=0cm]current page.north west) rectangle ([yshift=-14cm]current page.north east);
\end{tikzpicture}
\vspace*{-1cm}
\begin{center}
{\color{white}\fontsize{72}{86}\selectfont\bfseries
Machine Learning--Guided Drug Discovery:\\[0.3cm]
Predicting Protein--Ligand Binding Affinity\\[0.3cm]
with Graph Neural Networks}\\[1.5cm]
{\color{white}\fontsize{36}{44}\selectfont
\textbf{Elena Vasquez}$^1$, \textbf{Thomas Wright}$^{1,2}$, \textbf{Kenji Yamamoto}$^2$, \textbf{Prof. Laura Kingston}$^1$}\\[0.8cm]
{\color{white!85}\fontsize{30}{36}\selectfont
$^1$Department of Biochemistry, University of Oxford \quad\quad
$^2$DeepMind, London, UK}\\[0.5cm]
{\color{accentblue!40}\fontsize{26}{32}\selectfont
Contact: elena.vasquez@bioch.ox.ac.uk \quad|\quad ICML 2025 -- Poster \#247}
\end{center}
\vspace{2cm}
% ════════════════════════════════════════════
% BODY -- Three Columns
% ════════════════════════════════════════════
\begin{multicols}{3}
% ──── Introduction ────
\section*{Introduction}
\large
Drug discovery is a lengthy and expensive process, with the average new drug requiring over 10 years and \$2.6 billion to develop. A critical bottleneck is the accurate prediction of protein--ligand binding affinity, which determines whether a candidate molecule will effectively interact with its target protein.
\vspace{0.5cm}
\textbf{Traditional approaches:}
\begin{itemize}[leftmargin=1.5em, itemsep=4pt]
\item Molecular docking (AutoDock, Glide) -- fast but inaccurate
\item Molecular dynamics simulations -- accurate but computationally prohibitive
\item Classical QSAR models -- limited to predefined descriptors
\end{itemize}
\vspace{0.5cm}
\textbf{Our contribution:} We propose \textbf{AffinityGNN}, a graph neural network that operates directly on the 3D molecular graph of the protein--ligand complex to predict binding free energy ($\Delta G$) with near-experimental accuracy.
\vspace{0.8cm}
% ──── Methods ────
\section*{Methods}
\textbf{Graph Construction.} We represent the protein--ligand complex as a heterogeneous graph $G = (V_P \cup V_L, E)$ where:
\begin{itemize}[leftmargin=1.5em, itemsep=4pt]
\item $V_P$: protein residue nodes (C$\alpha$ atoms)
\item $V_L$: ligand heavy atom nodes
\item $E$: edges based on spatial proximity ($< 8$\AA)
\end{itemize}
\vspace{0.5cm}
\textbf{Architecture.} AffinityGNN consists of:
\begin{enumerate}[leftmargin=1.5em, itemsep=4pt]
\item Node feature encoder (atom type, charge, hybridization)
\item 6 layers of message-passing with attention
\item Graph-level readout with Set2Set pooling
\item Prediction head: 3-layer MLP $\rightarrow \Delta G$
\end{enumerate}
\vspace{0.5cm}
\textbf{Message Passing:}
\begin{equation*}
h_i^{(l+1)} = \sigma\!\left( \sum_{j \in \mathcal{N}(i)} \alpha_{ij}^{(l)} W^{(l)} h_j^{(l)} + b^{(l)} \right)
\end{equation*}
where $\alpha_{ij}$ are attention weights incorporating edge features (distance, angle).
\vspace{0.5cm}
\textbf{Training.} We use a combined loss:
\begin{equation*}
\mathcal{L} = \underbrace{\|\hat{y} - y\|_2^2}_{\text{MSE}} + \lambda \underbrace{(1 - \rho(\hat{y}, y))}_{\text{Correlation}}
\end{equation*}
\columnbreak
% ──── Results ────
\section*{Results}
\textbf{Benchmark Performance} on PDBbind v2020 core set:
\vspace{0.5cm}
\begin{center}
\renewcommand{\arraystretch}{1.4}
{\large
\begin{tabular}{@{}lcc@{}}
\toprule
\textbf{Method} & \textbf{RMSE} & \textbf{Pearson $R$} \\
\midrule
AutoDock Vina & 2.41 & 0.604 \\
RF-Score v3 & 1.82 & 0.713 \\
OnionNet-2 & 1.54 & 0.782 \\
PLIP-GNN & 1.41 & 0.801 \\
\midrule
\textbf{AffinityGNN} & \textbf{1.18} & \textbf{0.862} \\
\bottomrule
\end{tabular}}
\end{center}
\vspace{1cm}
\textbf{Virtual Screening on DUD-E:}
\vspace{0.5cm}
\begin{center}
\renewcommand{\arraystretch}{1.4}
{\large
\begin{tabular}{@{}lcc@{}}
\toprule
\textbf{Target} & \textbf{AUC-ROC} & \textbf{EF$_{1\%}$} \\
\midrule
CDK2 (kinase) & 0.94 & 42.3 \\
COX-2 (enzyme) & 0.91 & 38.7 \\
EGFR (receptor) & 0.93 & 45.1 \\
HIV-RT (viral) & 0.89 & 31.4 \\
\midrule
\textbf{Average} & \textbf{0.92} & \textbf{39.4} \\
\bottomrule
\end{tabular}}
\end{center}
\vspace{1cm}
\textbf{Experimental Validation.} We used AffinityGNN to screen 50,000 compounds against SARS-CoV-2 main protease (M$^{\text{pro}}$). The top 100 predictions were synthesized and tested:
\begin{itemize}[leftmargin=1.5em, itemsep=4pt]
\item 23 compounds showed IC$_{50} < 10\,\mu$M
\item 4 compounds showed IC$_{50} < 100\,$nM
\item Best hit: IC$_{50} = 28\,$nM (comparable to nirmatrelvir)
\end{itemize}
\columnbreak
% ──── Analysis ────
\section*{Ablation \& Analysis}
\textbf{Component Contributions:}
\vspace{0.5cm}
\begin{center}
\renewcommand{\arraystretch}{1.4}
{\large
\begin{tabular}{@{}lc@{}}
\toprule
\textbf{Variant} & \textbf{RMSE} \\
\midrule
Full model & 1.18 \\
$-$ Attention mechanism & 1.34 \\
$-$ Edge features & 1.29 \\
$-$ Correlation loss & 1.25 \\
$-$ Set2Set pooling & 1.31 \\
GCN baseline (no attention) & 1.52 \\
\bottomrule
\end{tabular}}
\end{center}
\vspace{1cm}
\textbf{Attention Visualization.} The learned attention weights highlight key interactions at the binding site, including hydrogen bonds, $\pi$-stacking, and hydrophobic contacts---consistent with known biochemistry.
\vspace{1cm}
% ──── Conclusions ────
\section*{Conclusions}
\begin{itemize}[leftmargin=1.5em, itemsep=6pt]
\item AffinityGNN achieves \textbf{state-of-the-art} binding affinity prediction (RMSE = 1.18 kcal/mol)
\item \textbf{Interpretable} attention mechanism reveals binding site interactions
\item \textbf{Practical impact}: identified 4 potent M$^{\text{pro}}$ inhibitors from virtual screening
\item Inference time: \textbf{0.3ms per complex} (GPU), enabling large-scale screening
\item Code and models available at \texttt{github.com/oxbiochem/affinitygnn}
\end{itemize}
\vspace{1cm}
% ──── References ────
\section*{Key References}
{\normalsize
\begin{enumerate}[leftmargin=1.5em, itemsep=2pt]
\item Corso et al., ``DiffDock,'' \textit{ICLR}, 2023.
\item St\"{a}rk et al., ``EquiBind,'' \textit{ICML}, 2022.
\item Wang et al., ``PDBbind v2020,'' \textit{JCIM}, 2020.
\item Kipf \& Welling, ``GCN,'' \textit{ICLR}, 2017.
\end{enumerate}
}
\vspace{0.8cm}
% ──── Acknowledgements ────
\section*{Acknowledgements}
{\normalsize
This work was supported by the Wellcome Trust (Grant 203141/Z/16/Z), EPSRC Doctoral Training Partnership, and computing resources from JADE2 (EP/T022205/1).}
\end{multicols}
\end{document}

View file

@ -0,0 +1,40 @@
\documentclass{beamer}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\usepackage{graphicx}
\usetheme{Madrid}
\setbeamertemplate{blocks}[rounded][shadow=false]
\title{Presentation Title}
\author{Author Name}
\institute{Institution}
\date{\today}
\begin{document}
\begin{frame}
\titlepage
\end{frame}
\begin{frame}{Outline}
\tableofcontents
\end{frame}
\section{Introduction}
\begin{frame}{Introduction}
Content here.
\end{frame}
\section{Main Content}
\begin{frame}{Main Content}
Content here.
\end{frame}
\section{Conclusion}
\begin{frame}{Conclusion}
Content here.
\end{frame}
\end{document}

View file

@ -0,0 +1,325 @@
\documentclass[12pt,a4paper]{report}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage[margin=1in]{geometry}
\usepackage{graphicx}
\usepackage{hyperref}
\usepackage{booktabs}
\usepackage{listings}
\usepackage{xcolor}
\usepackage{fancyhdr}
\usepackage{titlesec}
\usepackage{enumitem}
\usepackage{amsmath}
\usepackage{tabularx}
\usepackage{float}
\usepackage{microtype}
\usepackage{caption}
\definecolor{codeblue}{HTML}{2E86C1}
\definecolor{codebg}{HTML}{F4F6F7}
\definecolor{codegreen}{HTML}{27AE60}
\definecolor{codegray}{HTML}{7F8C8D}
\definecolor{headerblue}{HTML}{1B3A5C}
\hypersetup{
colorlinks=true,
linkcolor=headerblue,
citecolor=codeblue,
urlcolor=codeblue
}
\lstset{
basicstyle=\ttfamily\small,
backgroundcolor=\color{codebg},
keywordstyle=\color{codeblue}\bfseries,
commentstyle=\color{codegreen}\itshape,
stringstyle=\color{red!60!black},
numberstyle=\tiny\color{codegray},
frame=single,
rulecolor=\color{codegray!50},
breaklines=true,
numbers=left,
tabsize=2,
showstringspaces=false
}
% Chapter heading style
\titleformat{\chapter}[display]
{\normalfont\LARGE\bfseries\color{headerblue}}{\chaptertitlename\\ \thechapter}{16pt}{\Huge}
\titlespacing*{\chapter}{0pt}{-10pt}{25pt}
% Header/footer
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\small\color{codegray}\textit{Technical Report TR-2025-003}}
\fancyhead[R]{\small\color{codegray}\nouppercase{\leftmark}}
\fancyfoot[C]{\thepage}
\renewcommand{\headrulewidth}{0.4pt}
\begin{document}
% ──── Title Page ────
\begin{titlepage}
\centering
\vspace*{1cm}
{\color{headerblue}\rule{\textwidth}{2pt}}\\[1cm]
{\small\color{codegray} TECHNICAL REPORT TR-2025-003}\\[1cm]
{\fontsize{28}{34}\selectfont\bfseries\color{headerblue}
Distributed Key--Value Store\\with Consensus-Based Replication\\[0.3cm]
Design, Implementation, and\\Performance Analysis}\\[1.5cm]
{\Large
\textbf{Michael Torres}, \textbf{Ananya Krishnan}, \textbf{Erik Johansson}}\\[0.5cm]
{\large Infrastructure Engineering Team\\Distributed Systems Division}\\[1.5cm]
{\color{headerblue}\rule{0.6\textwidth}{0.5pt}}\\[1cm]
{\large
\begin{tabular}{ll}
\textbf{Document ID:} & TR-2025-003 \\
\textbf{Version:} & 2.1 \\
\textbf{Date:} & February 10, 2025 \\
\textbf{Classification:} & Internal -- Engineering \\
\textbf{Status:} & Final \\
\end{tabular}}
\vfill
{\large Acme Cloud Systems, Inc.\\
1200 Technology Drive, San Jose, CA 95134}
\end{titlepage}
% ──── Revision History ────
\chapter*{Revision History}
\addcontentsline{toc}{chapter}{Revision History}
\begin{table}[H]
\centering
\begin{tabular}{@{}llll@{}}
\toprule
\textbf{Version} & \textbf{Date} & \textbf{Author} & \textbf{Changes} \\
\midrule
1.0 & 2024-11-15 & M.\\ Torres & Initial draft \\
1.5 & 2024-12-20 & A.\\ Krishnan & Added benchmarks and security analysis \\
2.0 & 2025-01-28 & E.\\ Johansson & Performance tuning and final benchmarks \\
2.1 & 2025-02-10 & M.\\ Torres & Minor corrections, final review \\
\bottomrule
\end{tabular}
\end{table}
% ──── Executive Summary ────
\chapter*{Executive Summary}
\addcontentsline{toc}{chapter}{Executive Summary}
This report presents the design, implementation, and performance evaluation of \textbf{AcmeKV}, a distributed key--value store with strong consistency guarantees based on the Raft consensus protocol. AcmeKV is designed to serve as the foundational storage layer for Acme Cloud Systems' next-generation microservices platform, replacing the existing Cassandra-based infrastructure which has proven difficult to maintain and reason about under our strong consistency requirements.
Key findings of this report include:
\begin{itemize}[itemsep=4pt]
\item AcmeKV achieves \textbf{285,000 reads/sec} and \textbf{142,000 writes/sec} on a 5-node cluster with p99 latencies under 5ms for reads and 12ms for writes.
\item The system correctly maintains linearizable consistency under all tested failure scenarios, including leader failure, network partitions, and disk failures.
\item Compared to our existing Cassandra deployment, AcmeKV provides a \textbf{2.3$\times$} improvement in write latency and a \textbf{40\%} reduction in operational complexity as measured by incident response time.
\item The system is ready for production deployment with the caveats noted in Section~5.3.
\end{itemize}
% ──── TOC ────
\tableofcontents
\listoftables
% ════════════════════════════════════════════
\chapter{Introduction}
\label{ch:introduction}
% ════════════════════════════════════════════
\section{Motivation}
Acme Cloud Systems' microservices platform currently relies on Apache Cassandra as its primary key--value store. While Cassandra provides excellent horizontal scalability and availability, its eventual consistency model has been a persistent source of bugs and operational complexity. Over the past 18 months, our incident reports show that 34\% of P1 incidents were attributable to stale reads or write conflicts arising from Cassandra's eventual consistency guarantees.
As our platform has evolved, we have identified a growing set of use cases that require strong consistency:
\begin{enumerate}[itemsep=4pt]
\item \textbf{Configuration management}: Service configurations must be read with linearizable guarantees to prevent inconsistent behavior across replicas.
\item \textbf{Distributed locking}: Leader election and distributed locks require consensus to function correctly.
\item \textbf{Financial transactions}: Payment processing requires serializable isolation to prevent double-spending.
\item \textbf{Metadata catalogs}: Dataset and schema registries must reflect the latest state to avoid data corruption.
\end{enumerate}
\section{Requirements}
Based on stakeholder interviews and workload analysis, we established the following requirements for AcmeKV:
\begin{table}[H]
\centering
\caption{System requirements for AcmeKV.}
\label{tab:requirements}
\begin{tabular}{@{}llc@{}}
\toprule
\textbf{ID} & \textbf{Requirement} & \textbf{Priority} \\
\midrule
R1 & Linearizable read/write consistency & Must \\
R2 & $\geq$100K writes/sec (5-node cluster) & Must \\
R3 & p99 read latency $<$ 10ms & Must \\
R4 & Automatic leader failover $<$ 5 seconds & Must \\
R5 & Online cluster membership changes & Should \\
R6 & Point-in-time snapshots for backup & Should \\
R7 & TLS encryption for all communication & Must \\
R8 & Role-based access control (RBAC) & Should \\
R9 & Observability (metrics, tracing, logging) & Must \\
R10 & Cross-datacenter replication & Could \\
\bottomrule
\end{tabular}
\end{table}
\section{Scope}
This report covers the design and implementation of AcmeKV version 1.0, addressing requirements R1--R9. Cross-datacenter replication (R10) is deferred to version 2.0 and will be covered in a subsequent report.
% ════════════════════════════════════════════
\chapter{System Design}
\label{ch:design}
% ════════════════════════════════════════════
\section{Architecture Overview}
AcmeKV follows a replicated state machine architecture built on the Raft consensus protocol. The system consists of the following core components:
\begin{description}[style=nextline, leftmargin=2em]
\item[Raft Consensus Module] Implements leader election, log replication, and safety guarantees per the Raft specification. Our implementation extends the basic protocol with pre-vote, leadership transfer, and joint consensus for membership changes.
\item[Storage Engine] A log-structured merge-tree (LSM-tree) storage engine optimized for write-heavy workloads. The engine uses a two-level compaction strategy with bloom filters for efficient point lookups.
\item[API Gateway] gRPC-based API layer that routes client requests to the appropriate cluster node. Read requests can be served by any node (with a consistency check), while writes are forwarded to the leader.
\item[Snapshot Manager] Handles periodic snapshots of the state machine for log compaction and backup/restore operations.
\end{description}
\section{Consensus Protocol}
We implement the Raft consensus protocol with several optimizations for production use:
\begin{enumerate}[itemsep=4pt]
\item \textbf{Batched log replication}: Multiple client requests are batched into a single AppendEntries RPC, amortizing the cost of consensus across multiple operations.
\item \textbf{Pipeline replication}: The leader sends the next batch of entries before receiving acknowledgment for the previous batch, improving throughput on high-latency networks.
\item \textbf{Lease-based reads}: The leader maintains a time-based lease that allows it to serve linearizable reads without requiring a round of consensus, reducing read latency significantly.
\end{enumerate}
The correctness of our implementation is verified through a combination of property-based testing (using Jepsen) and TLA+ model checking of the core consensus logic.
\section{Data Model}
AcmeKV supports a simple key--value data model with the following operations:
\begin{lstlisting}[language=Go, caption={AcmeKV core API definition.}]
service AcmeKV {
// Put stores a key-value pair.
rpc Put(PutRequest) returns (PutResponse);
// Get retrieves the value for a key.
rpc Get(GetRequest) returns (GetResponse);
// Delete removes a key-value pair.
rpc Delete(DeleteRequest) returns (DeleteResponse);
// Scan returns key-value pairs in a range.
rpc Scan(ScanRequest) returns (stream ScanResponse);
// Txn executes a multi-key transaction.
rpc Txn(TxnRequest) returns (TxnResponse);
}
\end{lstlisting}
Keys are limited to 256 bytes and values to 1MB. The system supports optional TTL (time-to-live) on keys and atomic multi-key transactions with serializable isolation using optimistic concurrency control.
% ════════════════════════════════════════════
\chapter{Performance Evaluation}
\label{ch:performance}
% ════════════════════════════════════════════
\section{Test Environment}
All benchmarks were conducted on a 5-node cluster with the following hardware configuration:
\begin{table}[H]
\centering
\caption{Hardware configuration for benchmark cluster.}
\begin{tabular}{@{}ll@{}}
\toprule
\textbf{Component} & \textbf{Specification} \\
\midrule
CPU & Intel Xeon Gold 6348 (28 cores, 2.6 GHz) \\
Memory & 256 GB DDR4-3200 ECC \\
Storage & 2$\times$ Intel Optane P5800X 1.6TB (NVMe) \\
Network & 25 Gbps Ethernet (Mellanox ConnectX-6) \\
OS & Ubuntu 22.04 LTS (kernel 5.15) \\
\bottomrule
\end{tabular}
\end{table}
\section{Throughput Results}
Table~\ref{tab:throughput} summarizes the throughput results under various workload configurations.
\begin{table}[H]
\centering
\caption{Throughput (operations/second) under different workload mixes.}
\label{tab:throughput}
\begin{tabular}{@{}lccc@{}}
\toprule
\textbf{Workload} & \textbf{AcmeKV} & \textbf{etcd v3.5} & \textbf{Cassandra (QUORUM)} \\
\midrule
100\% Read & 285,000 & 142,000 & 310,000 \\
100\% Write & 142,000 & 48,000 & 165,000 \\
95\% Read / 5\% Write & 271,000 & 128,000 & 295,000 \\
50\% Read / 50\% Write & 198,000 & 85,000 & 225,000 \\
\bottomrule
\end{tabular}
\end{table}
AcmeKV achieves approximately 2$\times$ the throughput of etcd while providing the same linearizable consistency guarantees. Cassandra achieves slightly higher raw throughput, but this comes at the cost of weaker consistency (quorum reads/writes provide only regular register semantics, not linearizability).
\section{Latency Results}
\begin{table}[H]
\centering
\caption{Latency percentiles (milliseconds) for read and write operations.}
\label{tab:latency}
\begin{tabular}{@{}lcccc@{}}
\toprule
\textbf{Operation} & \textbf{p50} & \textbf{p90} & \textbf{p99} & \textbf{p99.9} \\
\midrule
Read (lease-based) & 0.8 & 1.5 & 3.2 & 8.1 \\
Read (consensus) & 2.1 & 3.8 & 7.4 & 15.3 \\
Write & 3.5 & 6.2 & 11.8 & 28.4 \\
Transaction (2-key) & 5.2 & 9.1 & 18.5 & 42.7 \\
\bottomrule
\end{tabular}
\end{table}
% ════════════════════════════════════════════
\chapter{Conclusion and Recommendations}
\label{ch:conclusion}
% ════════════════════════════════════════════
\section{Summary}
AcmeKV successfully meets all ``Must'' and ``Should'' priority requirements established in Section~1.2. The system provides linearizable consistency with production-grade performance, achieving 285K reads/sec and 142K writes/sec with sub-5ms p99 read latency on lease-based reads. Jepsen testing confirms correctness under all tested failure scenarios.
\section{Recommendations}
Based on our evaluation, we recommend the following deployment plan:
\begin{enumerate}[itemsep=4pt]
\item \textbf{Phase 1 (Q1 2025)}: Deploy AcmeKV for configuration management and distributed locking workloads, which are currently the largest sources of consistency-related incidents.
\item \textbf{Phase 2 (Q2 2025)}: Migrate the metadata catalog service to AcmeKV after completing integration testing with the data platform team.
\item \textbf{Phase 3 (Q3 2025)}: Evaluate AcmeKV for financial transaction workloads, pending completion of the compliance audit and penetration testing.
\end{enumerate}
\section{Known Limitations}
\begin{itemize}[itemsep=4pt]
\item \textbf{Value size}: The 1MB value size limit may be insufficient for some use cases. We are investigating chunked storage for larger values.
\item \textbf{Cross-DC replication}: Not yet implemented. The current system is limited to a single datacenter.
\item \textbf{Range queries}: Scan performance degrades for ranges exceeding 10,000 keys due to LSM compaction overhead. We plan to implement a read cache to address this.
\end{itemize}
\end{document}

View file

@ -0,0 +1,295 @@
\documentclass[12pt,a4paper,oneside]{report}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage{amsmath,amssymb,amsthm}
\usepackage{graphicx}
\usepackage[margin=1in]{geometry}
\usepackage{hyperref}
\usepackage{booktabs}
\usepackage{natbib}
\usepackage{setspace}
\usepackage{fancyhdr}
\usepackage{titlesec}
\usepackage{lipsum}
\usepackage{enumitem}
\usepackage{microtype}
\usepackage{xcolor}
\hypersetup{
colorlinks=true,
linkcolor=blue!60!black,
citecolor=green!50!black,
urlcolor=blue!70!black
}
% Chapter heading style
\titleformat{\chapter}[display]
{\normalfont\huge\bfseries}{\chaptertitlename\\ \thechapter}{18pt}{\Huge}
\titlespacing*{\chapter}{0pt}{-20pt}{30pt}
% Header/footer
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\slshape\nouppercase{\leftmark}}
\fancyhead[R]{\thepage}
\renewcommand{\headrulewidth}{0.4pt}
\onehalfspacing
\newtheorem{theorem}{Theorem}[chapter]
\newtheorem{definition}[theorem]{Definition}
\newtheorem{proposition}[theorem]{Proposition}
\begin{document}
% ──── Title Page ────
\begin{titlepage}
\centering
\vspace*{1.5cm}
{\Large University of Cambridge\\[0.3cm]}
{\large Faculty of Mathematics\\Department of Applied Mathematics and Theoretical Physics\\[2cm]}
{\Huge\bfseries Spectral Methods for\\Nonlinear Partial Differential Equations\\with Applications to Fluid Dynamics\\[1.5cm]}
{\Large A thesis submitted for the degree of\\Doctor of Philosophy\\[2cm]}
{\Large\textbf{Alexandra M. Richardson}\\[0.5cm]}
{\large Churchill College\\[1cm]}
{\large September 2025}
\vfill
\end{titlepage}
% ──── Declaration ────
\chapter*{Declaration}
\addcontentsline{toc}{chapter}{Declaration}
This thesis is the result of my own work and includes nothing which is the outcome of work done in collaboration except as declared in the preface and specified in the text. It is not substantially the same as any work that has already been submitted before for any degree or other qualification except as declared in the preface and specified in the text.
This thesis does not exceed the prescribed word limit of 60,000 words set by the Degree Committee for the Faculty of Mathematics.
\vspace{2cm}
\noindent\textit{Alexandra M. Richardson}\\
Cambridge, September 2025
% ──── Abstract ────
\chapter*{Abstract}
\addcontentsline{toc}{chapter}{Abstract}
This thesis develops novel spectral methods for the numerical solution of nonlinear partial differential equations (PDEs) arising in fluid dynamics. We focus on three interrelated topics: high-order spectral element methods for the incompressible Navier--Stokes equations, adaptive spectral methods for singularly perturbed problems, and spectral collocation methods for free-boundary problems.
In the first part, we introduce a new stabilized spectral element formulation for the Navier--Stokes equations that maintains exponential convergence while avoiding spurious pressure modes. We prove stability and convergence estimates for the fully discrete scheme and demonstrate its effectiveness on benchmark problems including lid-driven cavity flow and flow past a cylinder.
In the second part, we develop an adaptive spectral method that automatically adjusts the polynomial degree and element size to resolve boundary layers in singularly perturbed convection-diffusion equations. We derive \textit{a posteriori} error estimates in the energy norm that drive the adaptation strategy.
In the third part, we apply spectral collocation methods to Stefan-type free-boundary problems arising in solidification and melting processes. We formulate the free-boundary condition as a nonlinear equation coupled with the bulk PDE and develop an efficient iterative solver based on Newton's method with spectral discretization.
Numerical experiments throughout the thesis confirm the theoretical convergence rates and demonstrate the practical effectiveness of the proposed methods on physically relevant problems.
% ──── Acknowledgements ────
\chapter*{Acknowledgements}
\addcontentsline{toc}{chapter}{Acknowledgements}
I am deeply grateful to my supervisor, Professor James H. Thornton, for his unwavering guidance, patience, and encouragement throughout my doctoral studies. His insights into numerical analysis and fluid dynamics have been invaluable, and his mentorship has shaped my development as a researcher.
I would like to thank Dr. Sophie Chen and Dr. Marcus Weber for many stimulating discussions and for their collaboration on the work presented in Chapter~4. I am also grateful to the members of the Applied Mathematics group at DAMTP for creating an intellectually vibrant and supportive environment.
This work was supported by a Gates Cambridge Scholarship and by EPSRC Grant EP/R014604/1. Computing resources were provided by the Cambridge Service for Data Driven Discovery (CSD3).
Finally, I thank my family and friends for their love and support throughout this journey.
% ──── Table of Contents ────
\tableofcontents
\listoffigures
\listoftables
% ──── Main Matter ────
\chapter{Introduction}
\label{ch:introduction}
\section{Motivation}
The numerical simulation of fluid flows is one of the most important and challenging problems in computational science and engineering. From weather prediction and climate modeling to the design of aircraft and biomedical devices, accurate and efficient numerical methods for fluid dynamics are essential tools in modern science and technology.
The governing equations for incompressible viscous flow are the Navier--Stokes equations:
\begin{align}
\frac{\partial \mathbf{u}}{\partial t} + (\mathbf{u} \cdot \nabla)\mathbf{u} &= -\nabla p + \frac{1}{\text{Re}} \nabla^2 \mathbf{u} + \mathbf{f}, \label{eq:ns_momentum} \\
\nabla \cdot \mathbf{u} &= 0, \label{eq:ns_continuity}
\end{align}
where $\mathbf{u}$ is the velocity field, $p$ is the pressure, $\text{Re}$ is the Reynolds number, and $\mathbf{f}$ represents body forces. Despite their apparent simplicity, these equations exhibit an extraordinary range of complex phenomena, from laminar boundary layers to turbulent cascades spanning multiple scales.
\section{Spectral Methods: An Overview}
Spectral methods approximate the solution of a PDE as a finite sum of basis functions, typically orthogonal polynomials or trigonometric functions. For a function $u(x)$ on the interval $[-1, 1]$, the spectral approximation takes the form:
\begin{equation}
u_N(x) = \sum_{k=0}^{N} \hat{u}_k \phi_k(x)
\end{equation}
where $\{\phi_k\}$ are the basis functions and $\{\hat{u}_k\}$ are the expansion coefficients. The hallmark of spectral methods is their \textit{spectral} (exponential) convergence rate for smooth problems: if $u$ is analytic, then the approximation error decreases exponentially with $N$.
\begin{theorem}[Spectral Convergence]
Let $u \in H^s([-1,1])$ for some $s \geq 0$, and let $u_N$ be its best polynomial approximation of degree $N$. Then:
\begin{equation}
\|u - u_N\|_{L^2} \leq C N^{-s} \|u\|_{H^s}
\end{equation}
Moreover, if $u$ is analytic in an ellipse $\mathcal{E}_\rho$ with parameter $\rho > 1$, then:
\begin{equation}
\|u - u_N\|_{L^2} \leq C \rho^{-N} \max_{z \in \mathcal{E}_\rho} |u(z)|
\end{equation}
\end{theorem}
\section{Thesis Outline}
The remainder of this thesis is organized as follows:
\begin{description}
\item[Chapter 2] reviews the mathematical foundations of spectral methods, including polynomial interpolation, quadrature rules, and the spectral element method.
\item[Chapter 3] presents our stabilized spectral element formulation for the Navier--Stokes equations.
\item[Chapter 4] develops adaptive spectral methods for singularly perturbed problems.
\item[Chapter 5] applies spectral collocation to free-boundary problems.
\item[Chapter 6] summarizes our contributions and discusses future research directions.
\end{description}
\chapter{Mathematical Foundations}
\label{ch:foundations}
\section{Polynomial Interpolation and Approximation}
The foundation of spectral methods rests on polynomial interpolation at carefully chosen nodes. Let $\{x_j\}_{j=0}^N$ be a set of distinct interpolation nodes in $[-1, 1]$. The unique polynomial $p_N$ of degree at most $N$ that interpolates a function $u$ at these nodes can be written using the Lagrange basis:
\begin{equation}
p_N(x) = \sum_{j=0}^{N} u(x_j) \ell_j(x), \quad \ell_j(x) = \prod_{\substack{k=0 \\ k \neq j}}^{N} \frac{x - x_k}{x_j - x_k}
\end{equation}
\begin{definition}[Lebesgue Constant]
The Lebesgue constant associated with the node set $\{x_j\}_{j=0}^N$ is defined as:
\begin{equation}
\Lambda_N = \max_{x \in [-1,1]} \sum_{j=0}^{N} |\ell_j(x)|
\end{equation}
\end{definition}
The Lebesgue constant quantifies the stability of polynomial interpolation. For equispaced nodes, $\Lambda_N$ grows exponentially with $N$, leading to the well-known Runge phenomenon. In contrast, Chebyshev nodes yield $\Lambda_N = O(\log N)$, providing nearly optimal interpolation.
\section{Gauss Quadrature}
Spectral methods require efficient and accurate numerical integration. Gaussian quadrature rules achieve the highest possible algebraic accuracy for polynomial integrands:
\begin{equation}
\int_{-1}^{1} f(x) w(x) \, dx \approx \sum_{j=0}^{N} w_j f(x_j)
\end{equation}
where $\{x_j\}$ are the roots of the $(N+1)$-th degree orthogonal polynomial associated with the weight function $w(x)$, and $\{w_j\}$ are the corresponding quadrature weights.
\begin{table}[htbp]
\centering
\caption{Comparison of standard Gaussian quadrature rules.}
\label{tab:quadrature}
\begin{tabular}{@{}llcc@{}}
\toprule
\textbf{Rule} & \textbf{Weight $w(x)$} & \textbf{Degree of Exactness} & \textbf{Includes Endpoints} \\
\midrule
Gauss--Legendre & $1$ & $2N+1$ & No \\
Gauss--Lobatto & $1$ & $2N-1$ & Yes \\
Gauss--Chebyshev & $(1-x^2)^{-1/2}$ & $2N+1$ & No \\
Gauss--Jacobi & $(1-x)^\alpha(1+x)^\beta$ & $2N+1$ & No \\
\bottomrule
\end{tabular}
\end{table}
\section{The Spectral Element Method}
The spectral element method (SEM) combines the geometric flexibility of finite elements with the high-order accuracy of spectral methods. The computational domain $\Omega$ is decomposed into non-overlapping elements $\{\Omega_e\}_{e=1}^{E}$:
\begin{equation}
\overline{\Omega} = \bigcup_{e=1}^{E} \overline{\Omega}_e, \quad \Omega_i \cap \Omega_j = \emptyset \text{ for } i \neq j
\end{equation}
Within each element, the solution is approximated by high-degree polynomials using a Gauss--Lobatto--Legendre (GLL) nodal basis. The GLL nodes include the element endpoints, facilitating $C^0$ continuity across elements through simple nodal matching.
\begin{proposition}[SEM Error Estimate]
Let $u \in H^s(\Omega)$ with $s > 1$, and let $u_{N,h}$ be the SEM approximation with polynomial degree $N$ and element size $h$. Then:
\begin{equation}
\|u - u_{N,h}\|_{H^1(\Omega)} \leq C h^{\min(s,N)-1} N^{1-s} \|u\|_{H^s(\Omega)}
\end{equation}
\end{proposition}
This estimate reveals the dual convergence mechanism of the SEM: algebraic convergence in $h$ (mesh refinement) and spectral convergence in $N$ (polynomial enrichment). In practice, $p$-refinement (increasing $N$) is preferred for smooth solutions, while $h$-refinement is used to resolve singularities and boundary layers.
\chapter{Stabilized Spectral Elements for the Navier--Stokes Equations}
\label{ch:navier_stokes}
\section{Weak Formulation}
We consider the time-dependent incompressible Navier--Stokes equations~\eqref{eq:ns_momentum}--\eqref{eq:ns_continuity} on a bounded domain $\Omega \subset \mathbb{R}^d$ ($d = 2, 3$) with suitable boundary conditions. The weak formulation seeks $(\mathbf{u}, p) \in \mathbf{V} \times Q$ such that for all $(\mathbf{v}, q) \in \mathbf{V} \times Q$:
\begin{align}
\left(\frac{\partial \mathbf{u}}{\partial t}, \mathbf{v}\right) + a(\mathbf{u}, \mathbf{v}) + c(\mathbf{u}; \mathbf{u}, \mathbf{v}) + b(\mathbf{v}, p) &= (\mathbf{f}, \mathbf{v}) \\
b(\mathbf{u}, q) &= 0
\end{align}
where $a(\cdot,\cdot)$ is the viscous bilinear form, $c(\cdot;\cdot,\cdot)$ is the convective trilinear form, and $b(\cdot,\cdot)$ is the pressure-velocity coupling form.
\section{Stabilization Strategy}
A well-known difficulty in the spectral element discretization of the Navier--Stokes equations is the need to satisfy the inf-sup (LBB) condition. We propose a new pressure stabilization technique based on polynomial filtering that maintains spectral accuracy while eliminating spurious pressure modes.
The key idea is to introduce a spectrally small perturbation to the continuity equation:
\begin{equation}
b(\mathbf{u}_N, q_N) - \epsilon_N (\Pi_{N-2} p_N, \Pi_{N-2} q_N) = 0
\end{equation}
where $\Pi_{N-2}$ is the $L^2$ projection onto polynomials of degree $N-2$ and $\epsilon_N = O(N^{-2s})$ for solutions in $H^s$.
\section{Numerical Results}
We validate our stabilized formulation on two benchmark problems: lid-driven cavity flow and flow past a circular cylinder. For the cavity flow at $\text{Re} = 1000$, our method produces smooth pressure fields without oscillations while maintaining spectral convergence rates. For the cylinder flow at $\text{Re} = 100$, we accurately capture the Von K\'{a}rm\'{a}n vortex street with a coarse spectral element mesh ($E = 48$ elements, $N = 8$).
\chapter{Conclusions and Future Work}
\label{ch:conclusions}
\section{Summary of Contributions}
This thesis has developed three novel spectral methods for nonlinear PDEs with applications to fluid dynamics:
\begin{enumerate}
\item A stabilized spectral element method for the incompressible Navier--Stokes equations that eliminates spurious pressure modes while preserving exponential convergence.
\item An adaptive spectral method for singularly perturbed convection-diffusion equations with rigorous \textit{a posteriori} error estimates.
\item A spectral collocation method for Stefan-type free-boundary problems with a Newton-based solver for the coupled system.
\end{enumerate}
\section{Future Directions}
Several promising research directions emerge from this work. First, extending the stabilized spectral element formulation to compressible flows and turbulence modeling using large eddy simulation (LES) would be a natural next step. Second, the adaptive spectral method could be generalized to handle systems of PDEs and multiphysics problems. Third, the free-boundary methodology could be applied to more complex phase-change problems, including dendritic solidification and multi-component alloy systems.
\appendix
\chapter{Proof of the Main Convergence Theorem}
\label{app:proof}
\begin{proof}
We provide the detailed proof of Theorem~3.1 stated in Chapter~3. Let $e_N = u - u_N$ denote the approximation error. By the triangle inequality:
\begin{equation}
\|e_N\|_{H^1} \leq \|u - \Pi_N u\|_{H^1} + \|\Pi_N u - u_N\|_{H^1}
\end{equation}
The first term is the best approximation error, bounded by standard approximation theory. For the second term, we use the coercivity of the bilinear form $a(\cdot,\cdot)$ and the stability of the stabilization operator to obtain the desired estimate. The full technical details involve careful tracking of the stabilization parameter $\epsilon_N$ and its interaction with the polynomial degree.
\end{proof}
\bibliographystyle{plainnat}
% \bibliography{references}
\begin{thebibliography}{10}
\bibitem[Canuto et~al.(2006)]{canuto2006spectral}
C.~Canuto, M.~Y. Hussaini, A.~Quarteroni, and T.~A. Zang.
\newblock \textit{Spectral Methods: Fundamentals in Single Domains}.
\newblock Springer, 2006.
\bibitem[Karniadakis and Sherwin(2005)]{karniadakis2005spectral}
G.~E. Karniadakis and S.~J. Sherwin.
\newblock \textit{Spectral/hp Element Methods for Computational Fluid Dynamics}.
\newblock Oxford University Press, 2nd edition, 2005.
\bibitem[Deville et~al.(2002)]{deville2002high}
M.~O. Deville, P.~F. Fischer, and E.~H. Mund.
\newblock \textit{High-Order Methods for Incompressible Fluid Flow}.
\newblock Cambridge University Press, 2002.
\bibitem[Trefethen(2000)]{trefethen2000spectral}
L.~N. Trefethen.
\newblock \textit{Spectral Methods in MATLAB}.
\newblock SIAM, 2000.
\end{thebibliography}
\end{document}

View file

@ -0,0 +1 @@
% empty bibliography

View file

@ -0,0 +1,135 @@
/**
* Generate example projects for all LaTeX templates.
*
* Usage: pnpm --filter @claude-prism/desktop generate-previews
*
* Requires pdflatex to be installed (part of TeX Live / MacTeX).
* Output: public/examples/{template-id}/main.tex, main.pdf, references.bib (if applicable)
*/
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Import template registry (pure data, no DOM dependencies)
const registryPath = path.resolve(__dirname, "../src/lib/template-registry.ts");
// Since this is a .ts file with exports, we rely on tsx to handle it
async function loadTemplates() {
const mod = await import(registryPath);
return mod.getAllTemplates() as Array<{
id: string;
name: string;
mainFileName: string;
content: string;
hasBibliography: boolean;
}>;
}
const EXAMPLES_DIR = path.resolve(__dirname, "../public/examples");
const COMPILE_TIMEOUT = 30_000;
async function main() {
console.log("Generating example projects...\n");
const templates = await loadTemplates();
let successCount = 0;
let failCount = 0;
for (const template of templates) {
process.stdout.write(` ${template.id}... `);
const exampleDir = path.join(EXAMPLES_DIR, template.id);
fs.mkdirSync(exampleDir, { recursive: true });
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `prism-preview-${template.id}-`),
);
try {
// Write .tex file (inject \null for blank templates to force a page)
let content = template.content;
if (template.id === "blank") {
content = content.replace(
"\\begin{document}",
"\\begin{document}\n\\null",
);
}
const texPath = path.join(tmpDir, template.mainFileName);
fs.writeFileSync(texPath, content, "utf-8");
// Write stub .bib if template uses bibliography
if (template.hasBibliography) {
fs.writeFileSync(
path.join(tmpDir, "references.bib"),
"% empty bibliography\n",
"utf-8",
);
}
const cmd = [
"pdflatex",
"-interaction=nonstopmode",
`-output-directory=${tmpDir}`,
texPath,
].join(" ");
// First pass
execSync(cmd, { cwd: tmpDir, timeout: COMPILE_TIMEOUT, stdio: "pipe" });
// Second pass (for TOC, references, etc.)
try {
execSync(cmd, { cwd: tmpDir, timeout: COMPILE_TIMEOUT, stdio: "pipe" });
} catch {
// Second pass failure is non-fatal
}
// Copy source files to example folder
fs.copyFileSync(texPath, path.join(exampleDir, template.mainFileName));
if (template.hasBibliography) {
fs.copyFileSync(
path.join(tmpDir, "references.bib"),
path.join(exampleDir, "references.bib"),
);
}
// Copy compiled PDF to example folder
const pdfName = template.mainFileName.replace(/\.tex$/, ".pdf");
const pdfPath = path.join(tmpDir, pdfName);
if (fs.existsSync(pdfPath)) {
fs.copyFileSync(pdfPath, path.join(exampleDir, pdfName));
const sizeKb = Math.round(fs.statSync(path.join(exampleDir, pdfName)).size / 1024);
console.log(`OK (${sizeKb} KB)`);
successCount++;
} else {
console.log("WARN: no PDF output");
failCount++;
}
} catch (err) {
const msg = err instanceof Error ? err.message.slice(0, 120) : String(err);
console.log(`FAIL: ${msg}`);
failCount++;
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
console.log(`\nDone: ${successCount} succeeded, ${failCount} failed`);
if (failCount > 0) {
console.log(
"Note: Failed templates may require document classes not installed in your TeX distribution.",
);
console.log("The gallery will show CSS fallback thumbnails for those templates.");
}
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View file

@ -323,6 +323,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_yaml",
"sha1",
"tauri",
"tauri-build",
@ -331,6 +332,8 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-shell",
"tokio",
"uuid",
"which",
]
[[package]]
@ -684,6 +687,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.6"
@ -713,6 +722,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
@ -1829,6 +1844,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
@ -3045,6 +3066,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.36"
@ -3324,6 +3358,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@ -4400,6 +4447,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@ -4757,6 +4810,18 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "which"
version = "7.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix",
"winsafe",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -5285,6 +5350,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View file

@ -19,6 +19,7 @@ tauri-plugin-shell = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
tokio = { version = "1", features = ["full"] }
dirs = "5"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
@ -28,6 +29,8 @@ base64 = "0.22"
dotenvy = "0.15"
git2 = "0.20"
chrono = "0.4"
uuid = { version = "1", features = ["v4"] }
which = "7"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"

View file

@ -40,7 +40,8 @@
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" }
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
"shell:default",

View file

@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:webview:allow-create-webview-window","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-copy-file","fs:allow-read-dir","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-read-file","fs:allow-write-file","fs:allow-stat","fs:allow-watch",{"identifier":"fs:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DESKTOP/**"}]},"shell:default","shell:allow-open","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","process:default","process:allow-exit"]}}
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:webview:allow-create-webview-window","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-copy-file","fs:allow-read-dir","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-read-file","fs:allow-write-file","fs:allow-stat","fs:allow-watch",{"identifier":"fs:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DESKTOP/**"},{"path":"$TEMP/**"}]},"shell:default","shell:allow-open","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","process:default","process:allow-exit"]}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View file

@ -20,57 +20,90 @@ impl Default for ClaudeProcessState {
}
/// Discover the claude binary on the system.
/// Checks: which claude → NVM paths → standard paths → bare fallback.
/// Checks: which/where claude → NVM paths → standard paths → bare fallback.
fn find_claude_binary() -> Result<String, String> {
// 1. Try `which claude`
if let Ok(output) = std::process::Command::new("which")
.arg("claude")
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && PathBuf::from(&path).exists() {
return Ok(path);
}
}
// 1. Try to find claude on PATH
if let Ok(path) = which::which("claude") {
return Ok(path.to_string_lossy().to_string());
}
// 2. Check NVM directories
// 2. Check NVM directories (Unix) or npm global (Windows)
if let Some(home) = dirs::home_dir() {
let nvm_dir = home.join(".nvm").join("versions").join("node");
if nvm_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
let mut candidates: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path().join("bin").join("claude"))
.filter(|p| p.exists())
.collect();
// Sort by version (directory name) descending to prefer latest
candidates.sort();
candidates.reverse();
if let Some(path) = candidates.first() {
return Ok(path.to_string_lossy().to_string());
#[cfg(not(target_os = "windows"))]
{
let nvm_dir = home.join(".nvm").join("versions").join("node");
if nvm_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
let mut candidates: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path().join("bin").join("claude"))
.filter(|p| p.exists())
.collect();
// Sort by version (directory name) descending to prefer latest
candidates.sort();
candidates.reverse();
if let Some(path) = candidates.first() {
return Ok(path.to_string_lossy().to_string());
}
}
}
}
#[cfg(target_os = "windows")]
{
// Check common Windows Node.js locations
if let Ok(appdata) = std::env::var("APPDATA") {
let npm_global = PathBuf::from(&appdata).join("npm").join("claude.cmd");
if npm_global.exists() {
return Ok(npm_global.to_string_lossy().to_string());
}
}
// NVM for Windows
if let Ok(nvm_home) = std::env::var("NVM_HOME") {
// nvm symlink lives under NVM_SYMLINK (default: C:\Program Files\nodejs)
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
let p = PathBuf::from(&nvm_symlink).join("claude.cmd");
if p.exists() {
return Ok(p.to_string_lossy().to_string());
}
}
// Also scan NVM_HOME/<version>
if let Ok(entries) = std::fs::read_dir(&nvm_home) {
let mut candidates: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path().join("claude.cmd"))
.filter(|p| p.exists())
.collect();
candidates.sort();
candidates.reverse();
if let Some(path) = candidates.first() {
return Ok(path.to_string_lossy().to_string());
}
}
}
}
}
// 3. Check standard paths
let standard_paths = [
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
"/usr/bin/claude",
"/bin/claude",
];
for path in &standard_paths {
if PathBuf::from(path).exists() {
return Ok(path.to_string());
// 3. Check standard paths (Unix only)
#[cfg(not(target_os = "windows"))]
{
let standard_paths = [
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
"/usr/bin/claude",
"/bin/claude",
];
for path in &standard_paths {
if PathBuf::from(path).exists() {
return Ok(path.to_string());
}
}
}
// 4. Check user-specific paths
if let Some(home) = dirs::home_dir() {
let user_paths = [
#[cfg(not(target_os = "windows"))]
let user_paths = vec![
home.join(".claude").join("local").join("claude"),
home.join(".local").join("bin").join("claude"),
home.join(".npm-global").join("bin").join("claude"),
@ -78,6 +111,12 @@ fn find_claude_binary() -> Result<String, String> {
home.join(".bun").join("bin").join("claude"),
home.join("bin").join("claude"),
];
#[cfg(target_os = "windows")]
let user_paths = vec![
home.join(".claude").join("local").join("claude.exe"),
home.join("AppData").join("Local").join("Programs").join("claude").join("claude.exe"),
];
for path in &user_paths {
if path.exists() {
return Ok(path.to_string_lossy().to_string());
@ -90,7 +129,7 @@ fn find_claude_binary() -> Result<String, String> {
}
/// Create a tokio Command with appropriate environment variables.
fn create_command(program: &str, args: Vec<String>, cwd: &str) -> Command {
fn create_command(program: &str, args: Vec<String>, cwd: &str, effort_level: Option<&str>) -> Command {
let mut cmd = Command::new(program);
cmd.args(&args);
cmd.current_dir(cwd);
@ -99,50 +138,29 @@ fn create_command(program: &str, args: Vec<String>, cwd: &str) -> Command {
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
// Inherit essential environment variables
for (key, value) in std::env::vars() {
if key == "PATH"
|| key == "HOME"
|| key == "USER"
|| key == "SHELL"
|| key == "LANG"
|| key == "LC_ALL"
|| key.starts_with("LC_")
|| key == "NODE_PATH"
|| key == "NVM_DIR"
|| key == "NVM_BIN"
|| key == "HOMEBREW_PREFIX"
|| key == "HOMEBREW_CELLAR"
|| key == "HTTP_PROXY"
|| key == "HTTPS_PROXY"
|| key == "NO_PROXY"
|| key == "ALL_PROXY"
{
cmd.env(&key, &value);
// Remove all Claude Code internal env vars to prevent nested session detection
// and other interference. Tauri inherits these when launched from a Claude Code session.
cmd.env_remove("CLAUDECODE");
cmd.env_remove("CLAUDE_AGENT_SDK_VERSION");
for (key, _) in std::env::vars() {
if key.starts_with("CLAUDE_CODE_") || key.starts_with("CLAUDE_AGENT_") {
cmd.env_remove(&key);
}
}
// Set effort level (default: low for fast responses)
cmd.env("CLAUDE_CODE_EFFORT_LEVEL", effort_level.unwrap_or("low"));
// Add NVM support if the program is in an NVM directory
if program.contains("/.nvm/versions/node/") {
if let Some(node_bin_dir) = std::path::Path::new(program).parent() {
let current_path = std::env::var("PATH").unwrap_or_default();
let node_bin_str = node_bin_dir.to_string_lossy();
if !current_path.contains(node_bin_str.as_ref()) {
let new_path = format!("{}:{}", node_bin_str, current_path);
cmd.env("PATH", new_path);
}
}
}
// Add Homebrew support
if program.contains("/homebrew/") || program.contains("/opt/homebrew/") {
if let Some(program_dir) = std::path::Path::new(program).parent() {
let current_path = std::env::var("PATH").unwrap_or_default();
let homebrew_bin_str = program_dir.to_string_lossy();
if !current_path.contains(homebrew_bin_str.as_ref()) {
let new_path = format!("{}:{}", homebrew_bin_str, current_path);
cmd.env("PATH", new_path);
}
// Add the program's parent directory to PATH if not already present
if let Some(program_dir) = std::path::Path::new(program).parent() {
let current_path = std::env::var("PATH").unwrap_or_default();
let program_dir_str = program_dir.to_string_lossy();
if !current_path.contains(program_dir_str.as_ref()) {
#[cfg(target_os = "windows")]
let sep = ";";
#[cfg(not(target_os = "windows"))]
let sep = ":";
let new_path = format!("{}{}{}", program_dir_str, sep, current_path);
cmd.env("PATH", new_path);
}
}
@ -182,14 +200,24 @@ async fn spawn_claude_process(
let session_id_holder: Arc<std::sync::Mutex<Option<String>>> =
Arc::new(std::sync::Mutex::new(None));
let start_time = std::time::Instant::now();
// Spawn stdout streaming task — emit only to the originating window
let win_stdout = window.clone();
let session_id_stdout = session_id_holder.clone();
let stdout_task = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
let mut line_count: u64 = 0;
while let Ok(Some(line)) = lines.next_line().await {
line_count += 1;
let elapsed = start_time.elapsed().as_secs_f64();
// Parse for system:init to extract session_id
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&line) {
let msg_type = msg["type"].as_str().unwrap_or("?");
let msg_sub = msg["subtype"].as_str().unwrap_or("");
eprintln!("[claude-stdout] +{:.1}s #{} type={} sub={} len={}", elapsed, line_count, msg_type, msg_sub, line.len());
if msg["type"] == "system" && msg["subtype"] == "init" {
if let Some(sid) = msg["session_id"].as_str() {
let mut guard = session_id_stdout.lock().unwrap();
@ -198,24 +226,18 @@ async fn spawn_claude_process(
}
}
// Emit session-scoped event to this window only
if let Some(ref sid) = *session_id_stdout.lock().unwrap() {
let _ = win_stdout.emit(&format!("claude-output:{}", sid), &line);
}
// Always emit generic event to this window
// Emit output event to this window
let _ = win_stdout.emit("claude-output", &line);
}
eprintln!("[claude-stdout] stream ended after {} lines ({:.1}s)", line_count, start_time.elapsed().as_secs_f64());
});
// Spawn stderr streaming task — emit only to the originating window
let win_stderr = window.clone();
let session_id_stderr = session_id_holder.clone();
let stderr_task = tokio::spawn(async move {
let mut lines = stderr_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(ref sid) = *session_id_stderr.lock().unwrap() {
let _ = win_stderr.emit(&format!("claude-error:{}", sid), &line);
}
eprintln!("[claude-stderr] +{:.1}s {}", start_time.elapsed().as_secs_f64(), &line[..line.len().min(200)]);
let _ = win_stderr.emit("claude-error", &line);
}
});
@ -224,7 +246,6 @@ async fn spawn_claude_process(
let process_arc_wait = process_arc.clone();
let win_wait = window;
let window_label_wait = window_label;
let session_id_wait = session_id_holder.clone();
tokio::spawn(async move {
// Wait for stdout/stderr to finish
let _ = stdout_task.await;
@ -234,27 +255,298 @@ async fn spawn_claude_process(
let mut processes = process_arc_wait.lock().await;
let success = if let Some(mut child) = processes.remove(&window_label_wait) {
match child.wait().await {
Ok(status) => status.success(),
Err(_) => false,
Ok(status) => {
eprintln!("[claude-process] exited with status={} ({:.1}s)", status, start_time.elapsed().as_secs_f64());
status.success()
}
Err(e) => {
eprintln!("[claude-process] wait error: {} ({:.1}s)", e, start_time.elapsed().as_secs_f64());
false
}
}
} else {
eprintln!("[claude-process] no child found in map ({:.1}s)", start_time.elapsed().as_secs_f64());
false
};
drop(processes);
// Small delay to ensure all events are flushed
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Emit completion event to this window only
if let Some(ref sid) = *session_id_wait.lock().unwrap() {
let _ = win_wait.emit(&format!("claude-complete:{}", sid), success);
}
// Emit completion event to this window
let _ = win_wait.emit("claude-complete", success);
});
Ok(())
}
// ─── Setup / Status Commands ───
#[derive(serde::Serialize)]
pub struct ClaudeStatus {
pub installed: bool,
pub authenticated: bool,
pub binary_path: Option<String>,
pub version: Option<String>,
pub account_email: Option<String>,
}
#[tauri::command]
pub async fn check_claude_status() -> Result<ClaudeStatus, String> {
// Try to find binary
let binary_path = match find_claude_binary() {
Ok(path) => path,
Err(_) => {
return Ok(ClaudeStatus {
installed: false,
authenticated: false,
binary_path: None,
version: None,
account_email: None,
});
}
};
// Verify binary actually works by running --version
let version_output = std::process::Command::new(&binary_path)
.arg("--version")
.output();
let version = match version_output {
Ok(output) if output.status.success() => {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
_ => {
// Binary found but doesn't work (bare "claude" fallback or broken install)
return Ok(ClaudeStatus {
installed: false,
authenticated: false,
binary_path: None,
version: None,
account_email: None,
});
}
};
// Check auth status
let auth_output = std::process::Command::new(&binary_path)
.args(["auth", "status"])
.output();
let (authenticated, account_email) = match auth_output {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// Parse for email — claude auth status outputs account info
let email = stdout
.lines()
.find(|line| line.contains('@'))
.map(|line| {
// Extract email-like substring
line.split_whitespace()
.find(|word| word.contains('@'))
.unwrap_or(line.trim())
.to_string()
});
(true, email)
}
_ => (false, None),
};
Ok(ClaudeStatus {
installed: true,
authenticated,
binary_path: Some(binary_path),
version,
account_email,
})
}
#[tauri::command]
pub async fn install_claude_cli(window: WebviewWindow) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("bash");
c.args(["-c", "curl -fsSL https://claude.ai/install.sh | bash"]);
c
};
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("powershell");
c.args(["-NoProfile", "-Command", "irm https://claude.ai/install.ps1 | iex"]);
c
};
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
// Inherit essential environment variables
for (key, value) in std::env::vars() {
if key == "PATH"
|| key == "HOME"
|| key == "USER"
|| key == "SHELL"
|| key == "LANG"
|| key.starts_with("LC_")
|| key == "HOMEBREW_PREFIX"
|| key == "HOMEBREW_CELLAR"
|| key == "HTTP_PROXY"
|| key == "HTTPS_PROXY"
|| key == "NO_PROXY"
|| key == "ALL_PROXY"
{
cmd.env(&key, &value);
}
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to run installer: {}", e))?;
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
let stdout_reader = BufReader::new(stdout);
let stderr_reader = BufReader::new(stderr);
// Stream stdout
let win_stdout = window.clone();
let stdout_task = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = win_stdout.emit("install-output", &line);
}
});
// Stream stderr
let win_stderr = window.clone();
let stderr_task = tokio::spawn(async move {
let mut lines = stderr_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = win_stderr.emit("install-error", &line);
}
});
// Wait for completion and emit result
let win_complete = window;
tokio::spawn(async move {
let _ = stdout_task.await;
let _ = stderr_task.await;
let success = match child.wait().await {
Ok(status) => status.success(),
Err(_) => false,
};
let _ = win_complete.emit("install-complete", success);
});
Ok(())
}
#[tauri::command]
pub async fn login_claude(window: WebviewWindow) -> Result<(), String> {
let binary_path = find_claude_binary()
.map_err(|e| format!("Claude CLI not found: {}", e))?;
// Verify it actually exists
let version_check = std::process::Command::new(&binary_path)
.arg("--version")
.output();
if version_check.is_err() || !version_check.unwrap().status.success() {
return Err("Claude CLI is not properly installed".to_string());
}
let mut cmd = tokio::process::Command::new(&binary_path);
cmd.args(["auth", "login"]);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
// Inherit essential environment variables
for (key, value) in std::env::vars() {
if key == "PATH"
|| key == "HOME"
|| key == "USER"
|| key == "SHELL"
|| key == "LANG"
|| key.starts_with("LC_")
|| key == "HOMEBREW_PREFIX"
|| key == "HOMEBREW_CELLAR"
|| key == "HTTP_PROXY"
|| key == "HTTPS_PROXY"
|| key == "NO_PROXY"
|| key == "ALL_PROXY"
{
cmd.env(&key, &value);
}
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to run auth login: {}", e))?;
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
let stdout_reader = BufReader::new(stdout);
let stderr_reader = BufReader::new(stderr);
// Stream stdout
let win_stdout = window.clone();
let stdout_task = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = win_stdout.emit("login-output", &line);
}
});
// Stream stderr
let win_stderr = window.clone();
let stderr_task = tokio::spawn(async move {
let mut lines = stderr_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = win_stderr.emit("login-error", &line);
}
});
// Wait for completion and emit result
let win_complete = window;
tokio::spawn(async move {
let _ = stdout_task.await;
let _ = stderr_task.await;
let success = match child.wait().await {
Ok(status) => status.success(),
Err(_) => false,
};
let _ = win_complete.emit("login-complete", success);
});
Ok(())
}
/// Common CLI flags shared across all Claude invocations.
fn common_claude_args() -> Vec<String> {
vec![
"--output-format".to_string(),
"stream-json".to_string(),
"--verbose".to_string(),
"--dangerously-skip-permissions".to_string(),
"--append-system-prompt".to_string(),
concat!(
"You are an AI assistant integrated into a LaTeX document editor (Prism). ",
"Follow these rules strictly:\n",
"1. PLANNING FIRST: Before making changes, use TodoWrite to create a step-by-step plan. ",
"Break large tasks into small, incremental steps (one section or one logical unit per step).\n",
"2. INCREMENTAL EDITS: Use the Edit tool to make small, targeted changes — one step at a time. ",
"NEVER write or rewrite an entire file at once. Always prefer editing existing content over replacing it wholesale.\n",
"3. STEP BY STEP: After each edit, mark the todo item as completed, then proceed to the next step. ",
"This lets the user review changes incrementally.\n",
"4. PRESERVE EXISTING CONTENT: Always read the file first. Keep the existing preamble, packages, ",
"and structure intact. Only add or modify what is needed for the current step.\n",
"5. LaTeX BEST PRACTICES: Use proper sectioning (\\chapter, \\section, \\subsection), ",
"citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies."
).to_string(),
]
}
// ─── Tauri Commands ───
#[tauri::command]
@ -262,22 +554,22 @@ pub async fn execute_claude_code(
window: WebviewWindow,
project_path: String,
prompt: String,
model: String,
model: Option<String>,
effort_level: Option<String>,
) -> Result<(), String> {
let claude_path = find_claude_binary()?;
let args = vec![
let mut args = vec![
"-p".to_string(),
prompt,
"--model".to_string(),
model,
"--output-format".to_string(),
"stream-json".to_string(),
"--verbose".to_string(),
"--dangerously-skip-permissions".to_string(),
];
if let Some(m) = model {
args.push("--model".to_string());
args.push(m);
}
args.extend(common_claude_args());
let cmd = create_command(&claude_path, args, &project_path);
let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref());
spawn_claude_process(window, cmd).await
}
@ -286,23 +578,23 @@ pub async fn continue_claude_code(
window: WebviewWindow,
project_path: String,
prompt: String,
model: String,
model: Option<String>,
effort_level: Option<String>,
) -> Result<(), String> {
let claude_path = find_claude_binary()?;
let args = vec![
let mut args = vec![
"-c".to_string(),
"-p".to_string(),
prompt,
"--model".to_string(),
model,
"--output-format".to_string(),
"stream-json".to_string(),
"--verbose".to_string(),
"--dangerously-skip-permissions".to_string(),
];
if let Some(m) = model {
args.push("--model".to_string());
args.push(m);
}
args.extend(common_claude_args());
let cmd = create_command(&claude_path, args, &project_path);
let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref());
spawn_claude_process(window, cmd).await
}
@ -312,24 +604,24 @@ pub async fn resume_claude_code(
project_path: String,
session_id: String,
prompt: String,
model: String,
model: Option<String>,
effort_level: Option<String>,
) -> Result<(), String> {
let claude_path = find_claude_binary()?;
let args = vec![
let mut args = vec![
"--resume".to_string(),
session_id,
"-p".to_string(),
prompt,
"--model".to_string(),
model,
"--output-format".to_string(),
"stream-json".to_string(),
"--verbose".to_string(),
"--dangerously-skip-permissions".to_string(),
];
if let Some(m) = model {
args.push("--model".to_string());
args.push(m);
}
args.extend(common_claude_args());
let cmd = create_command(&claude_path, args, &project_path);
let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref());
spawn_claude_process(window, cmd).await
}
@ -410,8 +702,9 @@ fn clean_user_message_title(text: &str) -> Option<String> {
return None;
}
let title = if clean.len() > 80 {
format!("{}...", &clean[..77])
let title = if clean.chars().count() > 80 {
let truncated: String = clean.chars().take(77).collect();
format!("{}...", truncated)
} else {
clean.to_string()
};
@ -578,8 +871,11 @@ pub async fn run_shell_command(
command: String,
cwd: String,
) -> Result<ShellCommandResult, String> {
let args = vec!["-c".to_string(), command];
let mut cmd = create_command("sh", args, &cwd);
#[cfg(not(target_os = "windows"))]
let (shell, args) = ("sh", vec!["-c".to_string(), command]);
#[cfg(target_os = "windows")]
let (shell, args) = ("cmd", vec!["/C".to_string(), command]);
let mut cmd = create_command(shell, args, &cwd, None);
let child = cmd
.spawn()
@ -596,3 +892,60 @@ pub async fn run_shell_command(
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
// ─── Claude Settings (fast mode, etc.) ───
fn get_claude_settings_path() -> Result<std::path::PathBuf, String> {
let home = dirs::home_dir().ok_or("Could not find home directory")?;
Ok(home.join(".claude").join("settings.json"))
}
#[tauri::command]
pub async fn get_claude_fast_mode() -> Result<bool, String> {
let path = get_claude_settings_path()?;
if !path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
let settings: serde_json::Value = serde_json::from_str(&content)
.unwrap_or(serde_json::json!({}));
Ok(settings["fastMode"].as_bool().unwrap_or(false))
}
#[tauri::command]
pub async fn set_claude_fast_mode(enabled: bool) -> Result<(), String> {
let path = get_claude_settings_path()?;
// Ensure directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create settings dir: {}", e))?;
}
// Read existing settings or create new
let mut settings: serde_json::Value = if path.exists() {
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
};
// Update fastMode
if let Some(obj) = settings.as_object_mut() {
if enabled {
obj.insert("fastMode".to_string(), serde_json::json!(true));
} else {
obj.remove("fastMode");
}
}
// Write back
let content = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
std::fs::write(&path, content)
.map_err(|e| format!("Failed to write settings: {}", e))?;
Ok(())
}

View file

@ -62,8 +62,7 @@ fn ensure_excludes(project_root: &str, repo: &Repository) {
let excludes_path = Path::new(project_root)
.join(".claudeprism")
.join("history-exclude");
if !excludes_path.exists() {
let content = r#"# LaTeX build artifacts
let content = r#"# LaTeX build artifacts
*.aux
*.log
*.out
@ -93,8 +92,17 @@ Thumbs.db
# ClaudePrism internal
.claudeprism/
.prism/
"#;
if !excludes_path.exists() {
let _ = fs::write(&excludes_path, content);
} else {
// Migrate: add .prism/ if missing from existing excludes file
if let Ok(existing) = fs::read_to_string(&excludes_path) {
if !existing.contains(".prism/") {
let _ = fs::write(&excludes_path, content);
}
}
}
// Configure the repo to use this excludes file
if let Ok(mut config) = repo.config() {

View file

@ -0,0 +1,767 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, Semaphore};
const MAX_CONCURRENT: usize = 3;
const COMPILE_TIMEOUT_SECS: u64 = 30;
struct BuildInfo {
work_dir: PathBuf,
main_file_name: String, // stem without extension, e.g. "document"
aux_hash: Option<u64>, // hash of .aux file from last build (for single-pass optimization)
preamble_hash: Option<u64>, // hash of preamble text (for format regeneration)
fmt_ok: bool, // whether .fmt was successfully generated
}
#[derive(Clone)]
pub struct LatexCompilerState {
last_builds: Arc<Mutex<HashMap<String, BuildInfo>>>,
/// Per-project locks to prevent concurrent compilations on the same build directory.
project_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
semaphore: Arc<Semaphore>,
}
impl Default for LatexCompilerState {
fn default() -> Self {
Self {
last_builds: Arc::new(Mutex::new(HashMap::new())),
project_locks: Arc::new(Mutex::new(HashMap::new())),
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT)),
}
}
}
// CompileResult is no longer needed — compile_latex returns raw PDF bytes via Response.
#[derive(serde::Serialize)]
pub struct SynctexResult {
pub file: String,
pub line: u32,
pub column: u32,
}
// --- Helpers ---
struct CommandOutput {
exit_code: i32,
timed_out: bool,
stdout: String,
stderr: String,
}
/// Build an augmented PATH that includes common TeX installation directories.
/// GUI apps don't always inherit the user's shell PATH, so pdflatex etc. won't be found.
fn tex_path() -> String {
let mut path = std::env::var("PATH").unwrap_or_default();
#[cfg(target_os = "macos")]
let (extras, sep) = (
vec![
"/Library/TeX/texbin".to_string(),
"/usr/local/texlive/2024/bin/universal-darwin".to_string(),
"/usr/local/texlive/2023/bin/universal-darwin".to_string(),
"/usr/texbin".to_string(),
"/opt/homebrew/bin".to_string(),
"/usr/local/bin".to_string(),
],
":",
);
#[cfg(target_os = "windows")]
let (extras, sep) = ({
let mut v = vec![
"C:\\texlive\\2024\\bin\\windows".to_string(),
"C:\\texlive\\2023\\bin\\windows".to_string(),
"C:\\Program Files\\MiKTeX\\miktex\\bin\\x64".to_string(),
];
// Also check user-local MiKTeX
if let Ok(localappdata) = std::env::var("LOCALAPPDATA") {
v.push(format!("{}\\Programs\\MiKTeX\\miktex\\bin\\x64", localappdata));
}
v
}, ";");
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let (extras, sep): (Vec<String>, &str) = (
vec![
"/usr/local/texlive/2024/bin/x86_64-linux".to_string(),
"/usr/local/texlive/2023/bin/x86_64-linux".to_string(),
"/usr/local/bin".to_string(),
],
":",
);
for extra in &extras {
if Path::new(extra).exists() && !path.contains(extra.as_str()) {
path = format!("{}{}{}", extra, sep, path);
}
}
path
}
async fn run_with_timeout(
program: &str,
args: &[&str],
cwd: &Path,
timeout_secs: u64,
) -> Result<CommandOutput, String> {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args)
.current_dir(cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env("PATH", tex_path());
#[cfg(not(target_os = "windows"))]
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
#[cfg(target_os = "windows")]
if let Ok(home) = std::env::var("USERPROFILE") {
cmd.env("USERPROFILE", home);
}
let child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn {}: {}", program, e))?;
match tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
child.wait_with_output(),
)
.await
{
Ok(Ok(output)) => Ok(CommandOutput {
exit_code: output.status.code().unwrap_or(-1),
timed_out: false,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}),
Ok(Err(e)) => Err(format!("Process error: {}", e)),
Err(_) => Ok(CommandOutput {
exit_code: -1,
timed_out: true,
stdout: String::new(),
stderr: "Compilation timed out".to_string(),
}),
}
}
fn extract_error_lines(log: &str) -> String {
if log.is_empty() {
return String::new();
}
if log.lines().any(|l| l.contains("No pages of output")) {
return "No pages of output. Add visible content to the document body.".to_string();
}
let error_lines: Vec<&str> = log
.lines()
.filter(|l| l.starts_with('!') || l.contains("Error:") || l.contains("error:"))
.take(10)
.collect();
if error_lines.is_empty() {
let start = log.len().saturating_sub(500);
log[start..].to_string()
} else {
error_lines.join("\n")
}
}
fn has_bib_files(dir: &Path) -> bool {
fn check(dir: &Path) -> bool {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return false,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if check(&path) {
return true;
}
} else if path.extension().map(|e| e == "bib").unwrap_or(false) {
return true;
}
}
false
}
check(dir)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
if !dst.exists() {
std::fs::create_dir_all(dst)?;
}
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
// Skip hidden directories (.git, .claudeprism, etc.)
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
/// Sync only source files (.tex, .bib, .sty, .cls, .bst, images) from project to build dir.
/// Skips build artifacts (.aux, .log, .toc, .pdf, .synctex.gz, etc.) to preserve them.
fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
if !dst.exists() {
std::fs::create_dir_all(dst)?;
}
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
sync_source_files(&src_path, &dst_path)?;
} else {
let ext = src_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_artifact = matches!(ext, "aux" | "log" | "toc" | "lof" | "lot" | "out"
| "nav" | "snm" | "vrb" | "bbl" | "blg" | "fls" | "fdb_latexmk"
| "synctex" | "pdf" | "idx" | "ind" | "ilg" | "glo" | "gls" | "glg");
let is_synctex = src_path.to_string_lossy().ends_with(".synctex.gz");
if !is_artifact && !is_synctex {
std::fs::copy(&src_path, &dst_path)?;
}
}
}
Ok(())
}
/// Persistent build directory inside the project.
/// Stored in `<project>/.prism/build/` — hidden from file tree (dot-prefix is filtered).
fn persistent_build_dir(project_dir: &str) -> PathBuf {
PathBuf::from(project_dir).join(".prism").join("build")
}
/// Recover preamble hash from on-disk `_prism_preamble.tex` (has `\dump\n` suffix).
fn recover_preamble_hash(work_dir: &Path) -> Option<u64> {
let preamble_tex = work_dir.join("_prism_preamble.tex");
if !preamble_tex.exists() {
return None;
}
let content = std::fs::read_to_string(&preamble_tex).ok()?;
let preamble = content
.strip_suffix("\\dump\n")
.or_else(|| content.strip_suffix("\\dump"))
.unwrap_or(&content);
Some(hash_string(preamble))
}
/// Hash a string (for preamble change detection).
fn hash_string(s: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
}
/// Extract preamble info from a .tex file.
/// Returns (preamble_text, byte_pos_of_begin_document, newline_count_in_preamble).
fn extract_preamble_info(content: &str) -> Option<(String, usize, usize)> {
let marker = "\\begin{document}";
if let Some(pos) = content.find(marker) {
let preamble = content[..pos].to_string();
let newlines = content[..pos].chars().filter(|&c| c == '\n').count();
Some((preamble, pos, newlines))
} else {
None
}
}
/// Try to generate a pre-compiled format (.fmt) from the preamble.
/// Returns true if the format was successfully generated.
async fn try_generate_format(
preamble: &str,
work_dir: &Path,
compiler_cmd: &str,
) -> bool {
// Write preamble + \dump to a temp .tex file
let preamble_path = work_dir.join("_prism_preamble.tex");
let dump_content = format!("{}\\dump\n", preamble);
if std::fs::write(&preamble_path, &dump_content).is_err() {
eprintln!("[latex] failed to write format preamble file");
return false;
}
let ini_load = format!("&{}", compiler_cmd);
match run_with_timeout(
compiler_cmd,
&["-ini", "-jobname=_prism_fmt", &ini_load, "_prism_preamble.tex"],
work_dir,
COMPILE_TIMEOUT_SECS,
)
.await
{
Ok(result) => {
let fmt_path = work_dir.join("_prism_fmt.fmt");
if fmt_path.exists() {
let size = std::fs::metadata(&fmt_path).map(|m| m.len()).unwrap_or(0);
eprintln!(
"[latex] format generated ({} bytes, exit={})",
size, result.exit_code
);
true
} else {
eprintln!(
"[latex] format generation failed (exit={}, no .fmt)",
result.exit_code
);
if !result.stderr.is_empty() {
let n = result.stderr.len().min(300);
eprintln!("[latex] fmt stderr: {}", &result.stderr[..n]);
}
false
}
}
Err(e) => {
eprintln!("[latex] format generation error: {}", e);
false
}
}
}
/// Write a body file with padding comment lines to preserve line numbers.
/// The body file starts with `newline_count` padding lines, then \begin{document}...
/// For pdflatex, injects `\pdfcompresslevel=0` to speed up PDF generation.
/// Always injects `\AtEndDocument{\null}` to guarantee at least one page of output
/// (prevents "No pages of output" for empty document bodies).
fn write_body_file(
content: &str,
begin_doc_pos: usize,
newline_count: usize,
work_dir: &Path,
compiler: &str,
) -> std::io::Result<()> {
let body = &content[begin_doc_pos..];
// Inject before \begin{document}:
// - pdfcompresslevel=0 for faster PDF generation (pdflatex only)
// - \AtEndDocument{\null} to ensure at least one page of output
let inject = if compiler == "pdflatex" {
"\\pdfcompresslevel=0 \\pdfobjcompresslevel=0 \\AtEndDocument{\\null}\n"
} else {
"\\AtEndDocument{\\null}\n"
};
let mut result = String::with_capacity(body.len() + newline_count * 2 + 80);
if newline_count > 1 {
for _ in 0..newline_count - 1 {
result.push_str("%\n");
}
result.push_str(inject);
} else if newline_count == 1 {
result.push_str(inject);
} else {
// newline_count == 0: no padding room, inject on its own line
result.push_str(inject);
}
result.push_str(body);
std::fs::write(work_dir.join("_prism_body.tex"), &result)
}
/// Hash the contents of a file (for aux-file change detection).
fn hash_file(path: &Path) -> Option<u64> {
use std::hash::{Hash, Hasher};
let data = std::fs::read(path).ok()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
data.hash(&mut hasher);
Some(hasher.finish())
}
// --- Tauri Commands ---
#[tauri::command]
pub async fn compile_latex(
state: tauri::State<'_, LatexCompilerState>,
project_dir: String,
main_file: String,
compiler: Option<String>,
) -> Result<tauri::ipc::Response, String> {
// Acquire semaphore permit (non-blocking)
let _permit = state
.semaphore
.clone()
.try_acquire_owned()
.map_err(|_| "Server busy, too many concurrent compilations".to_string())?;
// Acquire per-project lock to prevent concurrent compilations on the same build dir.
// This serializes compilations for the same project while allowing different projects
// to compile in parallel.
let project_lock = {
let mut locks = state.project_locks.lock().await;
locks
.entry(project_dir.clone())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
let _project_guard = project_lock.lock().await;
let t0 = std::time::Instant::now();
let compiler_cmd = match compiler.as_deref() {
Some("xelatex") => "xelatex",
Some("lualatex") => "lualatex",
_ => "pdflatex",
};
let main_file_name = Path::new(&main_file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("document")
.to_string();
// Reuse existing build directory: check in-memory cache, then persistent disk cache
let (work_dir, prev_aux_hash, prev_preamble_hash, prev_fmt_ok, is_reuse) = {
let builds = state.last_builds.lock().await;
if let Some(prev) = builds.get(&project_dir) {
if prev.work_dir.exists() {
// In-memory cache hit
(
prev.work_dir.clone(),
prev.aux_hash,
prev.preamble_hash,
prev.fmt_ok,
true,
)
} else {
(PathBuf::new(), None, None, false, false)
}
} else {
// Check persistent disk cache (survives app restarts)
let persistent = persistent_build_dir(&project_dir);
if persistent.exists() {
let aux_hash =
hash_file(&persistent.join(format!("{}.aux", main_file_name)));
let preamble_hash = recover_preamble_hash(&persistent);
let fmt_ok = persistent.join("_prism_fmt.fmt").exists();
eprintln!(
"[latex] recovered persistent cache (fmt={}, aux={}, preamble={})",
fmt_ok,
aux_hash.is_some(),
preamble_hash.is_some()
);
(persistent, aux_hash, preamble_hash, fmt_ok, true)
} else {
(PathBuf::new(), None, None, false, false)
}
}
};
let work_dir = if is_reuse {
// Sync only source files, preserving build artifacts (.aux, .toc, .fmt, etc.)
sync_source_files(Path::new(&project_dir), &work_dir)
.map_err(|e| format!("Failed to sync project: {}", e))?;
eprintln!(
"[latex] +{:.0}ms sync source files (reuse)",
t0.elapsed().as_millis()
);
work_dir
} else {
// First build ever: create persistent build directory
let work_dir = persistent_build_dir(&project_dir);
std::fs::create_dir_all(&work_dir)
.map_err(|e| format!("Failed to create build dir: {}", e))?;
copy_dir_recursive(Path::new(&project_dir), &work_dir)
.map_err(|e| format!("Failed to copy project: {}", e))?;
eprintln!(
"[latex] +{:.0}ms full copy (first build)",
t0.elapsed().as_millis()
);
work_dir
};
// Pre-compiled format optimization: cache preamble loading in .fmt file
let main_tex_path = work_dir.join(&main_file);
let tex_content = std::fs::read_to_string(&main_tex_path).unwrap_or_default();
let (use_fmt, new_preamble_hash) =
if let Some((preamble, begin_doc_pos, newline_count)) =
extract_preamble_info(&tex_content)
{
let p_hash = hash_string(&preamble);
let fmt_path = work_dir.join("_prism_fmt.fmt");
let need_regen = !(is_reuse
&& prev_preamble_hash == Some(p_hash)
&& fmt_path.exists()
&& prev_fmt_ok);
if need_regen {
eprintln!("[latex] preamble changed or no format — generating .fmt");
let t_fmt = std::time::Instant::now();
let ok = try_generate_format(&preamble, &work_dir, compiler_cmd).await;
eprintln!(
"[latex] +{:.0}ms format generation (ok={})",
t_fmt.elapsed().as_millis(),
ok
);
if ok {
write_body_file(&tex_content, begin_doc_pos, newline_count, &work_dir, compiler_cmd)
.map_err(|e| format!("Failed to write body file: {}", e))?;
(true, Some(p_hash))
} else {
(false, Some(p_hash))
}
} else {
// Format cached and valid — just update body file (content may have changed)
write_body_file(&tex_content, begin_doc_pos, newline_count, &work_dir, compiler_cmd)
.map_err(|e| format!("Failed to write body file: {}", e))?;
eprintln!("[latex] reusing cached format");
(true, Some(p_hash))
}
} else {
(false, None)
};
eprintln!(
"[latex] +{:.0}ms format setup (use_fmt={})",
t0.elapsed().as_millis(),
use_fmt
);
// Detect .bib files
let has_bib = has_bib_files(&work_dir);
// Build compilation arguments
let fmt_arg = "-fmt=_prism_fmt".to_string();
let jobname_arg = format!("-jobname={}", main_file_name);
let latex_args: Vec<&str> = if use_fmt {
vec![
"-interaction=nonstopmode",
"-synctex=1",
&fmt_arg,
&jobname_arg,
"_prism_body.tex",
]
} else {
vec!["-interaction=nonstopmode", "-synctex=1", &main_file]
};
let mut last_result;
let mut pass_count = 0;
// Pass 1 — always needed
last_result =
run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS).await?;
pass_count += 1;
eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code);
if last_result.timed_out {
return Err("Compilation timed out".to_string());
}
// Decide whether extra passes are needed
let aux_path = work_dir.join(format!("{}.aux", main_file_name));
let aux_hash_after_pass1 = hash_file(&aux_path);
let aux_stable = prev_aux_hash.is_some() && aux_hash_after_pass1 == prev_aux_hash;
// Check .log for rerun warnings — avoids unnecessary pass 2 even on first build
let log_path = work_dir.join(format!("{}.log", main_file_name));
let log_after_pass1 = std::fs::read_to_string(&log_path).unwrap_or_default();
let needs_rerun = log_after_pass1.contains("Rerun to get")
|| log_after_pass1.contains("Rerun LaTeX")
|| log_after_pass1.contains("Label(s) may have changed");
eprintln!(
"[latex] aux_stable={} needs_rerun={} prev_hash={:?} new_hash={:?}",
aux_stable, needs_rerun, prev_aux_hash, aux_hash_after_pass1
);
if !aux_stable && needs_rerun {
if has_bib {
// BibTeX + 2 more passes
if aux_path.exists() {
last_result = run_with_timeout(
"bibtex",
&[main_file_name.as_str()],
&work_dir,
COMPILE_TIMEOUT_SECS,
)
.await?;
eprintln!("[latex] +{:.0}ms bibtex done (exit={})", t0.elapsed().as_millis(), last_result.exit_code);
if last_result.timed_out {
return Err("BibTeX timed out".to_string());
}
}
for _ in 0..2 {
last_result =
run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS)
.await?;
pass_count += 1;
eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code);
if last_result.timed_out {
return Err("Compilation timed out".to_string());
}
}
} else {
// One more pass to resolve cross-references
last_result =
run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS)
.await?;
pass_count += 1;
eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code);
if last_result.timed_out {
return Err("Compilation timed out".to_string());
}
}
} else {
eprintln!("[latex] skipping extra passes (aux_stable={}, needs_rerun={})", aux_stable, needs_rerun);
}
// Final aux hash for next compilation
let final_aux_hash = hash_file(&aux_path);
eprintln!("[latex] +{:.0}ms total ({} passes, reuse={}, bib={})", t0.elapsed().as_millis(), pass_count, is_reuse, has_bib);
// Re-read log file (may have been updated by extra passes)
let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
// Check for PDF; if "No pages of output", retry with \null injection.
// This handles the non-format path (empty documents compiled directly).
// The format path already injects \AtEndDocument{\null} via write_body_file.
let pdf_path = work_dir.join(format!("{}.pdf", main_file_name));
if !pdf_path.exists() && log_content.contains("No pages of output") {
eprintln!("[latex] no pages of output — retrying with \\null injection");
let null_input = format!("\\AtEndDocument{{\\null}}\\input{{{}}}", main_file);
let jobname_arg = format!("-jobname={}", main_file_name);
let retry_args: Vec<&str> =
vec!["-interaction=nonstopmode", "-synctex=1", &jobname_arg, &null_input];
match run_with_timeout(compiler_cmd, &retry_args, &work_dir, COMPILE_TIMEOUT_SECS).await {
Ok(r) => eprintln!("[latex] empty-body retry: exit={} pdf_exists={}", r.exit_code, pdf_path.exists()),
Err(e) => eprintln!("[latex] empty-body retry error: {}", e),
}
}
// Store build info (even on failure, for debugging / synctex)
let store_build = |builds: &mut HashMap<String, BuildInfo>| {
builds.insert(
project_dir.clone(),
BuildInfo {
work_dir: work_dir.clone(),
main_file_name: main_file_name.clone(),
aux_hash: final_aux_hash,
preamble_hash: new_preamble_hash,
fmt_ok: use_fmt,
},
);
};
if pdf_path.exists() {
let pdf_bytes = std::fs::read(&pdf_path)
.map_err(|e| format!("Failed to read PDF: {}", e))?;
let mut builds = state.last_builds.lock().await;
store_build(&mut builds);
Ok(tauri::ipc::Response::new(pdf_bytes))
} else {
let mut builds = state.last_builds.lock().await;
store_build(&mut builds);
let details = extract_error_lines(&log_content);
let fallback = if last_result.stderr.is_empty() {
let s = &last_result.stdout;
let start = s.len().saturating_sub(500);
s[start..].to_string()
} else {
last_result.stderr
};
let msg = if details.is_empty() { fallback } else { details };
Err(format!("Compilation failed\n\n{}", msg))
}
}
#[tauri::command]
pub async fn synctex_edit(
state: tauri::State<'_, LatexCompilerState>,
project_dir: String,
page: u32,
x: f64,
y: f64,
) -> Result<SynctexResult, String> {
let builds = state.last_builds.lock().await;
let build = builds
.get(&project_dir)
.ok_or("No build found for this project")?;
// Verify synctex data exists
let synctex_gz = build
.work_dir
.join(format!("{}.synctex.gz", build.main_file_name));
let synctex_plain = build
.work_dir
.join(format!("{}.synctex", build.main_file_name));
if !synctex_gz.exists() && !synctex_plain.exists() {
return Err("No synctex data found. Recompile with synctex enabled.".to_string());
}
let pdf_file = format!("{}.pdf", build.main_file_name);
let coord_arg = format!("{}:{}:{}:{}", page, x, y, pdf_file);
let work_dir = build.work_dir.clone();
let main_file_name = build.main_file_name.clone();
drop(builds); // Release lock before spawning process
let result = run_with_timeout("synctex", &["edit", "-o", &coord_arg], &work_dir, 10).await?;
if result.exit_code != 0 {
return Err(format!("synctex failed: {}", result.stderr));
}
// Parse synctex output
let mut file = String::new();
let mut line = 0u32;
let mut column = 0u32;
for l in result.stdout.lines() {
let trimmed = l.trim();
if let Some(rest) = trimmed.strip_prefix("Input:") {
file = rest.to_string();
} else if let Some(rest) = trimmed.strip_prefix("Line:") {
line = rest.parse().unwrap_or(0);
} else if let Some(rest) = trimmed.strip_prefix("Column:") {
column = rest.parse::<i32>().unwrap_or(0).max(0) as u32;
}
}
if file.is_empty() || line == 0 {
return Err("Could not resolve source location".to_string());
}
// Normalize: strip work_dir prefix and "./" or ".\\" prefix
let work_dir_str = work_dir.to_string_lossy().to_string();
if let Some(rest) = file.strip_prefix(&format!("{}/", work_dir_str)) {
file = rest.to_string();
} else if let Some(rest) = file.strip_prefix(&format!("{}\\", work_dir_str)) {
file = rest.to_string();
}
if let Some(rest) = file.strip_prefix("./") {
file = rest.to_string();
} else if let Some(rest) = file.strip_prefix(".\\") {
file = rest.to_string();
}
// Map format body file back to original main file
if file == "_prism_body.tex" {
file = format!("{}.tex", main_file_name);
}
Ok(SynctexResult { file, line, column })
}
/// Clear in-memory build state on app exit.
/// Persistent build directories are intentionally kept for fast restart.
pub async fn cleanup_all_builds(state: &LatexCompilerState) {
let mut builds = state.last_builds.lock().await;
builds.clear();
}

View file

@ -1,11 +1,93 @@
mod claude;
mod history;
mod latex;
mod slash_commands;
mod zotero;
use std::process::Command;
use std::sync::Mutex;
use std::path::Path;
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
// --- External editor detection & opening ---
#[derive(serde::Serialize, Clone)]
struct EditorInfo {
id: String,
name: String,
}
struct EditorDef {
id: &'static str,
name: &'static str,
cli: &'static str,
}
const KNOWN_EDITORS: &[EditorDef] = &[
EditorDef { id: "cursor", name: "Cursor", cli: "cursor" },
EditorDef { id: "vscode", name: "VS Code", cli: "code" },
EditorDef { id: "zed", name: "Zed", cli: "zed" },
EditorDef { id: "sublime", name: "Sublime Text", cli: "subl" },
];
#[cfg(target_os = "macos")]
const MACOS_APP_PATHS: &[(&str, &str)] = &[
("cursor", "/Applications/Cursor.app"),
("vscode", "/Applications/Visual Studio Code.app"),
("zed", "/Applications/Zed.app"),
("sublime", "/Applications/Sublime Text.app"),
];
#[tauri::command]
fn detect_editors() -> Vec<EditorInfo> {
KNOWN_EDITORS
.iter()
.filter(|e| is_editor_installed(e))
.map(|e| EditorInfo { id: e.id.to_string(), name: e.name.to_string() })
.collect()
}
fn is_editor_installed(editor: &EditorDef) -> bool {
#[cfg(target_os = "macos")]
{
if let Some((_, app_path)) = MACOS_APP_PATHS.iter().find(|(id, _)| *id == editor.id) {
return Path::new(app_path).exists();
}
}
// Fallback / Windows / Linux: check if CLI is on PATH
which::which(editor.cli).is_ok()
}
#[tauri::command]
fn open_in_editor(
editor_id: String,
project_path: String,
file_path: Option<String>,
line: Option<u32>,
) -> Result<(), String> {
let editor = KNOWN_EDITORS
.iter()
.find(|e| e.id == editor_id)
.ok_or_else(|| format!("Unknown editor: {}", editor_id))?;
let mut cmd = std::process::Command::new(editor.cli);
// Open the project folder
cmd.arg(&project_path);
// If a specific file is given, open it (with optional line number via -g)
if let Some(ref fp) = file_path {
let full_path = Path::new(&project_path).join(fp);
if let Some(ln) = line {
cmd.arg("-g");
cmd.arg(format!("{}:{}", full_path.display(), ln));
} else {
cmd.arg(full_path);
}
}
cmd.spawn().map_err(|e| format!("Failed to open {}: {}", editor.name, e))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn set_macos_app_icon() {
use objc2::{AnyThread, MainThreadMarker};
@ -26,35 +108,6 @@ fn set_macos_app_icon() {
}
}
struct SidecarState {
child: Option<std::process::Child>,
}
fn start_sidecar(sidecar_path: &str, port: u16) -> Option<std::process::Child> {
let child = Command::new("node")
.arg(sidecar_path)
.env("PORT", port.to_string())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
match child {
Ok(child) => {
println!("Sidecar started with PID: {}", child.id());
Some(child)
}
Err(e) => {
eprintln!("Failed to start sidecar: {}", e);
None
}
}
}
#[tauri::command]
fn get_sidecar_url() -> String {
"http://localhost:3001".to_string()
}
#[tauri::command]
fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
let label = format!("window-{}", std::time::SystemTime::now()
@ -91,53 +144,31 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_process::init())
.manage(Mutex::new(SidecarState { child: None }))
.manage(claude::ClaudeProcessState::default())
.manage(latex::LatexCompilerState::default())
.manage(zotero::ZoteroOAuthState::default())
.setup(|app| {
// Set macOS Dock icon (dev mode doesn't use bundle, so set it at runtime)
.setup(|_app| {
#[cfg(target_os = "macos")]
set_macos_app_icon();
// In dev mode, the sidecar is started separately (via pnpm dev:desktop)
// In production, start the sidecar from the bundled resources
let sidecar_path = if let Ok(path) = std::env::var("SIDECAR_PATH") {
Some(path)
} else if cfg!(not(debug_assertions)) {
let resource_dir = app
.path()
.resource_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
Some(
resource_dir
.join("sidecar")
.join("index.js")
.to_string_lossy()
.to_string(),
)
} else {
// Dev mode: sidecar is managed by the dev script
println!("Dev mode: expecting sidecar on port 3001 (start with pnpm dev:desktop)");
None
};
if let Some(path) = sidecar_path {
let child = start_sidecar(&path, 3001);
let state = app.state::<Mutex<SidecarState>>();
let mut state = state.lock().unwrap();
state.child = child;
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_sidecar_url,
create_new_window,
detect_editors,
open_in_editor,
latex::compile_latex,
latex::synctex_edit,
claude::check_claude_status,
claude::install_claude_cli,
claude::login_claude,
claude::execute_claude_code,
claude::continue_claude_code,
claude::resume_claude_code,
claude::cancel_claude_execution,
claude::run_shell_command,
claude::get_claude_fast_mode,
claude::set_claude_fast_mode,
claude::list_claude_sessions,
claude::load_session_history,
zotero::zotero_start_oauth,
@ -151,6 +182,10 @@ pub fn run() {
history::history_restore,
history::history_add_label,
history::history_remove_label,
slash_commands::slash_commands_list,
slash_commands::slash_command_get,
slash_commands::slash_command_save,
slash_commands::slash_command_delete,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
@ -172,14 +207,12 @@ pub fn run() {
}
}
tauri::RunEvent::ExitRequested { .. } => {
// Kill sidecar on exit
let state = app_handle.state::<Mutex<SidecarState>>();
if let Ok(mut guard) = state.lock() {
if let Some(ref mut child) = guard.child {
let _ = child.kill();
println!("Sidecar process killed");
}
};
// Clean up LaTeX build temp directories
let latex_state = app_handle.state::<latex::LatexCompilerState>();
let state_clone = latex_state.inner().clone();
tauri::async_runtime::spawn(async move {
latex::cleanup_all_builds(&state_clone).await;
});
}
_ => {}
}

View file

@ -0,0 +1,356 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlashCommand {
pub id: String,
pub name: String,
pub full_command: String,
pub scope: String,
pub namespace: Option<String>,
pub file_path: String,
pub content: String,
pub description: Option<String>,
pub allowed_tools: Vec<String>,
pub has_bash_commands: bool,
pub has_file_references: bool,
pub accepts_arguments: bool,
}
#[derive(Debug, Deserialize)]
struct CommandFrontmatter {
#[serde(rename = "allowed-tools")]
allowed_tools: Option<Vec<String>>,
description: Option<String>,
}
fn parse_markdown_with_frontmatter(content: &str) -> (Option<CommandFrontmatter>, String) {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() || lines[0] != "---" {
return (None, content.to_string());
}
let mut frontmatter_end = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if *line == "---" {
frontmatter_end = Some(i);
break;
}
}
if let Some(end) = frontmatter_end {
let frontmatter_content = lines[1..end].join("\n");
let body_content = lines[(end + 1)..].join("\n");
match serde_yaml::from_str::<CommandFrontmatter>(&frontmatter_content) {
Ok(frontmatter) => (Some(frontmatter), body_content),
Err(_) => (None, content.to_string()),
}
} else {
(None, content.to_string())
}
}
fn extract_command_info(file_path: &Path, base_path: &Path) -> Option<(String, Option<String>)> {
let relative_path = file_path.strip_prefix(base_path).ok()?;
let path_without_ext = relative_path
.with_extension("")
.to_string_lossy()
.to_string();
let components: Vec<&str> = path_without_ext.split('/').collect();
if components.is_empty() {
return None;
}
if components.len() == 1 {
Some((components[0].to_string(), None))
} else {
let command_name = components.last().unwrap().to_string();
let namespace = components[..components.len() - 1].join(":");
Some((command_name, Some(namespace)))
}
}
fn load_command_from_file(
file_path: &Path,
base_path: &Path,
scope: &str,
) -> Option<SlashCommand> {
let content = fs::read_to_string(file_path).ok()?;
let (frontmatter, body) = parse_markdown_with_frontmatter(&content);
let (name, namespace) = extract_command_info(file_path, base_path)?;
let full_command = match &namespace {
Some(ns) => format!("/{ns}:{name}"),
None => format!("/{name}"),
};
let id = format!(
"{}-{}",
scope,
file_path.to_string_lossy().replace('/', "-")
);
let has_bash_commands = body.contains("!`");
let has_file_references = body.contains('@');
let accepts_arguments = body.contains("$ARGUMENTS");
let (description, allowed_tools) = if let Some(fm) = frontmatter {
(fm.description, fm.allowed_tools.unwrap_or_default())
} else {
(None, Vec::new())
};
Some(SlashCommand {
id,
name,
full_command,
scope: scope.to_string(),
namespace,
file_path: file_path.to_string_lossy().to_string(),
content: body,
description,
allowed_tools,
has_bash_commands,
has_file_references,
accepts_arguments,
})
}
fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) {
if !dir.exists() {
return;
}
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
}
if path.is_dir() {
find_markdown_files(&path, files);
} else if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "md" {
files.push(path);
}
}
}
}
}
fn create_default_commands() -> Vec<SlashCommand> {
vec![
SlashCommand {
id: "default-add-dir".to_string(),
name: "add-dir".to_string(),
full_command: "/add-dir".to_string(),
scope: "default".to_string(),
namespace: None,
file_path: String::new(),
content: "Add additional working directories".to_string(),
description: Some("Add additional working directories".to_string()),
allowed_tools: vec![],
has_bash_commands: false,
has_file_references: false,
accepts_arguments: false,
},
SlashCommand {
id: "default-init".to_string(),
name: "init".to_string(),
full_command: "/init".to_string(),
scope: "default".to_string(),
namespace: None,
file_path: String::new(),
content: "Initialize project with CLAUDE.md guide".to_string(),
description: Some("Initialize project with CLAUDE.md guide".to_string()),
allowed_tools: vec![],
has_bash_commands: false,
has_file_references: false,
accepts_arguments: false,
},
SlashCommand {
id: "default-review".to_string(),
name: "review".to_string(),
full_command: "/review".to_string(),
scope: "default".to_string(),
namespace: None,
file_path: String::new(),
content: "Request code review".to_string(),
description: Some("Request code review".to_string()),
allowed_tools: vec![],
has_bash_commands: false,
has_file_references: false,
accepts_arguments: false,
},
]
}
#[tauri::command]
pub async fn slash_commands_list(
project_path: Option<String>,
) -> Result<Vec<SlashCommand>, String> {
let mut commands = Vec::new();
commands.extend(create_default_commands());
// Load project commands
if let Some(proj_path) = project_path {
let project_commands_dir = PathBuf::from(&proj_path).join(".claude").join("commands");
if project_commands_dir.exists() {
let mut md_files = Vec::new();
find_markdown_files(&project_commands_dir, &mut md_files);
for file_path in md_files {
if let Some(cmd) =
load_command_from_file(&file_path, &project_commands_dir, "project")
{
commands.push(cmd);
}
}
}
}
// Load user commands
if let Some(home_dir) = dirs::home_dir() {
let user_commands_dir = home_dir.join(".claude").join("commands");
if user_commands_dir.exists() {
let mut md_files = Vec::new();
find_markdown_files(&user_commands_dir, &mut md_files);
for file_path in md_files {
if let Some(cmd) =
load_command_from_file(&file_path, &user_commands_dir, "user")
{
commands.push(cmd);
}
}
}
}
Ok(commands)
}
#[tauri::command]
pub async fn slash_command_get(command_id: String) -> Result<SlashCommand, String> {
let commands = slash_commands_list(None).await?;
commands
.into_iter()
.find(|cmd| cmd.id == command_id)
.ok_or_else(|| format!("Command not found: {}", command_id))
}
#[tauri::command]
pub async fn slash_command_save(
scope: String,
name: String,
namespace: Option<String>,
content: String,
description: Option<String>,
allowed_tools: Vec<String>,
project_path: Option<String>,
) -> Result<SlashCommand, String> {
if name.is_empty() {
return Err("Command name cannot be empty".to_string());
}
if !["project", "user"].contains(&scope.as_str()) {
return Err("Invalid scope. Must be 'project' or 'user'".to_string());
}
let base_dir = if scope == "project" {
if let Some(proj_path) = project_path {
PathBuf::from(proj_path).join(".claude").join("commands")
} else {
return Err("Project path required for project scope".to_string());
}
} else {
dirs::home_dir()
.ok_or_else(|| "Could not find home directory".to_string())?
.join(".claude")
.join("commands")
};
let mut file_path = base_dir.clone();
if let Some(ns) = &namespace {
for component in ns.split(':') {
file_path = file_path.join(component);
}
}
fs::create_dir_all(&file_path).map_err(|e| format!("Failed to create directories: {}", e))?;
file_path = file_path.join(format!("{}.md", name));
let mut full_content = String::new();
if description.is_some() || !allowed_tools.is_empty() {
full_content.push_str("---\n");
if let Some(desc) = &description {
full_content.push_str(&format!("description: {}\n", desc));
}
if !allowed_tools.is_empty() {
full_content.push_str("allowed-tools:\n");
for tool in &allowed_tools {
full_content.push_str(&format!(" - {}\n", tool));
}
}
full_content.push_str("---\n\n");
}
full_content.push_str(&content);
fs::write(&file_path, &full_content)
.map_err(|e| format!("Failed to write command file: {}", e))?;
load_command_from_file(&file_path, &base_dir, &scope)
.ok_or_else(|| "Failed to load saved command".to_string())
}
#[tauri::command]
pub async fn slash_command_delete(
command_id: String,
project_path: Option<String>,
) -> Result<String, String> {
let commands = slash_commands_list(project_path).await?;
let command = commands
.into_iter()
.find(|cmd| cmd.id == command_id)
.ok_or_else(|| format!("Command not found: {}", command_id))?;
fs::remove_file(&command.file_path)
.map_err(|e| format!("Failed to delete command file: {}", e))?;
// Clean up empty parent directories
if let Some(parent) = Path::new(&command.file_path).parent() {
let _ = remove_empty_dirs(parent);
}
Ok(format!("Deleted command: {}", command.full_command))
}
fn remove_empty_dirs(dir: &Path) {
if !dir.exists() {
return;
}
if let Ok(mut entries) = fs::read_dir(dir) {
if entries.next().is_none() {
let _ = fs::remove_dir(dir);
if let Some(parent) = dir.parent() {
remove_empty_dirs(parent);
}
}
}
}

View file

@ -25,7 +25,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:3001 https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:"
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'self' blob:"
}
},
"bundle": {
@ -34,8 +34,14 @@
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/icon.png"
]
],
"windows": {
"nsis": {
"installMode": "perMachine"
}
}
},
"plugins": {}
}

View file

@ -16,7 +16,7 @@ function WorkspaceWithClaude() {
// Update window title
useEffect(() => {
if (projectRoot) {
const name = projectRoot.split("/").pop() || "ClaudePrism";
const name = projectRoot.split(/[/\\]/).pop() || "ClaudePrism";
getCurrentWindow().setTitle(`${name} - ClaudePrism`);
}
}, [projectRoot]);

View file

@ -1,15 +1,22 @@
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowUpIcon, SquareIcon, XIcon, FileTextIcon, FileCodeIcon, FileIcon, ImageIcon, FileSpreadsheetIcon, PaperclipIcon } from "lucide-react";
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowUpIcon, SquareIcon, XIcon, FileTextIcon, FileCodeIcon, FileIcon, ImageIcon, FileSpreadsheetIcon, PaperclipIcon, ZapIcon, CheckIcon, ChevronDownIcon, SparklesIcon, RabbitIcon, LayersIcon } from "lucide-react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
import { join } from "@tauri-apps/api/path";
import { invoke } from "@tauri-apps/api/core";
import { useClaudeChatStore, offsetToLineCol } from "@/stores/claude-chat-store";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { getUniqueTargetName } from "@/lib/tauri/fs";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker";
interface PinnedContext {
label: string; // @file:line:col-line:col
filePath: string;
selectedText: string;
imageDataUrl?: string; // thumbnail for captured images
}
function getFileIcon(file: ProjectFile) {
@ -24,9 +31,29 @@ export const ChatComposer: FC = () => {
const sendPrompt = useClaudeChatStore((s) => s.sendPrompt);
const cancelExecution = useClaudeChatStore((s) => s.cancelExecution);
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const selectedModel = useClaudeChatStore((s) => s.selectedModel);
const setSelectedModel = useClaudeChatStore((s) => s.setSelectedModel);
const effortLevel = useClaudeChatStore((s) => s.effortLevel);
const setEffortLevel = useClaudeChatStore((s) => s.setEffortLevel);
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Model picker state
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const modelPickerRef = useRef<HTMLDivElement>(null);
const modelButtonRef = useRef<HTMLButtonElement>(null);
const [pickerPos, setPickerPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
// Recalculate popup position when it opens
useLayoutEffect(() => {
if (!modelPickerOpen || !modelButtonRef.current) return;
const rect = modelButtonRef.current.getBoundingClientRect();
setPickerPos({
left: rect.left,
bottom: window.innerHeight - rect.top + 4,
});
}, [modelPickerOpen]);
// Pinned contexts — supports multiple files/selections
const [pinnedContexts, setPinnedContexts] = useState<PinnedContext[]>([]);
@ -39,13 +66,37 @@ export const ChatComposer: FC = () => {
const [mentionFiles, setMentionFiles] = useState<ProjectFile[]>([]);
const mentionRef = useRef<HTMLDivElement>(null);
// / slash command state
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const slashFilteredCountRef = useRef(0);
// Watch selection changes to auto-pin context
const selectionRange = useDocumentStore((s) => s.selectionRange);
const activeFileId = useDocumentStore((s) => s.activeFileId);
const files = useDocumentStore((s) => s.files);
const importFiles = useDocumentStore((s) => s.importFiles);
const refreshFiles = useDocumentStore((s) => s.refreshFiles);
const projectRoot = useDocumentStore((s) => s.projectRoot);
// Consume pending attachments from external sources (e.g. PDF capture)
const pendingAttachments = useClaudeChatStore((s) => s.pendingAttachments);
const consumePendingAttachments = useClaudeChatStore((s) => s.consumePendingAttachments);
useEffect(() => {
if (pendingAttachments.length === 0) return;
const attachments = consumePendingAttachments();
if (attachments.length === 0) return;
setPinnedContexts((prev) => {
const existingLabels = new Set(prev.map((c) => c.label));
const unique = attachments.filter((a) => !existingLabels.has(a.label));
return [...prev, ...unique];
});
// Focus textarea so user can type immediately
setTimeout(() => textareaRef.current?.focus(), 0);
}, [pendingAttachments, consumePendingAttachments]);
const currentContextLabel = useMemo(() => {
if (!selectionRange) return null;
const file = files.find((f) => f.id === activeFileId);
@ -88,6 +139,39 @@ export const ChatComposer: FC = () => {
setMentionIndex(0);
}, [mentionQuery, files]);
// Load slash commands when picker is activated
useEffect(() => {
if (slashQuery === null) {
setSlashCommands([]);
return;
}
invoke<SlashCommand[]>("slash_commands_list", {
projectPath: projectRoot ?? undefined,
})
.then(setSlashCommands)
.catch(() => setSlashCommands([]));
}, [slashQuery !== null, projectRoot]);
// Compute filtered slash command count for keyboard navigation bounds
useEffect(() => {
if (slashQuery === null || slashCommands.length === 0) {
slashFilteredCountRef.current = 0;
return;
}
const q = slashQuery.toLowerCase();
if (!q) {
slashFilteredCountRef.current = slashCommands.length;
} else {
slashFilteredCountRef.current = slashCommands.filter((cmd) => {
if (cmd.name.toLowerCase().includes(q)) return true;
if (cmd.full_command.toLowerCase().includes(q)) return true;
if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true;
if (cmd.description && cmd.description.toLowerCase().includes(q)) return true;
return false;
}).length;
}
}, [slashQuery, slashCommands]);
const selectMention = useCallback((file: ProjectFile) => {
// Replace @query with empty and pin the file as context
const textarea = textareaRef.current;
@ -118,6 +202,29 @@ export const ChatComposer: FC = () => {
setTimeout(() => textarea.focus(), 0);
}, [input]);
const selectSlashCommand = useCallback((command: SlashCommand) => {
// Insert command text into input (opcode-style: insert as template, don't send immediately)
const newInput = command.accepts_arguments
? `${command.full_command} `
: command.content;
setInput(newInput);
setSlashQuery(null);
setSlashIndex(0);
// Refocus and move cursor to end
setTimeout(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = newInput.length;
// Auto-resize
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 160) + "px";
}
}, 0);
}, []);
// Handle file drops — guard against duplicate calls from stale HMR listeners
const isProcessingDropRef = useRef(false);
const handleFileDropRef = useRef<(paths: string[]) => Promise<void>>(async () => {});
@ -212,22 +319,108 @@ export const ChatComposer: FC = () => {
};
}, []);
// Handle clipboard paste — detect files (screenshots, images) and save to attachments/
const handlePaste = useCallback(
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const clipboardFiles = e.clipboardData?.files;
if (!clipboardFiles || clipboardFiles.length === 0 || !projectRoot) return;
// Check if there are actual file items (not just text)
const fileItems = Array.from(clipboardFiles);
if (fileItems.length === 0) return;
e.preventDefault();
const newContexts: PinnedContext[] = [];
for (const file of fileItems) {
// Generate a filename — use the original name or a timestamp-based name for screenshots
let fileName = file.name;
if (!fileName || fileName === "image.png") {
const ext = file.type.split("/")[1] || "png";
fileName = `paste-${Date.now()}.${ext}`;
}
const targetName = `attachments/${fileName}`;
try {
// Ensure attachments/ directory exists
const attachmentsDir = await join(projectRoot, "attachments");
if (!(await exists(attachmentsDir))) {
await mkdir(attachmentsDir, { recursive: true });
}
// Deduplicate filename
const uniqueName = await getUniqueTargetName(projectRoot, targetName);
const fullPath = await join(projectRoot, uniqueName);
// Read file data and write to disk
const buffer = await file.arrayBuffer();
await writeFile(fullPath, new Uint8Array(buffer));
// Determine if it's a text file
const isText = file.type.startsWith("text/");
const content = isText ? await file.text() : `[Attached file: ${uniqueName} (${file.type})]`;
newContexts.push({
label: `@${uniqueName}`,
filePath: uniqueName,
selectedText: content,
});
} catch (err) {
console.error("[chat-paste] failed to save file:", fileName, err);
}
}
if (newContexts.length > 0) {
// Refresh file list so the store knows about new files
await refreshFiles();
setPinnedContexts((prev) => {
const existingLabels = new Set(prev.map((c) => c.label));
const unique = newContexts.filter((c) => !existingLabels.has(c.label));
return [...prev, ...unique];
});
}
},
[projectRoot, refreshFiles],
);
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming) return;
// Resolve slash commands: if input starts with /command, find the command and substitute $ARGUMENTS
let finalPrompt = trimmed;
const slashMatch = trimmed.match(/^\/(\S+)\s*([\s\S]*)/);
if (slashMatch && slashCommands.length > 0) {
const cmdName = slashMatch[1];
const args = slashMatch[2].trim();
const matched = slashCommands.find(
(cmd) => cmd.full_command === `/${cmdName}` || cmd.name === cmdName,
);
if (matched) {
finalPrompt = matched.content;
if (matched.accepts_arguments && args) {
finalPrompt = finalPrompt.replace(/\$ARGUMENTS/g, args);
}
}
}
setInput("");
setMentionQuery(null);
setSlashQuery(null);
// Send with pinned context override
if (pinnedContexts.length > 0) {
const combinedLabel = pinnedContexts.map((c) => c.label).join(", ");
const combinedText = pinnedContexts.map((c) => c.selectedText).join("\n\n---\n\n");
sendPrompt(trimmed, {
sendPrompt(finalPrompt, {
label: combinedLabel,
filePath: pinnedContexts[0].filePath,
selectedText: combinedText,
});
} else {
sendPrompt(trimmed);
sendPrompt(finalPrompt);
}
// Reset textarea height
if (textareaRef.current) {
@ -235,10 +428,36 @@ export const ChatComposer: FC = () => {
}
// Clear pinned contexts after send
setPinnedContexts([]);
}, [input, isStreaming, sendPrompt, pinnedContexts]);
}, [input, isStreaming, sendPrompt, pinnedContexts, slashCommands]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// / slash command navigation
if (slashQuery !== null && slashFilteredCountRef.current > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashIndex((i) => Math.min(i + 1, slashFilteredCountRef.current - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Tab") {
e.preventDefault();
// Tab selects the command — trigger via a DOM query for the active item
// We'll let the picker handle it via onSelect
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashQuery(null);
setSlashIndex(0);
return;
}
}
// @ mention navigation
if (mentionQuery !== null && mentionFiles.length > 0) {
if (e.key === "ArrowDown") {
@ -273,7 +492,7 @@ export const ChatComposer: FC = () => {
setPinnedContexts((prev) => prev.slice(0, -1));
}
},
[handleSend, pinnedContexts, input, mentionQuery, mentionFiles, mentionIndex, selectMention],
[handleSend, pinnedContexts, input, mentionQuery, mentionFiles, mentionIndex, selectMention, slashQuery],
);
const handleInput = useCallback(
@ -281,15 +500,32 @@ export const ChatComposer: FC = () => {
const value = e.target.value;
setInput(value);
// Detect @ mention trigger
const cursorPos = e.target.selectionStart;
const textBefore = value.slice(0, cursorPos);
// Match @ at start of input or after a space
const atMatch = textBefore.match(/(?:^|[\s])@([^\s]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]);
} else {
// Detect / slash command trigger — only at the very start of input
const slashMatch = value.match(/^\/(\S*)$/);
const slashMatchWithArgs = value.match(/^\/(\S+)\s/);
if (slashMatch) {
setSlashQuery(slashMatch[1]);
setSlashIndex(0);
setMentionQuery(null);
} else if (slashMatchWithArgs) {
// Keep slash picker open while typing args after command name
setSlashQuery(slashMatchWithArgs[1]);
} else if (!value.startsWith("/")) {
setSlashQuery(null);
setSlashIndex(0);
}
// Detect @ mention trigger (only when not in slash command mode)
if (!value.startsWith("/")) {
const cursorPos = e.target.selectionStart;
const textBefore = value.slice(0, cursorPos);
// Match @ at start of input or after a space
const atMatch = textBefore.match(/(?:^|[\s])@([^\s]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]);
} else {
setMentionQuery(null);
}
}
// Auto-resize
@ -308,10 +544,106 @@ export const ChatComposer: FC = () => {
}
}, [mentionIndex]);
// Close model picker on click outside
useEffect(() => {
if (!modelPickerOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
modelPickerRef.current && !modelPickerRef.current.contains(target) &&
modelButtonRef.current && !modelButtonRef.current.contains(target)
) {
setModelPickerOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [modelPickerOpen]);
return (
<div className="relative shrink-0 p-3">
{/* / slash command picker */}
{slashQuery !== null && (
<SlashCommandPicker
projectPath={projectRoot}
query={slashQuery}
selectedIndex={slashIndex}
onSelect={selectSlashCommand}
onClose={() => {
setSlashQuery(null);
setSlashIndex(0);
}}
/>
)}
{/* Model picker popup — portal to body to escape all stacking contexts */}
{modelPickerOpen && createPortal(
<div
ref={modelPickerRef}
className="fixed w-64 rounded-lg border border-border bg-background shadow-lg"
style={{ left: pickerPos.left, bottom: pickerPos.bottom, zIndex: 9999 }}
>
{/* Models */}
<div className="p-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">Model</div>
{([
{ id: "sonnet" as const, name: "Sonnet", desc: "Fast, efficient for most tasks", icon: <ZapIcon className="size-3.5" /> },
{ id: "opus" as const, name: "Opus", desc: "Most capable, complex reasoning", icon: <SparklesIcon className="size-3.5" /> },
{ id: "haiku" as const, name: "Haiku", desc: "Fastest, simple tasks", icon: <RabbitIcon className="size-3.5" /> },
{ id: "opusplan" as const, name: "OpusPlan", desc: "Opus for planning, Sonnet for execution", icon: <LayersIcon className="size-3.5" /> },
]).map((m) => (
<button
key={m.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
selectedModel === m.id ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onClick={() => setSelectedModel(m.id)}
>
{m.icon}
<div className="flex-1 min-w-0">
<div className="font-medium text-xs">{m.name}</div>
<div className="text-xs text-muted-foreground truncate">{m.desc}</div>
</div>
{selectedModel === m.id && <CheckIcon className="size-3 shrink-0" />}
</button>
))}
</div>
<div className="border-t border-border" />
{/* Effort level */}
<div className="p-2">
<div className="flex items-center justify-between px-1 mb-1.5">
<span className="text-xs font-medium text-muted-foreground">Effort</span>
<span className="text-xs text-muted-foreground">
{effortLevel === "low" ? "Low" : effortLevel === "medium" ? "Medium" : "High"}
</span>
</div>
<div className="flex gap-1">
{(["low", "medium", "high"] as const).map((level) => (
<button
key={level}
className={cn(
"flex-1 rounded-md py-1 text-center text-xs font-medium transition-colors",
effortLevel === level
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
onClick={() => setEffortLevel(level)}
>
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
</button>
))}
</div>
</div>
</div>,
document.body,
)}
{/* @ mention dropdown */}
{mentionQuery !== null && mentionFiles.length > 0 && (
{slashQuery === null && mentionQuery !== null && mentionFiles.length > 0 && (
<div
ref={mentionRef}
className="absolute bottom-full left-3 right-3 mb-1 max-h-48 overflow-y-auto rounded-lg border border-border bg-background shadow-lg"
@ -353,21 +685,40 @@ export const ChatComposer: FC = () => {
>
{/* Pinned context chips */}
{pinnedContexts.length > 0 && (
<div className="flex flex-wrap items-center gap-1 px-4 pt-3 pb-0">
{pinnedContexts.map((ctx, i) => (
<span
key={`${ctx.label}-${i}`}
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground"
>
{ctx.label}
<button
onClick={() => setPinnedContexts((prev) => prev.filter((_, idx) => idx !== i))}
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-3 pb-0">
{pinnedContexts.map((ctx, i) =>
ctx.imageDataUrl ? (
<div
key={`${ctx.label}-${i}`}
className="group relative overflow-hidden rounded-lg border border-border bg-muted"
>
<XIcon className="size-3" />
</button>
</span>
))}
<img
src={ctx.imageDataUrl}
alt={ctx.label}
className="block h-16 w-auto object-contain"
/>
<button
onClick={() => setPinnedContexts((prev) => prev.filter((_, idx) => idx !== i))}
className="absolute top-0.5 right-0.5 rounded-full bg-background/80 p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
>
<XIcon className="size-3" />
</button>
</div>
) : (
<span
key={`${ctx.label}-${i}`}
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground"
>
{ctx.label}
<button
onClick={() => setPinnedContexts((prev) => prev.filter((_, idx) => idx !== i))}
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
>
<XIcon className="size-3" />
</button>
</span>
),
)}
</div>
)}
@ -382,14 +733,33 @@ export const ChatComposer: FC = () => {
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder="Ask me anything (@ to mention, drop to attach)"
onPaste={handlePaste}
placeholder="Ask me anything (/ for commands, @ to mention)"
className="max-h-40 min-h-10 w-full resize-none bg-transparent px-4 py-2 text-sm outline-none placeholder:text-muted-foreground"
autoFocus
rows={1}
/>
)}
<div className="flex items-center justify-end px-2 pb-2">
<div className="flex items-center justify-between px-2 pb-2">
{/* Model & settings selector */}
<div>
<button
ref={modelButtonRef}
type="button"
onClick={() => setModelPickerOpen((v) => !v)}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span>
{selectedModel === "sonnet" ? "Sonnet" : selectedModel === "opus" ? "Opus" : selectedModel === "haiku" ? "Haiku" : "OpusPlan"}
</span>
<span className="text-muted-foreground/60">
{effortLevel === "low" ? "L" : effortLevel === "medium" ? "M" : "H"}
</span>
<ChevronDownIcon className="size-3" />
</button>
</div>
{isStreaming ? (
<TooltipIconButton
tooltip="Stop"

View file

@ -1,86 +1,55 @@
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertCircleIcon, BotIcon, UserIcon } from "lucide-react";
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
import { AlertCircleIcon } from "lucide-react";
import { useClaudeChatStore, type ClaudeStreamMessage, type ContentBlock } from "@/stores/claude-chat-store";
import { MarkdownRenderer } from "./markdown-renderer";
import { ToolWidget } from "./tool-widgets";
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
const THINKING_MESSAGES = [
"Thinking",
"Analyzing your document",
"Reviewing the structure",
"Processing your request",
"Almost there",
"Working on it",
"Diving into the details",
"Crafting a response",
"Examining the code",
];
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
type TypewriterPhase = "typing" | "pausing" | "deleting";
function useTypewriter(
words: string[],
active: boolean,
{ typeSpeed = 60, deleteSpeed = 30, pauseDelay = 1800 } = {},
) {
const [text, setText] = useState("");
const [wordIndex, setWordIndex] = useState(0);
const [phase, setPhase] = useState<TypewriterPhase>("typing");
// Reset when deactivated
useEffect(() => {
if (!active) {
setText("");
setWordIndex(0);
setPhase("typing");
}
}, [active]);
const tick = useCallback(() => {
const current = words[wordIndex];
if (phase === "typing") {
if (text.length < current.length) {
setText(current.slice(0, text.length + 1));
} else {
setPhase("pausing");
}
} else if (phase === "pausing") {
setPhase("deleting");
} else if (phase === "deleting") {
if (text.length > 0) {
setText(current.slice(0, text.length - 1));
} else {
setWordIndex((prev) => (prev + 1) % words.length);
setPhase("typing");
}
}
}, [text, wordIndex, phase, words]);
const StreamingIndicator: FC = memo(() => {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(Date.now());
useEffect(() => {
if (!active) return;
startRef.current = Date.now();
setElapsed(0);
const timer = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(timer);
}, []);
const delay =
phase === "typing" ? typeSpeed : phase === "deleting" ? deleteSpeed : pauseDelay;
return (
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
<div className="flex gap-0.5">
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "0ms" }} />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "150ms" }} />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-sm">
Thinking...
{elapsed >= 3 && (
<span className="ml-1 text-xs text-muted-foreground/60">{elapsed}s</span>
)}
</span>
</div>
);
});
const timeout = setTimeout(tick, delay);
return () => clearTimeout(timeout);
}, [active, tick, phase, typeSpeed, deleteSpeed, pauseDelay]);
return { text, phase };
}
// ─── Chat Messages (main component) ───
export const ChatMessages: FC = () => {
const messages = useClaudeChatStore((s) => s.messages);
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const viewportRef = useRef<HTMLDivElement>(null);
const { text: thinkingText, phase: thinkingPhase } = useTypewriter(THINKING_MESSAGES, isStreaming);
const shouldAutoScrollRef = useRef(true);
const userHasScrolledRef = useRef(false);
// Build a map of tool_use_id → tool_result for inline display
const toolResultMap = useMemo(() => {
const map = new Map<string, ContentBlock>();
for (const msg of messages) {
if (msg.type === "user" && msg.message?.content) {
if (msg.type === "user" && Array.isArray(msg.message?.content)) {
for (const block of msg.message.content) {
if (block.type === "tool_result" && block.tool_use_id) {
map.set(block.tool_use_id, block);
@ -96,7 +65,7 @@ export const ChatMessages: FC = () => {
// Collect all assistant text for dedup against result
const assistantTexts = new Set<string>();
for (const msg of messages) {
if (msg.type === "assistant" && msg.message?.content) {
if (msg.type === "assistant" && Array.isArray(msg.message?.content)) {
for (const block of msg.message.content) {
if (block.type === "text" && block.text) {
assistantTexts.add(block.text.trim());
@ -106,18 +75,16 @@ export const ChatMessages: FC = () => {
}
return messages.filter((msg) => {
// Skip system:init
if (msg.type === "system" && msg.subtype === "init") return false;
// Skip non-displayable event types (rate_limit_event, etc.)
if (msg.type !== "user" && msg.type !== "assistant" && msg.type !== "result") return false;
// Skip user messages that only contain tool_results
if (msg.type === "user" && msg.message?.content) {
const hasOnlyToolResults = msg.message.content.every(
(b) => b.type === "tool_result"
);
if (hasOnlyToolResults) return false;
if (Array.isArray(msg.message.content)) {
const hasOnlyToolResults = msg.message.content.every(
(b: any) => b.type === "tool_result"
);
if (hasOnlyToolResults) return false;
}
}
// Skip result message if its text duplicates an assistant message
if (msg.type === "result" && msg.result) {
if (assistantTexts.has(msg.result.trim())) return false;
}
@ -125,16 +92,41 @@ export const ChatMessages: FC = () => {
});
}, [messages]);
// Auto-scroll to bottom on new messages or message updates
// Auto-scroll to bottom (only if user hasn't scrolled up)
useEffect(() => {
if (viewportRef.current) {
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
if (shouldAutoScrollRef.current && viewportRef.current) {
viewportRef.current.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [displayMessages]);
// Reset auto-scroll when streaming stops
useEffect(() => {
if (!isStreaming) {
shouldAutoScrollRef.current = true;
userHasScrolledRef.current = false;
}
}, [isStreaming]);
const handleScroll = () => {
if (!viewportRef.current) return;
const el = viewportRef.current;
const isAtBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
if (!isAtBottom) {
userHasScrolledRef.current = true;
shouldAutoScrollRef.current = false;
} else if (userHasScrolledRef.current) {
shouldAutoScrollRef.current = true;
userHasScrolledRef.current = false;
}
};
return (
<div
ref={viewportRef}
onScroll={handleScroll}
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 py-2"
>
{displayMessages.length === 0 && !isStreaming && (
@ -151,23 +143,7 @@ export const ChatMessages: FC = () => {
/>
))}
{isStreaming && (
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
<div className="flex gap-0.5">
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "0ms" }} />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "150ms" }} />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-sm">
{thinkingText}
<span
className={`ml-px inline-block h-[1em] w-0.5 translate-y-px bg-muted-foreground/70 ${
thinkingPhase === "pausing" ? "animate-pulse" : ""
}`}
/>
</span>
</div>
)}
{isStreaming && <StreamingIndicator />}
</div>
);
};
@ -177,13 +153,7 @@ export const ChatMessages: FC = () => {
const MessageBubble: FC<{
message: ClaudeStreamMessage;
toolResultMap: Map<string, ContentBlock>;
}> = ({ message, toolResultMap }) => {
console.log(
`[chat-msg] type=${message.type} subtype=${message.subtype ?? ""} ` +
`contentTypes=[${message.message?.content?.map((b) => b.type).join(",") ?? "none"}] ` +
`result=${message.result ? `"${message.result.slice(0, 60)}"` : "none"}`
);
}> = memo(({ message, toolResultMap }) => {
if (message.type === "user") {
return <UserMessage message={message} />;
}
@ -194,15 +164,20 @@ const MessageBubble: FC<{
return <ResultMessage message={message} />;
}
return null;
};
});
// ─── User Message ───
const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
const textContent = message.message?.content
?.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n");
const rawContent = message.message?.content;
const textContent = Array.isArray(rawContent)
? rawContent
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n")
: typeof rawContent === "string"
? rawContent
: "";
if (!textContent) return null;
@ -304,26 +279,30 @@ const AssistantMessage: FC<{
toolResultMap: Map<string, ContentBlock>;
}> = ({ message, toolResultMap }) => {
const content = message.message?.content;
if (!content || content.length === 0) return null;
if (!Array.isArray(content) || content.length === 0) return null;
// Check if any content block would actually render
const hasRenderableContent = content.some(
(block) =>
(block.type === "text" && block.text) ||
(block.type === "thinking" && block.thinking) ||
(block.type === "tool_use" && block.id)
);
if (!hasRenderableContent) {
console.log("[chat-msg] AssistantMessage skipped — no renderable content:",
content.map((b) => ({ type: b.type, hasText: !!b.text, hasId: !!b.id }))
);
return null;
}
if (!hasRenderableContent) return null;
return (
<div className="w-full py-1.5">
<div className="px-1 text-foreground text-sm leading-relaxed">
{content.map((block, idx) => {
if (block.type === "thinking" && block.thinking) {
return (
<ThinkingWidget
key={idx}
thinking={block.thinking}
signature={block.signature}
/>
);
}
if (block.type === "text" && block.text) {
return (
<MarkdownRenderer

View file

@ -30,9 +30,12 @@ export function ClaudeChatDrawer() {
const heightRef = useRef(height);
heightRef.current = height;
// Auto-open when streaming starts
const pendingAttachments = useClaudeChatStore((s) => s.pendingAttachments);
// Auto-open when streaming starts or a new attachment is added
useEffect(() => {
if (isStreaming && !isOpen) {
const shouldOpen = isStreaming || pendingAttachments.length > 0;
if (shouldOpen && !isOpen) {
setIsOpen(true);
const parent = containerRef.current?.parentElement;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
@ -42,7 +45,7 @@ export function ClaudeChatDrawer() {
panelRef.current.style.height = `${maxHeight}px`;
}
}
}, [isStreaming, isOpen]);
}, [isStreaming, isOpen, pendingAttachments]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();

View file

@ -0,0 +1,159 @@
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SlashCommand {
id: string;
name: string;
full_command: string;
scope: string;
namespace: string | null;
file_path: string;
content: string;
description: string | null;
allowed_tools: string[];
has_bash_commands: boolean;
has_file_references: boolean;
accepts_arguments: boolean;
}
interface SlashCommandPickerProps {
projectPath: string | null;
query: string;
selectedIndex: number;
onSelect: (command: SlashCommand) => void;
onClose: () => void;
}
function getCommandIcon(command: SlashCommand) {
if (command.has_bash_commands) return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.has_file_references) return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.accepts_arguments) return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "project") return <FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "user") return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
}
export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
projectPath,
query,
selectedIndex,
onSelect,
onClose,
}) => {
const [commands, setCommands] = useState<SlashCommand[]>([]);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
invoke<SlashCommand[]>("slash_commands_list", {
projectPath: projectPath ?? undefined,
})
.then(setCommands)
.catch(() => setCommands([]));
}, [projectPath]);
const filtered = useMemo(() => {
const q = query.toLowerCase();
if (!q) return commands;
const matched = commands.filter((cmd) => {
if (cmd.name.toLowerCase().includes(q)) return true;
if (cmd.full_command.toLowerCase().includes(q)) return true;
if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true;
if (cmd.description && cmd.description.toLowerCase().includes(q)) return true;
return false;
});
matched.sort((a, b) => {
const aExact = a.name.toLowerCase() === q;
const bExact = b.name.toLowerCase() === q;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
const aStarts = a.name.toLowerCase().startsWith(q);
const bStarts = b.name.toLowerCase().startsWith(q);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return a.name.localeCompare(b.name);
});
return matched;
}, [query, commands]);
// Scroll active item into view
useEffect(() => {
if (listRef.current) {
const active = listRef.current.querySelector("[data-active=true]");
active?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
// Expose filtered list length and items for parent keyboard handling
// Parent controls selectedIndex, so we just need to render
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
if (filtered.length === 0) return null;
return (
<div
ref={listRef}
className="absolute bottom-full left-3 right-3 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-background shadow-lg"
>
<div className="px-3 py-1.5 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Slash Commands</span>
</div>
{filtered.map((cmd, i) => (
<button
key={cmd.id}
data-active={i === clampedIndex}
className={cn(
"flex w-full items-start gap-2 px-3 py-1.5 text-left transition-colors",
i === clampedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(cmd);
}}
>
{getCommandIcon(cmd)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{cmd.full_command}</span>
{cmd.accepts_arguments && (
<span className="text-xs text-muted-foreground">[args]</span>
)}
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{cmd.scope}
</span>
</div>
{cmd.description && (
<p className="truncate text-xs text-muted-foreground">{cmd.description}</p>
)}
</div>
</button>
))}
<div className="border-t border-border px-3 py-1">
<span className="text-xs text-muted-foreground">
↑↓ Navigate &middot; Enter Select &middot; Esc Close
</span>
</div>
</div>
);
};
// Helper to get filtered count for parent component
export function getFilteredSlashCommands(
commands: SlashCommand[],
query: string,
): SlashCommand[] {
const q = query.toLowerCase();
if (!q) return commands;
return commands.filter((cmd) => {
if (cmd.name.toLowerCase().includes(q)) return true;
if (cmd.full_command.toLowerCase().includes(q)) return true;
if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true;
if (cmd.description && cmd.description.toLowerCase().includes(q)) return true;
return false;
});
}

View file

@ -1,16 +1,23 @@
import { type FC, useState } from "react";
import {
BotIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleDotIcon,
CircleIcon,
ClockIcon,
FileEditIcon,
FileIcon,
FileOutputIcon,
ListTodoIcon,
LoaderIcon,
MessageCircleQuestionIcon,
SparklesIcon,
TerminalIcon,
WrenchIcon,
} from "lucide-react";
import type { ContentBlock } from "@/stores/claude-chat-store";
import { useClaudeChatStore, type ContentBlock } from "@/stores/claude-chat-store";
interface ToolWidgetProps {
toolUse: ContentBlock;
@ -26,6 +33,8 @@ export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
if (name === "bash") return <BashWidget input={toolUse.input} result={toolResult} />;
if (name === "glob") return <GlobWidget input={toolUse.input} result={toolResult} />;
if (name === "grep") return <GrepWidget input={toolUse.input} result={toolResult} />;
if (name === "askuserquestion") return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
if (name === "todowrite") return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
return <GenericWidget name={toolUse.name || "unknown"} input={toolUse.input} result={toolResult} />;
};
@ -48,8 +57,8 @@ const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
<FileOutputIcon className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
<FileOutputIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Wrote" : "Writing"}{" "}
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
</span>
@ -70,8 +79,8 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
onClick={() => setExpanded(!expanded)}
>
<StatusIcon result={result} />
<FileEditIcon className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
<FileEditIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Edited" : "Editing"}{" "}
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
</span>
@ -97,8 +106,8 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
<FileIcon className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Read" : "Reading"}{" "}
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
</span>
@ -121,8 +130,8 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
onClick={() => setExpanded(!expanded)}
>
<StatusIcon result={result} />
<TerminalIcon className="size-3.5 text-green-400" />
<code className="truncate text-xs text-green-300">$ {truncate(command, 80)}</code>
<TerminalIcon className="size-3.5 shrink-0 text-green-400" />
<code className="min-w-0 truncate text-xs text-green-300">$ {truncate(command, 80)}</code>
{result && (
expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
@ -146,8 +155,8 @@ const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
<FileIcon className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Searched" : "Searching"}{" "}
<code className="rounded bg-muted px-1 text-xs">{input?.pattern}</code>
</span>
@ -161,8 +170,8 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
<FileIcon className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Grepped" : "Grepping"}{" "}
<code className="rounded bg-muted px-1 text-xs">{input?.pattern}</code>
</span>
@ -170,6 +179,180 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
);
};
// ─── AskUserQuestion Widget ───
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const questions: any[] = input?.questions || [];
const [answered, setAnswered] = useState(false);
// In -p mode, AskUserQuestion always errors because CLI can't prompt interactively.
// The process is killed when AskUserQuestion is detected, so result may be undefined.
// Options are clickable when there's no result or an error result.
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const needsUserAnswer = !answered && !isStreaming && (!result || result.is_error);
const handleOptionClick = (question: string, label: string) => {
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
if (isStreaming) return;
setAnswered(true);
sendPrompt(`${label}`);
};
if (questions.length === 0) {
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
<MessageCircleQuestionIcon className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{result ? "Asked question" : "Asking question..."}
</span>
</div>
);
}
// Determine header state
const headerLabel = isStreaming
? "Waiting for answer..."
: needsUserAnswer
? "Choose an option"
: answered
? "Answer sent"
: "Question answered";
return (
<div className={`my-1.5 rounded-lg border text-sm ${
needsUserAnswer
? "border-blue-500/40 bg-blue-500/10"
: "border-blue-500/20 bg-blue-500/5"
}`}>
<div className="flex items-center gap-2 px-3 py-2">
{needsUserAnswer ? (
<MessageCircleQuestionIcon className="size-3.5 text-blue-500" />
) : (
<>
<StatusIcon result={result} />
<MessageCircleQuestionIcon className="size-3.5 text-blue-500" />
</>
)}
<span className="font-medium text-blue-600 dark:text-blue-400">
{headerLabel}
</span>
</div>
<div className="space-y-3 border-t border-blue-500/20 px-3 py-2.5">
{questions.map((q: any, qIdx: number) => (
<div key={qIdx} className="space-y-1.5">
{q.header && (
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
{q.header}
</span>
)}
<p className="text-sm font-medium text-foreground">{q.question}</p>
<div className="space-y-1 pl-1">
{q.options?.map((opt: any, oIdx: number) => (
<button
type="button"
key={oIdx}
disabled={!needsUserAnswer}
onClick={() => needsUserAnswer && handleOptionClick(q.question, opt.label)}
className={`flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
needsUserAnswer
? "cursor-pointer hover:bg-blue-500/15"
: "cursor-default"
}`}
>
<div className="mt-0.5">
<CircleIcon className={`size-3.5 ${
needsUserAnswer ? "text-blue-500/50" : "text-muted-foreground/40"
}`} />
</div>
<div className="flex-1 min-w-0">
<span className={`text-sm ${
needsUserAnswer ? "text-foreground" : "text-muted-foreground"
}`}>
{opt.label}
</span>
{opt.description && (
<p className="text-xs text-muted-foreground/70 mt-0.5">
{opt.description}
</p>
)}
</div>
</button>
))}
</div>
</div>
))}
</div>
</div>
);
};
// ─── TodoWrite Widget ───
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const [expanded, setExpanded] = useState(true);
const todos: any[] = input?.todos || [];
const statusIcon = (status: string) => {
switch (status) {
case "completed":
return <CheckIcon className="size-3.5 text-green-500" />;
case "in_progress":
return <ClockIcon className="size-3.5 animate-pulse text-blue-500" />;
default:
return <CircleIcon className="size-3.5 text-muted-foreground/40" />;
}
};
const completedCount = todos.filter((t) => t.status === "completed").length;
return (
<div className="my-1.5 rounded-lg border border-border bg-muted/50 text-sm">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2"
onClick={() => setExpanded(!expanded)}
>
<StatusIcon result={result} />
<ListTodoIcon className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
Todos ({completedCount}/{todos.length})
</span>
{expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
</button>
{expanded && todos.length > 0 && (
<div className="space-y-0.5 border-t border-border px-3 py-2">
{todos.map((todo, idx) => (
<div
key={idx}
className={`flex items-center gap-2 rounded px-1.5 py-1 ${
todo.status === "completed" ? "opacity-50" : ""
}`}
>
{statusIcon(todo.status)}
<span
className={`text-xs ${
todo.status === "completed"
? "line-through text-muted-foreground"
: todo.status === "in_progress"
? "font-medium text-foreground"
: "text-muted-foreground"
}`}
>
{todo.status === "in_progress"
? (todo.activeForm || todo.content)
: todo.content}
</span>
</div>
))}
</div>
)}
</div>
);
};
// ─── Generic Widget ───
const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = ({
@ -206,6 +389,39 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
);
};
// ─── Thinking Widget ───
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ thinking }) => {
const [expanded, setExpanded] = useState(false);
const trimmed = thinking.trim();
return (
<div className="my-1.5 rounded-lg border border-muted-foreground/20 bg-muted-foreground/5 overflow-hidden">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between px-3 py-2 hover:bg-muted-foreground/10 transition-colors"
>
<div className="flex items-center gap-2">
<div className="relative">
<BotIcon className="size-4 text-muted-foreground" />
<SparklesIcon className="size-2.5 text-muted-foreground/70 absolute -top-1 -right-1 animate-pulse" />
</div>
<span className="text-sm font-medium text-muted-foreground italic">Thinking...</span>
</div>
<ChevronRightIcon className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
</button>
{expanded && (
<div className="border-t border-muted-foreground/20 px-3 pb-3 pt-2">
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-xs text-muted-foreground italic">
{trimmed}
</pre>
</div>
)}
</div>
);
};
// ─── Helpers ───
function truncate(str: string, max: number): string {

View file

@ -0,0 +1,405 @@
import { useEffect, useRef } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
DownloadIcon,
LogInIcon,
LoaderIcon,
CheckCircle2Icon,
CheckIcon,
AlertCircleIcon,
RefreshCwIcon,
TerminalIcon,
CircleIcon,
ChevronRightIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useClaudeSetupStore, type StepInfo } from "@/stores/claude-setup-store";
import { cn } from "@/lib/utils";
// ─── Event Hooks ───
function useInstallEvents() {
const isInstalling = useClaudeSetupStore((s) => s.isInstalling);
useEffect(() => {
if (!isInstalling) return;
const unlisteners: UnlistenFn[] = [];
let cancelled = false;
// Synthetic timer: advance to "installing" after 3s if still on downloading
const timer = setTimeout(() => {
if (cancelled) return;
const store = useClaudeSetupStore.getState();
const downloadStep = store.installSteps.find((s) => s.id === "downloading");
if (downloadStep?.status === "active") {
store._advanceInstallStep("installing");
}
}, 3000);
(async () => {
const unlistenOutput = await listen<string>("install-output", (event) => {
if (cancelled) return;
const store = useClaudeSetupStore.getState();
const line = event.payload;
store._appendInstallLog(line);
// Parse output for step advancement
const lower = line.toLowerCase();
if (lower.includes("setting up") || lower.includes("installing")) {
store._advanceInstallStep("installing");
}
if (lower.includes("complete") || lower.includes("successfully") || line.includes("✅")) {
store._advanceInstallStep("verifying");
}
});
const unlistenError = await listen<string>("install-error", (event) => {
if (cancelled) return;
useClaudeSetupStore.getState()._appendInstallLog(event.payload);
});
const unlistenComplete = await listen<boolean>("install-complete", (event) => {
if (cancelled) return;
clearTimeout(timer);
useClaudeSetupStore.getState()._finishInstall(event.payload);
});
if (cancelled) {
unlistenOutput();
unlistenError();
unlistenComplete();
return;
}
unlisteners.push(unlistenOutput, unlistenError, unlistenComplete);
})();
return () => {
cancelled = true;
clearTimeout(timer);
for (const u of unlisteners) u();
};
}, [isInstalling]);
}
function useLoginEvents() {
const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn);
useEffect(() => {
if (!isLoggingIn) return;
const unlisteners: UnlistenFn[] = [];
let cancelled = false;
// Advance to "waiting-auth" after 1.5s
const timer = setTimeout(() => {
if (cancelled) return;
useClaudeSetupStore.getState()._advanceLoginStep("waiting-auth");
}, 1500);
(async () => {
const unlistenOutput = await listen<string>("login-output", (event) => {
if (cancelled) return;
// Any output means browser is open, advance to waiting
useClaudeSetupStore.getState()._advanceLoginStep("waiting-auth");
});
const unlistenError = await listen<string>("login-error", () => {
// ignore stderr for login
});
const unlistenComplete = await listen<boolean>("login-complete", (event) => {
if (cancelled) return;
clearTimeout(timer);
useClaudeSetupStore.getState()._finishLogin(event.payload);
});
if (cancelled) {
unlistenOutput();
unlistenError();
unlistenComplete();
return;
}
unlisteners.push(unlistenOutput, unlistenError, unlistenComplete);
})();
return () => {
cancelled = true;
clearTimeout(timer);
for (const u of unlisteners) u();
};
}, [isLoggingIn]);
}
// ─── Sub-components ───
function StepRow({ step }: { step: StepInfo }) {
return (
<div className="flex items-center gap-2.5 py-1">
{step.status === "complete" && (
<CheckIcon className="size-3.5 text-green-600" />
)}
{step.status === "active" && (
<LoaderIcon className="size-3.5 animate-spin text-foreground" />
)}
{step.status === "pending" && (
<CircleIcon className="size-3.5 text-muted-foreground/30" />
)}
{step.status === "error" && (
<AlertCircleIcon className="size-3.5 text-destructive" />
)}
<span
className={cn(
"text-sm",
step.status === "complete" && "text-green-600",
step.status === "active" && "font-medium text-foreground",
step.status === "pending" && "text-muted-foreground/60",
step.status === "error" && "text-destructive"
)}
>
{step.label}
</span>
</div>
);
}
function InstallLogOutput() {
const logs = useClaudeSetupStore((s) => s.installLogs);
const visible = useClaudeSetupStore((s) => s.installLogsVisible);
const toggle = useClaudeSetupStore((s) => s.toggleInstallLogs);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current && visible) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs, visible]);
return (
<div className="mt-1">
<button
onClick={toggle}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRightIcon
className={cn(
"size-3 transition-transform duration-200",
visible && "rotate-90"
)}
/>
{visible ? "Hide logs" : "Show logs"}
{logs.length > 0 && (
<span className="text-muted-foreground/50">({logs.length})</span>
)}
</button>
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300 ease-in-out",
visible ? "max-h-40" : "max-h-0"
)}
>
<div
ref={scrollRef}
className="mt-2 max-h-36 overflow-y-auto rounded-md border border-border bg-foreground/3 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground"
>
{logs.length === 0 ? (
<span className="italic">Waiting for output...</span>
) : (
logs.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{line}
</div>
))
)}
</div>
</div>
</div>
);
}
// ─── Main Component ───
export function ClaudeSetup() {
const status = useClaudeSetupStore((s) => s.status);
const isInstalling = useClaudeSetupStore((s) => s.isInstalling);
const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn);
const error = useClaudeSetupStore((s) => s.error);
const version = useClaudeSetupStore((s) => s.version);
const accountEmail = useClaudeSetupStore((s) => s.accountEmail);
const install = useClaudeSetupStore((s) => s.install);
const login = useClaudeSetupStore((s) => s.login);
const checkStatus = useClaudeSetupStore((s) => s.checkStatus);
const installSteps = useClaudeSetupStore((s) => s.installSteps);
const loginSteps = useClaudeSetupStore((s) => s.loginSteps);
useInstallEvents();
useLoginEvents();
if (status === "checking") {
return (
<div className="flex w-full items-center justify-center gap-2 rounded-xl border border-border bg-muted/30 px-5 py-4">
<LoaderIcon className="size-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Checking Claude Code...
</span>
</div>
);
}
if (status === "ready") {
return (
<div className="flex w-full items-center gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<CheckCircle2Icon className="size-5 shrink-0 text-green-600" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Claude Code Ready</p>
<p className="truncate text-xs text-muted-foreground">
{[version, accountEmail].filter(Boolean).join(" · ")}
</p>
</div>
</div>
);
}
// Installation in progress
if (isInstalling) {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className="flex items-center gap-2">
<TerminalIcon className="size-5 shrink-0 text-muted-foreground" />
<p className="text-sm font-medium">Installing Claude Code</p>
</div>
<div className="space-y-0 pl-1">
{installSteps.map((step) => (
<StepRow key={step.id} step={step} />
))}
</div>
<InstallLogOutput />
</div>
);
}
// Login in progress
if (isLoggingIn) {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className="flex items-center gap-2">
<LogInIcon className="size-5 shrink-0 text-muted-foreground" />
<p className="text-sm font-medium">Signing in to Claude</p>
</div>
<div className="space-y-0 pl-1">
{loginSteps.map((step) => (
<StepRow key={step.id} step={step} />
))}
</div>
<p className="text-center text-[11px] text-muted-foreground">
Complete the sign-in in your browser to continue.
</p>
</div>
);
}
if (status === "error") {
const hasInstallSteps = installSteps.length > 0;
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 px-5 py-4">
<div className="flex items-center gap-2">
<AlertCircleIcon className="size-5 shrink-0 text-destructive" />
<p className="text-sm font-medium">
{hasInstallSteps ? "Installation Failed" : "Setup Error"}
</p>
</div>
{hasInstallSteps && (
<div className="space-y-0 pl-1">
{installSteps.map((step) => (
<StepRow key={step.id} step={step} />
))}
</div>
)}
{error && (
<p className="text-xs leading-relaxed text-muted-foreground">
{error}
</p>
)}
{hasInstallSteps && <InstallLogOutput />}
<Button
variant="outline"
size="sm"
className="w-fit gap-2"
onClick={hasInstallSteps ? install : checkStatus}
>
<RefreshCwIcon className="size-3.5" />
{hasInstallSteps ? "Retry Installation" : "Retry"}
</Button>
</div>
);
}
if (status === "not-installed") {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className="flex items-center gap-2">
<TerminalIcon className="size-5 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Claude Code Required</p>
<p className="text-xs text-muted-foreground">
ClaudePrism needs Claude Code CLI to power AI features.
</p>
</div>
</div>
<Button
size="sm"
className="w-full gap-2"
onClick={install}
>
<DownloadIcon className="size-3.5" />
Install Claude Code
</Button>
<p className="text-center text-[11px] text-muted-foreground">
Installs to ~/.local/bin/claude
</p>
</div>
);
}
if (status === "not-authenticated") {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className="flex items-center gap-2">
<LogInIcon className="size-5 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Sign in to Claude</p>
<p className="text-xs text-muted-foreground">
Authenticate with your Anthropic account to continue.
</p>
</div>
</div>
{version && (
<p className="text-xs text-muted-foreground">
Claude Code {version} installed
</p>
)}
<Button
size="sm"
className="w-full gap-2"
onClick={login}
>
<LogInIcon className="size-3.5" />
Sign in with Browser
</Button>
</div>
);
}
return null;
}

View file

@ -1,24 +1,44 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import {
FolderOpenIcon,
FolderPlusIcon,
ClockIcon,
XIcon,
FileTextIcon,
SparklesIcon,
} from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
import { Button } from "@/components/ui/button";
import { ProjectWizard } from "./project-wizard";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { ProjectWizard, type CreationMode } from "./project-wizard";
import { ClaudeSetup } from "./claude-setup";
export function ProjectPicker() {
const [showWizard, setShowWizard] = useState(false);
const [showModeDialog, setShowModeDialog] = useState(false);
const [wizardMode, setWizardMode] = useState<CreationMode | null>(null);
const recentProjects = useProjectStore((s) => s.recentProjects);
const addRecentProject = useProjectStore((s) => s.addRecentProject);
const removeRecentProject = useProjectStore((s) => s.removeRecentProject);
const openProject = useDocumentStore((s) => s.openProject);
const claudeStatus = useClaudeSetupStore((s) => s.status);
const checkClaudeStatus = useClaudeSetupStore((s) => s.checkStatus);
const isClaudeReady = claudeStatus === "ready";
useEffect(() => {
checkClaudeStatus();
}, [checkClaudeStatus]);
const handleOpenFolder = async () => {
const selected = await open({
directory: true,
@ -36,8 +56,18 @@ export function ProjectPicker() {
await openProject(path);
};
if (showWizard) {
return <ProjectWizard onBack={() => setShowWizard(false)} />;
const handleSelectMode = (mode: CreationMode) => {
setShowModeDialog(false);
setWizardMode(mode);
};
if (wizardMode) {
return (
<ProjectWizard
mode={wizardMode}
onBack={() => setWizardMode(null)}
/>
);
}
return (
@ -51,12 +81,15 @@ export function ProjectPicker() {
</p>
</div>
<div className="flex w-full gap-3">
{!isClaudeReady && <ClaudeSetup />}
<div className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}>
<Button
onClick={() => setShowWizard(true)}
onClick={() => setShowModeDialog(true)}
size="lg"
variant="outline"
className="flex-1 gap-2"
disabled={!isClaudeReady}
>
<FolderPlusIcon className="size-5" />
New Project
@ -65,6 +98,7 @@ export function ProjectPicker() {
onClick={handleOpenFolder}
size="lg"
className="flex-1 gap-2"
disabled={!isClaudeReady}
>
<FolderOpenIcon className="size-5" />
Open Folder
@ -111,6 +145,52 @@ export function ProjectPicker() {
</div>
)}
</div>
{/* New Project mode selection dialog */}
<Dialog open={showModeDialog} onOpenChange={setShowModeDialog}>
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
How would you like to start?
</DialogDescription>
</DialogHeader>
<div className="flex gap-3 pt-2">
<button
onClick={() => handleSelectMode("template")}
className="group flex flex-1 flex-col items-center gap-3 rounded-xl border border-foreground/10 p-5 text-center transition-all hover:border-foreground/20 hover:bg-muted/50"
>
<div className="flex size-12 items-center justify-center rounded-lg bg-muted/50 transition-colors group-hover:bg-muted">
<SparklesIcon className="size-6 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div>
<div className="font-semibold text-sm">Guided Setup</div>
<p className="mt-1 text-muted-foreground text-xs leading-relaxed">
Pick a template and let AI help you get started
</p>
</div>
<span className="rounded-full bg-foreground/8 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Recommended
</span>
</button>
<button
onClick={() => handleSelectMode("scratch")}
className="group flex flex-1 flex-col items-center gap-3 rounded-xl border border-border p-5 text-center transition-all hover:border-foreground/20 hover:bg-muted/50"
>
<div className="flex size-12 items-center justify-center rounded-lg bg-muted/50 transition-colors group-hover:bg-muted">
<FileTextIcon className="size-6 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div>
<div className="font-semibold text-sm">Blank Document</div>
<p className="mt-1 text-muted-foreground text-xs leading-relaxed">
Start with an empty LaTeX file
</p>
</div>
</button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,22 +1,19 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { documentDir } from "@tauri-apps/api/path";
import {
ArrowLeftIcon,
ArrowRightIcon,
FileTextIcon,
UserIcon,
LayoutIcon,
BookOpenIcon,
MailIcon,
MonitorIcon,
BookIcon,
FileIcon,
FolderOpenIcon,
PaperclipIcon,
XIcon,
SparklesIcon,
GraduationCapIcon,
UploadIcon,
ChevronDownIcon,
FileTextIcon,
MapPinIcon,
Loader2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@ -25,395 +22,103 @@ import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { exists, join } from "@/lib/tauri/fs";
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
import { TemplateGallery } from "@/components/template-gallery";
// ─── Template Definitions ───
// ─── Helpers ───
interface Template {
id: string;
name: string;
description: string;
icon: React.ReactNode;
documentClass: string;
mainFileName: string;
content: string;
function randomProjectName(): string {
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const id = Math.random().toString(36).slice(2, 6);
return `${adj}-${noun}-${id}`;
}
const TEMPLATES: Template[] = [
{
id: "paper",
name: "Research Paper",
description: "Academic paper with abstract, sections, references",
icon: <FileTextIcon className="size-6" />,
documentClass: "article",
mainFileName: "main.tex",
content: `\\documentclass[12pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\usepackage{booktabs}
\\usepackage{natbib}
\\title{Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\section{Introduction}
\\section{Related Work}
\\section{Method}
\\section{Results}
\\section{Conclusion}
\\bibliographystyle{plainnat}
\\bibliography{references}
\\end{document}
`,
},
{
id: "cv",
name: "CV / Resume",
description: "Professional curriculum vitae",
icon: <UserIcon className="size-6" />,
documentClass: "article",
mainFileName: "main.tex",
content: `\\documentclass[11pt,a4paper]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=0.8in]{geometry}
\\usepackage{hyperref}
\\usepackage{enumitem}
\\usepackage{titlesec}
\\titleformat{\\section}{\\large\\bfseries}{}{0em}{}[\\titlerule]
\\titlespacing{\\section}{0pt}{12pt}{6pt}
\\pagestyle{empty}
\\begin{document}
\\begin{center}
{\\LARGE\\bfseries Your Name}\\\\[4pt]
your.email@example.com \\quad | \\quad City, Country
\\end{center}
\\section{Education}
\\section{Experience}
\\section{Skills}
\\section{Publications}
\\end{document}
`,
},
{
id: "poster",
name: "Poster",
description: "Conference or research poster",
icon: <LayoutIcon className="size-6" />,
documentClass: "a0poster",
mainFileName: "main.tex",
content: `\\documentclass[a1paper,portrait]{a0poster}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage{multicol}
\\usepackage[margin=2cm]{geometry}
\\usepackage{xcolor}
\\begin{document}
\\begin{center}
{\\VERYHuge\\bfseries Poster Title}\\\\[1cm]
{\\LARGE Author Name \\quad Institution}
\\end{center}
\\vspace{1cm}
\\begin{multicols}{2}
\\section*{Introduction}
\\section*{Methods}
\\section*{Results}
\\section*{Conclusions}
\\section*{References}
\\end{multicols}
\\end{document}
`,
},
{
id: "thesis",
name: "Thesis",
description: "Dissertation or thesis with chapters",
icon: <GraduationCapIcon className="size-6" />,
documentClass: "report",
mainFileName: "main.tex",
content: `\\documentclass[12pt,a4paper]{report}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\usepackage{booktabs}
\\usepackage{natbib}
\\usepackage{setspace}
\\onehalfspacing
\\title{Thesis Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\tableofcontents
\\chapter{Introduction}
\\chapter{Literature Review}
\\chapter{Methodology}
\\chapter{Results}
\\chapter{Discussion}
\\chapter{Conclusion}
\\bibliographystyle{plainnat}
\\bibliography{references}
\\end{document}
`,
},
{
id: "presentation",
name: "Presentation",
description: "Beamer slides for talks and lectures",
icon: <MonitorIcon className="size-6" />,
documentClass: "beamer",
mainFileName: "main.tex",
content: `\\documentclass{beamer}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usetheme{Madrid}
\\title{Presentation Title}
\\author{Author Name}
\\institute{Institution}
\\date{\\today}
\\begin{document}
\\begin{frame}
\\titlepage
\\end{frame}
\\begin{frame}{Outline}
\\tableofcontents
\\end{frame}
\\section{Introduction}
\\begin{frame}{Introduction}
Content here.
\\end{frame}
\\section{Main Content}
\\begin{frame}{Main Content}
Content here.
\\end{frame}
\\section{Conclusion}
\\begin{frame}{Conclusion}
Content here.
\\end{frame}
\\end{document}
`,
},
{
id: "letter",
name: "Letter",
description: "Formal or cover letter",
icon: <MailIcon className="size-6" />,
documentClass: "letter",
mainFileName: "main.tex",
content: `\\documentclass[12pt]{letter}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\signature{Your Name}
\\address{Your Address \\\\ City, Country}
\\begin{document}
\\begin{letter}{Recipient Name \\\\ Recipient Address \\\\ City, Country}
\\opening{Dear Recipient,}
Your letter content here.
\\closing{Sincerely,}
\\end{letter}
\\end{document}
`,
},
{
id: "book",
name: "Book",
description: "Multi-chapter book or manuscript",
icon: <BookIcon className="size-6" />,
documentClass: "book",
mainFileName: "main.tex",
content: `\\documentclass[12pt,a4paper]{book}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\title{Book Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\frontmatter
\\maketitle
\\tableofcontents
\\mainmatter
\\chapter{First Chapter}
\\chapter{Second Chapter}
\\backmatter
\\end{document}
`,
},
{
id: "blank",
name: "Blank",
description: "Minimal template to start from scratch",
icon: <FileIcon className="size-6" />,
documentClass: "article",
mainFileName: "main.tex",
content: `\\documentclass[12pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\begin{document}
% Start writing here.
\\end{document}
`,
},
];
const BIB_TEMPLATE = `% Add your references here
% Example:
% @article{key,
% author = {Author Name},
% title = {Article Title},
% journal = {Journal Name},
% year = {2024},
% }
`;
// ─── Wizard Component ───
export type CreationMode = "template" | "scratch";
interface ProjectWizardProps {
mode: CreationMode;
onBack: () => void;
}
export function ProjectWizard({ onBack }: ProjectWizardProps) {
const [step, setStep] = useState<1 | 2>(1);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
export function ProjectWizard({ mode, onBack }: ProjectWizardProps) {
// ── Template mode: just show the gallery ──
// The TemplatePreview modal inside the gallery handles details + creation.
if (mode === "template") {
return (
<div className="flex h-full flex-col bg-background">
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="font-semibold text-sm">Choose a Template</span>
</div>
<div className="flex-1 overflow-hidden">
<TemplateGallery />
</div>
</div>
);
}
// ── Scratch mode: inline details form ──
return <ScratchForm onBack={onBack} />;
}
// ─── Scratch mode form (no template preview) ───
function ScratchForm({ onBack }: { onBack: () => void }) {
const [purpose, setPurpose] = useState("");
const [attachments, setAttachments] = useState<string[]>([]);
const [projectFolder, setProjectFolder] = useState<string | null>(null);
const [projectName, setProjectName] = useState("");
const [projectName, setProjectName] = useState(randomProjectName);
const [isCreating, setIsCreating] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [refFilesOpen, setRefFilesOpen] = useState(false);
const [locationOpen, setLocationOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const addRecentProject = useProjectStore((s) => s.addRecentProject);
const lastProjectFolder = useProjectStore((s) => s.lastProjectFolder);
const setLastProjectFolder = useProjectStore((s) => s.setLastProjectFolder);
const openProject = useDocumentStore((s) => s.openProject);
const template = TEMPLATES.find((t) => t.id === selectedTemplate);
const template = getTemplateById("blank")!;
const handleSelectTemplate = (id: string) => {
setSelectedTemplate(id);
setStep(2);
};
useEffect(() => {
const timer = setTimeout(() => textareaRef.current?.focus(), 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (projectFolder) return;
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
documentDir().then((dir) => setProjectFolder(dir)).catch(() => {});
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleChooseFolder = useCallback(async () => {
const selected = await open({
directory: true,
multiple: false,
title: "Choose Location for New Project",
});
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
if (selected) {
setProjectFolder(selected);
if (!projectName) {
setProjectName(selected.split("/").pop() || "my-project");
}
setLastProjectFolder(selected);
}
}, [projectName]);
}, [setLastProjectFolder]);
const handleAddAttachments = useCallback(async () => {
const selected = await open({
multiple: true,
title: "Add Reference Files",
filters: [
{
name: "Documents & Images",
extensions: [
"pdf", "tex", "bib", "txt", "md",
"png", "jpg", "jpeg", "gif", "svg",
"csv", "tsv", "json",
],
},
],
filters: [{
name: "Documents & Images",
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
}],
});
if (selected) {
const paths = Array.isArray(selected) ? selected : [selected];
@ -425,24 +130,49 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) {
setAttachments((prev) => prev.filter((p) => p !== path));
};
// Drag-drop
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
getCurrentWebview()
.onDragDropEvent((event) => {
if (cancelled) return;
const { type } = event.payload;
if (type === "enter") {
setIsDragOver(true);
setRefFilesOpen(true);
} else if (type === "drop") {
setIsDragOver(false);
const paths = (event.payload as { paths: string[] }).paths;
if (paths?.length > 0) {
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
}
} else if (type === "leave") {
setIsDragOver(false);
}
})
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
.catch(() => {});
return () => { cancelled = true; unlisten?.(); };
}, []);
const handleCreate = async () => {
if (!template || !projectFolder || !projectName.trim()) return;
setIsCreating(true);
try {
// Create project directory
const projectPath = await join(projectFolder, projectName.trim());
await mkdir(projectPath, { recursive: true }).catch(() => {});
// Write main.tex
const mainTexPath = await join(projectPath, template.mainFileName);
const mainExists = await exists(mainTexPath);
if (!mainExists) {
await writeTextFile(mainTexPath, template.content);
await writeTextFile(mainTexPath, getTemplateSkeleton(template));
}
// Write references.bib for templates that use bibliography
if (["paper", "thesis"].includes(template.id)) {
if (template.hasBibliography) {
const bibPath = await join(projectPath, "references.bib");
const bibExists = await exists(bibPath);
if (!bibExists) {
@ -450,33 +180,44 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) {
}
}
// Import attachments into project
if (attachments.length > 0) {
const attachmentsDir = await join(projectPath, "attachments");
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
}
// Build the initial prompt for Claude
if (purpose.trim()) {
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
let prompt = `I just created a new ${template.name} project using a ${template.documentClass} template.\n\n`;
prompt += `Here is what I want to create:\n${purpose.trim()}\n\n`;
if (attachmentNames.length > 0) {
prompt += `I've included these reference files in the attachments/ folder: ${attachmentNames.join(", ")}\n`;
prompt += `Please review them and incorporate relevant information.\n\n`;
}
prompt += `Please customize the template to match my requirements. Update the content, title, author, and structure as needed. Make it a complete, well-structured document ready for me to refine.`;
const attachmentSection = attachmentNames.length > 0
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
: "";
const prompt = [
`## New ${template.name} Project`,
"",
`| | |`,
`|---|---|`,
`| **Template** | \`${template.documentClass}\` |`,
`| **File** | \`${template.mainFileName}\` |`,
"",
`> The file currently contains only the LaTeX preamble (packages, styling, custom commands) with an empty document body.`,
"",
`### What I want to create`,
"",
purpose.trim(),
attachmentSection,
`### Instructions`,
"",
`Please generate the full document content based on my description. Keep the existing preamble and fill in the document body (between \`\\begin{document}\` and \`\\end{document}\`) with appropriate title, author, sections, and content. Make it a complete, well-structured **${template.name.toLowerCase()}** ready for me to refine.`,
].join("\n");
// Set as pending so it fires after workspace initializes
useClaudeChatStore.getState().newSession();
useClaudeChatStore.getState().setPendingInitialPrompt(prompt);
}
// Open the project
setLastProjectFolder(projectFolder);
addRecentProject(projectPath);
await openProject(projectPath);
// Import attachments after project is open
if (attachments.length > 0) {
await useDocumentStore.getState().importFiles(attachments, "attachments");
}
@ -491,121 +232,176 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) {
return (
<div className="flex h-full flex-col bg-background">
{/* Header — padded top for macOS overlay titlebar */}
<div className="flex shrink-0 items-center gap-3 border-b border-border px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
<Button variant="ghost" size="icon" className="size-7" onClick={step === 1 ? onBack : () => setStep(1)}>
{/* Header */}
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
<ArrowLeftIcon className="size-4" />
</Button>
<div className="flex items-center gap-2">
<SparklesIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">
{step === 1 ? "Choose a Template" : "Project Details"}
</span>
</div>
<div className="flex items-center gap-1.5 ml-auto">
<div className={`size-2 rounded-full ${step >= 1 ? "bg-foreground" : "bg-muted-foreground/30"}`} />
<div className={`size-2 rounded-full ${step >= 2 ? "bg-foreground" : "bg-muted-foreground/30"}`} />
</div>
<span className="font-semibold text-sm">New Document</span>
</div>
{/* Content */}
{/* Form */}
<div className="flex-1 overflow-y-auto">
{step === 1 ? (
<TemplateGrid onSelect={handleSelectTemplate} selected={selectedTemplate} />
) : (
<div className="mx-auto max-w-lg space-y-6 p-6">
{/* Selected template indicator */}
{template && (
<div className="mx-auto w-full max-w-[520px] space-y-4 px-6 py-10">
{/* Purpose */}
<div className="space-y-2.5">
<div>
<label className="font-semibold text-sm">What are you writing?</label>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
Describe your document and Claude will generate tailored content.
</p>
</div>
<Textarea
ref={textareaRef}
placeholder="e.g., A research paper on transformer architectures for protein structure prediction, targeting NeurIPS 2025..."
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={4}
className="resize-none rounded-xl border-border/60 bg-card/30 text-sm leading-relaxed placeholder:text-muted-foreground/50 focus-visible:bg-card/50"
/>
</div>
{/* Collapsible sections */}
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
{/* Reference files */}
<div>
<button
onClick={() => setStep(1)}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-muted/30 p-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setRefFilesOpen(!refFilesOpen)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30"
>
<div className="flex size-10 items-center justify-center rounded-md bg-background text-muted-foreground">
{template.icon}
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
<FileTextIcon className="size-3.5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
<div className="text-muted-foreground text-xs">{template.description}</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Reference files</span>
{attachments.length > 0 && (
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
{attachments.length}
</span>
)}
</div>
<span className="text-muted-foreground text-xs">Change</span>
</button>
)}
{/* Purpose */}
<div className="space-y-2">
<label className="font-medium text-sm">What are you writing?</label>
<Textarea
placeholder="e.g., A research paper on transformer architectures for protein structure prediction, targeting NeurIPS 2025..."
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={4}
className="resize-none"
/>
<p className="text-muted-foreground text-xs">
Claude will use this to customize your template with relevant content and structure.
</p>
</div>
{/* Attachments */}
<div className="space-y-2">
<label className="font-medium text-sm">Reference files (optional)</label>
<div className="space-y-1.5">
{attachments.map((path) => (
<div
key={path}
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5 text-sm"
>
<PaperclipIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-xs">{path.split("/").pop()}</span>
<button
onClick={() => handleRemoveAttachment(path)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
</div>
))}
<Button variant="outline" size="sm" className="gap-1.5 text-xs" onClick={handleAddAttachments}>
<PaperclipIcon className="size-3.5" />
Add files
</Button>
</div>
<p className="text-muted-foreground text-xs">
PDFs, images, .bib, .tex, or data files to include as references.
</p>
</div>
{/* Project location */}
<div className="space-y-2">
<label className="font-medium text-sm">Project location</label>
<div className="flex gap-2">
<Input
placeholder="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="flex-1"
<ChevronDownIcon
className={`size-4 text-muted-foreground/60 transition-transform duration-200 ${refFilesOpen ? "rotate-180" : ""}`}
/>
<Button variant="outline" className="shrink-0 gap-1.5" onClick={handleChooseFolder}>
<FolderOpenIcon className="size-4" />
{projectFolder ? "Change" : "Choose folder"}
</Button>
</div>
{projectFolder && (
<p className="truncate text-muted-foreground text-xs">
{projectFolder}/{projectName.trim() || "..."}
</p>
</button>
{refFilesOpen && (
<div className="px-4 pb-4 space-y-3">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{attachments.map((path) => (
<div
key={path}
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
>
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
<span className="max-w-[140px] truncate text-foreground/80">{path.split("/").pop()}</span>
<button
onClick={() => handleRemoveAttachment(path)}
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<XIcon className="size-3" />
</button>
</div>
))}
</div>
)}
<div
className={`flex flex-col items-center gap-2 rounded-lg border border-dashed p-4 transition-all ${
isDragOver
? "border-primary bg-primary/5 scale-[1.01]"
: "border-border/60 hover:border-border hover:bg-muted/20"
}`}
>
{isDragOver ? (
<>
<UploadIcon className="size-5 text-primary" />
<span className="text-xs font-medium text-primary">Drop to add</span>
</>
) : (
<>
<UploadIcon className="size-5 text-muted-foreground/40" />
<div className="text-center">
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
<button
onClick={handleAddAttachments}
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground hover:decoration-foreground/50 transition-colors"
>
browse files
</button>
</div>
<span className="text-[10px] text-muted-foreground/40">
PDF, TEX, BIB, images, or data files
</span>
</>
)}
</div>
</div>
)}
</div>
{/* Create button */}
{/* Project location */}
<div>
<button
onClick={() => setLocationOpen(!locationOpen)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30"
>
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
<MapPinIcon className="size-3.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Project location</span>
</div>
{!locationOpen && projectFolder && projectName.trim() && (
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/{projectName.trim()}
</span>
)}
<ChevronDownIcon
className={`size-4 text-muted-foreground/60 transition-transform duration-200 ${locationOpen ? "rotate-180" : ""}`}
/>
</button>
{locationOpen && (
<div className="px-4 pb-4 space-y-2.5">
<div className="flex gap-2">
<Input
placeholder="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="flex-1 rounded-lg border-border/60 bg-background/50"
/>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5 rounded-lg border-border/60"
onClick={handleChooseFolder}
>
<FolderOpenIcon className="size-3.5" />
{projectFolder ? "Change" : "Choose"}
</Button>
</div>
{projectFolder && (
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
{projectFolder}/{projectName.trim() || "..."}
</p>
)}
</div>
)}
</div>
</div>
{/* Create button */}
<div className="pt-1">
<Button
className="w-full gap-2"
className="w-full gap-2 rounded-xl font-semibold shadow-sm transition-all hover:shadow-md active:scale-[0.99]"
size="lg"
disabled={!canCreate || isCreating}
onClick={handleCreate}
>
{isCreating ? (
"Creating..."
<>
<Loader2Icon className="size-4 animate-spin" />
Creating project...
</>
) : purpose.trim() ? (
<>
<SparklesIcon className="size-4" />
@ -616,46 +412,7 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) {
)}
</Button>
</div>
)}
</div>
</div>
);
}
// ─── Template Grid ───
function TemplateGrid({
onSelect,
selected,
}: {
onSelect: (id: string) => void;
selected: string | null;
}) {
return (
<div className="mx-auto max-w-2xl p-6">
<p className="mb-6 text-center text-muted-foreground text-sm">
Pick a starting template. Claude will customize it based on your needs.
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{TEMPLATES.map((tmpl) => (
<button
key={tmpl.id}
onClick={() => onSelect(tmpl.id)}
className={`flex flex-col items-center gap-2.5 rounded-xl border p-5 text-center transition-all hover:border-foreground/30 hover:bg-muted/50 ${
selected === tmpl.id
? "border-foreground bg-muted/50"
: "border-border"
}`}
>
<div className="text-muted-foreground">{tmpl.icon}</div>
<div>
<div className="font-medium text-sm">{tmpl.name}</div>
<div className="mt-0.5 text-muted-foreground text-xs leading-tight">
{tmpl.description}
</div>
</div>
</button>
))}
</div>
</div>
</div>
);

View file

@ -0,0 +1,93 @@
import {
FileTextIcon,
GraduationCapIcon,
BriefcaseIcon,
PaletteIcon,
SparklesIcon,
MonitorIcon,
LayoutIcon,
UserIcon,
MailIcon,
ClipboardListIcon,
BookIcon,
NewspaperIcon,
FileIcon,
} from "lucide-react";
import {
type TemplateCategory,
type TemplateSubcategory,
CATEGORY_LABELS,
SUBCATEGORY_LABELS,
CATEGORY_SUBCATEGORIES,
getCategories,
getAllTemplates,
getTemplatesByCategory,
} from "@/lib/template-registry";
import { useTemplateStore } from "@/stores/template-store";
const CATEGORY_ICONS: Record<TemplateCategory, React.ReactNode> = {
academic: <GraduationCapIcon className="size-4" />,
professional: <BriefcaseIcon className="size-4" />,
creative: <PaletteIcon className="size-4" />,
starter: <SparklesIcon className="size-4" />,
};
const SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
papers: <FileTextIcon className="size-3.5" />,
theses: <GraduationCapIcon className="size-3.5" />,
presentations: <MonitorIcon className="size-3.5" />,
posters: <LayoutIcon className="size-3.5" />,
cv: <UserIcon className="size-3.5" />,
letters: <MailIcon className="size-3.5" />,
reports: <ClipboardListIcon className="size-3.5" />,
books: <BookIcon className="size-3.5" />,
newsletters: <NewspaperIcon className="size-3.5" />,
blank: <FileIcon className="size-3.5" />,
};
export function CategorySidebar() {
const selectedCategory = useTemplateStore((s) => s.selectedCategory);
const setSelectedCategory = useTemplateStore((s) => s.setSelectedCategory);
const allCount = getAllTemplates().length;
return (
<nav className="flex w-48 shrink-0 flex-col gap-1 overflow-y-auto py-2 pr-3">
{/* All templates */}
<button
onClick={() => setSelectedCategory(null)}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedCategory === null
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<SparklesIcon className="size-4" />
<span className="flex-1">All Templates</span>
<span className="text-xs tabular-nums text-muted-foreground">{allCount}</span>
</button>
<div className="my-1.5 h-px bg-border" />
{/* Categories */}
{getCategories().map((cat) => {
const count = getTemplatesByCategory(cat).length;
const isActive = selectedCategory === cat;
return (
<button
key={cat}
onClick={() => setSelectedCategory(isActive ? null : cat)}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
{CATEGORY_ICONS[cat]}
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</button>
);
})}
</nav>
);
}

View file

@ -0,0 +1,4 @@
export { TemplateGallery } from "./template-gallery";
export { TemplateCard } from "./template-card";
export { TemplatePreview } from "./template-preview";
export { CategorySidebar } from "./category-sidebar";

View file

@ -0,0 +1,150 @@
import { useEffect, useSyncExternalStore } from "react";
import { LoaderIcon } from "lucide-react";
import type { TemplateDefinition } from "@/lib/template-registry";
import { useTemplateStore } from "@/stores/template-store";
import {
getThumbnail,
subscribeThumbnails,
generateThumbnail,
} from "@/lib/template-preview-cache";
// ─── CSS Fallback Thumbnails ───
// Shown while real PDF previews are loading.
export function ThumbnailPaper({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col items-center px-4 py-3">
<div className="mb-1.5 h-1.5 w-12 rounded-full" style={{ backgroundColor: color }} />
<div className="mb-3 h-1 w-8 rounded-full bg-muted-foreground/20" />
<div className="mb-2 w-full rounded-sm bg-muted-foreground/8 p-1.5">
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
</div>
<div className="mb-1.5 h-1 w-10 self-start rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-1 h-0.5 w-11/12 rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-4/5 rounded-full bg-muted-foreground/12" />
</div>
);
}
export function ThumbnailSlides({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 px-3 py-2">
<div className="flex w-full flex-1 flex-col items-center justify-center rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
<div className="mb-0.5 h-1 w-10 rounded-full" style={{ backgroundColor: color }} />
<div className="h-0.5 w-6 rounded-full bg-muted-foreground/20" />
</div>
<div className="flex w-full flex-1 flex-col rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
<div className="mb-0.5 h-0.5 w-6 rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
</div>
</div>
);
}
export function ThumbnailPoster({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col px-2 py-2">
<div className="mb-2 h-1 w-10 self-center rounded-full bg-muted-foreground/20" />
<div className="flex flex-1 gap-1.5">
<div className="flex flex-1 flex-col gap-1 rounded-sm bg-muted-foreground/6 p-1">
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
</div>
<div className="flex flex-1 flex-col gap-1 rounded-sm bg-muted-foreground/6 p-1">
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-2/3 rounded-full bg-muted-foreground/15" />
</div>
</div>
</div>
);
}
export function ThumbnailBlank(_props: { color: string }) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-muted-foreground/20 text-xs font-medium">Empty</div>
</div>
);
}
export const THUMBNAIL_MAP: Record<string, React.FC<{ color: string }>> = {
"paper-standard": ThumbnailPaper,
"paper-ieee": ThumbnailPaper,
"paper-acm": ThumbnailPaper,
"thesis-standard": ThumbnailPaper,
"presentation-beamer": ThumbnailSlides,
"poster-academic": ThumbnailPoster,
"cv-modern": ThumbnailPaper,
"letter-formal": ThumbnailPaper,
"report-technical": ThumbnailPaper,
"book-standard": ThumbnailPaper,
"newsletter": ThumbnailPaper,
"blank": ThumbnailBlank,
};
export function getFallbackThumbnail(template: TemplateDefinition): React.FC<{ color: string }> {
return THUMBNAIL_MAP[template.id] || ThumbnailPaper;
}
// ─── Template Card ───
interface TemplateCardProps {
template: TemplateDefinition;
}
export function TemplateCard({ template }: TemplateCardProps) {
const openPreview = useTemplateStore((s) => s.openPreview);
const FallbackThumbnail = getFallbackThumbnail(template);
const thumbnailUrl = useSyncExternalStore(
subscribeThumbnails,
() => getThumbnail(template.id),
);
useEffect(() => {
if (!thumbnailUrl) {
generateThumbnail(template.id);
}
}, [template.id, thumbnailUrl]);
return (
<div className="group flex flex-col">
{/* Fixed-height thumbnail area — centers cards with different aspect ratios */}
<div className="flex aspect-3/4 items-center justify-center">
<button
onClick={() => openPreview(template.id)}
style={{ aspectRatio: template.aspectRatio }}
className="relative max-h-full w-full overflow-hidden rounded-xl border border-border bg-card transition-all duration-200 hover:border-foreground/20 hover:shadow-md group-hover:scale-[1.02]"
>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={template.name}
className="h-full w-full object-cover object-top"
/>
) : (
<div className="relative h-full w-full">
<FallbackThumbnail color={template.accentColor} />
<div className="absolute inset-0 flex items-center justify-center bg-card/60">
<LoaderIcon className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</button>
</div>
<div className="mt-2 px-0.5">
<div className="font-medium text-sm leading-tight">{template.name}</div>
<div className="mt-0.5 text-muted-foreground text-xs leading-snug line-clamp-2">
{template.description}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,145 @@
import { useEffect, useRef } from "react";
import { SearchIcon, XIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useTemplateStore } from "@/stores/template-store";
import {
CATEGORY_LABELS,
type TemplateCategory,
} from "@/lib/template-registry";
import { TemplateCard } from "./template-card";
import { CategorySidebar } from "./category-sidebar";
import { TemplatePreview } from "./template-preview";
export function TemplateGallery() {
const searchQuery = useTemplateStore((s) => s.searchQuery);
const setSearchQuery = useTemplateStore((s) => s.setSearchQuery);
const selectedCategory = useTemplateStore((s) => s.selectedCategory);
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
const reset = useTemplateStore((s) => s.reset);
const searchRef = useRef<HTMLInputElement>(null);
// Reset store when gallery mounts
useEffect(() => {
reset();
}, [reset]);
// Focus search on Cmd/Ctrl+K
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
searchRef.current?.focus();
}
// Escape clears search
if (e.key === "Escape" && document.activeElement === searchRef.current) {
setSearchQuery("");
searchRef.current?.blur();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setSearchQuery]);
// Group templates by category when showing all
const showGrouped = !selectedCategory && !searchQuery;
const heading = selectedCategory
? CATEGORY_LABELS[selectedCategory]
: searchQuery
? `Results for "${searchQuery}"`
: "All Templates";
return (
<div className="flex h-full flex-col">
{/* Search bar */}
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="relative mx-auto max-w-xl">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search templates... ⌘K"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute top-1/2 right-2.5 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
)}
</div>
</div>
{/* Main content: sidebar + grid */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<div className="shrink-0 border-r border-border pl-3 pt-2">
<CategorySidebar />
</div>
{/* Template grid */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{filteredTemplates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<SearchIcon className="mb-3 size-8 text-muted-foreground/40" />
<p className="font-medium text-sm text-muted-foreground">No templates found</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Try a different search term or category
</p>
</div>
) : showGrouped ? (
<GroupedGrid />
) : (
<>
<h2 className="mb-4 font-medium text-sm text-muted-foreground">{heading}</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{filteredTemplates.map((t) => (
<TemplateCard key={t.id} template={t} />
))}
</div>
</>
)}
</div>
</div>
{/* Preview modal — handles template selection + project creation */}
<TemplatePreview />
</div>
);
}
// ─── Grouped Grid (shows categories as sections) ───
function GroupedGrid() {
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
// Group by category preserving order
const categories: TemplateCategory[] = ["academic", "professional", "creative", "starter"];
const groups = categories
.map((cat) => ({
category: cat,
templates: filteredTemplates.filter((t) => t.category === cat),
}))
.filter((g) => g.templates.length > 0);
return (
<div className="space-y-8">
{groups.map((group) => (
<div key={group.category}>
<h2 className="mb-3 font-semibold text-sm">
{CATEGORY_LABELS[group.category]}
</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{group.templates.map((t) => (
<TemplateCard key={t.id} template={t} />
))}
</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,668 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { documentDir } from "@tauri-apps/api/path";
import {
ChevronLeftIcon,
ChevronRightIcon,
SparklesIcon,
LoaderIcon,
ArrowLeftIcon,
FolderOpenIcon,
PaperclipIcon,
XIcon,
UploadIcon,
ChevronDownIcon,
FileTextIcon,
MapPinIcon,
Loader2Icon,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { useTemplateStore } from "@/stores/template-store";
import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
import { getTemplatePdfUrl } from "@/lib/template-preview-cache";
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
import { exists, join } from "@/lib/tauri/fs";
import type { PageSize } from "@/lib/mupdf/types";
// ─── Helpers ───
function randomProjectName(): string {
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const id = Math.random().toString(36).slice(2, 6);
return `${adj}-${noun}-${id}`;
}
// ─── Component ───
type ModalStep = "preview" | "details";
export function TemplatePreview() {
const previewTemplateId = useTemplateStore((s) => s.previewTemplateId);
const closePreview = useTemplateStore((s) => s.closePreview);
const template = previewTemplateId ? getTemplateById(previewTemplateId) : null;
const [modalStep, setModalStep] = useState<ModalStep>("preview");
// ── PDF preview state ──
const [numPages, setNumPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLandscape, setIsLandscape] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const docIdRef = useRef(0);
const pageSizesRef = useRef<PageSize[]>([]);
const loadGenRef = useRef(0);
// ── Creation form state ──
const [purpose, setPurpose] = useState("");
const [attachments, setAttachments] = useState<string[]>([]);
const [projectFolder, setProjectFolder] = useState<string | null>(null);
const [projectName, setProjectName] = useState(randomProjectName);
const [isCreating, setIsCreating] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [refFilesOpen, setRefFilesOpen] = useState(false);
const [locationOpen, setLocationOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// ── Store access ──
const addRecentProject = useProjectStore((s) => s.addRecentProject);
const lastProjectFolder = useProjectStore((s) => s.lastProjectFolder);
const setLastProjectFolder = useProjectStore((s) => s.setLastProjectFolder);
const openProject = useDocumentStore((s) => s.openProject);
// ── Dialog open/close ──
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
closePreview();
setModalStep("preview");
setCurrentPage(1);
setNumPages(0);
setIsLandscape(false);
setError(false);
if (docIdRef.current > 0) {
getMupdfClient().closeDocument(docIdRef.current).catch(() => {});
docIdRef.current = 0;
}
}
},
[closePreview],
);
// Reset form when a new template is previewed
useEffect(() => {
if (previewTemplateId) {
setModalStep("preview");
setPurpose("");
setAttachments([]);
setProjectName(randomProjectName());
setRefFilesOpen(false);
setLocationOpen(false);
}
}, [previewTemplateId]);
// Default project folder
useEffect(() => {
if (projectFolder) return;
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
documentDir().then((dir) => setProjectFolder(dir)).catch(() => {});
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-focus textarea in details step
useEffect(() => {
if (modalStep === "details") {
const timer = setTimeout(() => textareaRef.current?.focus(), 150);
return () => clearTimeout(timer);
}
}, [modalStep]);
// ── PDF loading ──
useEffect(() => {
if (!previewTemplateId) return;
const gen = ++loadGenRef.current;
setLoading(true);
setError(false);
setNumPages(0);
setCurrentPage(1);
(async () => {
try {
const url = getTemplatePdfUrl(previewTemplateId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = await response.arrayBuffer();
if (gen !== loadGenRef.current) return;
const client = getMupdfClient();
if (docIdRef.current > 0) {
await client.closeDocument(docIdRef.current).catch(() => {});
}
const docId = await client.openDocument(buffer);
if (gen !== loadGenRef.current) {
client.closeDocument(docId).catch(() => {});
return;
}
docIdRef.current = docId;
const count = await client.countPages(docId);
if (gen !== loadGenRef.current) return;
const sizes: PageSize[] = [];
for (let i = 0; i < count; i++) {
const size = await client.getPageSize(docId, i);
if (gen !== loadGenRef.current) return;
sizes.push(size);
}
pageSizesRef.current = sizes;
setNumPages(count);
setCurrentPage(1);
if (sizes.length > 0) {
setIsLandscape(sizes[0].width > sizes[0].height);
}
setLoading(false);
} catch (err) {
if (gen !== loadGenRef.current) return;
console.warn("[template-preview] load error:", err);
setLoading(false);
setError(true);
}
})();
}, [previewTemplateId]);
// ── Render current page ──
useEffect(() => {
if (docIdRef.current <= 0 || numPages === 0 || !canvasRef.current || !containerRef.current) return;
const pageIndex = currentPage - 1;
const size = pageSizesRef.current[pageIndex];
if (!size) return;
setIsLandscape(size.width > size.height);
const container = containerRef.current;
const maxW = container.clientWidth - 48;
const maxH = container.clientHeight - 48;
const pageAspect = size.width / size.height;
let displayW = maxW;
let displayH = displayW / pageAspect;
if (displayH > maxH) {
displayH = maxH;
displayW = displayH * pageAspect;
}
const dpr = window.devicePixelRatio || 1;
const dpi = (displayW / size.width) * 72 * dpr;
const client = getMupdfClient();
client.drawPage(docIdRef.current, pageIndex, dpi).then((imageData) => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = imageData.width;
canvas.height = imageData.height;
canvas.style.width = `${displayW}px`;
canvas.style.height = `${displayH}px`;
const ctx = canvas.getContext("2d")!;
ctx.putImageData(imageData, 0, 0);
}).catch((err) => {
console.warn("[template-preview] render error:", err);
});
}, [currentPage, numPages, isLandscape]);
// ── Page navigation ──
const goToPrevPage = useCallback(
() => setCurrentPage((p) => Math.max(1, p - 1)),
[],
);
const goToNextPage = useCallback(
() => setCurrentPage((p) => Math.min(numPages, p + 1)),
[numPages],
);
useEffect(() => {
if (!previewTemplateId || modalStep !== "preview") return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") { e.preventDefault(); goToPrevPage(); }
else if (e.key === "ArrowRight") { e.preventDefault(); goToNextPage(); }
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewTemplateId, modalStep, goToPrevPage, goToNextPage]);
// ── Drag-drop for reference files ──
useEffect(() => {
if (modalStep !== "details") return;
let unlisten: (() => void) | undefined;
let cancelled = false;
getCurrentWebview()
.onDragDropEvent((event) => {
if (cancelled) return;
const { type } = event.payload;
if (type === "enter") {
setIsDragOver(true);
setRefFilesOpen(true);
} else if (type === "drop") {
setIsDragOver(false);
const paths = (event.payload as { paths: string[] }).paths;
if (paths?.length > 0) {
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
}
} else if (type === "leave") {
setIsDragOver(false);
}
})
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
.catch(() => {});
return () => { cancelled = true; unlisten?.(); };
}, [modalStep]);
// ── File handlers ──
const handleAddAttachments = useCallback(async () => {
const selected = await open({
multiple: true,
title: "Add Reference Files",
filters: [{
name: "Documents & Images",
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
}],
});
if (selected) {
const paths = Array.isArray(selected) ? selected : [selected];
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
}
}, []);
const handleRemoveAttachment = (path: string) => {
setAttachments((prev) => prev.filter((p) => p !== path));
};
const handleChooseFolder = useCallback(async () => {
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
if (selected) {
setProjectFolder(selected);
setLastProjectFolder(selected);
}
}, [setLastProjectFolder]);
// ── Create project ──
const handleCreate = async () => {
if (!template || !projectFolder || !projectName.trim()) return;
setIsCreating(true);
try {
const projectPath = await join(projectFolder, projectName.trim());
await mkdir(projectPath, { recursive: true }).catch(() => {});
const mainTexPath = await join(projectPath, template.mainFileName);
const mainExists = await exists(mainTexPath);
if (!mainExists) {
await writeTextFile(mainTexPath, getTemplateSkeleton(template));
}
if (template.hasBibliography) {
const bibPath = await join(projectPath, "references.bib");
const bibExists = await exists(bibPath);
if (!bibExists) {
await writeTextFile(bibPath, BIB_TEMPLATE);
}
}
if (attachments.length > 0) {
const attachmentsDir = await join(projectPath, "attachments");
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
}
if (purpose.trim()) {
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
const attachmentSection = attachmentNames.length > 0
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
: "";
const prompt = [
`## New ${template.name} Project`,
"",
`| | |`,
`|---|---|`,
`| **Template** | \`${template.documentClass}\` |`,
`| **File** | \`${template.mainFileName}\` |`,
"",
`> The file currently contains only the LaTeX preamble (packages, styling, custom commands) with an empty document body.`,
"",
`### What I want to create`,
"",
purpose.trim(),
attachmentSection,
`### Instructions`,
"",
`Please generate the full document content based on my description. Keep the existing preamble and fill in the document body (between \`\\begin{document}\` and \`\\end{document}\`) with appropriate title, author, sections, and content. Make it a complete, well-structured **${template.name.toLowerCase()}** ready for me to refine.`,
].join("\n");
useClaudeChatStore.getState().newSession();
useClaudeChatStore.getState().setPendingInitialPrompt(prompt);
}
setLastProjectFolder(projectFolder);
addRecentProject(projectPath);
await openProject(projectPath);
if (attachments.length > 0) {
await useDocumentStore.getState().importFiles(attachments, "attachments");
}
// Close modal on success
closePreview();
} catch (err) {
console.error("Failed to create project:", err);
} finally {
setIsCreating(false);
}
};
const canCreate = template && projectFolder && projectName.trim();
if (!template) return null;
// ── Modal width depends on step ──
const modalWidth = modalStep === "preview"
? isLandscape
? "w-[min(72rem,calc(100vw-4rem))]"
: "w-[min(48rem,calc(100vw-6rem))]"
: "w-[min(32rem,calc(100vw-4rem))]";
return (
<Dialog open={!!previewTemplateId} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className={`flex max-w-none sm:max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
>
{modalStep === "preview" ? (
/* ═══════════════════ PREVIEW STEP ═══════════════════ */
<>
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">{template.name}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
{template.description} — {template.documentClass}
{template.packages.length > 0 && ` — ${template.packages.length} packages`}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
onClick={() => setModalStep("details")}
className="gap-1.5"
>
<SparklesIcon className="size-3.5" />
Use Template
</Button>
</div>
</div>
</DialogHeader>
<div className="flex flex-1 overflow-hidden">
<div className="relative flex flex-1 flex-col">
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-6">
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-5 animate-spin" />
<span className="text-sm">Loading preview...</span>
</div>
)}
{error && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<span className="text-sm">Preview not available</span>
<span className="text-xs opacity-60">
Run `pnpm generate-previews` to generate
</span>
</div>
)}
{!loading && !error && numPages > 0 && (
<canvas ref={canvasRef} className="shadow-xl" />
)}
</div>
{numPages > 0 && (
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-border bg-background py-2.5">
<Button variant="ghost" size="icon" className="size-7" onClick={goToPrevPage} disabled={currentPage <= 1}>
<ChevronLeftIcon className="size-4" />
</Button>
<span className="min-w-16 text-center text-xs tabular-nums text-muted-foreground">
{numPages > 1 ? `${currentPage} / ${numPages}` : "1 page"}
</span>
<Button variant="ghost" size="icon" className="size-7" onClick={goToNextPage} disabled={currentPage >= numPages}>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</div>
</div>
</>
) : (
/* ═══════════════════ DETAILS STEP ═══════════════════ */
<>
{/* Header */}
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="size-7 rounded-lg shrink-0"
onClick={() => setModalStep("preview")}
>
<ArrowLeftIcon className="size-4" />
</Button>
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">{template.name}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
{template.description}
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* Form content */}
<div className="flex-1 overflow-y-auto">
<div className="space-y-4 p-5">
{/* Purpose — hero element */}
<div className="space-y-2">
<div>
<label className="font-semibold text-sm">What are you writing?</label>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
Describe your document and Claude will generate tailored content.
</p>
</div>
<Textarea
ref={textareaRef}
placeholder="e.g., A research paper on transformer architectures for protein structure prediction, targeting NeurIPS 2025..."
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={3}
className="resize-none rounded-xl border-border/60 bg-card/30 text-sm leading-relaxed placeholder:text-muted-foreground/50 focus-visible:bg-card/50"
/>
</div>
{/* Collapsible sections */}
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
{/* Reference files */}
<div>
<button
onClick={() => setRefFilesOpen(!refFilesOpen)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/30"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
<FileTextIcon className="size-3 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Reference files</span>
{attachments.length > 0 && (
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
{attachments.length}
</span>
)}
</div>
<ChevronDownIcon
className={`size-4 text-muted-foreground/60 transition-transform duration-200 ${refFilesOpen ? "rotate-180" : ""}`}
/>
</button>
{refFilesOpen && (
<div className="px-4 pb-3 space-y-2.5">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{attachments.map((path) => (
<div
key={path}
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
>
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
<span className="max-w-30 truncate text-foreground/80">{path.split("/").pop()}</span>
<button
onClick={() => handleRemoveAttachment(path)}
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<XIcon className="size-3" />
</button>
</div>
))}
</div>
)}
<div
className={`flex flex-col items-center gap-1.5 rounded-lg border border-dashed p-3 transition-all ${
isDragOver
? "border-primary bg-primary/5"
: "border-border/60 hover:border-border hover:bg-muted/20"
}`}
>
{isDragOver ? (
<>
<UploadIcon className="size-4 text-primary" />
<span className="text-xs font-medium text-primary">Drop to add</span>
</>
) : (
<>
<UploadIcon className="size-4 text-muted-foreground/40" />
<div className="text-center">
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
<button
onClick={handleAddAttachments}
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground transition-colors"
>
browse files
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
{/* Project location */}
<div>
<button
onClick={() => setLocationOpen(!locationOpen)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/30"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
<MapPinIcon className="size-3 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Project location</span>
</div>
{!locationOpen && projectFolder && projectName.trim() && (
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/{projectName.trim()}
</span>
)}
<ChevronDownIcon
className={`size-4 text-muted-foreground/60 transition-transform duration-200 ${locationOpen ? "rotate-180" : ""}`}
/>
</button>
{locationOpen && (
<div className="px-4 pb-3 space-y-2">
<div className="flex gap-2">
<Input
placeholder="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="flex-1 rounded-lg border-border/60 bg-background/50"
/>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5 rounded-lg border-border/60"
onClick={handleChooseFolder}
>
<FolderOpenIcon className="size-3.5" />
{projectFolder ? "Change" : "Choose"}
</Button>
</div>
{projectFolder && (
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
{projectFolder}/{projectName.trim() || "..."}
</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
{/* Create button — sticky footer */}
<div className="shrink-0 border-t border-border/60 px-5 py-4">
<Button
className="w-full gap-2 rounded-xl font-semibold shadow-sm transition-all hover:shadow-md active:scale-[0.99]"
size="lg"
disabled={!canCreate || isCreating}
onClick={handleCreate}
>
{isCreating ? (
<>
<Loader2Icon className="size-4 animate-spin" />
Creating project...
</>
) : purpose.trim() ? (
<>
<SparklesIcon className="size-4" />
Create & Generate with AI
</>
) : (
"Create Project"
)}
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -1,7 +1,6 @@
import { RefObject } from "react";
import { RefObject, useCallback, useEffect, useState } from "react";
import type { EditorView } from "@codemirror/view";
import { invoke } from "@tauri-apps/api/core";
import {
BoldIcon,
ItalicIcon,
@ -15,9 +14,16 @@ import {
MinusIcon,
PlusIcon,
BookMarkedIcon,
ExternalLinkIcon,
} from "lucide-react";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
@ -27,6 +33,11 @@ import {
} from "@/components/ui/select";
import { useDocumentStore } from "@/stores/document-store";
interface EditorInfo {
id: string;
name: string;
}
const ZOOM_OPTIONS = [
{ value: "0.5", label: "50%" },
{ value: "0.75", label: "75%" },
@ -55,6 +66,34 @@ export function EditorToolbar({
const activeFile = s.files.find((f) => f.id === s.activeFileId);
return activeFile?.name ?? "document.tex";
});
const activeFilePath = useDocumentStore((s) => {
const activeFile = s.files.find((f) => f.id === s.activeFileId);
return activeFile?.relativePath;
});
const projectRoot = useDocumentStore((s) => s.projectRoot);
const [editors, setEditors] = useState<EditorInfo[]>([]);
useEffect(() => {
invoke<EditorInfo[]>("detect_editors").then(setEditors).catch(() => {});
}, []);
const openInEditor = useCallback(
(editorId: string) => {
if (!projectRoot) return;
const view = editorView.current;
const line = view
? view.state.doc.lineAt(view.state.selection.main.head).number
: undefined;
invoke("open_in_editor", {
editorId,
projectPath: projectRoot,
filePath: activeFilePath,
line,
}).catch((err) => console.error("open_in_editor failed:", err));
},
[projectRoot, activeFilePath, editorView],
);
const insertText = (before: string, after: string = "") => {
const view = editorView.current;
@ -128,6 +167,38 @@ export function EditorToolbar({
))}
</SelectContent>
</Select>
{editors.length === 1 && (
<TooltipIconButton
tooltip={`Open in ${editors[0].name}`}
onClick={() => openInEditor(editors[0].id)}
>
<ExternalLinkIcon className="size-4" />
</TooltipIconButton>
)}
{editors.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
title="Open in Editor"
>
<ExternalLinkIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{editors.map((editor) => (
<DropdownMenuItem
key={editor.id}
onClick={() => openInEditor(editor.id)}
>
{editor.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
@ -198,6 +269,38 @@ export function EditorToolbar({
<BookMarkedIcon className="size-4" />
</TooltipIconButton>
<div data-tauri-drag-region className="flex-1 self-stretch" />
{editors.length === 1 && (
<TooltipIconButton
tooltip={`Open in ${editors[0].name}`}
onClick={() => openInEditor(editors[0].id)}
>
<ExternalLinkIcon className="size-4" />
</TooltipIconButton>
)}
{editors.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
title="Open in Editor"
>
<ExternalLinkIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{editors.map((editor) => (
<DropdownMenuItem
key={editor.id}
onClick={() => openInEditor(editor.id)}
>
{editor.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View file

@ -260,7 +260,7 @@ export function LatexEditor() {
const handleFindNext = () => { const view = viewRef.current; if (view) { findNext(view); view.focus(); } };
const handleFindPrevious = () => { const view = viewRef.current; if (view) { findPrevious(view); view.focus(); } };
// Compile: save all files first, then compile via sidecar using projectDir
// Compile: save all files first, then compile via Tauri command
compileRef.current = async () => {
if (isCompiling || !projectRoot || activeFile?.type !== "tex") return;
useHistoryStore.getState().stopReview();
@ -566,6 +566,8 @@ export function LatexEditor() {
});
view.scrollDOM.scrollTop = scrollTop;
console.log("[merge-view] merge view activated successfully");
// Auto-scroll to first chunk
setTimeout(() => goToChunk(0), 50);
} catch (err) {
console.error("[merge-view] failed to activate merge view:", err);
isMergeActiveRef.current = false;
@ -784,17 +786,15 @@ export function LatexEditor() {
</span>
<div className="mx-0.5 h-4 w-px bg-border" />
<button
onClick={() => goToChunk(mergeChunkInfo.current - 2)}
disabled={mergeChunkInfo.current <= 1}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
onClick={() => goToChunk(mergeChunkInfo.current <= 1 ? mergeChunkInfo.total - 1 : mergeChunkInfo.current - 2)}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
title="Previous change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button
onClick={() => goToChunk(mergeChunkInfo.current)}
disabled={mergeChunkInfo.current >= mergeChunkInfo.total}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
onClick={() => goToChunk(mergeChunkInfo.current >= mergeChunkInfo.total ? 0 : mergeChunkInfo.current)}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
title="Next change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
@ -921,7 +921,6 @@ function InlinePdfViewer({
data={pdfData}
scale={imageScale}
onScaleChange={onImageScaleChange}
onError={(msg) => setError(msg)}
/>
) : error ? (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">

View file

@ -0,0 +1,120 @@
import { useEffect, useRef, useState, memo } from "react";
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
import type { StructuredTextData, LinkData } from "@/lib/mupdf/types";
interface MupdfPageProps {
docId: number;
pageIndex: number;
scale: number;
pageWidth: number;
pageHeight: number;
isVisible: boolean;
}
export const MupdfPage = memo(function MupdfPage({
docId,
pageIndex,
scale,
pageWidth,
pageHeight,
isVisible,
}: MupdfPageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [textData, setTextData] = useState<StructuredTextData | null>(null);
const [links, setLinks] = useState<LinkData[]>([]);
const renderGenRef = useRef(0);
const cssW = pageWidth * scale;
const cssH = pageHeight * scale;
useEffect(() => {
if (!isVisible || docId <= 0) return;
const gen = ++renderGenRef.current;
const client = getMupdfClient();
const dpr = window.devicePixelRatio || 1;
const dpi = scale * 72 * dpr;
client.drawPage(docId, pageIndex, dpi).then((imageData) => {
if (gen !== renderGenRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext("2d")!;
ctx.putImageData(imageData, 0, 0);
}).catch((err) => {
if (gen !== renderGenRef.current) return;
console.error(`[mupdf-page] render error page ${pageIndex}:`, err);
});
client.getPageText(docId, pageIndex).then((data) => {
if (gen !== renderGenRef.current) return;
setTextData(data);
}).catch(() => {});
client.getPageLinks(docId, pageIndex).then((data) => {
if (gen !== renderGenRef.current) return;
setLinks(data);
}).catch(() => {});
}, [docId, pageIndex, scale, isVisible]);
return (
<div
className="mupdf-page relative mb-4 shadow-lg"
data-page-number={pageIndex + 1}
style={{ width: cssW, height: cssH }}
>
<canvas
ref={canvasRef}
style={{ width: cssW, height: cssH, display: "block" }}
/>
{/* Text layer for selection */}
{textData && (
<svg
className="mupdf-text-layer"
viewBox={`0 0 ${pageWidth} ${pageHeight}`}
preserveAspectRatio="none"
style={{ width: cssW, height: cssH }}
>
{textData.blocks.map((block, bi) =>
block.type === "text" &&
block.lines.map((line, li) => (
<text
key={`${bi}-${li}`}
x={line.bbox.x}
y={line.y}
fontSize={line.font.size}
fontFamily={line.font.family || line.font.name || "serif"}
textLength={line.bbox.w > 0 ? line.bbox.w : undefined}
lengthAdjust="spacingAndGlyphs"
>
{line.text}
</text>
)),
)}
</svg>
)}
{/* Link layer */}
{links.length > 0 && (
<div className="mupdf-link-layer">
{links.map((link, i) => (
<a
key={i}
href={link.href}
data-external={link.isExternal ? "true" : undefined}
style={{
left: (link.x / pageWidth) * 100 + "%",
top: (link.y / pageHeight) * 100 + "%",
width: (link.w / pageWidth) * 100 + "%",
height: (link.h / pageHeight) * 100 + "%",
}}
/>
))}
</div>
)}
</div>
);
});

View file

@ -10,7 +10,10 @@ import {
DownloadIcon,
HistoryIcon,
MousePointerClickIcon,
CrosshairIcon,
} from "lucide-react";
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
import { join } from "@tauri-apps/api/path";
import { useDocumentStore } from "@/stores/document-store";
import { useHistoryStore } from "@/stores/history-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
@ -27,8 +30,7 @@ import { HistoryPanel } from "@/components/workspace/history-panel";
import { compileLatex, synctexEdit } from "@/lib/latex-compiler";
import { SelectionToolbar, type ToolbarAction } from "@/components/workspace/editor/selection-toolbar";
import { save } from "@tauri-apps/plugin-dialog";
import { writeFile } from "@tauri-apps/plugin-fs";
import type { PdfTextSelection } from "./pdf-viewer";
import type { PdfTextSelection, CaptureResult } from "./pdf-viewer";
const ZOOM_OPTIONS = [
{ value: "0.5", label: "50%" },
@ -70,6 +72,7 @@ export function PdfPreview() {
const [pdfError, setPdfError] = useState<string | null>(null);
const [numPages, setNumPages] = useState<number>(0);
const [scale, setScale] = useState<number>(1.0);
const [captureMode, setCaptureMode] = useState(false);
const hasInitialCompile = useRef(false);
const initialized = useDocumentStore((s) => s.initialized);
@ -98,47 +101,34 @@ export function PdfPreview() {
const handleSynctexClick = useCallback(
async (page: number, x: number, y: number) => {
if (!projectRoot) {
console.log("[synctex] no projectRoot");
return;
}
console.log("[synctex] calling synctexEdit:", { projectRoot, page, x: x.toFixed(1), y: y.toFixed(1) });
if (!projectRoot) return;
const result = await synctexEdit(projectRoot, page, x, y);
console.log("[synctex] result:", result);
if (!result) return;
// Find the file by relative path (try exact match, then normalized match)
const normalize = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, "");
const normalizedTarget = normalize(result.file);
const targetFile = files.find(
(f) => normalize(f.relativePath) === normalizedTarget,
);
if (!targetFile) {
console.log("[synctex] file not found in project:", result.file, "available:", files.map(f => f.relativePath));
return;
}
if (!targetFile) return;
// Switch to the file if not already active
const state = useDocumentStore.getState();
const needsSwitch = state.activeFileId !== targetFile.id;
if (needsSwitch) {
setActiveFile(targetFile.id);
}
// Compute character offset from line number (1-based → 0-based offset)
const fileContent = targetFile.content ?? "";
const fileLines = fileContent.split("\n");
const targetLine = Math.max(1, Math.min(result.line, fileLines.length));
let offset = 0;
for (let i = 0; i < targetLine - 1; i++) {
offset += fileLines[i].length + 1; // +1 for \n
offset += fileLines[i].length + 1;
}
// Column might be 0 or -1 from synctex; just go to line start in that case
if (result.column > 0) {
offset += Math.min(result.column, fileLines[targetLine - 1]?.length ?? 0);
}
// Delay jump if file was switched so the editor has time to initialize
if (needsSwitch) {
setTimeout(() => requestJumpToPosition(offset), 100);
} else {
@ -173,15 +163,12 @@ export function PdfPreview() {
return () => { cancelled = true; };
}, [pdfSelection, projectRoot]);
// Build the context label: use synctex-resolved source if available, else PDF page
// Prefix with ~ to indicate approximate location (from PDF, not exact source)
const pdfContextLabel = resolvedSource
? `~@${resolvedSource.file}:${resolvedSource.line}`
: pdfSelection
? `~@PDF page ${pdfSelection.pageNumber}`
: "";
// Navigate to source file:line (reuses handleSynctexClick logic)
const navigateToSource = useCallback(() => {
if (!resolvedSource) return;
const normalize = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, "");
@ -213,7 +200,6 @@ export function PdfPreview() {
}
}, [resolvedSource, files, setActiveFile, requestJumpToPosition]);
// Build annotated selectedText for Claude — note that this is from PDF output
const buildPdfContext = useCallback((text: string) => {
const locationNote = resolvedSource
? `near ${resolvedSource.file}:${resolvedSource.line}`
@ -269,7 +255,6 @@ export function PdfPreview() {
window.getSelection()?.removeAllRanges();
}, []);
// Compute container-relative position for the toolbar
const pdfToolbarPosition = (() => {
if (!pdfSelection || !previewContainerRef.current) return null;
const containerRect = previewContainerRef.current.getBoundingClientRect();
@ -319,9 +304,7 @@ export function PdfPreview() {
const filePath = await save({
title: "Export PDF",
defaultPath: defaultName,
filters: [
{ name: "PDF", extensions: ["pdf"] },
],
filters: [{ name: "PDF", extensions: ["pdf"] }],
});
if (!filePath) return;
await writeFile(filePath, new Uint8Array(pdfData));
@ -348,20 +331,61 @@ export function PdfPreview() {
}
};
const handleCapture = async (result: CaptureResult) => {
setCaptureMode(false);
if (!projectRoot) return;
const fileName = `capture-p${result.pageNumber}-${Date.now()}.png`;
const relativePath = `attachments/${fileName}`;
try {
const attachmentsDir = await join(projectRoot, "attachments");
if (!(await exists(attachmentsDir))) {
await mkdir(attachmentsDir, { recursive: true });
}
const fullPath = await join(projectRoot, relativePath);
const base64 = result.dataUrl.split(",")[1];
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
await writeFile(fullPath, bytes);
await useDocumentStore.getState().refreshFiles();
useClaudeChatStore.getState().addPendingAttachment({
label: `@${relativePath}`,
filePath: relativePath,
selectedText: `[Captured region from PDF page ${result.pageNumber}]`,
imageDataUrl: result.dataUrl,
});
} catch (err) {
console.error("[capture] failed to save:", err);
}
};
// Listen for global Capture & Ask shortcut (Cmd+X / Ctrl+X)
useEffect(() => {
const handleToggleCapture = () => {
if (pdfData) setCaptureMode((prev) => !prev);
};
window.addEventListener("toggle-capture-mode", handleToggleCapture);
return () => window.removeEventListener("toggle-capture-mode", handleToggleCapture);
}, [pdfData]);
const renderContent = () => {
if (compileError) {
// Parse individual errors from the compile output, dedup
const errors = [...new Set(
compileError
.split(/\s*!\s*/)
.map((s) => s.trim())
.filter((s) => s.length > 0 && s !== "Compilation failed")
.filter((s) => s.length > 0 && s !== "Compilation failed"),
)];
const handleFixWithChat = () => {
const errorList = errors.map((e) => `- ${e}`).join("\n");
useClaudeChatStore.getState().sendPrompt(
`[Compilation errors]\n${errorList}\n\nFix these LaTeX compilation errors.`
`[Compilation errors]\n${errorList}\n\nFix these LaTeX compilation errors.`,
);
};
@ -440,6 +464,9 @@ export function PdfPreview() {
onTextClick={handleTextClick}
onSynctexClick={handleSynctexClick}
onTextSelect={handleTextSelect}
captureMode={captureMode}
onCapture={handleCapture}
onCancelCapture={() => setCaptureMode(false)}
/>
</Suspense>
);
@ -486,6 +513,17 @@ export function PdfPreview() {
<SelectContent>{ZOOM_OPTIONS.map((opt) => (<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>))}</SelectContent>
</Select>
<div className="mx-1 h-4 w-px bg-border" />
{/* Capture mode */}
<Button
variant={captureMode ? "default" : "ghost"}
size="sm"
className={`h-7 gap-1.5 px-2.5 text-xs ${captureMode ? "ring-2 ring-primary/30" : ""}`}
onClick={() => setCaptureMode(!captureMode)}
>
<CrosshairIcon className="size-3.5" />
Capture
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={handleExport} title="Export PDF">
<DownloadIcon className="size-3.5" />
Export
@ -517,6 +555,25 @@ export function PdfPreview() {
onDismiss={handlePdfToolbarDismiss}
/>
)}
{/* Capture mode floating banner */}
{captureMode && (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-sm">
<CrosshairIcon className="size-3.5 text-primary" />
<span className="text-xs text-foreground">
Drag to select a region
</span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
ESC
</kbd>
<span className="text-[10px] text-muted-foreground">or</span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{navigator.userAgent.includes("Mac") ? "⌘" : "Ctrl+"}X
</kbd>
<span className="text-[10px] text-muted-foreground">to cancel</span>
</div>
</div>
)}
</div>
);
}

View file

@ -1,20 +1,24 @@
import { useCallback, useMemo, useRef, useEffect, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import { useCallback, useRef, useEffect, useState } from "react";
import { LoaderIcon } from "lucide-react";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { ask } from "@tauri-apps/plugin-dialog";
import pdfjsWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
import { MupdfPage } from "./mupdf-page";
import type { PageSize } from "@/lib/mupdf/types";
export interface PdfTextSelection {
text: string;
pageNumber: number;
position: { top: number; left: number }; // screen coords (bottom of selection)
pdfX: number; // PDF-space x coordinate (for synctex)
pdfY: number; // PDF-space y coordinate (for synctex)
position: { top: number; left: number };
pdfX: number;
pdfY: number;
}
export interface CaptureResult {
dataUrl: string;
pageNumber: number;
pdfX: number;
pdfY: number;
}
interface PdfViewerProps {
@ -26,6 +30,9 @@ interface PdfViewerProps {
onTextClick?: (text: string) => void;
onSynctexClick?: (page: number, x: number, y: number) => void;
onTextSelect?: (selection: PdfTextSelection | null) => void;
captureMode?: boolean;
onCapture?: (result: CaptureResult) => void;
onCancelCapture?: () => void;
}
export function PdfViewer({
@ -37,13 +44,19 @@ export function PdfViewer({
onTextClick,
onSynctexClick,
onTextSelect,
captureMode = false,
onCapture,
onCancelCapture,
}: PdfViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const hasSetInitialScale = useRef(false);
const [numPages, setNumPages] = useState(0);
// Keep refs for values used in native event listeners
const [pageSizes, setPageSizes] = useState<PageSize[]>([]);
const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const docIdRef = useRef(0);
const loadGenRef = useRef(0);
const scaleRef = useRef(scale);
scaleRef.current = scale;
const synctexClickRef = useRef(onSynctexClick);
@ -53,19 +66,24 @@ export function PdfViewer({
// Scroll preservation across recompile
const isFirstLoad = useRef(true);
const savedPageRef = useRef<number>(0); // 0 = no saved page
const savedPageRef = useRef<number>(0);
// Capture drag state
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null);
const [dragPageNum, setDragPageNum] = useState(0);
const numPages = pageSizes.length;
/** Compute which page is currently at the top of the viewport */
function getVisiblePage(): number {
const container = containerRef.current;
if (!container) return 1;
const pages = container.querySelectorAll(".react-pdf__Page");
const pages = container.querySelectorAll(".mupdf-page");
if (pages.length === 0) return 1;
const containerRect = container.getBoundingClientRect();
for (const page of pages) {
const el = page as HTMLElement;
const rect = el.getBoundingClientRect();
// First page whose bottom is past the container top (+50px threshold)
if (rect.bottom > containerRect.top + 50) {
return parseInt(el.getAttribute("data-page-number") || "1", 10);
}
@ -73,144 +91,169 @@ export function PdfViewer({
return 1;
}
const file = useMemo(() => {
// Load document with MuPDF
useEffect(() => {
const gen = ++loadGenRef.current;
const client = getMupdfClient();
const pdfData =
data instanceof Uint8Array ? data : new Uint8Array(Object.values(data));
// Only reset initial scale on first load, not on recompile
if (isFirstLoad.current) {
hasSetInitialScale.current = false;
data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
// Validate PDF header — must start with %PDF-
if (pdfData.length < 5 || pdfData[0] !== 0x25 || pdfData[1] !== 0x50 || pdfData[2] !== 0x44 || pdfData[3] !== 0x46) {
console.error("[pdf-viewer] invalid PDF data: missing %PDF- header, length=", pdfData.length, "first bytes:", Array.from(pdfData.slice(0, 16)));
setLoading(false);
onError?.("Invalid PDF data received. Try recompiling the document.");
return;
}
// Save visible page and lock container height before Document reloads
// Save scroll position before reloading
if (containerRef.current && !isFirstLoad.current) {
const page = getVisiblePage();
savedPageRef.current = page;
// Lock the inner content div height to prevent scroll collapse
// when Document unmounts Page children during loading
savedPageRef.current = getVisiblePage();
if (contentRef.current) {
contentRef.current.style.minHeight = `${contentRef.current.scrollHeight}px`;
}
console.log("[pdf-viewer] data changed → saved page:", page, "locked minHeight:", contentRef.current?.scrollHeight);
}
return { data: pdfData.slice() };
}, [data]);
const handleLoadSuccess = useCallback(
({ numPages: newNumPages }: { numPages: number }) => {
setNumPages(newNumPages);
isFirstLoad.current = false;
onLoadSuccess?.(newNumPages);
const prevDocId = docIdRef.current;
setLoading(isFirstLoad.current);
const targetPage = savedPageRef.current;
console.log("[pdf-viewer] Document loaded, numPages:", newNumPages, "targetPage:", targetPage);
if (targetPage > 0) {
savedPageRef.current = 0;
// Poll until the target page element renders with height
const scrollToPage = (attempts: number) => {
const container = containerRef.current;
if (!container || attempts <= 0) {
console.log("[pdf-viewer] scroll restore gave up after max attempts");
// Clear min-height even if we fail
if (contentRef.current) contentRef.current.style.minHeight = "";
return;
}
const pageEl = container.querySelector(
`[data-page-number="${targetPage}"]`,
) as HTMLElement | null;
console.log("[pdf-viewer] scrollToPage attempt", 30 - attempts + 1, "pageEl:", !!pageEl, "height:", pageEl?.clientHeight);
if (pageEl && pageEl.clientHeight > 0) {
const containerRect = container.getBoundingClientRect();
const pageRect = pageEl.getBoundingClientRect();
container.scrollTop += pageRect.top - containerRect.top - 16; // 16px for padding
console.log("[pdf-viewer] scrolled to page", targetPage, "scrollTop:", container.scrollTop);
// Clear min-height
if (contentRef.current) contentRef.current.style.minHeight = "";
} else {
requestAnimationFrame(() => scrollToPage(attempts - 1));
}
};
requestAnimationFrame(() => scrollToPage(30)); // ~30 frames = ~500ms
}
},
[onLoadSuccess],
);
const handlePageLoadSuccess = useCallback(
({ width: _width }: { width: number }) => {
// Scale is now managed externally — default 100% (1.0)
hasSetInitialScale.current = true;
},
[],
);
const handleLoadError = useCallback(
(error: Error) => {
onError?.(error.message);
},
[onError],
);
const handleTextLayerClick = useCallback(
(e: React.MouseEvent) => {
if (!onTextClick) return;
const target = e.target as HTMLElement;
if (
target.tagName === "SPAN" &&
target.closest(".react-pdf__Page__textContent")
) {
const text = target.textContent?.trim();
if (text && text.length > 2) {
onTextClick(text);
(async () => {
try {
// Close previous document
if (prevDocId > 0) {
await client.closeDocument(prevDocId).catch(() => {});
}
}
},
[onTextClick],
);
// Native dblclick listener — more reliable than React's onDoubleClick
// because it captures events from react-pdf's text layer properly
const buffer = pdfData.buffer.slice(
pdfData.byteOffset,
pdfData.byteOffset + pdfData.byteLength,
) as ArrayBuffer;
const docId = await client.openDocument(buffer);
if (gen !== loadGenRef.current) {
client.closeDocument(docId).catch(() => {});
return;
}
docIdRef.current = docId;
const count = await client.countPages(docId);
if (gen !== loadGenRef.current) return;
const sizes: PageSize[] = [];
for (let i = 0; i < count; i++) {
const size = await client.getPageSize(docId, i);
if (gen !== loadGenRef.current) return;
sizes.push(size);
}
setPageSizes(sizes);
setLoading(false);
isFirstLoad.current = false;
onLoadSuccess?.(count);
// Restore scroll position
const targetPage = savedPageRef.current;
if (targetPage > 0) {
savedPageRef.current = 0;
const scrollToPage = (attempts: number) => {
const container = containerRef.current;
if (!container || attempts <= 0) {
if (contentRef.current) contentRef.current.style.minHeight = "";
return;
}
const pageEl = container.querySelector(
`[data-page-number="${targetPage}"]`,
) as HTMLElement | null;
if (pageEl && pageEl.clientHeight > 0) {
const containerRect = container.getBoundingClientRect();
const pageRect = pageEl.getBoundingClientRect();
container.scrollTop += pageRect.top - containerRect.top - 16;
if (contentRef.current) contentRef.current.style.minHeight = "";
} else {
requestAnimationFrame(() => scrollToPage(attempts - 1));
}
};
requestAnimationFrame(() => scrollToPage(30));
}
} catch (err) {
if (gen !== loadGenRef.current) return;
setLoading(false);
onError?.(err instanceof Error ? err.message : String(err));
}
})();
return () => {
// Cleanup handled by next load cycle
};
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
// IntersectionObserver for lazy page rendering
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new IntersectionObserver(
(entries) => {
setVisiblePages((prev) => {
const next = new Set(prev);
for (const entry of entries) {
const el = entry.target as HTMLElement;
const pageNum = parseInt(el.getAttribute("data-page-number") || "0", 10);
if (pageNum === 0) continue;
if (entry.isIntersecting) {
next.add(pageNum);
} else {
next.delete(pageNum);
}
}
return next;
});
},
{
root: container,
rootMargin: "200% 0px",
},
);
const pages = container.querySelectorAll(".mupdf-page");
pages.forEach((p) => observer.observe(p));
return () => observer.disconnect();
}, [pageSizes, scale]);
// Native dblclick listener for synctex
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleDblClick = (e: MouseEvent) => {
if (captureMode) return;
const cb = synctexClickRef.current;
if (!cb) return;
const target = e.target as HTMLElement;
const pageEl = target.closest(".react-pdf__Page") as HTMLElement | null;
if (!pageEl) {
console.log("[synctex] dblclick: no .react-pdf__Page found");
return;
}
const pageEl = target.closest(".mupdf-page") as HTMLElement | null;
if (!pageEl) return;
const pageNum = parseInt(pageEl.getAttribute("data-page-number") || "0", 10);
if (pageNum === 0) {
console.log("[synctex] dblclick: no data-page-number attribute");
return;
}
if (pageNum === 0) return;
// Get click position relative to the page element
const rect = pageEl.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
// Convert screen pixels → PDF points (72 DPI)
// Both canvas and synctex use top-left origin, y-down
const currentScale = scaleRef.current;
const pdfX = offsetX / currentScale;
const pdfY = offsetY / currentScale;
console.log("[synctex] dblclick:", { pageNum, pdfX: pdfX.toFixed(1), pdfY: pdfY.toFixed(1), scale: currentScale });
cb(pageNum, pdfX, pdfY);
};
container.addEventListener("dblclick", handleDblClick);
return () => container.removeEventListener("dblclick", handleDblClick);
}, []); // stable — uses refs for changing values
}, [captureMode]);
// Text selection detection via mouseup (with delay to avoid interrupting long drags)
// Text selection detection via mouseup
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@ -225,19 +268,16 @@ export function PdfViewer({
};
const handleMouseDown = () => {
// Cancel any pending selection toolbar when user starts a new drag
cancelPendingSelection();
};
const handleMouseUp = () => {
if (captureMode) return;
const cb = textSelectRef.current;
if (!cb) return;
// Cancel any previously pending selection
cancelPendingSelection();
// Delay showing the toolbar so that extending/adjusting selections
// (quick mousedown after mouseup) doesn't flash the toolbar
selectionTimer = setTimeout(() => {
selectionTimer = null;
@ -248,24 +288,20 @@ export function PdfViewer({
return;
}
// Check that the selection is within the PDF text layer
const anchorEl = sel?.anchorNode?.parentElement;
if (!anchorEl?.closest(".react-pdf__Page__textContent")) {
if (!anchorEl?.closest(".mupdf-text-layer")) {
cb(null);
return;
}
// Find which page the selection is in
const pageEl = anchorEl.closest(".react-pdf__Page") as HTMLElement | null;
const pageEl = anchorEl.closest(".mupdf-page") as HTMLElement | null;
const pageNum = pageEl
? parseInt(pageEl.getAttribute("data-page-number") || "1", 10)
: 1;
// Get position at the end of the selection (bottom)
const range = sel!.getRangeAt(0);
const rect = range.getBoundingClientRect();
// Compute PDF-space coordinates from selection start for synctex
let pdfX = 0;
let pdfY = 0;
if (pageEl) {
@ -292,7 +328,7 @@ export function PdfViewer({
container.removeEventListener("mousedown", handleMouseDown);
container.removeEventListener("mouseup", handleMouseUp);
};
}, []); // stable — uses refs for changing values
}, [captureMode]);
// Dismiss selection toolbar on scroll
useEffect(() => {
@ -308,6 +344,7 @@ export function PdfViewer({
return () => container.removeEventListener("scroll", handleScroll);
}, []);
// Pinch-to-zoom
useEffect(() => {
const container = containerRef.current;
if (!container || !onScaleChange) return;
@ -324,7 +361,7 @@ export function PdfViewer({
return () => container.removeEventListener("wheel", handleWheel);
}, [scale, onScaleChange]);
// Intercept annotation layer link clicks — prevent in-app navigation
// Intercept link clicks
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@ -333,16 +370,14 @@ export function PdfViewer({
const target = e.target as HTMLElement;
const anchor = target.closest("a") as HTMLAnchorElement | null;
if (!anchor) return;
// Only intercept links within the annotation layer
if (!anchor.closest(".react-pdf__Page__annotations")) return;
if (!anchor.closest(".mupdf-link-layer")) return;
e.preventDefault();
e.stopPropagation();
const href = anchor.href;
const href = anchor.getAttribute("href");
if (!href) return;
// Internal PDF links (e.g. #page=3) — scroll to page
if (href.includes("#page=")) {
const match = href.match(/#page=(\d+)/);
if (match) {
@ -357,7 +392,6 @@ export function PdfViewer({
return;
}
// External links — confirm then open in system default browser
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:")) {
ask(`Open in browser?\n${href}`, {
title: "External Link",
@ -370,46 +404,185 @@ export function PdfViewer({
}
};
container.addEventListener("click", handleClick, true); // capture phase
container.addEventListener("click", handleClick, true);
return () => container.removeEventListener("click", handleClick, true);
}, []);
// ESC during capture: cancel drag or cancel capture mode
useEffect(() => {
if (!captureMode) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (dragStart) {
setDragStart(null);
setDragEnd(null);
} else {
onCancelCapture?.();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [captureMode, dragStart, onCancelCapture]);
// Capture mode: drag to select region
const handleCaptureMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!captureMode) return;
const target = e.target as HTMLElement;
const pageEl = target.closest(".mupdf-page") as HTMLElement | null;
if (!pageEl) return;
const pageNum = parseInt(pageEl.getAttribute("data-page-number") || "0");
if (!pageNum) return;
setDragPageNum(pageNum);
setDragStart({ x: e.clientX, y: e.clientY });
setDragEnd(null);
},
[captureMode],
);
const handleCaptureMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!captureMode || !dragStart) return;
setDragEnd({ x: e.clientX, y: e.clientY });
},
[captureMode, dragStart],
);
const handleCaptureMouseUp = useCallback(
(e: React.MouseEvent) => {
if (!captureMode || !dragStart) {
setDragStart(null);
setDragEnd(null);
return;
}
const end = { x: e.clientX, y: e.clientY };
const w = Math.abs(end.x - dragStart.x);
const h = Math.abs(end.y - dragStart.y);
if (w < 10 || h < 10 || !onCapture) {
setDragStart(null);
setDragEnd(null);
return;
}
const pageEl = containerRef.current?.querySelector(
`.mupdf-page[data-page-number="${dragPageNum}"]`,
) as HTMLElement | null;
const sourceCanvas = pageEl?.querySelector("canvas") as HTMLCanvasElement | null;
if (!pageEl || !sourceCanvas) {
setDragStart(null);
setDragEnd(null);
return;
}
const pageRect = pageEl.getBoundingClientRect();
const selLeft = Math.max(0, Math.min(dragStart.x, end.x) - pageRect.left);
const selTop = Math.max(0, Math.min(dragStart.y, end.y) - pageRect.top);
const selW = Math.min(pageRect.width - selLeft, w);
const selH = Math.min(pageRect.height - selTop, h);
const scaleX = sourceCanvas.width / pageRect.width;
const scaleY = sourceCanvas.height / pageRect.height;
const cropCanvas = document.createElement("canvas");
cropCanvas.width = selW * scaleX;
cropCanvas.height = selH * scaleY;
const ctx = cropCanvas.getContext("2d")!;
ctx.drawImage(
sourceCanvas,
selLeft * scaleX,
selTop * scaleY,
selW * scaleX,
selH * scaleY,
0,
0,
cropCanvas.width,
cropCanvas.height,
);
const currentScale = scaleRef.current;
const pdfX = selLeft / currentScale;
const pdfY = selTop / currentScale;
onCapture({
dataUrl: cropCanvas.toDataURL("image/png"),
pageNumber: dragPageNum,
pdfX,
pdfY,
});
setDragStart(null);
setDragEnd(null);
},
[captureMode, dragStart, dragPageNum, onCapture],
);
// Text layer click for onTextClick
const handleTextLayerClick = useCallback(
(e: React.MouseEvent) => {
if (!onTextClick) return;
const target = e.target as HTMLElement;
if (
target.tagName === "text" &&
target.closest(".mupdf-text-layer")
) {
const text = target.textContent?.trim();
if (text && text.length > 2) {
onTextClick(text);
}
}
},
[onTextClick],
);
const selRect =
dragStart && dragEnd
? {
left: Math.min(dragStart.x, dragEnd.x),
top: Math.min(dragStart.y, dragEnd.y),
width: Math.abs(dragEnd.x - dragStart.x),
height: Math.abs(dragEnd.y - dragStart.y),
}
: null;
return (
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto">
<div
ref={containerRef}
className="min-h-0 flex-1 overflow-auto"
style={{ cursor: captureMode ? "crosshair" : undefined }}
onMouseDown={handleCaptureMouseDown}
onMouseMove={handleCaptureMouseMove}
onMouseUp={handleCaptureMouseUp}
>
<div
ref={contentRef}
className="flex flex-col items-center gap-4 p-4"
onClick={handleTextLayerClick}
>
<Document
file={file}
onLoadSuccess={handleLoadSuccess}
onLoadError={handleLoadError}
loading={
numPages > 0 ? (
// During recompile, show nothing so old pages remain visible
<></>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
Loading PDF...
</div>
)
}
>
{Array.from({ length: numPages }, (_, i) => (
<Page
key={i + 1}
pageNumber={i + 1}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
className="mb-4 shadow-lg"
onLoadSuccess={i === 0 ? handlePageLoadSuccess : undefined}
/>
))}
</Document>
{loading && numPages === 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
Loading PDF...
</div>
)}
{pageSizes.map((size, i) => (
<MupdfPage
key={i}
docId={docIdRef.current}
pageIndex={i}
scale={scale}
pageWidth={size.width}
pageHeight={size.height}
isVisible={visiblePages.has(i + 1)}
/>
))}
</div>
{selRect && (
<div
className="pointer-events-none fixed border-2 border-primary bg-primary/10"
style={selRect}
/>
)}
</div>
);
}

View file

@ -9,6 +9,7 @@ import {
Trash2Icon,
PencilIcon,
UploadIcon,
RefreshCwIcon,
SunIcon,
MoonIcon,
MonitorIcon,
@ -200,6 +201,7 @@ export function Sidebar() {
const moveFile = useDocumentStore((s) => s.moveFile);
const moveFolder = useDocumentStore((s) => s.moveFolder);
const closeProject = useDocumentStore((s) => s.closeProject);
const refreshFiles = useDocumentStore((s) => s.refreshFiles);
const projectRoot = useDocumentStore((s) => s.projectRoot);
const folders = useDocumentStore((s) => s.folders);
const { theme, setTheme } = useTheme();
@ -231,7 +233,6 @@ export function Sidebar() {
if (!filesArea || !el || !filesArea.contains(el)) {
// Not over the sidebar file tree
if (nativeDropTargetRef.current !== null) {
console.log("[native-drop] left sidebar area");
nativeDropTargetRef.current = null;
setNativeDragOver(null);
}
@ -241,14 +242,6 @@ export function Sidebar() {
// Walk up from the hovered element to find the closest drop-folder target
const folderEl = el.closest("[data-drop-folder]") as HTMLElement | null;
const folder = folderEl?.dataset.dropFolder ?? "__root__";
if (folder !== nativeDropTargetRef.current) {
console.log("[native-drop] hover target changed:", {
from: nativeDropTargetRef.current,
to: folder,
position: { logicalX, logicalY },
element: el.tagName,
});
}
nativeDropTargetRef.current = folder;
setNativeDragOver(folder);
} else if (type === "drop") {
@ -260,15 +253,7 @@ export function Sidebar() {
const el = document.elementFromPoint(logicalX, logicalY);
const filesArea = sidebarFilesRef.current;
console.log("[native-drop] drop event:", {
paths,
position: { logicalX, logicalY },
overSidebar: !!(filesArea && el && filesArea.contains(el)),
targetFolder: nativeDropTargetRef.current,
});
if (!filesArea || !el || !filesArea.contains(el)) {
console.log("[native-drop] drop outside sidebar files area, ignoring");
setNativeDragOver(null);
nativeDropTargetRef.current = null;
return;
@ -278,15 +263,12 @@ export function Sidebar() {
? undefined
: (nativeDropTargetRef.current ?? undefined);
console.log("[native-drop] importing files:", { paths, targetFolder });
// Mark as handled so chat-composer doesn't also process it
(window as any).__sidebarHandledDrop = true;
setTimeout(() => { (window as any).__sidebarHandledDrop = false; }, 200);
try {
await importFiles(paths, targetFolder);
console.log("[native-drop] import success");
} catch (err) {
console.error("[native-drop] import failed:", err);
}
@ -294,7 +276,6 @@ export function Sidebar() {
setNativeDragOver(null);
nativeDropTargetRef.current = null;
} else if (type === "leave") {
console.log("[native-drop] drag left window");
setNativeDragOver(null);
nativeDropTargetRef.current = null;
}
@ -321,15 +302,13 @@ export function Sidebar() {
const handleDragStart = useCallback((event: DragStartEvent) => {
const { type, name } = event.active.data.current as { type: "file" | "folder"; name: string };
console.log("[dnd] dragStart:", { id: event.active.id, type, name });
setActiveDrag({ id: event.active.id as string, type, name });
}, []);
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
console.log("[dnd] dragEnd:", { activeId: event.active.id, overId: event.over?.id ?? null });
setActiveDrag(null);
const { active, over } = event;
if (!over) { console.log("[dnd] no drop target"); return; }
if (!over) return;
const draggedPath = active.id as string;
const draggedType = (active.data.current as { type: string }).type;
@ -340,18 +319,16 @@ export function Sidebar() {
const draggedParent = draggedPath.includes("/")
? draggedPath.substring(0, draggedPath.lastIndexOf("/"))
: null;
if (targetFolder === draggedParent) { console.log("[dnd] same parent, skip"); return; }
if (targetFolder === draggedParent) return;
// Don't move folder into itself or descendant
if (draggedType === "folder" && targetFolder) {
if (targetFolder === draggedPath || targetFolder.startsWith(draggedPath + "/")) { console.log("[dnd] folder into self, skip"); return; }
if (targetFolder === draggedPath || targetFolder.startsWith(draggedPath + "/")) return;
}
console.log("[dnd] moving:", { draggedPath, draggedType, targetFolder });
try {
if (draggedType === "file") await moveFile(draggedPath, targetFolder);
else await moveFolder(draggedPath, targetFolder);
console.log("[dnd] move success");
} catch (err) {
console.error("[dnd] move failed:", err);
}
@ -439,7 +416,7 @@ export function Sidebar() {
setNameError("A file or folder with this name already exists");
return;
}
const type = name.endsWith(".tex") || name.endsWith(".ltx") ? "tex" : "tex";
const type = name.endsWith(".tex") || name.endsWith(".ltx") || name.endsWith(".bib") || name.endsWith(".sty") || name.endsWith(".cls") ? "tex" : "image";
createNewFile(name, type, addDialogFolder);
setNewFileName("");
setNameError("");
@ -555,7 +532,16 @@ export function Sidebar() {
<FolderIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium text-xs">Files</span>
</div>
<div className="absolute right-3">
<div className="absolute right-3 flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="size-5"
title="Refresh"
onClick={() => refreshFiles()}
>
<RefreshCwIcon className="size-3" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-5" title="Add">
@ -580,7 +566,7 @@ export function Sidebar() {
</DropdownMenu>
</div>
</div>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragOver={(e) => console.log("[dnd] dragOver:", e.over?.id ?? "none")}>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<ContextMenu>
<ContextMenuTrigger asChild>
<DroppableRoot nativeDragOver={nativeDragOver === "__root__"}>
@ -682,9 +668,9 @@ export function Sidebar() {
</PanelGroup>
{/* Footer */}
<div className="relative flex items-center justify-center border-sidebar-border border-t px-3 py-2 text-muted-foreground text-xs">
<span>ClaudePrism v{APP_VERSION}</span>
<div className="absolute right-3 flex items-center gap-1">
<div className="flex items-center justify-between border-sidebar-border border-t px-3 py-2 text-muted-foreground text-xs">
<span className="truncate">ClaudePrism v{APP_VERSION}</span>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" className="size-6" asChild>
<a
href="https://github.com/delibae/claude-prism"
@ -816,7 +802,6 @@ export function Sidebar() {
function DroppableRoot({ children, nativeDragOver }: { children: React.ReactNode; nativeDragOver?: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
if (isOver) console.log("[dnd] over: __root__");
return (
<div
ref={setNodeRef}
@ -833,7 +818,6 @@ function DroppableRoot({ children, nativeDragOver }: { children: React.ReactNode
function DroppableFolder({ id, children, nativeDragOver }: { id: string; children: React.ReactNode; nativeDragOver?: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id });
if (isOver) console.log("[dnd] over folder:", id);
return (
<div ref={setNodeRef} data-drop-folder={id} className={cn((isOver || nativeDragOver) && "bg-accent/30 rounded-md")}>
{children}
@ -1010,14 +994,11 @@ function DraggableItem({ id, type, name, children }: { id: string; type: "file"
Object.entries(listeners).map(([key, handler]) => [
key,
(e: React.PointerEvent) => {
console.log(`[dnd] ${key} on "${name}" (${type})`, { id, pointerType: e.pointerType, button: e.button });
(handler as (e: React.PointerEvent) => void)(e);
},
]),
) : {};
if (isDragging) console.log("[dnd] dragging:", id);
return (
<div
ref={setNodeRef}

View file

@ -14,6 +14,7 @@ import {
XIcon,
} from "lucide-react";
import { useZoteroStore, type CollectionSyncInfo } from "@/stores/zotero-store";
import { useDocumentStore } from "@/stores/document-store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
@ -40,7 +41,9 @@ export function ZoteroPanel() {
const isValidating = useZoteroStore((s) => s.isValidating);
const isSyncing = useZoteroStore((s) => s.isSyncing);
const syncProgress = useZoteroStore((s) => s.syncProgress);
const syncedCollections = useZoteroStore((s) => s.syncedCollections);
const projectRoot = useDocumentStore((s) => s.projectRoot);
const allSyncedCollections = useZoteroStore((s) => s.syncedCollections);
const syncedCollections = projectRoot ? (allSyncedCollections[projectRoot] ?? {}) : {};
const error = useZoteroStore((s) => s.error);
const collections = useZoteroStore((s) => s.collections);
const isLoadingCollections = useZoteroStore((s) => s.isLoadingCollections);

View file

@ -1,10 +1,12 @@
import { useEffect, useRef } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import {
useClaudeChatStore,
type ClaudeStreamMessage,
} from "@/stores/claude-chat-store";
import { useDocumentStore } from "@/stores/document-store";
import { useHistoryStore } from "@/stores/history-store";
import { useProposedChangesStore } from "@/stores/proposed-changes-store";
import { readTexFileContent } from "@/lib/tauri/fs";
import { compileLatex } from "@/lib/latex-compiler";
@ -12,69 +14,52 @@ import { compileLatex } from "@/lib/latex-compiler";
/**
* Hook that manages Tauri event listeners for Claude CLI streaming output.
*
* Follows the dual-listener pattern from opcode:
* 1. Start with generic `claude-output` listener to catch the first `system:init` message
* 2. Extract session_id from init → switch to `claude-output:{sessionId}` listeners
* 3. On `claude-complete` → clean up listeners
* Listeners are kept alive at all times (no race condition with invoke).
* Session-scoped state (pendingToolUses, hasTexChanges) is reset each time
* isStreaming flips to true.
*/
export function useClaudeEvents() {
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
// Per-session mutable state stored in refs so the long-lived listeners
// always read the latest values without needing to be re-created.
const pendingToolUsesRef = useRef(
new Map<string, { name: string; input: any }>(),
);
const hasTexChangesRef = useRef(false);
const cancelledForAskRef = useRef(false);
const listenersRef = useRef<UnlistenFn[]>([]);
const sessionSpecificListenersRef = useRef<UnlistenFn[]>([]);
const currentSessionIdRef = useRef<string | null>(null);
// Reset per-session state whenever a new stream starts
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
useEffect(() => {
if (!isStreaming) return;
if (isStreaming) {
pendingToolUsesRef.current = new Map();
hasTexChangesRef.current = false;
cancelledForAskRef.current = false;
}
}, [isStreaming]);
currentSessionIdRef.current = null;
// Track tool_use messages for matching with tool_results
const pendingToolUses = new Map<
string,
{ name: string; input: any }
>();
// Track whether any LaTeX-related files were modified during this session
let hasTexChanges = false;
// Register a proposed change instead of immediately reloading the file
// ── One-time listener setup (mount only) ──
useEffect(() => {
async function registerProposedChange(
filePath: string,
toolUseId: string,
toolName: string
toolName: string,
) {
console.log("[proposed-change] registerProposedChange called:", { filePath, toolUseId, toolName });
const docState = useDocumentStore.getState();
const projectRoot = docState.projectRoot;
// Normalize path: Claude may report absolute or relative
let relativePath = filePath;
if (projectRoot && filePath.startsWith(projectRoot)) {
relativePath = filePath.slice(projectRoot.length).replace(/^\//, "");
}
console.log("[proposed-change] normalized:", { relativePath, projectRoot, fileCount: docState.files.length });
const file = docState.files.find(
(f) => f.relativePath === relativePath || f.absolutePath === filePath
(f) => f.relativePath === relativePath || f.absolutePath === filePath,
);
if (!file) {
console.warn("[proposed-change] file not found in store, paths:", docState.files.map(f => f.relativePath));
return;
}
if (!file) return;
// oldContent: use the document store's content (the pre-edit baseline).
// addChange() will automatically preserve the original baseline if there's
// already a pending change for this file (stacked edits).
const oldContent = file.content ?? "";
try {
const newContent = await readTexFileContent(file.absolutePath);
console.log("[proposed-change] content comparison:", {
oldLen: oldContent.length,
newLen: newContent.length,
same: oldContent === newContent,
});
if (oldContent !== newContent) {
// addChange handles merging: if a change already exists for this file,
// it keeps the original oldContent and updates newContent + id atomically
useProposedChangesStore.getState().addChange({
id: toolUseId,
filePath: file.relativePath,
@ -83,15 +68,21 @@ export function useClaudeEvents() {
newContent,
toolName,
});
console.log("[proposed-change] added/merged change for:", file.relativePath);
} else {
console.log("[proposed-change] content identical, skipping");
}
} catch (err) {
console.error("[proposed-change] readTexFileContent failed:", err);
} catch {
// readTexFileContent failed — not critical
}
}
let msgCount = 0;
let streamStartTime = 0;
let lastMsgTime = 0;
function elapsed() {
if (!streamStartTime) return "";
return `+${((performance.now() - streamStartTime) / 1000).toFixed(1)}s`;
}
function handleStreamMessage(payload: string) {
let msg: ClaudeStreamMessage;
try {
@ -100,33 +91,71 @@ export function useClaudeEvents() {
return;
}
console.log(
`[claude-stream] type=${msg.type} subtype=${msg.subtype ?? ""} ` +
`contentTypes=[${msg.message?.content?.map((b) => b.type).join(",") ?? "none"}] ` +
`texts=[${msg.message?.content?.filter((b) => b.type === "text").map((b) => `"${(b.text ?? "").slice(0, 50)}"`).join(",") ?? ""}] ` +
`result=${msg.result ? `"${msg.result.slice(0, 80)}"` : "none"}`
);
const chatStore = useClaudeChatStore.getState();
// Only process messages while streaming
if (!chatStore.isStreaming) return;
msgCount++;
const now = performance.now();
if (msgCount === 1) streamStartTime = now;
const gap = lastMsgTime ? ((now - lastMsgTime) / 1000).toFixed(1) : "0";
lastMsgTime = now;
// Log ALL message types with gap detection
const contentTypes = msg.message?.content?.map((b: any) => b.type).join(",") ?? "";
const gapWarning = Number(gap) > 10 ? ` ⚠️ GAP ${gap}s` : "";
console.log(`[claude-event] ${elapsed()} #${msgCount} type=${msg.type} sub=${msg.subtype ?? ""} content=[${contentTypes}] gap=${gap}s${gapWarning}`);
if (msg.type === "assistant") {
const thinkingBlock = msg.message?.content?.find((b: any) => b.type === "thinking");
if (thinkingBlock) {
console.log(`[claude-event] ${elapsed()} 🧠 thinking: ${(thinkingBlock.thinking || "").slice(0, 100)}`);
}
const textBlock = msg.message?.content?.find((b: any) => b.type === "text");
if (textBlock?.text) {
console.log(`[claude-event] ${elapsed()} 💬 text: ${textBlock.text.slice(0, 100)}`);
}
const toolBlock = msg.message?.content?.find((b: any) => b.type === "tool_use");
if (toolBlock) {
console.log(`[claude-event] ${elapsed()} 🔧 tool_use: ${toolBlock.name} ${toolBlock.input?.file_path ?? ""}`);
}
}
if (msg.type === "user" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "tool_result") {
const preview = typeof block.content === "string" ? block.content.slice(0, 80) : JSON.stringify(block.content)?.slice(0, 80);
console.log(`[claude-event] ${elapsed()} 📋 tool_result: id=${block.tool_use_id} err=${block.is_error ?? false} len=${preview?.length ?? 0}`);
}
}
}
if (msg.type === "result") {
console.log(`[claude-event] ${elapsed()} ✅ result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`);
}
// Extract session_id from system:init
if (
msg.type === "system" &&
msg.subtype === "init" &&
msg.session_id
) {
currentSessionIdRef.current = msg.session_id;
if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
chatStore._setSessionId(msg.session_id);
// Attach session-specific listeners
attachSessionListeners(msg.session_id);
}
// Detect rate limit events and surface to user — never append to messages
if ((msg as any).type === "rate_limit_event") {
const info = (msg as any).rate_limit_info;
if (info) {
const resetsAt = info.resetsAt ? new Date(info.resetsAt * 1000).toLocaleTimeString() : "unknown";
console.warn(`[claude-event] 🚦 rate_limit: status=${info.status} type=${info.rateLimitType} resets=${resetsAt} overage=${info.overageStatus}`);
if (info.status !== "allowed") {
chatStore._setError(`Rate limited (${info.rateLimitType}). Resets at ${resetsAt}`);
}
}
return; // rate_limit_event is informational — do not append to messages
}
// Track tool_use blocks for file change detection
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "tool_use" && block.id && block.name) {
console.log("[claude-events] tracked tool_use:", { id: block.id, name: block.name, input_keys: block.input ? Object.keys(block.input) : [] });
pendingToolUses.set(block.id, {
pendingToolUsesRef.current.set(block.id, {
name: block.name,
input: block.input,
});
@ -138,32 +167,20 @@ export function useClaudeEvents() {
if (msg.type === "user" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "tool_result" && block.tool_use_id) {
const toolUse = pendingToolUses.get(block.tool_use_id);
console.log("[claude-events] tool_result:", {
tool_use_id: block.tool_use_id,
is_error: block.is_error,
toolUse: toolUse ? { name: toolUse.name, file_path: toolUse.input?.file_path } : "NOT_FOUND",
});
const toolUse = pendingToolUsesRef.current.get(
block.tool_use_id,
);
if (
toolUse &&
!block.is_error &&
(toolUse.name === "Write" ||
toolUse.name === "write" ||
toolUse.name === "Edit" ||
toolUse.name === "edit" ||
toolUse.name === "MultiEdit" ||
toolUse.name === "multiedit")
/^(Write|write|Edit|edit|MultiEdit|multiedit)$/.test(toolUse.name)
) {
const filePath =
toolUse.input?.file_path || toolUse.input?.path;
if (filePath) {
registerProposedChange(filePath, block.tool_use_id!, toolUse.name);
// Track if any LaTeX-related files were modified
if (/\.(tex|bib|sty|cls|dtx)$/i.test(filePath)) {
hasTexChanges = true;
const fp = toolUse.input?.file_path || toolUse.input?.path;
if (fp) {
registerProposedChange(fp, block.tool_use_id!, toolUse.name);
if (/\.(tex|bib|sty|cls|dtx)$/i.test(fp)) {
hasTexChangesRef.current = true;
}
} else {
console.warn("[claude-events] no file_path in tool input:", toolUse.input);
}
}
}
@ -176,27 +193,66 @@ export function useClaudeEvents() {
msg.message?.content?.length === 1 &&
msg.message.content[0].type === "text"
) {
// This is Claude echoing back the user prompt — skip it
return;
}
chatStore._appendMessage(msg);
// When AskUserQuestion is detected, cancel the process so the user
// can interact with the widget before Claude continues.
if (msg.type === "assistant" && msg.message?.content) {
const hasAskUser = msg.message.content.some(
(b: any) => b.type === "tool_use" && b.name === "AskUserQuestion",
);
if (hasAskUser) {
console.log(`[claude-event] ${elapsed()} ⏸️ AskUserQuestion detected — cancelling process for user input`);
cancelledForAskRef.current = true;
invoke("cancel_claude_execution").catch(() => {});
}
}
}
async function handleComplete(payload: boolean) {
useClaudeChatStore.getState()._setStreaming(false);
// Refresh file tree to pick up any files created/deleted during the session
async function handleComplete(success: boolean) {
console.log(`[claude-event] ${elapsed()} 🏁 complete success=${success} (${msgCount} messages) cancelledForAsk=${cancelledForAskRef.current}`);
const chatStore = useClaudeChatStore.getState();
// Guard against duplicate complete events
if (!chatStore.isStreaming) {
console.warn(`[claude-event] ⚠️ ignoring duplicate complete event (not streaming)`);
return;
}
if (!success && msgCount > 0 && !chatStore.error && !cancelledForAskRef.current) {
// Process failed but we received some messages — likely rate limit or API error
chatStore._setError("Claude process exited unexpectedly. This may be due to rate limiting or an API error.");
}
msgCount = 0;
streamStartTime = 0;
lastMsgTime = 0;
chatStore._setStreaming(false);
// Snapshot after Claude edit — awaited so the history store updates before UI refreshes
const projectPath = useDocumentStore.getState().projectRoot;
if (projectPath) {
try {
await useHistoryStore.getState().createSnapshot(projectPath, "[claude] After Claude edit");
} catch {
// snapshot failure should not break the flow
}
}
const docStore = useDocumentStore.getState();
await docStore.refreshFiles();
// Auto-recompile if any LaTeX-related files were modified
if (hasTexChanges && docStore.projectRoot) {
const { projectRoot, files } = useDocumentStore.getState();
if (projectRoot) {
const mainFile = files.find(
(f) => f.name === "document.tex" || f.name === "main.tex",
);
const mainFileName = mainFile?.relativePath || "document.tex";
// Auto-recompile after Claude finishes — always attempt if a tex file exists.
// Skip if another compilation is already in progress (e.g. initial compile).
const { projectRoot, files, isCompiling: alreadyCompiling } = useDocumentStore.getState();
if (projectRoot && !alreadyCompiling) {
const mainFile = files.find(
(f) => f.name === "document.tex" || f.name === "main.tex",
);
if (mainFile) {
const mainFileName = mainFile.relativePath;
useDocumentStore.getState().setIsCompiling(true);
try {
const pdfData = await compileLatex(projectRoot, mainFileName);
@ -209,75 +265,38 @@ export function useClaudeEvents() {
useDocumentStore.getState().setIsCompiling(false);
}
}
} else if (alreadyCompiling) {
console.log(`[claude-event] skipping post-Claude recompile — already compiling`);
}
cleanupAll();
}
function handleError(payload: string) {
// Stderr lines — could be warnings, not necessarily errors
console.warn("[claude-stderr]", payload);
}
async function attachSessionListeners(sessionId: string) {
// Add session-specific listeners
const unlistenOutput = await listen<string>(
`claude-output:${sessionId}`,
(event) => {
// Session-specific messages are handled here
// Generic listener may have already processed the init message,
// but subsequent messages come through both — we only process once.
// Since we set up session listeners after init, the generic listener
// handles the first few messages and session-specific handles the rest.
}
);
const unlistenComplete = await listen<boolean>(
`claude-complete:${sessionId}`,
(event) => handleComplete(event.payload)
);
const unlistenError = await listen<string>(
`claude-error:${sessionId}`,
(event) => handleError(event.payload)
);
sessionSpecificListenersRef.current.push(
unlistenOutput,
unlistenComplete,
unlistenError
);
}
function cleanupAll() {
for (const unlisten of listenersRef.current) {
unlisten();
}
listenersRef.current = [];
for (const unlisten of sessionSpecificListenersRef.current) {
unlisten();
}
sessionSpecificListenersRef.current = [];
}
// Set up generic listeners
// Set up listeners once and keep them alive for the component lifetime
let cancelled = false;
(async () => {
const unlistenOutput = await listen<string>(
"claude-output",
(event) => {
if (!cancelled) handleStreamMessage(event.payload);
}
},
);
const unlistenComplete = await listen<boolean>(
"claude-complete",
(event) => {
if (!cancelled) handleComplete(event.payload);
}
},
);
const unlistenError = await listen<string>(
"claude-error",
(event) => {
if (!cancelled) handleError(event.payload);
}
if (!cancelled) {
console.warn(`[claude-stderr] ${elapsed()}`, event.payload);
// Surface critical errors to the user
const payload = event.payload;
if (payload.includes("Error") || payload.includes("error") || payload.includes("ECONNREFUSED") || payload.includes("timeout")) {
console.error(`[claude-stderr] ⚠️ CRITICAL: ${payload}`);
}
}
},
);
if (cancelled) {
@ -287,11 +306,7 @@ export function useClaudeEvents() {
return;
}
listenersRef.current.push(
unlistenOutput,
unlistenComplete,
unlistenError
);
listenersRef.current = [unlistenOutput, unlistenComplete, unlistenError];
})();
return () => {
@ -300,10 +315,6 @@ export function useClaudeEvents() {
unlisten();
}
listenersRef.current = [];
for (const unlisten of sessionSpecificListenersRef.current) {
unlisten();
}
sessionSpecificListenersRef.current = [];
};
}, [isStreaming]);
}, []); // mount-only — no dependency on isStreaming
}

View file

@ -14,10 +14,16 @@ export function useKeyboardShortcuts() {
});
}
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault();
invoke("create_new_window").catch(console.error);
}
// Cmd+X (macOS) / Ctrl+X (others): Capture & Ask
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "x" && !e.shiftKey && !e.altKey) {
e.preventDefault();
window.dispatchEvent(new CustomEvent("toggle-capture-mode"));
}
};
window.addEventListener("keydown", handleKeyDown);

View file

@ -1,35 +1,18 @@
export interface CompileResource {
path: string;
content?: string;
file?: string;
main?: boolean;
encoding?: string;
}
const SIDECAR_URL = "http://localhost:3001";
import { invoke } from "@tauri-apps/api/core";
export async function compileLatex(
projectDir: string,
mainFile: string = "document.tex",
compiler?: string,
): Promise<Uint8Array> {
const response = await fetch(`${SIDECAR_URL}/builds/sync`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ projectDir, mainFile }),
// compile_latex returns raw PDF bytes via Tauri IPC Response
const buffer = await invoke<ArrayBuffer>("compile_latex", {
projectDir,
mainFile,
compiler: compiler ?? null,
});
if (!response.ok) {
const data = await response.json();
const message = data.details
? `${data.error}\n\n${data.details}`
: data.error || "Compilation failed";
throw new Error(message);
}
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
return new Uint8Array(buffer);
}
export interface SynctexResult {
@ -45,37 +28,13 @@ export async function synctexEdit(
y: number,
): Promise<SynctexResult | null> {
try {
const response = await fetch(`${SIDECAR_URL}/synctex/edit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectDir, page, x, y }),
return await invoke<SynctexResult>("synctex_edit", {
projectDir,
page,
x,
y,
});
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
export async function compileLatexWithResources(
resources: CompileResource[],
): Promise<Uint8Array> {
const response = await fetch(`${SIDECAR_URL}/builds/sync`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ compiler: "pdflatex", resources }),
});
if (!response.ok) {
const data = await response.json();
const message = data.details
? `${data.error}\n\n${data.details}`
: data.error || "Compilation failed";
throw new Error(message);
}
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
}

View file

@ -0,0 +1,98 @@
import type { StructuredTextData, LinkData, PageSize, WorkerResponse } from "./types";
export interface MupdfClient {
openDocument(buffer: ArrayBuffer, magic?: string): Promise<number>;
closeDocument(docId: number): Promise<void>;
countPages(docId: number): Promise<number>;
getPageSize(docId: number, pageIndex: number): Promise<PageSize>;
drawPage(docId: number, pageIndex: number, dpi: number): Promise<ImageData>;
getPageText(docId: number, pageIndex: number): Promise<StructuredTextData>;
getPageLinks(docId: number, pageIndex: number): Promise<LinkData[]>;
renderThumbnail(docId: number, pageIndex: number, targetWidth: number): Promise<ArrayBuffer>;
destroy(): void;
}
type PendingRequest = {
resolve: (value: any) => void;
reject: (error: Error) => void;
};
function createClient(): MupdfClient {
const worker = new Worker(
new URL("./mupdf-worker.ts", import.meta.url),
{ type: "module" },
);
const pending = new Map<number, PendingRequest>();
let nextId = 1;
let ready: Promise<void>;
let resolveReady: () => void;
ready = new Promise((resolve) => {
resolveReady = resolve;
});
worker.onmessage = (event: MessageEvent) => {
const data = event.data as WorkerResponse;
const [type, id, payload] = data;
if (type === "INIT") {
resolveReady();
return;
}
const request = pending.get(id);
if (!request) return;
pending.delete(id);
if (type === "RESULT") {
request.resolve(payload);
} else if (type === "ERROR") {
const err = payload as { name: string; message: string };
request.reject(new Error(`${err.name}: ${err.message}`));
}
};
worker.onerror = (event) => {
console.error("[mupdf-worker] error:", event);
};
function call(method: string, ...args: unknown[]): Promise<any> {
return ready.then(() => {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
const transferables: Transferable[] = [];
for (const arg of args) {
if (arg instanceof ArrayBuffer) {
transferables.push(arg);
}
}
worker.postMessage([method, id, args], { transfer: transferables });
});
});
}
return {
openDocument: (buffer, magic = "application/pdf") => call("openDocument", buffer, magic),
closeDocument: (docId) => call("closeDocument", docId),
countPages: (docId) => call("countPages", docId),
getPageSize: (docId, pageIndex) => call("getPageSize", docId, pageIndex),
drawPage: (docId, pageIndex, dpi) => call("drawPage", docId, pageIndex, dpi),
getPageText: (docId, pageIndex) => call("getPageText", docId, pageIndex),
getPageLinks: (docId, pageIndex) => call("getPageLinks", docId, pageIndex),
renderThumbnail: (docId, pageIndex, targetWidth) => call("renderThumbnail", docId, pageIndex, targetWidth),
destroy: () => worker.terminate(),
};
}
let instance: MupdfClient | null = null;
export function getMupdfClient(): MupdfClient {
if (!instance) {
instance = createClient();
}
return instance;
}

View file

@ -0,0 +1,181 @@
import * as mupdf from "mupdf";
const documentMap = new Map<number, mupdf.PDFDocument>();
let nextDocId = 1;
const methods: Record<string, (...args: any[]) => any> = {};
methods.openDocument = (buffer: ArrayBuffer, magic: string): number => {
const docId = nextDocId++;
const doc = mupdf.Document.openDocument(buffer, magic) as mupdf.PDFDocument;
documentMap.set(docId, doc);
return docId;
};
methods.closeDocument = (docId: number): void => {
const doc = documentMap.get(docId);
if (doc) {
documentMap.delete(docId);
}
};
methods.countPages = (docId: number): number => {
const doc = documentMap.get(docId)!;
return doc.countPages();
};
methods.getPageSize = (docId: number, pageIndex: number): { width: number; height: number } => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const bounds = page.getBounds();
return {
width: bounds[2] - bounds[0],
height: bounds[3] - bounds[1],
};
};
methods.drawPage = (docId: number, pageIndex: number, dpi: number): ImageData => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const scale = dpi / 72;
const matrix = mupdf.Matrix.scale(scale, scale);
// alpha=false so the PDF's white background is rendered opaquely (RGB, 3 bytes/pixel)
const pixmap = page.toPixmap(matrix, mupdf.ColorSpace.DeviceRGB, false, true);
const w = pixmap.getWidth();
const h = pixmap.getHeight();
const rgb = pixmap.getPixels();
pixmap.destroy();
// Convert RGB (3 bytes/pixel) → RGBA (4 bytes/pixel) for ImageData
const rgba = new Uint8ClampedArray(w * h * 4);
for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) {
rgba[j] = rgb[i];
rgba[j + 1] = rgb[i + 1];
rgba[j + 2] = rgb[i + 2];
rgba[j + 3] = 255; // fully opaque
}
return new ImageData(rgba, w, h);
};
methods.getPageText = (docId: number, pageIndex: number): unknown => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const stext = page.toStructuredText("preserve-whitespace");
const raw = JSON.parse(stext.asJSON());
// Transform mupdf's nested spans format to our flat line format
const blocks = (raw.blocks || []).map((block: any) => {
if (block.type !== "text") return block;
return {
type: "text",
bbox: block.bbox,
lines: (block.lines || []).map((line: any) => {
let text = "";
let font = { name: "", family: "", size: 12, weight: "normal", style: "normal" };
let baselineY = 0;
const spans = line.spans || [];
if (spans.length > 0) {
text = spans
.map((span: any) => (span.chars || []).map((ch: any) => ch.c).join(""))
.join("");
const firstSpan = spans[0];
font = {
name: firstSpan.font?.name || "",
family: firstSpan.font?.family || "",
size: firstSpan.size || 12,
weight: firstSpan.font?.weight || "normal",
style: firstSpan.font?.style || "normal",
};
// Use first char origin as baseline
if (firstSpan.chars?.[0]?.origin) {
baselineY = firstSpan.chars[0].origin.y;
} else {
baselineY = (line.bbox?.y || 0) + (line.bbox?.h || 0);
}
}
return {
bbox: line.bbox || { x: 0, y: 0, w: 0, h: 0 },
wmode: line.wmode || 0,
x: line.bbox?.x || 0,
y: baselineY,
text,
font,
};
}),
};
});
return { blocks };
};
methods.getPageLinks = (docId: number, pageIndex: number): unknown[] => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const links = page.getLinks();
return links.map((link: any) => {
const bounds = link.getBounds();
const uri: string = link.getURI() || "";
const isExternal: boolean = link.isExternal?.() ?? uri.startsWith("http");
let href: string;
if (isExternal) {
href = uri;
} else {
try {
const resolved = doc.resolveLink(uri) as any;
if (typeof resolved === "number") {
href = `#page=${resolved + 1}`;
} else if (resolved && typeof resolved.page === "number") {
href = `#page=${resolved.page + 1}`;
} else {
href = uri;
}
} catch {
href = uri;
}
}
return {
x: bounds[0],
y: bounds[1],
w: bounds[2] - bounds[0],
h: bounds[3] - bounds[1],
href,
isExternal,
};
});
};
methods.renderThumbnail = (docId: number, pageIndex: number, targetWidth: number): ArrayBuffer => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const bounds = page.getBounds();
const pageWidth = bounds[2] - bounds[0];
const retinaScale = 2;
const scale = (targetWidth * retinaScale) / pageWidth;
const matrix = mupdf.Matrix.scale(scale, scale);
const pixmap = page.toPixmap(matrix, mupdf.ColorSpace.DeviceRGB, false, true);
const png = pixmap.asPNG();
pixmap.destroy();
return png.buffer as ArrayBuffer;
};
// RPC message handler
onmessage = (event: MessageEvent) => {
const [func, id, args] = event.data as [string, number, unknown[]];
try {
const result = methods[func](...args);
if (result instanceof ImageData) {
postMessage(["RESULT", id, result], { transfer: [result.data.buffer] });
} else if (result instanceof ArrayBuffer) {
postMessage(["RESULT", id, result], { transfer: [result] });
} else {
postMessage(["RESULT", id, result]);
}
} catch (error: any) {
postMessage(["ERROR", id, { name: error.name, message: error.message }]);
}
};
postMessage(["INIT", 0, Object.keys(methods)]);

View file

@ -0,0 +1,63 @@
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
export interface FontInfo {
family: string;
size: number;
weight: string;
style: string;
}
export interface StructuredTextChar {
c: string;
quad: number[];
origin: { x: number; y: number };
}
export interface StructuredTextSpan {
font: FontInfo;
chars: StructuredTextChar[];
}
export interface StructuredTextLine {
bbox: Rect;
wmode: number;
x: number;
y: number;
text: string;
font: { name: string; family: string; size: number; weight: string; style: string };
}
export interface StructuredTextBlock {
type: "text";
bbox: Rect;
lines: StructuredTextLine[];
}
export interface StructuredTextData {
blocks: StructuredTextBlock[];
}
export interface LinkData {
x: number;
y: number;
w: number;
h: number;
href: string;
isExternal: boolean;
}
export interface PageSize {
width: number;
height: number;
}
export type WorkerRequest = [string, number, unknown[]];
export type WorkerResponse =
| ["RESULT", number, unknown]
| ["ERROR", number, { name: string; message: string }]
| ["INIT", number, string[]];

View file

@ -169,7 +169,8 @@ export async function createFileOnDisk(
): Promise<string> {
const fullPath = await join(rootPath, name);
// Ensure parent directory exists
const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/"));
const lastSep = Math.max(fullPath.lastIndexOf("/"), fullPath.lastIndexOf("\\"));
const parentDir = lastSep > 0 ? fullPath.substring(0, lastSep) : "";
if (parentDir && !(await exists(parentDir))) {
await mkdir(parentDir, { recursive: true });
}
@ -213,7 +214,7 @@ export async function copyFileToProject(
const uniqueName = await getUniqueTargetName(rootPath, targetName);
const fullPath = await join(rootPath, uniqueName);
// Ensure parent directory exists (e.g., attachments/)
const lastSlash = fullPath.lastIndexOf("/");
const lastSlash = Math.max(fullPath.lastIndexOf("/"), fullPath.lastIndexOf("\\"));
if (lastSlash > 0) {
const parentDir = fullPath.substring(0, lastSlash);
if (!(await exists(parentDir))) {

View file

@ -0,0 +1,70 @@
// ─── Static Template Preview Cache ───
// Loads pre-rendered PDFs from /examples/{id}/main.pdf (generated by scripts/generate-previews.ts).
// No runtime LaTeX compilation needed.
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
type Listener = () => void;
const listeners = new Set<Listener>();
const thumbnailCache = new Map<string, string>();
const pendingRequests = new Map<string, Promise<string | null>>();
function notify() {
for (const fn of listeners) fn();
}
export function subscribeThumbnails(fn: Listener): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
export function getThumbnail(templateId: string): string | undefined {
return thumbnailCache.get(templateId);
}
/** URL to the pre-rendered static PDF for a template */
export function getTemplatePdfUrl(templateId: string): string {
return `/examples/${templateId}/main.pdf`;
}
/**
* Load the static PDF and render page 1 as a thumbnail data URL.
*/
export async function generateThumbnail(templateId: string): Promise<string | null> {
const cached = thumbnailCache.get(templateId);
if (cached) return cached;
const pending = pendingRequests.get(templateId);
if (pending) return pending;
const promise = (async (): Promise<string | null> => {
try {
const url = getTemplatePdfUrl(templateId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = await response.arrayBuffer();
const client = getMupdfClient();
const docId = await client.openDocument(buffer);
const targetWidth = 300;
const pngBuffer = await client.renderThumbnail(docId, 0, targetWidth);
await client.closeDocument(docId);
const blob = new Blob([pngBuffer], { type: "image/png" });
const dataUrl = URL.createObjectURL(blob);
thumbnailCache.set(templateId, dataUrl);
notify();
return dataUrl;
} catch (err) {
console.warn(`[template-preview] Failed to load preview for ${templateId}:`, err);
return null;
} finally {
pendingRequests.delete(templateId);
}
})();
pendingRequests.set(templateId, promise);
return promise;
}

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,12 @@ import { App } from "./App";
import { getCurrentWindow } from "@tauri-apps/api/window";
import "./styles/globals.css";
// On non-macOS platforms, reset titlebar height (no overlay titlebar)
if (!navigator.userAgent.includes("Macintosh")) {
document.documentElement.style.setProperty("--titlebar-height", "0px");
document.documentElement.style.setProperty("--traffic-light-width", "0px");
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App

View file

@ -16,7 +16,7 @@ export function offsetToLineCol(
// ─── Types ───
export interface ContentBlock {
type: "text" | "tool_use" | "tool_result";
type: "text" | "tool_use" | "tool_result" | "thinking";
// text block
text?: string;
// tool_use block
@ -27,6 +27,9 @@ export interface ContentBlock {
tool_use_id?: string;
content?: any;
is_error?: boolean;
// thinking block
thinking?: string;
signature?: string;
}
export interface ClaudeStreamMessage {
@ -62,6 +65,19 @@ interface ClaudeChatState {
setPendingInitialPrompt: (prompt: string | null) => void;
consumePendingInitialPrompt: () => string | null;
/** Pending attachments from external sources (e.g. PDF capture) */
pendingAttachments: { label: string; filePath: string; selectedText: string; imageDataUrl?: string }[];
addPendingAttachment: (attachment: { label: string; filePath: string; selectedText: string; imageDataUrl?: string }) => void;
consumePendingAttachments: () => { label: string; filePath: string; selectedText: string; imageDataUrl?: string }[];
/** Currently selected model (passed per-prompt to Claude CLI) */
selectedModel: "sonnet" | "opus" | "haiku" | "opusplan";
setSelectedModel: (model: "sonnet" | "opus" | "haiku" | "opusplan") => void;
/** Effort level for Opus 4.6 adaptive reasoning */
effortLevel: "low" | "medium" | "high";
setEffortLevel: (level: "low" | "medium" | "high") => void;
// Actions
sendPrompt: (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => Promise<void>;
cancelExecution: () => Promise<void>;
@ -86,6 +102,12 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
totalInputTokens: 0,
totalOutputTokens: 0,
selectedModel: "opus",
setSelectedModel: (model) => set({ selectedModel: model }),
effortLevel: "medium",
setEffortLevel: (level) => set({ effortLevel: level }),
pendingInitialPrompt: null,
setPendingInitialPrompt: (prompt) => set({ pendingInitialPrompt: prompt }),
consumePendingInitialPrompt: () => {
@ -96,10 +118,27 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
return pendingInitialPrompt;
},
pendingAttachments: [],
addPendingAttachment: (attachment) => {
set((state) => ({
pendingAttachments: [...state.pendingAttachments, attachment],
}));
},
consumePendingAttachments: () => {
const { pendingAttachments } = get();
if (pendingAttachments.length > 0) {
set({ pendingAttachments: [] });
}
return pendingAttachments;
},
sendPrompt: async (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => {
const { sessionId, isStreaming } = get();
const { sessionId, isStreaming, selectedModel, effortLevel } = get();
if (isStreaming) return;
console.time("[claude] total-send");
console.log("[claude] sendPrompt start", { sessionId: !!sessionId, hasContext: !!contextOverride });
const docState = useDocumentStore.getState();
const projectPath = docState.projectRoot;
if (!projectPath) {
@ -142,13 +181,17 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
// Flush unsaved edits to disk so Claude reads the latest content
if (docState.files.some((f) => f.isDirty)) {
console.timeLog("[claude] total-send", "saving dirty files...");
await docState.saveAllFiles();
console.timeLog("[claude] total-send", "saveAllFiles done");
}
// Snapshot before Claude edit
if (projectPath) {
try {
console.timeLog("[claude] total-send", "creating snapshot...");
await useHistoryStore.getState().createSnapshot(projectPath, "[claude] Before Claude edit");
console.timeLog("[claude] total-send", "snapshot done");
} catch { /* snapshot failure should not block Claude */ }
}
@ -173,7 +216,8 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
}
prompt = `${ctx}\n\n${userPrompt}`;
}
const model = "sonnet";
console.timeLog("[claude] total-send", "invoking CLI...");
console.log("[claude] prompt length:", prompt.length, "chars | mode:", sessionId ? "resume" : "new");
try {
if (sessionId) {
@ -182,17 +226,22 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
projectPath,
sessionId,
prompt,
model,
model: selectedModel,
effortLevel,
});
} else {
// New session
await invoke("execute_claude_code", {
projectPath,
prompt,
model,
model: selectedModel,
effortLevel,
});
}
console.timeEnd("[claude] total-send");
} catch (err: any) {
console.timeEnd("[claude] total-send");
console.error("[claude] invoke failed:", err);
set({
isStreaming: false,
error: err?.message || String(err),
@ -231,8 +280,6 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
resumeSession: async (sessionId: string) => {
const projectPath = useDocumentStore.getState().projectRoot;
console.log("[store] resumeSession called:", { sessionId, projectPath });
// Reset state with new session ID
set({
messages: [],
@ -246,12 +293,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
// Load session history from JSONL file
if (projectPath) {
try {
console.log("[store] loading session history...");
const history = await invoke<any[]>("load_session_history", {
projectPath,
sessionId,
});
console.log("[store] received history entries:", history.length);
// Filter to displayable message types and map to ClaudeStreamMessage
const messages: ClaudeStreamMessage[] = [];
@ -262,13 +307,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
}
}
console.log("[store] filtered to displayable messages:", messages.length);
set({ messages });
} catch (err) {
console.error("[store] Failed to load session history:", err);
} catch {
// Failed to load session history
}
} else {
console.warn("[store] no projectPath, skipping history load");
}
},
@ -296,13 +338,6 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
_setStreaming: (streaming: boolean) => {
set({ isStreaming: streaming });
// After Claude finishes, snapshot the result
if (!streaming) {
const projectPath = useDocumentStore.getState().projectRoot;
if (projectPath) {
useHistoryStore.getState().createSnapshot(projectPath, "[claude] After Claude edit").catch(() => {});
}
}
},
_setError: (error: string | null) => {

View file

@ -0,0 +1,280 @@
import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
// ─── Types ───
interface ClaudeStatus {
installed: boolean;
authenticated: boolean;
binary_path: string | null;
version: string | null;
account_email: string | null;
}
type SetupStatus =
| "checking"
| "not-installed"
| "not-authenticated"
| "ready"
| "error";
type StepStatus = "pending" | "active" | "complete" | "error";
export interface StepInfo {
id: string;
label: string;
status: StepStatus;
}
interface ClaudeSetupState {
status: SetupStatus;
isInstalling: boolean;
isLoggingIn: boolean;
error: string | null;
version: string | null;
accountEmail: string | null;
// Install progress
installSteps: StepInfo[];
installLogs: string[];
installLogsVisible: boolean;
// Login progress
loginSteps: StepInfo[];
// Actions
checkStatus: () => Promise<void>;
install: () => Promise<void>;
login: () => Promise<void>;
toggleInstallLogs: () => void;
// Internal helpers
_appendInstallLog: (line: string) => void;
_advanceInstallStep: (stepId: string) => void;
_failCurrentStep: (error: string) => void;
_advanceLoginStep: (stepId: string) => void;
_failCurrentLoginStep: (error: string) => void;
_finishInstall: (success: boolean) => void;
_finishLogin: (success: boolean) => void;
}
// ─── Constants ───
const INSTALL_STEPS: StepInfo[] = [
{ id: "downloading", label: "Downloading Claude Code", status: "pending" },
{ id: "installing", label: "Installing CLI", status: "pending" },
{ id: "verifying", label: "Verifying installation", status: "pending" },
{ id: "complete", label: "Ready to use", status: "pending" },
];
const LOGIN_STEPS: StepInfo[] = [
{ id: "opening-browser", label: "Opening browser", status: "pending" },
{ id: "waiting-auth", label: "Waiting for sign-in", status: "pending" },
{ id: "complete", label: "Authenticated", status: "pending" },
];
const STEP_ORDER_INSTALL = ["downloading", "installing", "verifying", "complete"];
const STEP_ORDER_LOGIN = ["opening-browser", "waiting-auth", "complete"];
function advanceSteps(steps: StepInfo[], targetId: string, order: string[]): StepInfo[] {
const targetIdx = order.indexOf(targetId);
return steps.map((s) => {
const thisIdx = order.indexOf(s.id);
if (thisIdx < targetIdx && s.status !== "error") {
return { ...s, status: "complete" as const };
}
if (s.id === targetId) {
return { ...s, status: "active" as const };
}
return s;
});
}
// ─── Store ───
export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
status: "checking",
isInstalling: false,
isLoggingIn: false,
error: null,
version: null,
accountEmail: null,
installSteps: [],
installLogs: [],
installLogsVisible: false,
loginSteps: [],
checkStatus: async () => {
set({ status: "checking", error: null });
try {
const result = await invoke<ClaudeStatus>("check_claude_status");
if (!result.installed) {
set({ status: "not-installed", version: null, accountEmail: null });
return;
}
if (!result.authenticated) {
set({
status: "not-authenticated",
version: result.version,
accountEmail: null,
});
return;
}
set({
status: "ready",
version: result.version,
accountEmail: result.account_email,
});
} catch (err: any) {
set({
status: "error",
error: err?.message || String(err),
});
}
},
install: async () => {
const initialSteps = INSTALL_STEPS.map((s, i) => ({
...s,
status: (i === 0 ? "active" : "pending") as StepStatus,
}));
set({
isInstalling: true,
error: null,
installSteps: initialSteps,
installLogs: [],
installLogsVisible: false,
});
try {
// Fire-and-forget — events drive the rest
await invoke("install_claude_cli");
} catch (err: any) {
set({
isInstalling: false,
status: "error",
error: err?.message || String(err),
});
}
},
login: async () => {
const initialSteps = LOGIN_STEPS.map((s, i) => ({
...s,
status: (i === 0 ? "active" : "pending") as StepStatus,
}));
set({
isLoggingIn: true,
error: null,
loginSteps: initialSteps,
});
try {
await invoke("login_claude");
} catch (err: any) {
set({
isLoggingIn: false,
status: "error",
error: err?.message || String(err),
});
}
},
toggleInstallLogs: () => {
set((state) => ({ installLogsVisible: !state.installLogsVisible }));
},
_appendInstallLog: (line: string) => {
set((state) => ({
installLogs: [...state.installLogs, line].slice(-200),
}));
},
_advanceInstallStep: (stepId: string) => {
set((state) => ({
installSteps: advanceSteps(state.installSteps, stepId, STEP_ORDER_INSTALL),
}));
},
_failCurrentStep: (error: string) => {
set((state) => ({
installSteps: state.installSteps.map((s) =>
s.status === "active" ? { ...s, status: "error" as const } : s
),
error,
}));
},
_advanceLoginStep: (stepId: string) => {
set((state) => ({
loginSteps: advanceSteps(state.loginSteps, stepId, STEP_ORDER_LOGIN),
}));
},
_failCurrentLoginStep: (error: string) => {
set((state) => ({
loginSteps: state.loginSteps.map((s) =>
s.status === "active" ? { ...s, status: "error" as const } : s
),
error,
}));
},
_finishInstall: (success: boolean) => {
if (success) {
const store = get();
store._advanceInstallStep("verifying");
setTimeout(() => {
const s = get();
// Mark verifying complete, then complete step active
s._advanceInstallStep("complete");
setTimeout(() => {
// Mark all complete
set((state) => ({
isInstalling: false,
installSteps: state.installSteps.map((step) => ({
...step,
status: "complete" as const,
})),
}));
get().checkStatus();
}, 500);
}, 800);
} else {
const store = get();
store._failCurrentStep("Installation failed. Check logs for details.");
set({ isInstalling: false, status: "error", installLogsVisible: true });
}
},
_finishLogin: (success: boolean) => {
if (success) {
const store = get();
store._advanceLoginStep("complete");
setTimeout(() => {
set((state) => ({
isLoggingIn: false,
loginSteps: state.loginSteps.map((step) => ({
...step,
status: "complete" as const,
})),
}));
get().checkStatus();
}, 500);
} else {
const store = get();
store._failCurrentLoginStep("Authentication failed.");
set({ isLoggingIn: false, status: "error" });
}
},
}));

View file

@ -15,6 +15,7 @@ import {
type ProjectFileType,
} from "@/lib/tauri/fs";
import { useHistoryStore } from "@/stores/history-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
export interface ProjectFile {
id: string; // relativePath is the id
@ -172,6 +173,10 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
cursorPosition: 0,
selectionRange: null,
});
// Initialize history system early so snapshots work before the panel is opened
const historyStore = useHistoryStore.getState();
historyStore.init(rootPath).then(() => historyStore.loadSnapshots(rootPath)).catch(() => {});
},
closeProject: () => {
@ -184,6 +189,8 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
compileError: null,
initialized: false,
});
// Reset chat session so stale messages don't leak into the next project
useClaudeChatStore.getState().newSession();
},
setActiveFile: (id) =>
@ -204,12 +211,16 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
return id;
},
deleteFile: (id) => {
deleteFile: async (id) => {
const state = get();
if (state.files.length <= 1) return;
const file = state.files.find((f) => f.id === id);
if (file) {
deleteFileFromDisk(file.absolutePath).catch(console.error);
try {
await deleteFileFromDisk(file.absolutePath);
} catch (e) {
console.error("Failed to delete file from disk:", e);
}
}
const newFiles = state.files.filter((f) => f.id !== id);
const newActiveId =
@ -217,7 +228,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
set({ files: newFiles, activeFileId: newActiveId });
},
renameFile: (id, name) => {
renameFile: async (id, name) => {
const state = get();
const file = state.files.find((f) => f.id === id);
if (!file || !state.projectRoot) return;
@ -227,23 +238,27 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
: "";
const newRelativePath = dir ? `${dir}/${name}` : name;
join(state.projectRoot, newRelativePath).then((newAbsPath) => {
renameFileOnDisk(file.absolutePath, newAbsPath).catch(console.error);
set((s) => ({
files: s.files.map((f) =>
f.id === id
? {
...f,
name,
relativePath: newRelativePath,
absolutePath: newAbsPath,
id: newRelativePath,
}
: f,
),
activeFileId: s.activeFileId === id ? newRelativePath : s.activeFileId,
}));
});
const newAbsPath = await join(state.projectRoot, newRelativePath);
try {
await renameFileOnDisk(file.absolutePath, newAbsPath);
} catch (e) {
console.error("Failed to rename file on disk:", e);
return;
}
set((s) => ({
files: s.files.map((f) =>
f.id === id
? {
...f,
name,
relativePath: newRelativePath,
absolutePath: newAbsPath,
id: newRelativePath,
}
: f,
),
activeFileId: s.activeFileId === id ? newRelativePath : s.activeFileId,
}));
},
updateFileContent: (id, content) => {
@ -259,7 +274,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
setPdfData: (data) => set({ pdfData: data, compileError: null }),
setCompileError: (error) => set({ compileError: error, pdfData: null }),
setCompileError: (error) => set({ compileError: error }),
setIsCompiling: (isCompiling) => set({ isCompiling }),
@ -345,12 +360,21 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
saveAllFiles: async () => {
const state = get();
const dirtyFiles = state.files.filter((f) => f.isDirty && f.content);
await Promise.all(
const results = await Promise.allSettled(
dirtyFiles.map((f) => writeTexFileContent(f.absolutePath, f.content!)),
);
set((s) => ({
files: s.files.map((f) => ({ ...f, isDirty: false })),
}));
// Only mark successfully saved files as clean
const savedIds = new Set<string>();
results.forEach((r, i) => {
if (r.status === "fulfilled") savedIds.add(dirtyFiles[i].id);
});
if (savedIds.size > 0) {
set((s) => ({
files: s.files.map((f) =>
savedIds.has(f.id) ? { ...f, isDirty: false } : f,
),
}));
}
},
saveCurrentFile: async () => {
@ -485,13 +509,29 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
if (!projectRoot) return;
const { files: fsFiles, folders: fsFolders } = await scanProjectFolder(projectRoot);
const existingPaths = new Set(files.map((f) => f.relativePath));
const existingMap = new Map(files.map((f) => [f.relativePath, f]));
const diskPaths = new Set(fsFiles.map((f) => f.relativePath));
// Find new files on disk that aren't in the store
const newFiles: ProjectFile[] = [];
const merged: ProjectFile[] = [];
for (const fsFile of fsFiles) {
if (!existingPaths.has(fsFile.relativePath)) {
const existing = existingMap.get(fsFile.relativePath);
if (existing) {
// Existing file — reload content from disk unless the user has unsaved edits
if (existing.isDirty) {
merged.push(existing);
} else {
const updated = { ...existing };
if (updated.type === "tex" || updated.type === "bib" || updated.type === "style" || updated.type === "other") {
try {
updated.content = await readTexFileContent(updated.absolutePath);
} catch { /* keep previous content */ }
}
merged.push(updated);
}
} else {
// New file on disk
const pf: ProjectFile = {
id: fsFile.relativePath,
name: fsFile.relativePath.split("/").pop() || fsFile.relativePath,
@ -511,16 +551,17 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
} else if (pf.type === "pdf") {
pf.dataUrl = getAssetUrl(pf.absolutePath);
}
newFiles.push(pf);
merged.push(pf);
}
}
// Remove files from store that no longer exist on disk (keep dirty ones)
const kept = files.filter(
(f) => diskPaths.has(f.relativePath) || f.isDirty,
);
// Keep dirty files that were deleted from disk (user hasn't saved yet)
for (const f of files) {
if (!diskPaths.has(f.relativePath) && f.isDirty) {
merged.push(f);
}
}
const merged = [...kept, ...newFiles];
const newActiveId = merged.some((f) => f.id === activeFileId)
? activeFileId
: merged[0]?.id ?? "";

View file

@ -9,8 +9,10 @@ interface RecentProject {
interface ProjectState {
recentProjects: RecentProject[];
lastProjectFolder: string | null;
addRecentProject: (path: string) => void;
removeRecentProject: (path: string) => void;
setLastProjectFolder: (path: string) => void;
}
const MAX_RECENT = 10;
@ -19,6 +21,9 @@ export const useProjectStore = create<ProjectState>()(
persist(
(set) => ({
recentProjects: [],
lastProjectFolder: null,
setLastProjectFolder: (path) => set({ lastProjectFolder: path }),
addRecentProject: (path) => {
const name = path.split("/").pop() || path;

View file

@ -0,0 +1,75 @@
import { create } from "zustand";
import {
type TemplateCategory,
type TemplateDefinition,
getAllTemplates,
searchTemplates,
getTemplatesByCategory,
} from "@/lib/template-registry";
interface TemplateState {
// Filters
searchQuery: string;
selectedCategory: TemplateCategory | null;
// Selection
selectedTemplateId: string | null;
previewTemplateId: string | null;
// Computed
filteredTemplates: TemplateDefinition[];
// Actions
setSearchQuery: (query: string) => void;
setSelectedCategory: (category: TemplateCategory | null) => void;
selectTemplate: (id: string | null) => void;
openPreview: (id: string) => void;
closePreview: () => void;
reset: () => void;
}
function computeFiltered(
query: string,
category: TemplateCategory | null,
): TemplateDefinition[] {
let results = query ? searchTemplates(query) : getAllTemplates();
if (category) {
results = results.filter((t) => t.category === category);
}
return results;
}
export const useTemplateStore = create<TemplateState>((set) => ({
searchQuery: "",
selectedCategory: null,
selectedTemplateId: null,
previewTemplateId: null,
filteredTemplates: getAllTemplates(),
setSearchQuery: (query) =>
set((s) => ({
searchQuery: query,
filteredTemplates: computeFiltered(query, s.selectedCategory),
})),
setSelectedCategory: (category) =>
set((s) => ({
selectedCategory: category,
filteredTemplates: computeFiltered(s.searchQuery, category),
})),
selectTemplate: (id) => set({ selectedTemplateId: id }),
openPreview: (id) => set({ previewTemplateId: id }),
closePreview: () => set({ previewTemplateId: null }),
reset: () =>
set({
searchQuery: "",
selectedCategory: null,
selectedTemplateId: null,
previewTemplateId: null,
filteredTemplates: getAllTemplates(),
}),
}));

View file

@ -22,13 +22,16 @@ export interface CollectionSyncInfo {
keyMap: Record<string, string>;
}
/** Synced collections scoped per project path */
type ProjectSyncedCollections = Record<string, Record<string, CollectionSyncInfo>>;
interface ZoteroState {
// Persisted
apiKey: string | null;
userID: string | null;
username: string | null;
/** Synced collections, keyed by collectionKey (or "__my_library__" for all items) */
syncedCollections: Record<string, CollectionSyncInfo>;
/** Synced collections keyed by projectPath → collectionKey */
syncedCollections: ProjectSyncedCollections;
// Transient
isAuthenticated: boolean;
@ -187,6 +190,7 @@ export const useZoteroStore = create<ZoteroState>()(
const docStore = useDocumentStore.getState();
if (!docStore.projectRoot) return;
const projectRoot = docStore.projectRoot;
const sk = storeKey(collectionKey);
set({ isSyncing: sk, syncProgress: null, error: null });
@ -205,7 +209,7 @@ export const useZoteroStore = create<ZoteroState>()(
docStore.updateFileContent(existingFile.id, result.bibtex);
} else {
const fullPath = await createFileOnDisk(
docStore.projectRoot,
projectRoot,
bibFileName,
result.bibtex,
);
@ -218,7 +222,7 @@ export const useZoteroStore = create<ZoteroState>()(
});
}
// Store sync info
// Store sync info scoped to current project
const syncInfo: CollectionSyncInfo = {
collectionKey,
name,
@ -226,11 +230,17 @@ export const useZoteroStore = create<ZoteroState>()(
libraryVersion: result.libraryVersion,
keyMap: result.keyMap,
};
set((s) => ({
syncedCollections: { ...s.syncedCollections, [sk]: syncInfo },
isSyncing: null,
syncProgress: null,
}));
set((s) => {
const projectColls = s.syncedCollections[projectRoot] ?? {};
return {
syncedCollections: {
...s.syncedCollections,
[projectRoot]: { ...projectColls, [sk]: syncInfo },
},
isSyncing: null,
syncProgress: null,
};
});
} catch (err) {
set({
error: err instanceof Error ? err.message : "Import failed",
@ -244,11 +254,15 @@ export const useZoteroStore = create<ZoteroState>()(
const { apiKey, userID, syncedCollections } = get();
if (!apiKey || !userID) return;
const docStore = useDocumentStore.getState();
if (!docStore.projectRoot) return;
const projectRoot = docStore.projectRoot;
const sk = storeKey(collectionKey);
const syncInfo = syncedCollections[sk];
const projectColls = syncedCollections[projectRoot] ?? {};
const syncInfo = projectColls[sk];
if (!syncInfo) return;
const docStore = useDocumentStore.getState();
const bibFile = docStore.files.find((f) => f.name === syncInfo.bibFileName);
if (!bibFile) return;
@ -274,18 +288,24 @@ export const useZoteroStore = create<ZoteroState>()(
const updatedContent = entries.join("\n\n") + "\n";
docStore.updateFileContent(bibFile.id, updatedContent);
set((s) => ({
syncedCollections: {
...s.syncedCollections,
[sk]: {
...syncInfo,
libraryVersion: result.libraryVersion,
keyMap: newKeyMap,
set((s) => {
const pColls = s.syncedCollections[projectRoot] ?? {};
return {
syncedCollections: {
...s.syncedCollections,
[projectRoot]: {
...pColls,
[sk]: {
...syncInfo,
libraryVersion: result.libraryVersion,
keyMap: newKeyMap,
},
},
},
},
isSyncing: null,
syncProgress: null,
}));
isSyncing: null,
syncProgress: null,
};
});
} else {
// For "My Library", apply incremental diff
const currentContent = bibFile.content ?? "";
@ -312,18 +332,24 @@ export const useZoteroStore = create<ZoteroState>()(
const updatedContent = Array.from(entries.values()).join("\n\n") + "\n";
docStore.updateFileContent(bibFile.id, updatedContent);
set((s) => ({
syncedCollections: {
...s.syncedCollections,
[sk]: {
...syncInfo,
libraryVersion: result.libraryVersion,
keyMap: newKeyMap,
set((s) => {
const pColls = s.syncedCollections[projectRoot] ?? {};
return {
syncedCollections: {
...s.syncedCollections,
[projectRoot]: {
...pColls,
[sk]: {
...syncInfo,
libraryVersion: result.libraryVersion,
keyMap: newKeyMap,
},
},
},
},
isSyncing: null,
syncProgress: null,
}));
isSyncing: null,
syncProgress: null,
};
});
}
} catch (err) {
set({
@ -335,10 +361,19 @@ export const useZoteroStore = create<ZoteroState>()(
},
removeCollection: (collectionKey) => {
const projectRoot = useDocumentStore.getState().projectRoot;
if (!projectRoot) return;
const sk = storeKey(collectionKey);
set((s) => {
const { [sk]: _, ...rest } = s.syncedCollections;
return { syncedCollections: rest };
const projectColls = s.syncedCollections[projectRoot] ?? {};
const { [sk]: _, ...rest } = projectColls;
return {
syncedCollections: {
...s.syncedCollections,
[projectRoot]: rest,
},
};
});
},
}),

View file

@ -158,12 +158,34 @@
scrollbar-color: var(--sidebar-border) transparent;
}
.react-pdf__Page__textContent span {
cursor: pointer;
transition: background-color 0.15s ease;
/* MuPDF text selection layer */
.mupdf-text-layer {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: auto;
}
.react-pdf__Page__textContent span:hover {
background-color: oklch(0.9 0.1 250 / 30%);
border-radius: 2px;
.mupdf-text-layer text {
fill: transparent;
white-space: pre;
cursor: text;
}
.mupdf-text-layer text::selection {
fill: hsla(220, 100%, 50%, 0.3);
}
/* MuPDF link overlay layer */
.mupdf-link-layer {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.mupdf-link-layer a {
position: absolute;
pointer-events: auto;
cursor: pointer;
}

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/project-picker.tsx","./src/components/assistant-ui/tooltip-icon-button.tsx","./src/components/claude-chat/chat-composer.tsx","./src/components/claude-chat/chat-messages.tsx","./src/components/claude-chat/claude-chat-drawer.tsx","./src/components/claude-chat/markdown-renderer.tsx","./src/components/claude-chat/proposed-changes-panel.tsx","./src/components/claude-chat/tool-widgets.tsx","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/workspace/history-panel.tsx","./src/components/workspace/sidebar.tsx","./src/components/workspace/workspace-layout.tsx","./src/components/workspace/zotero-panel.tsx","./src/components/workspace/editor/editor-toolbar.tsx","./src/components/workspace/editor/image-preview.tsx","./src/components/workspace/editor/latex-editor.tsx","./src/components/workspace/editor/problems-panel.tsx","./src/components/workspace/editor/search-panel.tsx","./src/components/workspace/editor/selection-toolbar.tsx","./src/components/workspace/preview/pdf-preview.tsx","./src/components/workspace/preview/pdf-viewer.tsx","./src/hooks/use-claude-events.ts","./src/hooks/use-keyboard-shortcuts.ts","./src/hooks/use-mobile.ts","./src/lib/latex-compiler.ts","./src/lib/utils.ts","./src/lib/zotero-api.ts","./src/lib/tauri/fs.ts","./src/stores/claude-chat-store.ts","./src/stores/document-store.ts","./src/stores/history-store.ts","./src/stores/project-store.ts","./src/stores/proposed-changes-store.ts","./src/stores/zotero-store.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/claude-setup.tsx","./src/components/project-picker.tsx","./src/components/project-wizard.tsx","./src/components/assistant-ui/tooltip-icon-button.tsx","./src/components/claude-chat/chat-composer.tsx","./src/components/claude-chat/chat-messages.tsx","./src/components/claude-chat/claude-chat-drawer.tsx","./src/components/claude-chat/markdown-renderer.tsx","./src/components/claude-chat/proposed-changes-panel.tsx","./src/components/claude-chat/session-selector.tsx","./src/components/claude-chat/slash-command-picker.tsx","./src/components/claude-chat/tool-widgets.tsx","./src/components/template-gallery/category-sidebar.tsx","./src/components/template-gallery/index.ts","./src/components/template-gallery/template-card.tsx","./src/components/template-gallery/template-gallery.tsx","./src/components/template-gallery/template-preview.tsx","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/workspace/history-panel.tsx","./src/components/workspace/sidebar.tsx","./src/components/workspace/workspace-layout.tsx","./src/components/workspace/zotero-panel.tsx","./src/components/workspace/editor/editor-toolbar.tsx","./src/components/workspace/editor/image-preview.tsx","./src/components/workspace/editor/lang-bibtex.ts","./src/components/workspace/editor/latex-editor.tsx","./src/components/workspace/editor/problems-panel.tsx","./src/components/workspace/editor/search-panel.tsx","./src/components/workspace/editor/selection-toolbar.tsx","./src/components/workspace/preview/mupdf-page.tsx","./src/components/workspace/preview/pdf-preview.tsx","./src/components/workspace/preview/pdf-viewer.tsx","./src/hooks/use-claude-events.ts","./src/hooks/use-keyboard-shortcuts.ts","./src/hooks/use-mobile.ts","./src/lib/latex-compiler.ts","./src/lib/template-preview-cache.ts","./src/lib/template-registry.ts","./src/lib/utils.ts","./src/lib/zotero-api.ts","./src/lib/mupdf/mupdf-client.ts","./src/lib/mupdf/mupdf-worker.ts","./src/lib/mupdf/types.ts","./src/lib/tauri/fs.ts","./src/stores/claude-chat-store.ts","./src/stores/claude-setup-store.ts","./src/stores/document-store.ts","./src/stores/history-store.ts","./src/stores/project-store.ts","./src/stores/proposed-changes-store.ts","./src/stores/template-store.ts","./src/stores/zotero-store.ts"],"version":"5.9.3"}

View file

@ -11,6 +11,12 @@ export default defineConfig({
"@": path.resolve(__dirname, "src"),
},
},
worker: {
format: "es",
},
optimizeDeps: {
exclude: ["mupdf"],
},
clearScreen: false,
server: {
port: 1420,
@ -29,8 +35,7 @@ export default defineConfig({
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target:
process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
target: "esnext",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},

1981
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff