chore: fix all lint and formatting issues (biome check)

- Fix unused imports and variables
- Apply biome formatting (template literals, semicolons, line width)
- Fix a11y issues (label associations, anchor content)
- Fix Node.js import protocol (node:fs, node:path)
- Use self.onmessage in web worker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
delibae 2026-03-16 17:04:36 +09:00
parent ea04d34bb4
commit 95c4072ec1
85 changed files with 11649 additions and 1740 deletions

View file

@ -104,7 +104,9 @@ async function main() {
if (fs.existsSync(pdfPath)) {
fs.copyFileSync(pdfPath, path.join(exampleDir, pdfName));
const sizeKb = Math.round(fs.statSync(path.join(exampleDir, pdfName)).size / 1024);
const sizeKb = Math.round(
fs.statSync(path.join(exampleDir, pdfName)).size / 1024,
);
console.log(`OK (${sizeKb} KB)`);
successCount++;
} else {
@ -112,7 +114,8 @@ async function main() {
failCount++;
}
} catch (err) {
const msg = err instanceof Error ? err.message.slice(0, 120) : String(err);
const msg =
err instanceof Error ? err.message.slice(0, 120) : String(err);
console.log(`FAIL: ${msg}`);
failCount++;
} finally {
@ -125,7 +128,9 @@ async function main() {
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.");
console.log(
"The gallery will show CSS fallback thumbnails for those templates.",
);
}
}

View file

@ -1,14 +1,6 @@
{
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"updater:default"
]
}
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["updater:default"]
}

View file

@ -65,7 +65,9 @@ function WorkspaceWithClaude() {
if (!initialized) return;
// Delay to let ClaudeChatDrawer mount and register event listeners
const timer = setTimeout(() => {
const prompt = useClaudeChatStore.getState().consumePendingInitialPrompt();
const prompt = useClaudeChatStore
.getState()
.consumePendingInitialPrompt();
if (prompt) {
useClaudeChatStore.getState().sendPrompt(prompt);
}
@ -91,7 +93,6 @@ export function App({ onReady }: { onReady?: () => void }) {
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
useKeyboardShortcuts();
useEffect(() => {
onReady?.();
}, [onReady]);

View file

@ -33,7 +33,7 @@ describe("Tauri IPC error message extraction", () => {
// This is what Tauri invoke() rejects with for Rust Err(String)
const error = "Compilation failed\n\n! Undefined control sequence.";
expect(extractErrorMessage(error)).toBe(
"Compilation failed\n\n! Undefined control sequence."
"Compilation failed\n\n! Undefined control sequence.",
);
});

View file

@ -16,21 +16,84 @@ import { describe, it, expect } from "vitest";
describe("getFileType logic", () => {
// Replicate the classification logic for testing
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".webp"]);
const STYLE_EXTENSIONS = new Set([".sty", ".cls", ".bst", ".def", ".cfg", ".fd", ".dtx", ".ins"]);
const IMAGE_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".gif",
".svg",
".bmp",
".webp",
]);
const STYLE_EXTENSIONS = new Set([
".sty",
".cls",
".bst",
".def",
".cfg",
".fd",
".dtx",
".ins",
]);
const IGNORED_EXTENSIONS = new Set([
".aux", ".log", ".out", ".toc", ".lof", ".lot", ".fls",
".fdb_latexmk", ".synctex.gz", ".synctex", ".blg", ".bbl",
".nav", ".snm", ".vrb", ".run.xml", ".bcf",
".aux",
".log",
".out",
".toc",
".lof",
".lot",
".fls",
".fdb_latexmk",
".synctex.gz",
".synctex",
".blg",
".bbl",
".nav",
".snm",
".vrb",
".run.xml",
".bcf",
// Binary / non-text files
".hwp", ".hwpx", ".doc", ".docx", ".xls", ".xlsx", ".xlsm",
".ppt", ".pptx", ".accdb", ".mdb",
".zip", ".rar", ".7z", ".tar", ".gz",
".exe", ".dll", ".so", ".dylib", ".o", ".obj",
".bin", ".dat", ".iso", ".dmg", ".msi",
".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac",
".psd", ".ai", ".sketch", ".fig",
".sqlite", ".db",
".hwp",
".hwpx",
".doc",
".docx",
".xls",
".xlsx",
".xlsm",
".ppt",
".pptx",
".accdb",
".mdb",
".zip",
".rar",
".7z",
".tar",
".gz",
".exe",
".dll",
".so",
".dylib",
".o",
".obj",
".bin",
".dat",
".iso",
".dmg",
".msi",
".mp3",
".mp4",
".avi",
".mov",
".mkv",
".wav",
".flac",
".psd",
".ai",
".sketch",
".fig",
".sqlite",
".db",
]);
function getFileType(name: string): string | null {

View file

@ -67,7 +67,12 @@ describe("template-registry", () => {
});
it("returns templates for each category", () => {
for (const cat of ["academic", "professional", "creative", "starter"] as const) {
for (const cat of [
"academic",
"professional",
"creative",
"starter",
] as const) {
expect(getTemplatesByCategory(cat).length).toBeGreaterThan(0);
}
});

View file

@ -9,7 +9,11 @@ interface StepInfo {
status: StepStatus;
}
function advanceSteps(steps: StepInfo[], targetId: string, order: string[]): StepInfo[] {
function advanceSteps(
steps: StepInfo[],
targetId: string,
order: string[],
): StepInfo[] {
const targetIdx = order.indexOf(targetId);
return steps.map((s) => {
const thisIdx = order.indexOf(s.id);

View file

@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useDocumentStore, getCurrentPdfBytes, clearPdfBytesCache, type ProjectFile } from "@/stores/document-store";
import {
useDocumentStore,
getCurrentPdfBytes,
clearPdfBytesCache,
type ProjectFile,
} from "@/stores/document-store";
// Mock history store
vi.mock("@/stores/history-store", () => ({
@ -61,7 +66,9 @@ describe("useDocumentStore", () => {
// Validates that getActiveFile() correctly resolves the active file
// by confirming insertAtCursor modifies the right file's content
useDocumentStore.getState().insertAtCursor("!");
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello! World");
});
@ -69,7 +76,9 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({ activeFileId: "nonexistent" });
useDocumentStore.getState().insertAtCursor("text");
// Files should not be modified
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello World");
});
});
@ -91,14 +100,18 @@ describe("useDocumentStore", () => {
it("inserts at beginning when cursor is at 0", () => {
useDocumentStore.setState({ cursorPosition: 0 });
useDocumentStore.getState().insertAtCursor(">> ");
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe(">> Hello World");
});
it("inserts at end when cursor is at content length", () => {
useDocumentStore.setState({ cursorPosition: 11 }); // "Hello World".length
useDocumentStore.getState().insertAtCursor("!");
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello World!");
});
@ -126,7 +139,9 @@ describe("useDocumentStore", () => {
it("replaces a range of text", () => {
// Replace "World" (indices 6-11) with "Universe"
useDocumentStore.getState().replaceSelection(6, 11, "Universe");
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello Universe");
expect(file.isDirty).toBe(true);
});
@ -138,13 +153,17 @@ describe("useDocumentStore", () => {
it("can delete text (empty replacement)", () => {
useDocumentStore.getState().replaceSelection(5, 11, "");
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello");
});
it("can insert at a point (start === end)", () => {
useDocumentStore.getState().replaceSelection(5, 5, " Beautiful");
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello Beautiful World");
});
@ -159,9 +178,13 @@ describe("useDocumentStore", () => {
describe("findAndReplace", () => {
it("replaces first occurrence", () => {
const result = useDocumentStore.getState().findAndReplace("World", "Universe");
const result = useDocumentStore
.getState()
.findAndReplace("World", "Universe");
expect(result).toBe(true);
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello Universe");
expect(file.isDirty).toBe(true);
});
@ -169,7 +192,9 @@ describe("useDocumentStore", () => {
it("returns false when find string is not found", () => {
const result = useDocumentStore.getState().findAndReplace("xyz", "abc");
expect(result).toBe(false);
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.isDirty).toBe(false); // not modified
});
@ -186,9 +211,13 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({
files: [makeFile({ content: "price is $10.00" })],
});
const result = useDocumentStore.getState().findAndReplace("$10.00", "€12.00");
const result = useDocumentStore
.getState()
.findAndReplace("$10.00", "€12.00");
expect(result).toBe(true);
expect(useDocumentStore.getState().files[0].content).toBe("price is €12.00");
expect(useDocumentStore.getState().files[0].content).toBe(
"price is €12.00",
);
});
it("does nothing for image files", () => {
@ -203,7 +232,9 @@ describe("useDocumentStore", () => {
describe("updateFileContent", () => {
it("updates content and marks dirty", () => {
useDocumentStore.getState().updateFileContent("main.tex", "New content");
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("New content");
expect(file.isDirty).toBe(true);
});
@ -212,11 +243,18 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({
files: [
makeFile(),
makeFile({ id: "other.tex", name: "other.tex", relativePath: "other.tex", content: "Other" }),
makeFile({
id: "other.tex",
name: "other.tex",
relativePath: "other.tex",
content: "Other",
}),
],
});
useDocumentStore.getState().updateFileContent("main.tex", "Changed");
const other = useDocumentStore.getState().files.find((f) => f.id === "other.tex")!;
const other = useDocumentStore
.getState()
.files.find((f) => f.id === "other.tex")!;
expect(other.content).toBe("Other");
expect(other.isDirty).toBe(false);
});
@ -268,7 +306,10 @@ describe("useDocumentStore", () => {
files: [makeFile({ isDirty: true, content: "saved content" })],
});
await useDocumentStore.getState().saveFile("main.tex");
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "saved content");
expect(writeTextFile).toHaveBeenCalledWith(
"/project/main.tex",
"saved content",
);
expect(useDocumentStore.getState().files[0].isDirty).toBe(false);
});
@ -283,7 +324,9 @@ describe("useDocumentStore", () => {
it("skips saving when content is null", async () => {
useDocumentStore.setState({
files: [makeFile({ isDirty: true, content: null as unknown as string })],
files: [
makeFile({ isDirty: true, content: null as unknown as string }),
],
});
await useDocumentStore.getState().saveFile("main.tex");
expect(writeTextFile).not.toHaveBeenCalled();
@ -308,19 +351,36 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({
files: [
makeFile({ isDirty: true, content: "dirty content" }),
makeFile({ id: "clean.tex", name: "clean.tex", absolutePath: "/project/clean.tex", relativePath: "clean.tex", isDirty: false, content: "clean" }),
makeFile({
id: "clean.tex",
name: "clean.tex",
absolutePath: "/project/clean.tex",
relativePath: "clean.tex",
isDirty: false,
content: "clean",
}),
],
});
await useDocumentStore.getState().saveAllFiles();
expect(writeTextFile).toHaveBeenCalledTimes(1);
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "dirty content");
expect(writeTextFile).toHaveBeenCalledWith(
"/project/main.tex",
"dirty content",
);
});
it("saves dirty files with empty string content (regression: empty string is not falsy-skipped)", async () => {
useDocumentStore.setState({
files: [
makeFile({ isDirty: true, content: "" }),
makeFile({ id: "slide.tex", name: "slide.tex", absolutePath: "/project/slide.tex", relativePath: "slide.tex", isDirty: true, content: "" }),
makeFile({
id: "slide.tex",
name: "slide.tex",
absolutePath: "/project/slide.tex",
relativePath: "slide.tex",
isDirty: true,
content: "",
}),
],
});
await useDocumentStore.getState().saveAllFiles();
@ -353,9 +413,11 @@ describe("useDocumentStore", () => {
});
it("setCompileError stores the error string (regression: Tauri string errors must be preserved)", () => {
useDocumentStore.getState().setCompileError("Compilation failed\n\n! Undefined control sequence.");
useDocumentStore
.getState()
.setCompileError("Compilation failed\n\n! Undefined control sequence.");
expect(useDocumentStore.getState().compileError).toBe(
"Compilation failed\n\n! Undefined control sequence."
"Compilation failed\n\n! Undefined control sequence.",
);
});
});

View file

@ -39,7 +39,19 @@ function resetStores() {
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
tabs: [{ id: "tab-default", title: "New Chat", sessionId: null, messages: [], isStreaming: false, error: null, totalInputTokens: 0, totalOutputTokens: 0, draft: { input: "", pinnedContexts: [] } }],
tabs: [
{
id: "tab-default",
title: "New Chat",
sessionId: null,
messages: [],
isStreaming: false,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
draft: { input: "", pinnedContexts: [] },
},
],
activeTabId: "tab-default",
_cancelledByUser: false,
});
@ -72,7 +84,9 @@ describe("Multi-tab merge triggers", () => {
chat.setActiveTab(tabB);
// Proposed change should still be visible — it's file-scoped, not tab-scoped
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
const change = useProposedChangesStore
.getState()
.getChangeForFile("main.tex");
expect(change).toBeDefined();
expect(change!.id).toBe("tool-from-tab-a");
expect(change!.newContent).toBe("edited by tab A");
@ -162,9 +176,30 @@ describe("Multi-tab merge triggers", () => {
it("three sequential edits to the same file all preserve the original baseline", () => {
const store = useProposedChangesStore.getState();
store.addChange({ id: "edit-1", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "baseline", newContent: "v1", toolName: "Edit" });
store.addChange({ id: "edit-2", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v1", newContent: "v2", toolName: "Edit" });
store.addChange({ id: "edit-3", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v2", newContent: "v3", toolName: "MultiEdit" });
store.addChange({
id: "edit-1",
filePath: "doc.tex",
absolutePath: "/project/doc.tex",
oldContent: "baseline",
newContent: "v1",
toolName: "Edit",
});
store.addChange({
id: "edit-2",
filePath: "doc.tex",
absolutePath: "/project/doc.tex",
oldContent: "v1",
newContent: "v2",
toolName: "Edit",
});
store.addChange({
id: "edit-3",
filePath: "doc.tex",
absolutePath: "/project/doc.tex",
oldContent: "v2",
newContent: "v3",
toolName: "MultiEdit",
});
const { changes } = useProposedChangesStore.getState();
expect(changes).toHaveLength(1);
@ -182,13 +217,17 @@ describe("Multi-tab merge triggers", () => {
// Tab A starts streaming
useClaudeChatStore.setState((s) => ({
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
tabs: s.tabs.map((t) =>
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
),
isStreaming: s.activeTabId === "tab-default",
}));
// Tab B can also be streaming independently
useClaudeChatStore.setState((s) => ({
tabs: s.tabs.map((t) => t.id === tabB ? { ...t, isStreaming: true } : t),
tabs: s.tabs.map((t) =>
t.id === tabB ? { ...t, isStreaming: true } : t,
),
isStreaming: s.activeTabId === tabB,
}));
@ -205,7 +244,9 @@ describe("Multi-tab merge triggers", () => {
// Mark tab A as streaming
useClaudeChatStore.setState((s) => ({
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
tabs: s.tabs.map((t) =>
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
),
}));
// User is viewing tab B (active), but message is for tab A
@ -221,7 +262,9 @@ describe("Multi-tab merge triggers", () => {
const tabBState = state.tabs.find((t) => t.id === tabB)!;
expect(tabAState.messages).toHaveLength(1);
expect(tabAState.messages[0].message?.content?.[0].text).toBe("Hello from stream");
expect(tabAState.messages[0].message?.content?.[0].text).toBe(
"Hello from stream",
);
expect(tabBState.messages).toHaveLength(0);
// Top-level projected messages should reflect the active tab (tab B) — empty
@ -257,7 +300,9 @@ describe("Multi-tab merge triggers", () => {
chat._setStreaming("tab-default", false);
const state = useClaudeChatStore.getState();
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(false);
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(
false,
);
expect(state.tabs.find((t) => t.id === tabB)!.isStreaming).toBe(true);
});
@ -327,7 +372,9 @@ describe("Multi-tab merge triggers", () => {
chat.setActiveTab(tabB);
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
expect(useProposedChangesStore.getState().changes[0].newContent).toBe("after");
expect(useProposedChangesStore.getState().changes[0].newContent).toBe(
"after",
);
});
it("keepAll clears all changes regardless of which tab is active", () => {
@ -335,12 +382,20 @@ describe("Multi-tab merge triggers", () => {
const tabB = chat.createTab();
useProposedChangesStore.getState().addChange({
id: "edit-1", filePath: "main.tex", absolutePath: "/project/main.tex",
oldContent: "old-main", newContent: "new-main", toolName: "Edit",
id: "edit-1",
filePath: "main.tex",
absolutePath: "/project/main.tex",
oldContent: "old-main",
newContent: "new-main",
toolName: "Edit",
});
useProposedChangesStore.getState().addChange({
id: "edit-2", filePath: "refs.bib", absolutePath: "/project/refs.bib",
oldContent: "old-bib", newContent: "new-bib", toolName: "Write",
id: "edit-2",
filePath: "refs.bib",
absolutePath: "/project/refs.bib",
oldContent: "old-bib",
newContent: "new-bib",
toolName: "Write",
});
chat.setActiveTab(tabB);
@ -368,7 +423,9 @@ describe("Multi-tab merge triggers", () => {
chat.closeTab(tabB);
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
expect(useProposedChangesStore.getState().changes[0].id).toBe("from-tab-b");
expect(useProposedChangesStore.getState().changes[0].id).toBe(
"from-tab-b",
);
});
it("creating a new tab does not clear existing proposed changes", () => {

View file

@ -43,12 +43,16 @@ describe("useProjectStore", () => {
it("extracts name from path correctly", () => {
useProjectStore.getState().addRecentProject("/a/b/c/deep-folder");
expect(useProjectStore.getState().recentProjects[0].name).toBe("deep-folder");
expect(useProjectStore.getState().recentProjects[0].name).toBe(
"deep-folder",
);
});
it("uses full path as name if no segments", () => {
useProjectStore.getState().addRecentProject("standalone");
expect(useProjectStore.getState().recentProjects[0].name).toBe("standalone");
expect(useProjectStore.getState().recentProjects[0].name).toBe(
"standalone",
);
});
});

View file

@ -111,13 +111,17 @@ describe("useProposedChangesStore", () => {
newContent: "b",
toolName: "Edit",
});
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
const change = useProposedChangesStore
.getState()
.getChangeForFile("main.tex");
expect(change).toBeDefined();
expect(change!.id).toBe("tool-1");
});
it("returns undefined for unknown file", () => {
const change = useProposedChangesStore.getState().getChangeForFile("nonexistent.tex");
const change = useProposedChangesStore
.getState()
.getChangeForFile("nonexistent.tex");
expect(change).toBeUndefined();
});
});

View file

@ -35,7 +35,9 @@ describe("useTemplateStore", () => {
useTemplateStore.getState().setSelectedCategory("academic");
const { filteredTemplates } = useTemplateStore.getState();
expect(filteredTemplates.length).toBeGreaterThan(0);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
true,
);
});
it("clears category filter with null", () => {
@ -53,7 +55,9 @@ describe("useTemplateStore", () => {
useTemplateStore.getState().setSelectedCategory("academic");
const { filteredTemplates } = useTemplateStore.getState();
expect(filteredTemplates.length).toBeGreaterThan(0);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
true,
);
});
});
});

View file

@ -9,7 +9,10 @@ function storeKey(collectionKey: string | null): string {
}
function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9_\-\s]/g, "").replace(/\s+/g, "-").toLowerCase();
return name
.replace(/[^a-zA-Z0-9_\-\s]/g, "")
.replace(/\s+/g, "-")
.toLowerCase();
}
function parseBibEntries(content: string): Map<string, string> {

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* Validates tauri.conf.json CSP (Content Security Policy) configuration.

View file

@ -1,5 +1,3 @@
import { ComponentPropsWithRef, forwardRef } from "react";
import { Slottable } from "@radix-ui/react-slot";

View file

@ -1,11 +1,38 @@
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "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 {
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 {
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";
@ -19,17 +46,23 @@ const log = createLogger("chat-composer");
export type { SlashCommand };
interface PinnedContext {
label: string; // @file:line:col-line:col
label: string; // @file:line:col-line:col
filePath: string;
selectedText: string;
imageDataUrl?: string; // thumbnail for captured images
}
function getFileIcon(file: ProjectFile) {
if (file.type === "image") return <ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (file.type === "pdf") return <FileSpreadsheetIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (file.type === "style") return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (file.type === "other") return <FileIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (file.type === "image")
return <ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (file.type === "pdf")
return (
<FileSpreadsheetIcon className="size-3.5 shrink-0 text-muted-foreground" />
);
if (file.type === "style")
return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (file.type === "other")
return <FileIcon className="size-3.5 shrink-0 text-muted-foreground" />;
return <FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />;
}
@ -49,7 +82,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
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 });
const [pickerPos, setPickerPos] = useState<{ left: number; bottom: number }>({
left: 0,
bottom: 0,
});
// Recalculate popup position when it opens
useLayoutEffect(() => {
@ -98,7 +134,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
prevTabIdRef.current = activeTabId;
// Restore draft from the new active tab
const tab = useClaudeChatStore.getState().tabs.find((t) => t.id === activeTabId);
const tab = useClaudeChatStore
.getState()
.tabs.find((t) => t.id === activeTabId);
const draft = tab?.draft;
setInput(draft?.input ?? "");
setPinnedContexts(draft?.pinnedContexts ?? []);
@ -121,7 +159,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
// Consume pending attachments from external sources (e.g. PDF capture)
const pendingAttachments = useClaudeChatStore((s) => s.pendingAttachments);
const consumePendingAttachments = useClaudeChatStore((s) => s.consumePendingAttachments);
const consumePendingAttachments = useClaudeChatStore(
(s) => s.consumePendingAttachments,
);
// Focus textarea when the drawer opens
const prevOpenRef = useRef(false);
@ -161,13 +201,18 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
if (!file?.content) return;
// Replace any existing selection-based context (keep file contexts)
setPinnedContexts((prev) => {
const filtered = prev.filter((c) => !c.label.includes(":") || c.label.startsWith("@attachments/"));
const filtered = prev.filter(
(c) => !c.label.includes(":") || c.label.startsWith("@attachments/"),
);
return [
...filtered,
{
label: currentContextLabel,
filePath: file.relativePath,
selectedText: file.content!.slice(selectionRange.start, selectionRange.end),
selectedText: file.content!.slice(
selectionRange.start,
selectionRange.end,
),
},
];
});
@ -181,7 +226,11 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
}
const q = mentionQuery.toLowerCase();
const matched = files
.filter((f) => f.relativePath.toLowerCase().includes(q) || f.name.toLowerCase().includes(q))
.filter(
(f) =>
f.relativePath.toLowerCase().includes(q) ||
f.name.toLowerCase().includes(q),
)
.slice(0, 8);
setMentionFiles(matched);
setMentionIndex(0);
@ -197,35 +246,42 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
.catch(() => setSlashCommands([]));
}, [slashQuery !== null, projectRoot]);
const selectMention = useCallback((file: ProjectFile) => {
// Replace @query with empty and pin the file as context
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
// Find the @ position before cursor
const textBefore = input.slice(0, cursorPos);
const atIndex = textBefore.lastIndexOf("@");
if (atIndex === -1) return;
const newInput = input.slice(0, atIndex) + input.slice(cursorPos);
setInput(newInput);
setMentionQuery(null);
const selectMention = useCallback(
(file: ProjectFile) => {
// Replace @query with empty and pin the file as context
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
// Find the @ position before cursor
const textBefore = input.slice(0, cursorPos);
const atIndex = textBefore.lastIndexOf("@");
if (atIndex === -1) return;
const newInput = input.slice(0, atIndex) + input.slice(cursorPos);
setInput(newInput);
setMentionQuery(null);
// Pin the whole file as context
const isTextFile = file.type === "tex" || file.type === "bib" || file.type === "style" || file.type === "other";
setPinnedContexts((prev) => [
...prev,
{
label: `@${file.relativePath}`,
filePath: file.relativePath,
selectedText: isTextFile
? (file.content ?? "")
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
},
]);
// Pin the whole file as context
const isTextFile =
file.type === "tex" ||
file.type === "bib" ||
file.type === "style" ||
file.type === "other";
setPinnedContexts((prev) => [
...prev,
{
label: `@${file.relativePath}`,
filePath: file.relativePath,
selectedText: isTextFile
? (file.content ?? "")
: `[Referenced file: ${file.relativePath} (${file.type} file)]`,
},
]);
// Refocus textarea
setTimeout(() => textarea.focus(), 0);
}, [input]);
// Refocus textarea
setTimeout(() => textarea.focus(), 0);
},
[input],
);
const selectSlashCommand = useCallback((command: SlashCommand) => {
// Insert command syntax into input (opcode-style)
@ -245,14 +301,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
textarea.selectionStart = textarea.selectionEnd = newInput.length;
// Auto-resize
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 160) + "px";
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 () => {});
const handleFileDropRef = useRef<(paths: string[]) => Promise<void>>(
async () => {},
);
handleFileDropRef.current = async (paths: string[]) => {
if (!projectRoot || paths.length === 0) return;
if (isProcessingDropRef.current) return;
@ -267,10 +325,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
const newContexts: PinnedContext[] = [];
for (const relativePath of importedPaths) {
const imported = storeFiles.find((f) => f.relativePath === relativePath);
const imported = storeFiles.find(
(f) => f.relativePath === relativePath,
);
if (imported) {
const isText = imported.type === "tex" || imported.type === "bib" || imported.type === "style" || imported.type === "other";
const isText =
imported.type === "tex" ||
imported.type === "bib" ||
imported.type === "style" ||
imported.type === "other";
newContexts.push({
label: `@${relativePath}`,
filePath: relativePath,
@ -292,7 +356,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
setPinnedContexts((prev) => {
// Deduplicate by label
const existingLabels = new Set(prev.map((c) => c.label));
const unique = newContexts.filter((c) => !existingLabels.has(c.label));
const unique = newContexts.filter(
(c) => !existingLabels.has(c.label),
);
return [...prev, ...unique];
});
}
@ -348,7 +414,8 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
const handlePaste = useCallback(
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const clipboardFiles = e.clipboardData?.files;
if (!clipboardFiles || clipboardFiles.length === 0 || !projectRoot) return;
if (!clipboardFiles || clipboardFiles.length === 0 || !projectRoot)
return;
// Check if there are actual file items (not just text)
const fileItems = Array.from(clipboardFiles);
@ -385,7 +452,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
// Determine if it's a text file
const isText = file.type.startsWith("text/");
const content = isText ? await file.text() : `[Attached file: ${uniqueName} (${file.type})]`;
const content = isText
? await file.text()
: `[Attached file: ${uniqueName} (${file.type})]`;
newContexts.push({
label: `@${uniqueName}`,
@ -393,7 +462,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
selectedText: content,
});
} catch (err) {
log.error("Failed to save pasted file", { fileName, error: String(err) });
log.error("Failed to save pasted file", {
fileName,
error: String(err),
});
}
}
@ -403,7 +475,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
setPinnedContexts((prev) => {
const existingLabels = new Set(prev.map((c) => c.label));
const unique = newContexts.filter((c) => !existingLabels.has(c.label));
const unique = newContexts.filter(
(c) => !existingLabels.has(c.label),
);
return [...prev, ...unique];
});
}
@ -440,7 +514,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
// 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");
const combinedText = pinnedContexts
.map((c) => c.selectedText)
.join("\n\n---\n\n");
sendPrompt(finalPrompt, {
label: combinedLabel,
filePath: pinnedContexts[0].filePath,
@ -462,7 +538,13 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
// Slash command picker is open — let the picker handle keyboard events
// (it uses window.addEventListener for ArrowUp/Down, Enter, Tab, Escape)
if (slashQuery !== null) {
if (e.key === "Enter" || e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab" || e.key === "Escape") {
if (
e.key === "Enter" ||
e.key === "ArrowDown" ||
e.key === "ArrowUp" ||
e.key === "Tab" ||
e.key === "Escape"
) {
e.preventDefault();
return;
}
@ -502,7 +584,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
setPinnedContexts((prev) => prev.slice(0, -1));
}
},
[handleSend, pinnedContexts, input, mentionQuery, mentionFiles, mentionIndex, selectMention, slashQuery],
[
handleSend,
pinnedContexts,
input,
mentionQuery,
mentionFiles,
mentionIndex,
selectMention,
slashQuery,
],
);
const handleInput = useCallback(
@ -539,7 +630,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
// Auto-resize
const el = e.target;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 160) + "px";
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
},
[],
);
@ -558,8 +649,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
modelPickerRef.current && !modelPickerRef.current.contains(target) &&
modelButtonRef.current && !modelButtonRef.current.contains(target)
modelPickerRef.current &&
!modelPickerRef.current.contains(target) &&
modelButtonRef.current &&
!modelButtonRef.current.contains(target)
) {
setModelPickerOpen(false);
}
@ -584,105 +677,149 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
)}
{/* 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) => (
{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 font-medium text-muted-foreground text-xs">
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={level}
key={m.id}
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",
"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={() => setEffortLevel(level)}
onClick={() => setSelectedModel(m.id)}
>
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
{m.icon}
<div className="min-w-0 flex-1">
<div className="font-medium text-xs">{m.name}</div>
<div className="truncate text-muted-foreground text-xs">
{m.desc}
</div>
</div>
{selectedModel === m.id && (
<CheckIcon className="size-3 shrink-0" />
)}
</button>
))}
</div>
</div>
</div>,
document.body,
)}
<div className="border-border border-t" />
{/* Effort level */}
<div className="p-2">
<div className="mb-1.5 flex items-center justify-between px-1">
<span className="font-medium text-muted-foreground text-xs">
Effort
</span>
<span className="text-muted-foreground text-xs">
{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 font-medium text-xs 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 */}
{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"
>
{mentionFiles.map((file, i) => {
const parts = file.relativePath.split("/");
const fileName = parts.pop()!;
const dirPath = parts.length > 0 ? parts.join("/") + "/" : "";
return (
<button
key={file.id}
data-active={i === mentionIndex}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors",
i === mentionIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
)}
onMouseDown={(e) => {
e.preventDefault(); // prevent textarea blur
selectMention(file);
}}
onMouseEnter={() => setMentionIndex(i)}
>
{getFileIcon(file)}
<span className="truncate font-mono text-sm">{fileName}</span>
{dirPath && (
<span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">{dirPath}</span>
)}
</button>
);
})}
</div>
)}
{slashQuery === null &&
mentionQuery !== null &&
mentionFiles.length > 0 && (
<div
ref={mentionRef}
className="absolute right-3 bottom-full left-3 mb-1 max-h-48 overflow-y-auto rounded-lg border border-border bg-background shadow-lg"
>
{mentionFiles.map((file, i) => {
const parts = file.relativePath.split("/");
const fileName = parts.pop()!;
const dirPath = parts.length > 0 ? `${parts.join("/")}/` : "";
return (
<button
key={file.id}
data-active={i === mentionIndex}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors",
i === mentionIndex
? "bg-accent text-accent-foreground"
: "hover:bg-muted",
)}
onMouseDown={(e) => {
e.preventDefault(); // prevent textarea blur
selectMention(file);
}}
onMouseEnter={() => setMentionIndex(i)}
>
{getFileIcon(file)}
<span className="truncate font-mono text-sm">{fileName}</span>
{dirPath && (
<span className="ml-auto shrink-0 font-mono text-muted-foreground text-xs">
{dirPath}
</span>
)}
</button>
);
})}
</div>
)}
<div
className={cn(
@ -706,7 +843,11 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
/>
<button
aria-label="Remove attachment"
onClick={() => setPinnedContexts((prev) => prev.filter((_, idx) => idx !== i))}
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" />
@ -715,12 +856,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
) : (
<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"
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 font-mono text-muted-foreground text-xs"
>
{ctx.label}
<button
aria-label="Remove context"
onClick={() => setPinnedContexts((prev) => prev.filter((_, idx) => idx !== i))}
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" />
@ -732,7 +877,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
)}
{isDragOver ? (
<div className="flex min-h-10 items-center justify-center px-4 py-3 text-sm text-muted-foreground">
<div className="flex min-h-10 items-center justify-center px-4 py-3 text-muted-foreground text-sm">
<PaperclipIcon className="mr-2 size-4" />
Drop files to attach
</div>
@ -756,13 +901,23 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
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"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
>
<span>
{selectedModel === "sonnet" ? "Sonnet" : selectedModel === "opus" ? "Opus" : selectedModel === "haiku" ? "Haiku" : "OpusPlan"}
{selectedModel === "sonnet"
? "Sonnet"
: selectedModel === "opus"
? "Opus"
: selectedModel === "haiku"
? "Haiku"
: "OpusPlan"}
</span>
<span className="text-muted-foreground/60">
{effortLevel === "low" ? "L" : effortLevel === "medium" ? "M" : "H"}
{effortLevel === "low"
? "L"
: effortLevel === "medium"
? "M"
: "H"}
</span>
<ChevronDownIcon className="size-3" />
</button>

View file

@ -1,6 +1,10 @@
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 {
useClaudeChatStore,
type ClaudeStreamMessage,
type ContentBlock,
} from "@/stores/claude-chat-store";
import { MarkdownRenderer } from "./markdown-renderer";
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
@ -22,14 +26,25 @@ const StreamingIndicator: FC = memo(() => {
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" }} />
<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 className="ml-1 text-muted-foreground/60 text-xs">
{elapsed}s
</span>
)}
</span>
</div>
@ -76,11 +91,16 @@ export const ChatMessages: FC = () => {
return messages.filter((msg) => {
if (msg.type === "system" && msg.subtype === "init") return false;
if (msg.type !== "user" && msg.type !== "assistant" && msg.type !== "result") return false;
if (
msg.type !== "user" &&
msg.type !== "assistant" &&
msg.type !== "result"
)
return false;
if (msg.type === "user" && msg.message?.content) {
if (Array.isArray(msg.message.content)) {
const hasOnlyToolResults = msg.message.content.every(
(b: any) => b.type === "tool_result"
(b: any) => b.type === "tool_result",
);
if (hasOnlyToolResults) return false;
}
@ -113,7 +133,8 @@ export const ChatMessages: FC = () => {
const handleScroll = () => {
if (!viewportRef.current) return;
const el = viewportRef.current;
const isAtBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
const isAtBottom =
Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
if (!isAtBottom) {
userHasScrolledRef.current = true;
shouldAutoScrollRef.current = false;
@ -136,11 +157,7 @@ export const ChatMessages: FC = () => {
)}
{displayMessages.map((msg, idx) => (
<MessageBubble
key={idx}
message={msg}
toolResultMap={toolResultMap}
/>
<MessageBubble key={idx} message={msg} toolResultMap={toolResultMap} />
))}
{isStreaming && <StreamingIndicator />}
@ -191,13 +208,13 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
// Lint multi: "[Lint errors in FILE]\n- FILE:LINE — MSG\n...\n\nPrompt"
// Compile: "[Compilation errors]\n- error1\n- error2\n...\n\nPrompt"
const lintSingleMatch = bodyText.match(
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/,
);
const lintMultiMatch = bodyText.match(
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
);
const compileErrorMatch = bodyText.match(
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
);
// Shared error block renderer
@ -209,14 +226,18 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
<div className="flex w-full flex-col items-end py-1.5">
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-2 text-foreground text-sm">
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
<div className="mb-1.5 text-xs font-medium text-red-400">{title}</div>
<div className="mb-1.5 font-medium text-red-400 text-xs">{title}</div>
<div className="space-y-1">
{errors.map((e, i) => (
<div key={i} className="flex items-start gap-1.5">
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
<span className="flex-1 text-xs text-foreground/80">{e.message}</span>
<span className="flex-1 text-foreground/80 text-xs">
{e.message}
</span>
{e.location && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">{e.location}</span>
<span className="shrink-0 font-mono text-muted-foreground text-xs">
{e.location}
</span>
)}
</div>
))}
@ -238,18 +259,26 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
if (lintMultiMatch) {
const [, fileName, errorLines, prompt] = lintMultiMatch;
const errors = errorLines.trim().split("\n").map((line) => {
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
return m ? { message: m[3], location: `${m[1]}:${m[2]}` } : { message: line.replace(/^- /, "") };
});
const errors = errorLines
.trim()
.split("\n")
.map((line) => {
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
return m
? { message: m[3], location: `${m[1]}:${m[2]}` }
: { message: line.replace(/^- /, "") };
});
return renderErrorBlock(`Lint Errors — ${fileName}`, errors, prompt);
}
if (compileErrorMatch) {
const [, errorLines, prompt] = compileErrorMatch;
const errors = errorLines.trim().split("\n").map((line) => ({
message: line.replace(/^- /, ""),
}));
const errors = errorLines
.trim()
.split("\n")
.map((line) => ({
message: line.replace(/^- /, ""),
}));
return renderErrorBlock(
`Compilation ${errors.length === 1 ? "Error" : "Errors"}`,
errors,
@ -261,7 +290,7 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
<div className="flex w-full flex-col items-end py-1.5">
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-1.5 text-foreground text-sm">
{contextLabel && (
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
{contextLabel}
</span>
)}
@ -288,7 +317,7 @@ const AssistantMessage: FC<{
(block) =>
(block.type === "text" && block.text) ||
(block.type === "thinking" && block.thinking) ||
(block.type === "tool_use" && block.id)
(block.type === "tool_use" && block.id),
);
if (!hasRenderableContent) return null;
@ -317,13 +346,7 @@ const AssistantMessage: FC<{
}
if (block.type === "tool_use" && block.id) {
const result = toolResultMap.get(block.id);
return (
<ToolWidget
key={idx}
toolUse={block}
toolResult={result}
/>
);
return <ToolWidget key={idx} toolUse={block} toolResult={result} />;
}
return null;
})}

View file

@ -14,8 +14,14 @@ export function ChatTabBar() {
// Scroll active tab into view when it changes
useEffect(() => {
const el = scrollRef.current?.querySelector(`[data-tab-id="${activeTabId}"]`);
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
const el = scrollRef.current?.querySelector(
`[data-tab-id="${activeTabId}"]`,
);
el?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}, [activeTabId]);
// Keyboard shortcuts: Ctrl+Tab / Ctrl+Shift+Tab to switch tabs, Ctrl+T new, Ctrl+W close
@ -72,10 +78,10 @@ export function ChatTabBar() {
);
return (
<div className="flex items-center border-b border-border">
<div className="flex items-center border-border border-b">
<div
ref={scrollRef}
className="flex min-w-0 flex-1 items-center overflow-x-auto scrollbar-none"
className="scrollbar-none flex min-w-0 flex-1 items-center overflow-x-auto"
>
{tabs.map((tab) => (
<TabButton

View file

@ -20,7 +20,9 @@ export function ClaudeChatDrawer() {
// Initialize event listeners for Claude streaming
useClaudeEvents();
const anyStreaming = useClaudeChatStore((s) => s.tabs.some((t) => t.isStreaming));
const anyStreaming = useClaudeChatStore((s) =>
s.tabs.some((t) => t.isStreaming),
);
const error = useClaudeChatStore((s) => s.error);
const [isOpen, setIsOpen] = useState(false);
@ -50,41 +52,44 @@ export function ClaudeChatDrawer() {
}
}, [anyStreaming, isOpen, pendingAttachments]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (isExpanded) return;
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isExpanded) return;
e.preventDefault();
setIsDragging(true);
hasDraggedRef.current = false;
e.preventDefault();
setIsDragging(true);
hasDraggedRef.current = false;
const startY = e.clientY;
const startHeight = heightRef.current;
const startY = e.clientY;
const startHeight = heightRef.current;
const handleMouseMove = (e: MouseEvent) => {
hasDraggedRef.current = true;
const parent = containerRef.current?.parentElement;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
const delta = startY - e.clientY;
const newHeight = Math.min(
Math.max(startHeight + delta, MIN_HEIGHT),
maxHeight
);
heightRef.current = newHeight;
if (panelRef.current) {
panelRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseMove = (e: MouseEvent) => {
hasDraggedRef.current = true;
const parent = containerRef.current?.parentElement;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
const delta = startY - e.clientY;
const newHeight = Math.min(
Math.max(startHeight + delta, MIN_HEIGHT),
maxHeight,
);
heightRef.current = newHeight;
if (panelRef.current) {
panelRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseUp = () => {
setIsDragging(false);
setHeight(heightRef.current);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
const handleMouseUp = () => {
setIsDragging(false);
setHeight(heightRef.current);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, [isExpanded]);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[isExpanded],
);
// Compute expanded dimensions from parent
const getExpandedDimensions = useCallback(() => {
@ -111,7 +116,7 @@ export function ClaudeChatDrawer() {
ref={containerRef}
className={cn(
"pointer-events-none absolute inset-0 z-10 flex items-end justify-center transition-[padding] duration-300 ease-out",
isExpanded ? "p-0" : "px-4 pb-6 pt-4"
isExpanded ? "p-0" : "px-4 pt-4 pb-6",
)}
>
{/* Floating toggle button */}
@ -122,7 +127,7 @@ export function ClaudeChatDrawer() {
"pointer-events-auto absolute right-4 bottom-6 flex size-12 items-center justify-center rounded-full border border-border bg-background shadow-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl",
isOpen
? "pointer-events-none scale-50 opacity-0"
: "scale-100 opacity-100"
: "scale-100 opacity-100",
)}
aria-label="Open AI Assistant"
>
@ -134,18 +139,20 @@ export function ClaudeChatDrawer() {
ref={panelRef}
className={cn(
"pointer-events-auto flex w-full flex-col overflow-hidden border bg-background transition-[height,max-width,border-radius,border-color,box-shadow,opacity,transform] duration-300 ease-out",
isExpanded ? "border-transparent shadow-none" : "border-border shadow-2xl",
isExpanded
? "border-transparent shadow-none"
: "border-border shadow-2xl",
isOpen
? "scale-100 opacity-100"
: "pointer-events-none origin-bottom scale-95 opacity-0",
isDragging && "!transition-none"
isDragging && "!transition-none",
)}
style={panelStyle()}
>
{/* Header with drag handle, tab bar, and session selector */}
{isExpanded ? (
<>
<div className="flex items-center justify-start border-b border-border px-2 py-1">
<div className="flex items-center justify-start border-border border-b px-2 py-1">
<button
type="button"
onClick={() => setIsExpanded(false)}

View file

@ -21,15 +21,43 @@ import { useDocumentStore } from "@/stores/document-store";
// ─── Shell Detection ───
const SHELL_LANGUAGES = new Set([
"bash", "sh", "shell", "zsh", "fish", "terminal", "console",
"bash",
"sh",
"shell",
"zsh",
"fish",
"terminal",
"console",
]);
function looksLikeShellCommand(code: string): boolean {
const firstLine = code.trim().split("\n")[0].replace(/^[\$#]\s*/, "").trim();
const firstLine = code
.trim()
.split("\n")[0]
.replace(/^[$#]\s*/, "")
.trim();
const prefixes = [
"wget", "curl", "tlmgr", "apt", "brew", "npm", "pip", "sudo",
"mkdir", "cd ", "cp ", "mv ", "rm ", "git ", "make", "tar ", "unzip",
"latexmk", "pdflatex", "xelatex", "bibtex",
"wget",
"curl",
"tlmgr",
"apt",
"brew",
"npm",
"pip",
"sudo",
"mkdir",
"cd ",
"cp ",
"mv ",
"rm ",
"git ",
"make",
"tar ",
"unzip",
"latexmk",
"pdflatex",
"xelatex",
"bibtex",
];
return prefixes.some((p) => firstLine.startsWith(p));
}
@ -64,7 +92,8 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
const match = /language-(\w+)/.exec(codeClassName || "");
const language = match?.[1];
const code = String(children).replace(/\n$/, "");
const isBlock = node?.position &&
const isBlock =
node?.position &&
node.position.start.line !== node.position.end.line;
if (!match && !isBlock) {
@ -138,9 +167,12 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
stderr: result.stderr,
});
// Refresh file tree to pick up any new/deleted files
useDocumentStore.getState().refreshFiles().catch((err) => {
console.error("Failed to refresh files:", err);
});
useDocumentStore
.getState()
.refreshFiles()
.catch((err) => {
console.error("Failed to refresh files:", err);
});
} catch (err: any) {
setRunState({ status: "error", message: err?.message || String(err) });
}
@ -186,7 +218,10 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<AlertTriangleIcon className="size-3.5 text-yellow-500" />
<span className="text-xs">
Run in <code className="rounded bg-muted px-1 text-xs">{projectRoot?.split("/").pop()}/</code>
Run in{" "}
<code className="rounded bg-muted px-1 text-xs">
{projectRoot?.split("/").pop()}/
</code>
</span>
</div>
<div className="flex gap-2">
@ -213,7 +248,9 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
{runState.status === "running" && (
<div className="mt-1 flex items-center gap-2 rounded-lg border border-border bg-[#1e1e2e] px-3 py-2 text-sm">
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
<span className="font-mono text-muted-foreground text-xs">Running...</span>
<span className="font-mono text-muted-foreground text-xs">
Running...
</span>
</div>
)}
@ -246,9 +283,9 @@ const CommandOutput: FC<{
}> = ({ exitCode, stdout, stderr }) => {
const [expanded, setExpanded] = useState(true);
const success = exitCode === 0;
const output = (stdout + (stderr ? "\n" + stderr : "")).trim();
const output = (stdout + (stderr ? `\n${stderr}` : "")).trim();
const truncated =
output.length > 2000 ? output.slice(0, 2000) + "\n..." : output;
output.length > 2000 ? `${output.slice(0, 2000)}\n...` : output;
return (
<div className="mt-1 rounded-lg border border-border bg-[#1e1e2e] text-sm">
@ -276,8 +313,8 @@ const CommandOutput: FC<{
</span>
</button>
{expanded && truncated && (
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-gray-300 text-xs">
{truncated}
</pre>
</div>

View file

@ -27,7 +27,7 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-foreground">Proposed Changes</span>
{totalChanges > 1 && (
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 font-medium text-violet-600 text-xs dark:text-violet-400">
{changeIndex + 1}/{totalChanges} files
</span>
)}
@ -39,7 +39,7 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
<div className="flex items-center gap-1.5">
<button
onClick={onKeep}
className="flex items-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs hover:bg-green-600/30 transition-colors"
className="flex items-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs transition-colors hover:bg-green-600/30"
>
<Check className="size-3.5" />
Keep All
@ -49,7 +49,7 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
</button>
<button
onClick={onUndo}
className="flex items-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs hover:bg-red-600/30 transition-colors"
className="flex items-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs transition-colors hover:bg-red-600/30"
>
<X className="size-3.5" />
Undo All

View file

@ -46,12 +46,11 @@ export function SessionSelector() {
const loadSessions = useCallback(async () => {
if (!projectRoot) return;
setIsLoading(true);
log.debug("loading sessions for projectRoot: " + projectRoot);
log.debug(`loading sessions for projectRoot: ${projectRoot}`);
try {
const result = await invoke<ClaudeSessionInfo[]>(
"list_claude_sessions",
{ projectPath: projectRoot },
);
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
projectPath: projectRoot,
});
log.debug("loaded sessions", { count: result.length });
setSessions(result);
} catch (err) {
@ -75,7 +74,7 @@ export function SessionSelector() {
(sid: string) => {
if (isStreaming) return;
if (sid === sessionId) return;
log.debug("selecting session: " + sid);
log.debug(`selecting session: ${sid}`);
resumeSession(sid);
},
[isStreaming, sessionId, resumeSession],
@ -119,7 +118,7 @@ export function SessionSelector() {
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
</div>
) : sessions.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
<div className="px-2 py-4 text-center text-muted-foreground text-sm">
No previous sessions
</div>
) : (
@ -132,7 +131,7 @@ export function SessionSelector() {
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{session.title}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{formatRelativeTime(session.last_modified)}
</span>
</div>

View file

@ -1,7 +1,27 @@
import { type FC, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
type FC,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon, XIcon, SearchIcon, FlaskConicalIcon, ChevronRightIcon, ChevronLeftIcon } from "lucide-react";
import {
CommandIcon,
FolderOpenIcon,
GlobeIcon,
TerminalIcon,
FileCodeIcon,
ZapIcon,
XIcon,
SearchIcon,
FlaskConicalIcon,
ChevronRightIcon,
ChevronLeftIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface SlashCommand {
@ -43,12 +63,22 @@ function scopeToTab(scope: string): Tab {
}
function getCommandIcon(command: SlashCommand) {
if (command.scope === "skill") return <FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
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.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" />;
if (command.scope === "default") return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "skill")
return (
<FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />
);
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.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" />;
if (command.scope === "default")
return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
}
@ -64,9 +94,13 @@ const SCORE_MATCH_CAPITAL = 0.7;
const SCORE_MATCH_DOT = 0.6;
const SCORE_MAX_LEADING_GAP = -0.05;
function isWordBoundary(prev: string, curr: string): boolean {
function _isWordBoundary(prev: string, curr: string): boolean {
return (
prev === "-" || prev === "_" || prev === " " || prev === "/" || prev === "." ||
prev === "-" ||
prev === "_" ||
prev === " " ||
prev === "/" ||
prev === "." ||
(prev === prev.toLowerCase() && curr === curr.toUpperCase())
);
}
@ -75,7 +109,8 @@ function bonusFor(prev: string, curr: string): number {
if (prev === "/") return SCORE_MATCH_SLASH;
if (prev === "-" || prev === "_" || prev === " ") return SCORE_MATCH_WORD;
if (prev === ".") return SCORE_MATCH_DOT;
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
if (prev === prev.toLowerCase() && curr === curr.toUpperCase())
return SCORE_MATCH_CAPITAL;
return 0;
}
@ -117,14 +152,17 @@ function fuzzyScore(query: string, candidate: string): number {
if (i === 0) {
// First char of query
score = j === 0
? SCORE_MATCH_CONSECUTIVE // start of string
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) + bonusFor(candidate[j - 1], candidate[j]);
score =
j === 0
? SCORE_MATCH_CONSECUTIVE // start of string
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) +
bonusFor(candidate[j - 1], candidate[j]);
} else if (j > 0) {
// Consecutive match bonus
const consecutive = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
// Non-consecutive: gap penalty from best previous match
const boundary = M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
const boundary =
M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
score = Math.max(consecutive, boundary);
}
@ -207,7 +245,7 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
// 2. Description: only substring (contains) match to avoid false positives on long text
let descScore = -Infinity;
if (cmd.description && cmd.description.toLowerCase().includes(q.toLowerCase())) {
if (cmd.description?.toLowerCase().includes(q.toLowerCase())) {
descScore = q.length * 0.3; // modest score for description-only match
}
@ -215,10 +253,7 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
if (fuzzy > -Infinity) return fuzzy;
// 3. Typo-tolerant fallback on command key / name (handles bioarxiv → biorxiv)
const typo = Math.max(
typoScore(q, cmdKey),
typoScore(q, cmd.name),
);
const typo = Math.max(typoScore(q, cmdKey), typoScore(q, cmd.name));
return typo;
}
@ -252,23 +287,58 @@ function SkillPreview({ content }: { content: string }) {
}
return (
<div className="prose prose-sm prose-invert max-w-none text-xs leading-relaxed text-muted-foreground">
<div className="prose prose-sm prose-invert max-w-none text-muted-foreground text-xs leading-relaxed">
{body.split("\n").map((line, i) => {
const trimmed = line.trimEnd();
if (trimmed.startsWith("# ")) {
return <h3 key={i} className="text-sm font-semibold text-foreground mt-3 mb-1">{trimmed.slice(2)}</h3>;
return (
<h3
key={i}
className="mt-3 mb-1 font-semibold text-foreground text-sm"
>
{trimmed.slice(2)}
</h3>
);
}
if (trimmed.startsWith("## ")) {
return <h4 key={i} className="text-xs font-semibold text-foreground mt-2.5 mb-0.5">{trimmed.slice(3)}</h4>;
return (
<h4
key={i}
className="mt-2.5 mb-0.5 font-semibold text-foreground text-xs"
>
{trimmed.slice(3)}
</h4>
);
}
if (trimmed.startsWith("### ")) {
return <h5 key={i} className="text-xs font-medium text-foreground mt-2 mb-0.5">{trimmed.slice(4)}</h5>;
return (
<h5
key={i}
className="mt-2 mb-0.5 font-medium text-foreground text-xs"
>
{trimmed.slice(4)}
</h5>
);
}
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
return <div key={i} className="pl-3 before:content-['·'] before:mr-1.5 before:text-muted-foreground/50">{trimmed.slice(2)}</div>;
return (
<div
key={i}
className="pl-3 before:mr-1.5 before:text-muted-foreground/50 before:content-['·']"
>
{trimmed.slice(2)}
</div>
);
}
if (trimmed.startsWith("```")) {
return <div key={i} className="font-mono text-[10px] text-muted-foreground/70">{trimmed}</div>;
return (
<div
key={i}
className="font-mono text-[10px] text-muted-foreground/70"
>
{trimmed}
</div>
);
}
if (trimmed === "") {
return <div key={i} className="h-1.5" />;
@ -292,7 +362,11 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
const [activeTab, setActiveTab] = useState<Tab>("skills");
const [showPreview, setShowPreview] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; right: number; bottom: number }>({ left: 0, right: 0, bottom: 0 });
const [pos, setPos] = useState<{
left: number;
right: number;
bottom: number;
}>({ left: 0, right: 0, bottom: 0 });
const isSearching = query.length > 0;
@ -352,9 +426,16 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
const searchGroups = useMemo(() => {
if (!isSearching) return null;
const groups: { label: string; items: { cmd: SlashCommand; globalIndex: number }[] }[] = [];
const groups: {
label: string;
items: { cmd: SlashCommand; globalIndex: number }[];
}[] = [];
const order: Tab[] = ["skills", "default", "custom"];
const labels: Record<Tab, string> = { skills: "Skills", default: "Default", custom: "Custom" };
const labels: Record<Tab, string> = {
skills: "Skills",
default: "Default",
custom: "Custom",
};
for (const tab of order) {
const items: { cmd: SlashCommand; globalIndex: number }[] = [];
@ -430,7 +511,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const selected = listRef.current.querySelector(`[data-index="${selectedIndex}"]`);
const selected = listRef.current.querySelector(
`[data-index="${selectedIndex}"]`,
);
selected?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedIndex]);
@ -443,7 +526,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
key={cmd.id}
data-index={index}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-colors",
"flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-left transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onMouseDown={(e) => {
@ -453,9 +536,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
onMouseEnter={() => setSelectedIndex(index)}
>
{getCommandIcon(cmd)}
<span className="font-mono text-sm truncate">{cmd.full_command}</span>
<span className="truncate font-mono text-sm">{cmd.full_command}</span>
{cmd.description && (
<span className="truncate text-xs text-muted-foreground flex-1 min-w-0">
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
{cmd.description}
</span>
)}
@ -482,29 +565,34 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
if (isSearching) {
return (
<div className="flex flex-col items-center justify-center py-8">
<SearchIcon className="size-6 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">No results for "{query}"</span>
<SearchIcon className="mb-2 size-6 text-muted-foreground" />
<span className="text-muted-foreground text-sm">
No results for "{query}"
</span>
</div>
);
}
const hints: Record<Tab, React.ReactNode> = {
skills: (
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
Install scientific skills from the sidebar menu.
</p>
),
default: null,
custom: (
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
Add commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
Add commands in <code className="px-1">.claude/commands/</code> or{" "}
<code className="px-1">~/.claude/commands/</code>
</p>
),
};
return (
<div className="flex flex-col items-center justify-center py-8">
<span className="text-sm text-muted-foreground">No commands available</span>
<span className="text-muted-foreground text-sm">
No commands available
</span>
{hints[activeTab]}
</div>
);
@ -514,7 +602,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<div className="flex-1 overflow-y-auto" ref={listRef}>
{isLoading && (
<div className="flex items-center justify-center py-8">
<span className="text-sm text-muted-foreground">Loading...</span>
<span className="text-muted-foreground text-sm">Loading...</span>
</div>
)}
@ -526,7 +614,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<div className="space-y-2">
{searchGroups.map((group) => (
<div key={group.label}>
<h3 className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<h3 className="px-3 py-1 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
{group.label}
</h3>
<div className="space-y-0.5">
@ -559,16 +647,18 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
}}
>
{/* Left side: list */}
<div className={cn(
"flex flex-col overflow-hidden transition-all",
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
)}>
<div
className={cn(
"flex flex-col overflow-hidden transition-all",
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
)}
>
{/* Header */}
<div className="border-b border-border px-3 pt-2.5 pb-2 shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="shrink-0 border-border border-b px-3 pt-2.5 pb-2">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<CommandIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
<span className="font-medium text-sm">
{isSearching ? `Search: "${query}"` : "Commands"}
</span>
</div>
@ -590,7 +680,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<button
key={tab}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-md px-2.5 py-1 font-medium text-xs transition-colors",
activeTab === tab
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
@ -600,14 +690,20 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
setActiveTab(tab);
}}
>
{tab === "skills" ? "Skills" : tab === "default" ? "Default" : "Custom"}
{tab === "skills"
? "Skills"
: tab === "default"
? "Default"
: "Custom"}
{tabCounts[tab] > 0 && (
<span className={cn(
"rounded-full px-1.5 text-[10px] leading-4 tabular-nums",
activeTab === tab
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted-foreground/15 text-muted-foreground",
)}>
<span
className={cn(
"rounded-full px-1.5 text-[10px] tabular-nums leading-4",
activeTab === tab
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted-foreground/15 text-muted-foreground",
)}
>
{tabCounts[tab]}
</span>
)}
@ -620,18 +716,19 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
{renderList()}
{/* Footer */}
<div className="border-t border-border px-3 py-1 shrink-0">
<div className="shrink-0 border-border border-t px-3 py-1">
<span className="text-[10px] text-muted-foreground">
Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc Close
Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc
Close
</span>
</div>
</div>
{/* Right side: preview panel */}
{showPreview && selectedCommand && (
<div className="flex flex-col w-[55%] border-l border-border">
<div className="flex w-[55%] flex-col border-border border-l">
{/* Preview header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-2 shrink-0">
<div className="flex shrink-0 items-center gap-2 border-border border-b px-3 py-2">
<button
aria-label="Close preview"
onMouseDown={(e) => {
@ -643,7 +740,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<ChevronLeftIcon className="size-3.5 text-muted-foreground" />
</button>
<FlaskConicalIcon className="size-3.5 text-muted-foreground" />
<span className="font-mono text-sm font-medium truncate">{selectedCommand.full_command}</span>
<span className="truncate font-medium font-mono text-sm">
{selectedCommand.full_command}
</span>
</div>
{/* Preview body */}

View file

@ -4,7 +4,6 @@ import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleDotIcon,
CircleIcon,
ClockIcon,
FileEditIcon,
@ -17,7 +16,10 @@ import {
TerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useClaudeChatStore, type ContentBlock } from "@/stores/claude-chat-store";
import {
useClaudeChatStore,
type ContentBlock,
} from "@/stores/claude-chat-store";
interface ToolWidgetProps {
toolUse: ContentBlock;
@ -27,16 +29,30 @@ interface ToolWidgetProps {
export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
const name = toolUse.name?.toLowerCase() || "";
if (name === "write") return <WriteWidget input={toolUse.input} result={toolResult} />;
if (name === "edit" || name === "multiedit") return <EditWidget input={toolUse.input} result={toolResult} />;
if (name === "read") return <ReadWidget input={toolUse.input} result={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} />;
if (name === "write")
return <WriteWidget input={toolUse.input} result={toolResult} />;
if (name === "edit" || name === "multiedit")
return <EditWidget input={toolUse.input} result={toolResult} />;
if (name === "read")
return <ReadWidget input={toolUse.input} result={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} />;
return (
<GenericWidget
name={toolUse.name || "unknown"}
input={toolUse.input}
result={toolResult}
/>
);
};
// ─── Status Icon ───
@ -48,24 +64,31 @@ const StatusIcon: FC<{ result?: ContentBlock }> = ({ result }) => {
// Tool was cancelled (stop pressed) — show stopped state
return <CircleIcon className="size-3.5 text-muted-foreground" />;
}
return <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />;
return (
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
);
}
if (result.is_error) {
return <span className="text-sm text-destructive">!</span>;
return <span className="text-destructive text-sm">!</span>;
}
return <CheckIcon className="size-3.5 text-green-600" />;
};
// ─── Write Widget ───
const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
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 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>
<code className="rounded bg-muted px-1 text-xs">
{input?.file_path}
</code>
</span>
</div>
);
@ -73,7 +96,10 @@ const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result
// ─── Edit Widget ───
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const [expanded, setExpanded] = useState(false);
return (
@ -87,18 +113,25 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
<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>
<code className="rounded bg-muted px-1 text-xs">
{input?.file_path}
</code>
</span>
{(input?.old_string || input?.edits) && (
expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
)}
{(input?.old_string || input?.edits) &&
(expanded ? (
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
) : (
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
))}
</button>
{expanded && input?.old_string && (
<div className="border-t border-border px-3 py-2 font-mono text-xs">
<div className="mb-1 text-red-500">- {truncate(input.old_string, 200)}</div>
<div className="text-green-500">+ {truncate(input.new_string, 200)}</div>
<div className="border-border border-t px-3 py-2 font-mono text-xs">
<div className="mb-1 text-red-500">
- {truncate(input.old_string, 200)}
</div>
<div className="text-green-500">
+ {truncate(input.new_string, 200)}
</div>
</div>
)}
</div>
@ -107,14 +140,19 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── Read Widget ───
const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
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 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>
<code className="rounded bg-muted px-1 text-xs">
{input?.file_path}
</code>
</span>
</div>
);
@ -122,10 +160,14 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── Bash Widget ───
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const [expanded, setExpanded] = useState(false);
const command = input?.command || input?.description || "";
const resultContent = typeof result?.content === "string" ? result.content : "";
const resultContent =
typeof result?.content === "string" ? result.content : "";
return (
<div className="my-1.5 rounded-lg border border-border bg-[#1e1e2e] text-sm">
@ -136,16 +178,19 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
>
<StatusIcon result={result} />
<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" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
)}
<code className="min-w-0 truncate text-green-300 text-xs">
$ {truncate(command, 80)}
</code>
{result &&
(expanded ? (
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
) : (
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
))}
</button>
{expanded && resultContent && (
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-gray-300 text-xs">
{truncate(resultContent, 2000)}
</pre>
</div>
@ -156,7 +201,10 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── Glob Widget ───
const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
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} />
@ -171,7 +219,10 @@ const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── Grep Widget ───
const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
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} />
@ -186,7 +237,10 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── AskUserQuestion Widget ───
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const questions: any[] = input?.questions || [];
const [answered, setAnswered] = useState(false);
@ -194,9 +248,10 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
// 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 needsUserAnswer =
!answered && !isStreaming && (!result || result.is_error);
const handleOptionClick = (question: string, label: string) => {
const handleOptionClick = (_question: string, label: string) => {
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
if (isStreaming) return;
setAnswered(true);
@ -225,11 +280,13 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
: "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={`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" />
@ -243,22 +300,24 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
{headerLabel}
</span>
</div>
<div className="space-y-3 border-t border-blue-500/20 px-3 py-2.5">
<div className="space-y-3 border-blue-500/20 border-t 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">
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 font-medium text-blue-600 text-xs dark:text-blue-400">
{q.header}
</span>
)}
<p className="text-sm font-medium text-foreground">{q.question}</p>
<p className="font-medium text-foreground text-sm">{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)}
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"
@ -266,18 +325,26 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
}`}
>
<div className="mt-0.5">
<CircleIcon className={`size-3.5 ${
needsUserAnswer ? "text-blue-500/50" : "text-muted-foreground/40"
}`} />
<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"
}`}>
<div className="min-w-0 flex-1">
<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">
<p className="mt-0.5 text-muted-foreground/70 text-xs">
{opt.description}
</p>
)}
@ -294,7 +361,10 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
// ─── TodoWrite Widget ───
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const [expanded, setExpanded] = useState(true);
const todos: any[] = Array.isArray(input?.todos) ? input.todos : [];
@ -323,12 +393,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
<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" />}
{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">
<div className="space-y-0.5 border-border border-t px-3 py-2">
{todos.map((todo, idx) => (
<div
key={idx}
@ -340,14 +412,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
<span
className={`text-xs ${
todo.status === "completed"
? "line-through text-muted-foreground"
? "text-muted-foreground line-through"
: todo.status === "in_progress"
? "font-medium text-foreground"
: "text-muted-foreground"
}`}
>
{todo.status === "in_progress"
? (todo.activeForm || todo.content)
? todo.activeForm || todo.content
: todo.content}
</span>
</div>
@ -360,11 +432,11 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
// ─── Generic Widget ───
const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = ({
name,
input,
result,
}) => {
const GenericWidget: FC<{
name: string;
input: any;
result?: ContentBlock;
}> = ({ name, input, result }) => {
const [expanded, setExpanded] = useState(false);
return (
@ -379,13 +451,15 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
<span className="text-muted-foreground">
{result ? "Ran" : "Running"} <code className="text-xs">{name}</code>
</span>
{expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
{expanded ? (
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
) : (
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
)}
</button>
{expanded && input && (
<div className="max-h-32 overflow-auto border-t border-border px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
<div className="max-h-32 overflow-auto border-border border-t px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-muted-foreground text-xs">
{JSON.stringify(input, null, 2)}
</pre>
</div>
@ -396,29 +470,35 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
// ─── Thinking Widget ───
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ thinking }) => {
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">
<div className="my-1.5 overflow-hidden rounded-lg border border-muted-foreground/20 bg-muted-foreground/5">
<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"
className="flex w-full items-center justify-between px-3 py-2 transition-colors hover:bg-muted-foreground/10"
>
<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" />
<SparklesIcon className="absolute -top-1 -right-1 size-2.5 animate-pulse text-muted-foreground/70" />
</div>
<span className="text-sm font-medium text-muted-foreground italic">Thinking...</span>
<span className="font-medium text-muted-foreground text-sm italic">
Thinking...
</span>
</div>
<ChevronRightIcon className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
<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">
<div className="border-muted-foreground/20 border-t px-3 pt-2 pb-3">
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-muted-foreground text-xs italic">
{trimmed}
</pre>
</div>
@ -431,5 +511,5 @@ export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ t
function truncate(str: string, max: number): string {
if (!str) return "";
return str.length > max ? str.slice(0, max) + "..." : str;
return str.length > max ? `${str.slice(0, max)}...` : str;
}

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
DownloadIcon,
@ -16,7 +16,10 @@ import {
} from "lucide-react";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { Button } from "@/components/ui/button";
import { useClaudeSetupStore, type StepInfo } from "@/stores/claude-setup-store";
import {
useClaudeSetupStore,
type StepInfo,
} from "@/stores/claude-setup-store";
import { cn } from "@/lib/utils";
// ─── Event Hooks ───
@ -34,7 +37,9 @@ function useInstallEvents() {
const timer = setTimeout(() => {
if (cancelled) return;
const store = useClaudeSetupStore.getState();
const downloadStep = store.installSteps.find((s) => s.id === "downloading");
const downloadStep = store.installSteps.find(
(s) => s.id === "downloading",
);
if (downloadStep?.status === "active") {
store._advanceInstallStep("installing");
}
@ -52,7 +57,11 @@ function useInstallEvents() {
if (lower.includes("setting up") || lower.includes("installing")) {
store._advanceInstallStep("installing");
}
if (lower.includes("complete") || lower.includes("successfully") || line.includes("✅")) {
if (
lower.includes("complete") ||
lower.includes("successfully") ||
line.includes("✅")
) {
store._advanceInstallStep("verifying");
}
});
@ -62,11 +71,14 @@ function useInstallEvents() {
useClaudeSetupStore.getState()._appendInstallLog(event.payload);
});
const unlistenComplete = await listen<boolean>("install-complete", (event) => {
if (cancelled) return;
clearTimeout(timer);
useClaudeSetupStore.getState()._finishInstall(event.payload);
});
const unlistenComplete = await listen<boolean>(
"install-complete",
(event) => {
if (cancelled) return;
clearTimeout(timer);
useClaudeSetupStore.getState()._finishInstall(event.payload);
},
);
if (cancelled) {
unlistenOutput();
@ -102,7 +114,7 @@ function useLoginEvents() {
}, 1500);
(async () => {
const unlistenOutput = await listen<string>("login-output", (event) => {
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");
@ -112,11 +124,14 @@ function useLoginEvents() {
// ignore stderr for login
});
const unlistenComplete = await listen<boolean>("login-complete", (event) => {
if (cancelled) return;
clearTimeout(timer);
useClaudeSetupStore.getState()._finishLogin(event.payload);
});
const unlistenComplete = await listen<boolean>(
"login-complete",
(event) => {
if (cancelled) return;
clearTimeout(timer);
useClaudeSetupStore.getState()._finishLogin(event.payload);
},
);
if (cancelled) {
unlistenOutput();
@ -159,7 +174,7 @@ function StepRow({ step }: { step: StepInfo }) {
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.status === "error" && "text-destructive",
)}
>
{step.label}
@ -184,12 +199,12 @@ function InstallLogOutput() {
<div className="mt-1">
<button
onClick={toggle}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground"
>
<ChevronRightIcon
className={cn(
"size-3 transition-transform duration-200",
visible && "rotate-90"
visible && "rotate-90",
)}
/>
{visible ? "Hide logs" : "Show logs"}
@ -200,12 +215,12 @@ function InstallLogOutput() {
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300 ease-in-out",
visible ? "max-h-40" : "max-h-0"
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"
className="mt-2 max-h-36 overflow-y-auto rounded-md border border-border bg-foreground/3 p-3 font-mono text-[11px] text-muted-foreground leading-relaxed"
>
{logs.length === 0 ? (
<span className="italic">Waiting for output...</span>
@ -244,7 +259,7 @@ export function ClaudeSetup() {
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">
<span className="text-muted-foreground text-sm">
Checking Claude Code...
</span>
</div>
@ -256,8 +271,8 @@ export function ClaudeSetup() {
<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">
<p className="font-medium text-sm">Claude Code Ready</p>
<p className="truncate text-muted-foreground text-xs">
{[version, accountEmail].filter(Boolean).join(" · ")}
</p>
</div>
@ -271,7 +286,7 @@ export function ClaudeSetup() {
<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>
<p className="font-medium text-sm">Installing Claude Code</p>
</div>
<div className="space-y-0 pl-1">
@ -291,7 +306,7 @@ export function ClaudeSetup() {
<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>
<p className="font-medium text-sm">Signing in to Claude</p>
</div>
<div className="space-y-0 pl-1">
@ -314,7 +329,7 @@ export function ClaudeSetup() {
<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">
<p className="font-medium text-sm">
{hasInstallSteps ? "Installation Failed" : "Setup Error"}
</p>
</div>
@ -328,7 +343,7 @@ export function ClaudeSetup() {
)}
{error && (
<p className="text-xs leading-relaxed text-muted-foreground">
<p className="text-muted-foreground text-xs leading-relaxed">
{error}
</p>
)}
@ -354,10 +369,10 @@ export function ClaudeSetup() {
<div className="flex items-center gap-2">
<GitBranchIcon className="size-5 shrink-0 text-amber-600" />
<div>
<p className="text-sm font-medium">Git for Windows Required</p>
<p className="text-xs text-muted-foreground">
Claude Code needs Git for Windows (git-bash) to work.
Please install it first, then click "I've installed Git".
<p className="font-medium text-sm">Git for Windows Required</p>
<p className="text-muted-foreground text-xs">
Claude Code needs Git for Windows (git-bash) to work. Please
install it first, then click "I've installed Git".
</p>
</div>
</div>
@ -391,17 +406,13 @@ export function ClaudeSetup() {
<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">
<p className="font-medium text-sm">Claude Code Required</p>
<p className="text-muted-foreground text-xs">
ClaudePrism needs Claude Code CLI to power AI features.
</p>
</div>
</div>
<Button
size="sm"
className="w-full gap-2"
onClick={install}
>
<Button size="sm" className="w-full gap-2" onClick={install}>
<DownloadIcon className="size-3.5" />
Install Claude Code
</Button>
@ -418,22 +429,18 @@ export function ClaudeSetup() {
<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">
<p className="font-medium text-sm">Sign in to Claude</p>
<p className="text-muted-foreground text-xs">
Authenticate with your Anthropic account to continue.
</p>
</div>
</div>
{version && (
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
Claude Code {version} installed
</p>
)}
<Button
size="sm"
className="w-full gap-2"
onClick={login}
>
<Button size="sm" className="w-full gap-2" onClick={login}>
<LogInIcon className="size-3.5" />
Sign in with Browser
</Button>

View file

@ -44,7 +44,9 @@ export function DebugPage() {
// Fetch system info once
useEffect(() => {
invoke<SystemInfo>("get_system_info").then(setSystemInfo).catch(() => {});
invoke<SystemInfo>("get_system_info")
.then(setSystemInfo)
.catch(() => {});
}, []);
// Auto-scroll logs only if already scrolled to bottom.
@ -55,7 +57,8 @@ export function DebugPage() {
const container = logContainerRef.current;
if (!container) return;
// Check before new content is painted
const gap = container.scrollHeight - container.scrollTop - container.clientHeight;
const gap =
container.scrollHeight - container.scrollTop - container.clientHeight;
wasAtBottomRef.current = gap < 40;
}); // runs every render, before paint
@ -87,7 +90,11 @@ export function DebugPage() {
const formatTime = (ts: number) => {
const d = new Date(ts);
return d.toLocaleTimeString("en-US", { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
return (
d.toLocaleTimeString("en-US", { hour12: false }) +
"." +
String(d.getMilliseconds()).padStart(3, "0")
);
};
return (
@ -96,14 +103,18 @@ export function DebugPage() {
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<BugIcon className="size-4" />
<h1 className="text-sm font-semibold">Debug</h1>
<h1 className="font-semibold text-sm">Debug</h1>
</div>
<button
type="button"
onClick={handleCopyReport}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 font-medium text-primary-foreground text-xs hover:bg-primary/90"
>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
{copied ? (
<CheckIcon className="size-3.5" />
) : (
<CopyIcon className="size-3.5" />
)}
{copied ? "Copied!" : "Copy Bug Report"}
</button>
</div>
@ -115,7 +126,7 @@ export function DebugPage() {
key={t}
type="button"
onClick={() => setTab(t)}
className={`px-3 py-1.5 text-xs font-medium capitalize border-b-2 transition-colors ${
className={`border-b-2 px-3 py-1.5 font-medium text-xs capitalize transition-colors ${
tab === t
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
@ -133,7 +144,9 @@ export function DebugPage() {
<div className="flex gap-2">
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value as LogLevel | "all")}
onChange={(e) =>
setLevelFilter(e.target.value as LogLevel | "all")
}
className="rounded border bg-background px-2 py-1 text-xs"
>
<option value="all">All levels</option>
@ -149,7 +162,9 @@ export function DebugPage() {
>
<option value="">All sources</option>
{sources.map((s) => (
<option key={s} value={s}>{s}</option>
<option key={s} value={s}>
{s}
</option>
))}
</select>
<input
@ -169,18 +184,31 @@ export function DebugPage() {
</button>
</div>
<div ref={logContainerRef} className="flex-1 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]">
<div
ref={logContainerRef}
className="flex-1 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]"
>
{filteredEntries.length === 0 && (
<p className="text-muted-foreground text-center py-4">No log entries</p>
<p className="py-4 text-center text-muted-foreground">
No log entries
</p>
)}
{filteredEntries.map((entry, i) => (
<div key={i} className="flex gap-2 py-0.5 hover:bg-muted/50">
<span className="text-muted-foreground shrink-0">{formatTime(entry.timestamp)}</span>
<span className={`shrink-0 w-10 uppercase font-semibold ${LEVEL_COLORS[entry.level]}`}>
<span className="shrink-0 text-muted-foreground">
{formatTime(entry.timestamp)}
</span>
<span
className={`w-10 shrink-0 font-semibold uppercase ${LEVEL_COLORS[entry.level]}`}
>
{entry.level}
</span>
<span className="text-muted-foreground shrink-0">[{entry.source}]</span>
<span className="text-foreground break-all">{entry.message}</span>
<span className="shrink-0 text-muted-foreground">
[{entry.source}]
</span>
<span className="break-all text-foreground">
{entry.message}
</span>
</div>
))}
<div ref={logEndRef} />
@ -194,7 +222,9 @@ export function DebugPage() {
{tab === "system" && (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase text-muted-foreground">System Information</h3>
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
System Information
</h3>
{systemInfo ? (
<div className="space-y-1 text-sm">
<Row label="OS" value={systemInfo.os} />
@ -203,13 +233,18 @@ export function DebugPage() {
<Row label="App Version" value={systemInfo.app_version} />
</div>
) : (
<p className="text-sm text-muted-foreground">Loading...</p>
<p className="text-muted-foreground text-sm">Loading...</p>
)}
<h3 className="text-xs font-semibold uppercase text-muted-foreground pt-4">Browser / WebView</h3>
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
Browser / WebView
</h3>
<div className="space-y-1 text-sm">
<Row label="User Agent" value={navigator.userAgent} />
<Row label="Device Pixel Ratio" value={String(window.devicePixelRatio)} />
<Row
label="Device Pixel Ratio"
value={String(window.devicePixelRatio)}
/>
<Row label="GPU Renderer" value={getGpuRenderer()} />
</div>
</div>
@ -217,22 +252,35 @@ export function DebugPage() {
{tab === "visibility" && (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase text-muted-foreground">Visibility State</h3>
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
Visibility State
</h3>
<div className="space-y-1 text-sm">
<Row
label="Current State"
value={document.visibilityState}
valueClass={document.visibilityState === "visible" ? "text-green-500" : "text-yellow-500"}
valueClass={
document.visibilityState === "visible"
? "text-green-500"
: "text-yellow-500"
}
/>
<Row
label="Window Focused"
value={document.hasFocus() ? "Yes" : "No"}
/>
<Row label="Window Focused" value={document.hasFocus() ? "Yes" : "No"} />
<Row label="Restore Events" value={String(visibilityCount)} />
</div>
<h3 className="text-xs font-semibold uppercase text-muted-foreground pt-4">Recent Visibility Logs</h3>
<div className="overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px] max-h-48">
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
Recent Visibility Logs
</h3>
<div className="max-h-48 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]">
{getVisibilityLogs().map((entry, i) => (
<div key={i} className="py-0.5">
<span className="text-muted-foreground">{formatTime(entry.timestamp)}</span>{" "}
<span className="text-muted-foreground">
{formatTime(entry.timestamp)}
</span>{" "}
<span>{entry.message}</span>
</div>
))}
@ -244,10 +292,18 @@ export function DebugPage() {
);
}
function Row({ label, value, valueClass }: { label: string; value: string; valueClass?: string }) {
function Row({
label,
value,
valueClass,
}: {
label: string;
value: string;
valueClass?: string;
}) {
return (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 w-32">{label}:</span>
<span className="w-32 shrink-0 text-muted-foreground">{label}:</span>
<span className={`break-all ${valueClass ?? ""}`}>{value}</span>
</div>
);

View file

@ -3,15 +3,15 @@ import type { FallbackProps } from "react-error-boundary";
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background p-8">
<div className="max-w-2xl w-full space-y-4">
<h1 className="text-2xl font-bold text-destructive">
<div className="w-full max-w-2xl space-y-4">
<h1 className="font-bold text-2xl text-destructive">
Something went wrong
</h1>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
An unexpected error occurred. You can try again or reload the app.
</p>
<pre className="max-h-64 overflow-auto rounded-md border bg-muted p-4 text-xs whitespace-pre-wrap">
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border bg-muted p-4 text-xs">
{error instanceof Error
? `${error.message}${error.stack ? `\n\n${error.stack}` : ""}`
: String(error)}
@ -21,14 +21,14 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
<button
type="button"
onClick={resetErrorBoundary}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground text-sm hover:bg-primary/90"
>
Try again
</button>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent"
className="rounded-md border px-4 py-2 font-medium text-sm hover:bg-accent"
>
Reload
</button>

View file

@ -12,8 +12,6 @@ import {
SparklesIcon,
CheckCircle2Icon,
CircleIcon,
TerminalIcon,
FlaskConicalIcon,
DownloadIcon,
Loader2Icon,
RefreshCwIcon,
@ -80,10 +78,7 @@ export function ProjectPicker() {
if (wizardMode) {
return (
<ProjectWizard
mode={wizardMode}
onBack={() => setWizardMode(null)}
/>
<ProjectWizard mode={wizardMode} onBack={() => setWizardMode(null)} />
);
}
@ -106,7 +101,9 @@ export function ProjectPicker() {
{!isClaudeReady ? <ClaudeSetup /> : <EnvironmentStatus />}
<div className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}>
<div
className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}
>
<Button
onClick={() => setShowModeDialog(true)}
size="lg"
@ -174,9 +171,7 @@ export function ProjectPicker() {
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
How would you like to start?
</DialogDescription>
<DialogDescription>How would you like to start?</DialogDescription>
</DialogHeader>
<div className="flex gap-3 pt-2">
<button
@ -192,7 +187,7 @@ export function ProjectPicker() {
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">
<span className="rounded-full bg-foreground/8 px-2 py-0.5 font-medium text-[10px] text-muted-foreground">
Recommended
</span>
</button>
@ -238,7 +233,7 @@ function EnvironmentStatus() {
const _finishUvInstall = useUvSetupStore((s) => s._finishInstall);
const [skillsStatus, setSkillsStatus] = useState<SkillsStatus | null>(null);
const [skillsInstalling, setSkillsInstalling] = useState(false);
const [skillsInstalling, _setSkillsInstalling] = useState(false);
const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
const checkSkills = useCallback(async () => {
@ -268,21 +263,24 @@ function EnvironmentStatus() {
}, [_finishUvInstall]);
// Lazy load skills onboarding
const [OnboardingComponent, setOnboardingComponent] = useState<React.ComponentType<{
onClose: () => void;
}> | null>(null);
const [OnboardingComponent, setOnboardingComponent] =
useState<React.ComponentType<{
onClose: () => void;
}> | null>(null);
useEffect(() => {
if (showSkillsOnboarding && !OnboardingComponent) {
import("@/components/scientific-skills/scientific-skills-onboarding").then(
(mod) => setOnboardingComponent(() => mod.ScientificSkillsOnboarding)
import(
"@/components/scientific-skills/scientific-skills-onboarding"
).then((mod) =>
setOnboardingComponent(() => mod.ScientificSkillsOnboarding),
);
}
}, [showSkillsOnboarding, OnboardingComponent]);
return (
<>
<div className="flex w-full flex-col rounded-xl border border-border bg-muted/30 px-4 py-3 gap-2">
<div className="flex w-full flex-col gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3">
{/* Claude Code — always ready here */}
<StatusRow
ok={true}
@ -298,7 +296,7 @@ function EnvironmentStatus() {
uvInstalling
? "Installing..."
: uvStatus === "ready"
? uvVersion ?? "Installed"
? (uvVersion ?? "Installed")
: uvStatus === "checking"
? "Checking..."
: "Not installed"
@ -325,7 +323,10 @@ function EnvironmentStatus() {
}
action={
!skillsStatus?.installed && !skillsInstalling
? { label: "Install", onClick: () => setShowSkillsOnboarding(true) }
? {
label: "Install",
onClick: () => setShowSkillsOnboarding(true),
}
: undefined
}
/>
@ -355,7 +356,7 @@ function StatusRow({
action?: { label: string; onClick?: () => void; loading?: boolean };
}) {
return (
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex min-w-0 items-center gap-2.5">
{ok ? (
<CheckCircle2Icon className="size-3.5 shrink-0 text-foreground" />
) : (
@ -363,20 +364,20 @@ function StatusRow({
)}
<span
className={cn(
"text-sm shrink-0",
ok ? "text-foreground" : "text-muted-foreground"
"shrink-0 text-sm",
ok ? "text-foreground" : "text-muted-foreground",
)}
>
{label}
</span>
<span className="text-xs text-muted-foreground truncate min-w-0 flex-1">
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
{detail}
</span>
{action && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs shrink-0"
className="h-6 shrink-0 px-2 text-xs"
onClick={action.onClick}
disabled={action.loading}
>
@ -412,16 +413,16 @@ function VersionBadge({
return (
<button
onClick={onInstall}
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/20"
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-primary text-xs transition-colors hover:bg-primary/20"
>
<ArrowUpCircleIcon className="size-3.5" />
v{updateStatus.version} available click to update
<ArrowUpCircleIcon className="size-3.5" />v{updateStatus.version}{" "}
available click to update
</button>
);
case "downloading":
return (
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-muted-foreground text-xs">
<Loader2Icon className="size-3.5 animate-spin" />
Downloading... {updateStatus.percent}%
</div>
@ -429,7 +430,7 @@ function VersionBadge({
case "installing":
return (
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-muted-foreground text-xs">
<Loader2Icon className="size-3.5 animate-spin" />
Installing...
</div>
@ -437,7 +438,7 @@ function VersionBadge({
case "ready":
return (
<div className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-3 py-1 text-xs text-green-600">
<div className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-3 py-1 text-green-600 text-xs">
<CheckCircle2Icon className="size-3.5" />
Update complete restarting...
</div>
@ -445,15 +446,15 @@ function VersionBadge({
case "checking":
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2Icon className="size-3 animate-spin" />
v{version} checking for updates...
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />v{version} checking
for updates...
</div>
);
case "error":
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span>v{version}</span>
<span className="mx-0.5">·</span>
<button
@ -468,7 +469,7 @@ function VersionBadge({
case "up-to-date":
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span>v{version}</span>
<span className="mx-0.5">·</span>
<button
@ -483,7 +484,7 @@ function VersionBadge({
default:
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span>v{version}</span>
<span className="mx-0.5">·</span>
<button

View file

@ -22,15 +22,41 @@ 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 {
getTemplateById,
getTemplateSkeleton,
BIB_TEMPLATE,
} from "@/lib/template-registry";
import { TemplateGallery } from "@/components/template-gallery";
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
// ─── 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 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);
@ -52,8 +78,13 @@ export function ProjectWizard({ mode, onBack }: ProjectWizardProps) {
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}>
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[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>
@ -100,15 +131,22 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
homeDir().then((home) => join(home, "Documents", "ClaudePrism")).then((dir) => {
mkdir(dir, { recursive: true }).catch(() => {});
setProjectFolder(dir);
}).catch(() => {});
homeDir()
.then((home) => join(home, "Documents", "ClaudePrism"))
.then((dir) => {
mkdir(dir, { recursive: true }).catch(() => {});
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);
setLastProjectFolder(selected);
@ -119,14 +157,33 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
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];
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
}
}, []);
@ -150,16 +207,25 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
setIsDragOver(false);
const paths = (event.payload as { paths: string[] }).paths;
if (paths?.length > 0) {
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
}
} else if (type === "leave") {
setIsDragOver(false);
}
})
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {});
return () => { cancelled = true; unlisten?.(); };
return () => {
cancelled = true;
unlisten?.();
};
}, []);
const handleCreate = async () => {
@ -197,10 +263,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
}
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 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`,
@ -228,7 +297,9 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
await openProject(projectPath);
if (attachments.length > 0) {
await useDocumentStore.getState().importFiles(attachments, "attachments");
await useDocumentStore
.getState()
.importFiles(attachments, "attachments");
}
} catch (err) {
console.error("Failed to create project:", err);
@ -242,8 +313,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
return (
<div className="flex h-full flex-col bg-background">
{/* 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}>
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[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">New Document</span>
@ -255,9 +331,12 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
{/* Purpose */}
<div className="space-y-2.5">
<div>
<label className="font-semibold text-sm">What are you writing?</label>
<span className="font-semibold text-sm">
What are you writing?
</span>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
Describe your document and Claude will generate tailored content.
Describe your document and Claude will generate tailored
content.
</p>
</div>
<Textarea
@ -271,7 +350,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
</div>
{/* Collapsible sections */}
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
{/* Reference files */}
<div>
<button
@ -281,10 +360,10 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
<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 min-w-0">
<span className="text-sm font-medium">Reference files</span>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">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">
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
{attachments.length}
</span>
)}
@ -294,16 +373,18 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
/>
</button>
{refFilesOpen && (
<div className="px-4 pb-4 space-y-3">
<div className="space-y-3 px-4 pb-4">
{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"
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 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>
<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"
@ -317,23 +398,27 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
<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]"
? "scale-[1.01] border-primary bg-primary/5"
: "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>
<span className="font-medium text-primary text-xs">
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>
<span className="text-muted-foreground/70 text-xs">
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"
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground hover:decoration-foreground/50"
>
browse files
</button>
@ -357,11 +442,11 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
<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 className="min-w-0 flex-1">
<span className="font-medium text-sm">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">
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/{projectName.trim()}
</span>
)}
@ -370,7 +455,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
/>
</button>
{locationOpen && (
<div className="px-4 pb-4 space-y-2.5">
<div className="space-y-2.5 px-4 pb-4">
<div className="flex gap-2">
<Input
placeholder="Project name"

View file

@ -11,7 +11,7 @@ const PHASE_MAP: Record<string, number> = {
"downloading tarball": 20,
"Download complete": 60,
"Copying skills": 70,
"Copied": 90,
Copied: 90,
"Cleanup complete": 95,
};
@ -70,7 +70,7 @@ export function InstallProgress({
<div className="space-y-2 py-1">
<Progress value={pct} />
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs truncate max-w-[80%]">
<p className="max-w-[80%] truncate text-muted-foreground text-xs">
{label}
</p>
<p className="font-mono text-muted-foreground text-xs tabular-nums">
@ -79,7 +79,10 @@ export function InstallProgress({
</div>
{logs.length > 0 && (
<ScrollArea className="h-28 rounded-md border border-border/60 bg-muted/30">
<div ref={scrollRef} className="p-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
<div
ref={scrollRef}
className="p-2 font-mono text-[11px] text-muted-foreground leading-relaxed"
>
{logs.map((line, i) => (
<div key={i}>{line}</div>
))}

View file

@ -56,7 +56,9 @@ export function ScientificSkillsOnboarding({
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [installResult, setInstallResult] = useState<InstallResult | null>(null);
const [installResult, setInstallResult] = useState<InstallResult | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<SkillsStatus | null>(null);
const [isUninstalling, setIsUninstalling] = useState(false);
@ -95,7 +97,9 @@ export function ScientificSkillsOnboarding({
setError(null);
try {
const result = await invoke<InstallResult>("install_scientific_skills_global");
const result = await invoke<InstallResult>(
"install_scientific_skills_global",
);
setInstallResult(result);
setIsComplete(true);
localStorage.setItem(STORAGE_KEY, "true");
@ -111,7 +115,9 @@ export function ScientificSkillsOnboarding({
try {
await invoke("uninstall_scientific_skills", { projectPath: null });
await checkStatus();
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", { projectPath: null });
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
if (!gsAfter.installed) {
localStorage.removeItem(STORAGE_KEY);
}
@ -125,7 +131,12 @@ export function ScientificSkillsOnboarding({
// ─── Installing / Complete state ───
if (isInstalling || isComplete) {
return (
<Dialog open onOpenChange={(open) => { if (!open && (isComplete || error)) onClose(); }}>
<Dialog
open
onOpenChange={(open) => {
if (!open && (isComplete || error)) onClose();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
@ -138,7 +149,8 @@ export function ScientificSkillsOnboarding({
</DialogTitle>
{isComplete && (
<DialogDescription>
{installResult?.skills_installed ?? 0} scientific skills are now available.
{installResult?.skills_installed ?? 0} scientific skills are now
available.
</DialogDescription>
)}
</DialogHeader>
@ -152,7 +164,9 @@ export function ScientificSkillsOnboarding({
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-xs leading-relaxed text-muted-foreground">{error}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{error}
</p>
</div>
)}
@ -161,7 +175,10 @@ export function ScientificSkillsOnboarding({
<Button
variant="outline"
size="sm"
onClick={() => { setError(null); setIsInstalling(false); }}
onClick={() => {
setError(null);
setIsInstalling(false);
}}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
@ -181,18 +198,24 @@ export function ScientificSkillsOnboarding({
// ─── Browse state — two-column layout ───
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
showCloseButton={false}
className="flex max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none w-[min(56rem,calc(100vw-4rem))] h-[min(36rem,calc(100vh-6rem))]"
className="flex h-[min(36rem,calc(100vh-6rem))] w-[min(56rem,calc(100vw-4rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none"
>
{/* Header */}
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">Scientific Skills</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
{totalSkills} AI skills across {categories.length} domains powered by{" "}
{totalSkills} AI skills across {categories.length} domains
powered by{" "}
<a
href="https://github.com/K-Dense-AI/claude-scientific-skills"
target="_blank"
@ -248,7 +271,7 @@ export function ScientificSkillsOnboarding({
{/* Body — sidebar + detail */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<nav className="w-64 shrink-0 overflow-hidden border-r border-border">
<nav className="w-64 shrink-0 overflow-hidden border-border border-r">
<ScrollArea className="h-full">
<div className="flex flex-col gap-0.5 p-2">
{categories.map((cat) => {
@ -266,8 +289,10 @@ export function ScientificSkillsOnboarding({
)}
>
<Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{cat.name}</span>
<span className="text-xs tabular-nums text-muted-foreground">
<span className="min-w-0 flex-1 truncate">
{cat.name}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{cat.skill_count}
</span>
</button>
@ -297,11 +322,16 @@ export function ScientificSkillsOnboarding({
</div>
{/* Footer */}
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/20 px-6 py-2.5">
<p className="font-mono text-muted-foreground/60 text-[11px]">
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5">
<p className="font-mono text-[11px] text-muted-foreground/60">
{isInstalled ? status?.location : "~/.claude/skills/"}
</p>
<Button variant="ghost" size="sm" onClick={onClose} className="text-muted-foreground">
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-muted-foreground"
>
Close
</Button>
</div>
@ -320,7 +350,9 @@ function CategoryDetail({
isInstalled: boolean;
}) {
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(null);
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(
null,
);
const [skillContent, setSkillContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
@ -364,8 +396,12 @@ function CategoryDetail({
return (
<div>
<button
onClick={() => { setSelectedSkill(null); setSkillContent(null); setFetchError(null); }}
className="mb-3 flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => {
setSelectedSkill(null);
setSkillContent(null);
setFetchError(null);
}}
className="mb-3 flex items-center gap-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
>
<ChevronLeftIcon className="size-3.5" />
{category.name}
@ -386,17 +422,19 @@ function CategoryDetail({
<Separator className="my-4" />
{loadingContent ? (
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2 py-4 text-muted-foreground text-xs">
<Loader2Icon className="size-3.5 animate-spin" />
Loading skill content
</div>
) : fetchError ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-xs leading-relaxed text-muted-foreground">{fetchError}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{fetchError}
</p>
</div>
) : skillContent ? (
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-xs leading-relaxed text-foreground/80">
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-foreground/80 text-xs leading-relaxed">
{skillContent}
</div>
) : null}

View file

@ -17,8 +17,6 @@ import {
type TemplateCategory,
type TemplateSubcategory,
CATEGORY_LABELS,
SUBCATEGORY_LABELS,
CATEGORY_SUBCATEGORIES,
getCategories,
getAllTemplates,
getTemplatesByCategory,
@ -32,7 +30,7 @@ const CATEGORY_ICONS: Record<TemplateCategory, React.ReactNode> = {
starter: <SparklesIcon className="size-4" />,
};
const SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
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" />,
@ -63,7 +61,9 @@ export function CategorySidebar() {
>
<SparklesIcon className="size-4" />
<span className="flex-1">All Templates</span>
<span className="text-xs tabular-nums text-muted-foreground">{allCount}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{allCount}
</span>
</button>
<div className="my-1.5 h-px bg-border" />
@ -84,7 +84,9 @@ export function CategorySidebar() {
>
{CATEGORY_ICONS[cat]}
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{count}
</span>
</button>
);
})}

View file

@ -15,14 +15,20 @@ import {
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-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.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" />
@ -35,11 +41,17 @@ 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="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-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>
@ -47,7 +59,7 @@ export function ThumbnailSlides({ color }: { color: string }) {
);
}
export function ThumbnailPoster({ color }: { color: string }) {
export function ThumbnailPoster({ color: _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" />
@ -70,7 +82,7 @@ export function ThumbnailPoster({ color }: { color: string }) {
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 className="font-medium text-muted-foreground/20 text-xs">Empty</div>
</div>
);
}
@ -86,11 +98,13 @@ export const THUMBNAIL_MAP: Record<string, React.FC<{ color: string }>> = {
"letter-formal": ThumbnailPaper,
"report-technical": ThumbnailPaper,
"book-standard": ThumbnailPaper,
"newsletter": ThumbnailPaper,
"blank": ThumbnailBlank,
newsletter: ThumbnailPaper,
blank: ThumbnailBlank,
};
export function getFallbackThumbnail(template: TemplateDefinition): React.FC<{ color: string }> {
export function getFallbackThumbnail(
template: TemplateDefinition,
): React.FC<{ color: string }> {
return THUMBNAIL_MAP[template.id] || ThumbnailPaper;
}
@ -104,14 +118,12 @@ export function TemplateCard({ template }: TemplateCardProps) {
const openPreview = useTemplateStore((s) => s.openPreview);
const FallbackThumbnail = getFallbackThumbnail(template);
const thumbnailUrl = useSyncExternalStore(
subscribeThumbnails,
() => getThumbnail(template.id),
const thumbnailUrl = useSyncExternalStore(subscribeThumbnails, () =>
getThumbnail(template.id),
);
const failed = useSyncExternalStore(
subscribeThumbnails,
() => isThumbnailFailed(template.id),
const failed = useSyncExternalStore(subscribeThumbnails, () =>
isThumbnailFailed(template.id),
);
useEffect(() => {
@ -149,7 +161,7 @@ export function TemplateCard({ template }: TemplateCardProps) {
</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">
<div className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-snug">
{template.description}
</div>
</div>

View file

@ -53,7 +53,7 @@ export function TemplateGallery() {
return (
<div className="flex h-full flex-col">
{/* Search bar */}
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="shrink-0 border-border border-b 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
@ -61,7 +61,7 @@ export function TemplateGallery() {
placeholder="Search templates... ⌘K"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8"
className="pr-8 pl-9"
/>
{searchQuery && (
<button
@ -77,7 +77,7 @@ export function TemplateGallery() {
{/* 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">
<div className="shrink-0 border-border border-r pt-2 pl-3">
<CategorySidebar />
</div>
@ -86,8 +86,10 @@ export function TemplateGallery() {
{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">
<p className="font-medium text-muted-foreground text-sm">
No templates found
</p>
<p className="mt-1 text-muted-foreground/70 text-xs">
Try a different search term or category
</p>
</div>
@ -95,7 +97,9 @@ export function TemplateGallery() {
<GroupedGrid />
) : (
<>
<h2 className="mb-4 font-medium text-sm text-muted-foreground">{heading}</h2>
<h2 className="mb-4 font-medium text-muted-foreground text-sm">
{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} />
@ -118,7 +122,12 @@ function GroupedGrid() {
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
// Group by category preserving order
const categories: TemplateCategory[] = ["academic", "professional", "creative", "starter"];
const categories: TemplateCategory[] = [
"academic",
"professional",
"creative",
"starter",
];
const groups = categories
.map((cat) => ({
category: cat,

View file

@ -32,7 +32,11 @@ 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 {
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";
@ -44,8 +48,30 @@ const log = createLogger("template-preview");
// ─── 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 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);
@ -59,7 +85,9 @@ 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 template = previewTemplateId
? getTemplateById(previewTemplateId)
: null;
const [modalStep, setModalStep] = useState<ModalStep>("preview");
@ -103,7 +131,9 @@ export function TemplatePreview() {
setIsLandscape(false);
setError(false);
if (docIdRef.current > 0) {
getMupdfClient().closeDocument(docIdRef.current).catch(() => {});
getMupdfClient()
.closeDocument(docIdRef.current)
.catch(() => {});
docIdRef.current = 0;
}
}
@ -129,7 +159,9 @@ export function TemplatePreview() {
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
documentDir().then((dir) => setProjectFolder(dir)).catch(() => {});
documentDir()
.then((dir) => setProjectFolder(dir))
.catch(() => {});
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
@ -199,7 +231,13 @@ export function TemplatePreview() {
// ── Render current page ──
useEffect(() => {
if (docIdRef.current <= 0 || numPages === 0 || !canvasRef.current || !containerRef.current) return;
if (
docIdRef.current <= 0 ||
numPages === 0 ||
!canvasRef.current ||
!containerRef.current
)
return;
const pageIndex = currentPage - 1;
const size = pageSizesRef.current[pageIndex];
@ -223,18 +261,21 @@ export function TemplatePreview() {
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) => {
log.warn("render error", { error: String(err) });
});
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) => {
log.warn("render error", { error: String(err) });
});
}, [currentPage, numPages, isLandscape]);
// ── Page navigation ──
@ -251,8 +292,13 @@ export function TemplatePreview() {
if (!previewTemplateId || modalStep !== "preview") return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") { e.preventDefault(); goToPrevPage(); }
else if (e.key === "ArrowRight") { e.preventDefault(); goToNextPage(); }
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevPage();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNextPage();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
@ -275,16 +321,25 @@ export function TemplatePreview() {
setIsDragOver(false);
const paths = (event.payload as { paths: string[] }).paths;
if (paths?.length > 0) {
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
}
} else if (type === "leave") {
setIsDragOver(false);
}
})
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {});
return () => { cancelled = true; unlisten?.(); };
return () => {
cancelled = true;
unlisten?.();
};
}, [modalStep]);
// ── File handlers ──
@ -292,14 +347,33 @@ export function TemplatePreview() {
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];
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
}
}, []);
@ -308,7 +382,11 @@ export function TemplatePreview() {
};
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);
setLastProjectFolder(selected);
@ -344,10 +422,13 @@ export function TemplatePreview() {
}
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 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`,
@ -375,7 +456,9 @@ export function TemplatePreview() {
await openProject(projectPath);
if (attachments.length > 0) {
await useDocumentStore.getState().importFiles(attachments, "attachments");
await useDocumentStore
.getState()
.importFiles(attachments, "attachments");
}
// Close modal on success
@ -392,28 +475,30 @@ export function TemplatePreview() {
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))]";
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]"}`}
className={`flex max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 sm:max-w-none ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
>
{modalStep === "preview" ? (
/* ═══════════════════ PREVIEW STEP ═══════════════════ */
<>
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
<DialogHeader className="shrink-0 border-border border-b 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`}
{template.packages.length > 0 &&
`${template.packages.length} packages`}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
@ -431,7 +516,10 @@ export function TemplatePreview() {
<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">
<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" />
@ -452,14 +540,26 @@ export function TemplatePreview() {
</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}>
<div className="flex shrink-0 items-center justify-center gap-3 border-border border-t 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">
<span className="min-w-16 text-center text-muted-foreground text-xs tabular-nums">
{numPages > 1 ? `${currentPage} / ${numPages}` : "1 page"}
</span>
<Button variant="ghost" size="icon" className="size-7" onClick={goToNextPage} disabled={currentPage >= numPages}>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
@ -471,12 +571,12 @@ export function TemplatePreview() {
/* ═══════════════════ DETAILS STEP ═══════════════════ */
<>
{/* Header */}
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-3">
<DialogHeader className="shrink-0 border-border/60 border-b px-5 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="size-7 rounded-lg shrink-0"
className="size-7 shrink-0 rounded-lg"
onClick={() => setModalStep("preview")}
>
<ArrowLeftIcon className="size-4" />
@ -496,9 +596,12 @@ export function TemplatePreview() {
{/* Purpose — hero element */}
<div className="space-y-2">
<div>
<label className="font-semibold text-sm">What are you writing?</label>
<span className="font-semibold text-sm">
What are you writing?
</span>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
Describe your document and Claude will generate tailored content.
Describe your document and Claude will generate tailored
content.
</p>
</div>
<Textarea
@ -512,7 +615,7 @@ export function TemplatePreview() {
</div>
{/* Collapsible sections */}
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
{/* Reference files */}
<div>
<button
@ -522,10 +625,12 @@ export function TemplatePreview() {
<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>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">
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">
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
{attachments.length}
</span>
)}
@ -535,16 +640,18 @@ export function TemplatePreview() {
/>
</button>
{refFilesOpen && (
<div className="px-4 pb-3 space-y-2.5">
<div className="space-y-2.5 px-4 pb-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"
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 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>
<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"
@ -565,16 +672,20 @@ export function TemplatePreview() {
{isDragOver ? (
<>
<UploadIcon className="size-4 text-primary" />
<span className="text-xs font-medium text-primary">Drop to add</span>
<span className="font-medium text-primary text-xs">
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>
<span className="text-muted-foreground/70 text-xs">
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"
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground"
>
browse files
</button>
@ -595,12 +706,15 @@ export function TemplatePreview() {
<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 className="min-w-0 flex-1">
<span className="font-medium text-sm">
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 className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/
{projectName.trim()}
</span>
)}
<ChevronDownIcon
@ -608,7 +722,7 @@ export function TemplatePreview() {
/>
</button>
{locationOpen && (
<div className="px-4 pb-3 space-y-2">
<div className="space-y-2 px-4 pb-3">
<div className="flex gap-2">
<Input
placeholder="Project name"
@ -639,7 +753,7 @@ export function TemplatePreview() {
</div>
{/* Create button — sticky footer */}
<div className="shrink-0 border-t border-border/60 px-5 py-4">
<div className="shrink-0 border-border/60 border-t 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"

View file

@ -4,14 +4,12 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-md border px-2 py-0.5 font-medium text-xs transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",

View file

@ -13,10 +13,7 @@ function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
{...props}
/>
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";

View file

@ -1,5 +1,3 @@
import {
CircleCheckIcon,
InfoIcon,

View file

@ -40,7 +40,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 font-medium text-sm ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";

View file

@ -1,5 +1,3 @@
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";

View file

@ -86,7 +86,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
{/* uv status */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<StatusIcon status={status} isInstalling={isInstalling} />
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm">
{status === "checking"
? "Checking uv..."
@ -97,12 +97,12 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
: "Error"}
</div>
{version && (
<div className="text-muted-foreground text-xs truncate">
<div className="truncate text-muted-foreground text-xs">
{version}
</div>
)}
{error && (
<div className="text-destructive text-xs mt-1">{error}</div>
<div className="mt-1 text-destructive text-xs">{error}</div>
)}
</div>
{status === "not-installed" && !isInstalling && (
@ -127,23 +127,33 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
"flex size-8 items-center justify-center rounded-full",
venvReady
? "bg-accent text-accent-foreground"
: "bg-muted text-muted-foreground"
: "bg-muted text-muted-foreground",
)}
>
<FolderIcon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm">
{venvReady ? "Virtual Environment Active" : "No Virtual Environment"}
{venvReady
? "Virtual Environment Active"
: "No Virtual Environment"}
</div>
{venvPath && (
<div className="text-muted-foreground text-xs truncate" title={venvPath}>
<div
className="truncate text-muted-foreground text-xs"
title={venvPath}
>
{venvPath}
</div>
)}
{pythonPath && (
<div className="text-muted-foreground text-xs truncate" title={pythonPath}>
Python: {pythonPath.split("/").pop() || pythonPath.split("\\").pop()}
<div
className="truncate text-muted-foreground text-xs"
title={pythonPath}
>
Python:{" "}
{pythonPath.split("/").pop() ||
pythonPath.split("\\").pop()}
</div>
)}
</div>
@ -159,8 +169,9 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
{status === "ready" && venvReady && (
<p className="text-muted-foreground text-xs leading-relaxed">
Claude Code will automatically use this environment when running
Python code. Use <code className="text-foreground">uv pip install</code> to
add packages.
Python code. Use{" "}
<code className="text-foreground">uv pip install</code> to add
packages.
</p>
)}
</div>

View file

@ -80,7 +80,9 @@ export function EditorToolbar({
const [editors, setEditors] = useState<EditorInfo[]>([]);
useEffect(() => {
invoke<EditorInfo[]>("detect_editors").then(setEditors).catch(() => {});
invoke<EditorInfo[]>("detect_editors")
.then(setEditors)
.catch(() => {});
}, []);
const openInEditor = useCallback(
@ -130,7 +132,7 @@ export function EditorToolbar({
if (fileType === "image") {
return (
<div className="flex items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
<div className="flex h-[calc(36px+var(--titlebar-height))] items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)]">
<div className="flex items-center gap-1">
<ImageIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-muted-foreground text-sm">
@ -224,7 +226,7 @@ export function EditorToolbar({
}
return (
<div className="flex items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
<div className="flex h-[calc(36px+var(--titlebar-height))] items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)]">
<FileTextIcon className="size-4 text-muted-foreground" />
<span className="mr-2 font-medium text-muted-foreground text-sm">
{fileName}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ImageIcon, CheckIcon, XIcon } from "lucide-react";
import { CheckIcon, XIcon } from "lucide-react";
import { writeFile } from "@tauri-apps/plugin-fs";
import { toast } from "sonner";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
@ -50,9 +50,17 @@ export function ImagePreview({
// Crop state
const [cropRect, setCropRect] = useState<CropRect | null>(null);
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(null);
const [handleDragStart, setHandleDragStart] = useState<{ x: number; y: number; rect: CropRect } | null>(null);
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(
null,
);
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(
null,
);
const [handleDragStart, setHandleDragStart] = useState<{
x: number;
y: number;
rect: CropRect;
} | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Reset crop rect when exiting crop mode
@ -121,18 +129,15 @@ export function ImagePreview({
}, [scale, onScaleChange, cropMode]);
// Get coordinates relative to the displayed image
const getImageRelativeCoords = useCallback(
(e: React.MouseEvent) => {
const img = imgRef.current;
if (!img) return null;
const rect = img.getBoundingClientRect();
return {
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
};
},
[],
);
const getImageRelativeCoords = useCallback((e: React.MouseEvent) => {
const img = imgRef.current;
if (!img) return null;
const rect = img.getBoundingClientRect();
return {
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
};
}, []);
// --- Crop drag: new selection ---
const handleCropMouseDown = useCallback(
@ -151,7 +156,11 @@ export function ImagePreview({
coords.y <= cropRect.y + cropRect.h
) {
setActiveHandle("move");
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
setHandleDragStart({
x: coords.x,
y: coords.y,
rect: { ...cropRect },
});
e.preventDefault();
return;
}
@ -230,11 +239,21 @@ export function ImagePreview({
setCropRect({ x, y, w, h });
}
},
[cropMode, dragStart, activeHandle, handleDragStart, getImageRelativeCoords],
[
cropMode,
dragStart,
activeHandle,
handleDragStart,
getImageRelativeCoords,
],
);
const handleCropMouseUp = useCallback(() => {
if (dragStart && cropRect && (cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)) {
if (
dragStart &&
cropRect &&
(cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)
) {
setCropRect(null);
}
setDragStart(null);
@ -328,14 +347,18 @@ export function ImagePreview({
// Use dataUrl if available (in-memory), otherwise fall back to asset URL (large images)
const imageSrc = file.dataUrl || getAssetUrl(file.absolutePath);
// Crop requires dataUrl (canvas manipulation needs same-origin data)
const canCrop = !!file.dataUrl;
const _canCrop = !!file.dataUrl;
return (
<div
ref={containerRef}
tabIndex={-1}
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
style={cropMode ? { cursor: cropRect && !dragStart ? "default" : "crosshair" } : undefined}
style={
cropMode
? { cursor: cropRect && !dragStart ? "default" : "crosshair" }
: undefined
}
onMouseMove={cropMode ? handleCropMouseMove : undefined}
onMouseUp={cropMode ? handleCropMouseUp : undefined}
onMouseLeave={cropMode ? handleCropMouseUp : undefined}
@ -348,7 +371,10 @@ export function ImagePreview({
)}
{/* Wrapper width = scale * 100% of container → CSS handles fit, no JS needed */}
<div className="relative" style={{ width: `${scale * 100}%`, margin: "0 auto" }}>
<div
className="relative"
style={{ width: `${scale * 100}%`, margin: "0 auto" }}
>
<img
ref={imgRef}
src={imageSrc}
@ -363,13 +389,40 @@ export function ImagePreview({
<>
{/* Dark overlay: 4 divs around the crop area */}
{/* Top */}
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: 0, right: 0, height: cropRect.y }} />
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{ left: 0, top: 0, right: 0, height: cropRect.y }}
/>
{/* Bottom */}
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y + cropRect.h, right: 0, bottom: 0 }} />
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{
left: 0,
top: cropRect.y + cropRect.h,
right: 0,
bottom: 0,
}}
/>
{/* Left */}
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y, width: cropRect.x, height: cropRect.h }} />
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{
left: 0,
top: cropRect.y,
width: cropRect.x,
height: cropRect.h,
}}
/>
{/* Right */}
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: cropRect.x + cropRect.w, top: cropRect.y, right: 0, height: cropRect.h }} />
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{
left: cropRect.x + cropRect.w,
top: cropRect.y,
right: 0,
height: cropRect.h,
}}
/>
{/* Crop border */}
<div
@ -388,7 +441,11 @@ export function ImagePreview({
const coords = getImageRelativeCoords(e);
if (!coords) return;
setActiveHandle("move");
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
setHandleDragStart({
x: coords.x,
y: coords.y,
rect: { ...cropRect },
});
}}
>
{/* Resize handles */}
@ -397,8 +454,18 @@ export function ImagePreview({
key={h.id}
className="absolute z-20 size-2.5 rounded-sm border border-gray-400 bg-white shadow-sm"
style={{
left: h.x === 0 ? -5 : h.x === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
top: h.y === 0 ? -5 : h.y === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
left:
h.x === 0
? -5
: h.x === 0.5
? "calc(50% - 5px)"
: "calc(100% - 5px)",
top:
h.y === 0
? -5
: h.y === 0.5
? "calc(50% - 5px)"
: "calc(100% - 5px)",
cursor: h.cursor,
}}
onMouseDown={(e) => handleHandleMouseDown(e, h.id)}

View file

@ -19,7 +19,15 @@ import { tags } from "@lezer/highlight";
interface BibState {
/** Current parsing context */
context: "top" | "entryType" | "citationKey" | "fields" | "fieldName" | "fieldSep" | "fieldValue" | "comment";
context:
| "top"
| "entryType"
| "citationKey"
| "fields"
| "fieldName"
| "fieldSep"
| "fieldValue"
| "comment";
/** Brace nesting depth inside a field value */
braceDepth: number;
/** Whether currently inside a quoted string value */

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,6 @@ import {
ChevronDownIcon,
ChevronRightIcon,
MessageSquareIcon,
SparklesIcon,
MousePointerClickIcon,
} from "lucide-react";
@ -31,7 +30,9 @@ function SeverityIcon({ severity }: { severity: string }) {
case "error":
return <AlertCircleIcon className="size-3.5 shrink-0 text-red-400" />;
case "warning":
return <AlertTriangleIcon className="size-3.5 shrink-0 text-yellow-400" />;
return (
<AlertTriangleIcon className="size-3.5 shrink-0 text-yellow-400" />
);
default:
return <InfoIcon className="size-3.5 shrink-0 text-blue-400" />;
}
@ -47,7 +48,9 @@ export function ProblemsPanel({
const [isCollapsed, setIsCollapsed] = useState(false);
const errorCount = diagnostics.filter((d) => d.severity === "error").length;
const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
const warningCount = diagnostics.filter(
(d) => d.severity === "warning",
).length;
return (
<div className="border-border border-t bg-background">
@ -55,7 +58,7 @@ export function ProblemsPanel({
<div className="flex items-center">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="flex flex-1 items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"
className="flex flex-1 items-center gap-2 px-3 py-1.5 text-xs transition-colors hover:bg-muted/50"
>
{isCollapsed ? (
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
@ -81,7 +84,7 @@ export function ProblemsPanel({
{diagnostics.length > 0 && onFixAllWithChat && (
<button
onClick={onFixAllWithChat}
className="flex items-center gap-1.5 mx-3 my-2 px-2.5 py-1 rounded-md text-xs font-medium text-primary-foreground bg-primary hover:bg-primary/90 transition-colors shadow-sm"
className="mx-3 my-2 flex items-center gap-1.5 rounded-md bg-primary px-2.5 py-1 font-medium text-primary-foreground text-xs shadow-sm transition-colors hover:bg-primary/90"
title="Fix all problems with AI"
>
<MousePointerClickIcon className="size-3" />
@ -96,7 +99,7 @@ export function ProblemsPanel({
{diagnostics.map((d, i) => (
<div
key={`${d.from}-${d.message}-${i}`}
className="group flex items-center gap-2 px-3 py-1 text-xs hover:bg-muted/50 cursor-pointer transition-colors"
className="group flex cursor-pointer items-center gap-2 px-3 py-1 text-xs transition-colors hover:bg-muted/50"
onClick={() => onNavigate(d.from)}
>
<SeverityIcon severity={d.severity} />
@ -111,7 +114,7 @@ export function ProblemsPanel({
e.stopPropagation();
onFixWithChat(d.message, d.line);
}}
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-muted hover:text-foreground transition-all"
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover:opacity-100"
title="Fix with chat"
>
<MessageSquareIcon className="size-3.5" />

View file

@ -1,5 +1,3 @@
import { useEffect, useRef } from "react";
import { XIcon, ChevronUpIcon, ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";

View file

@ -1,4 +1,10 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { ArrowUpIcon } from "lucide-react";
export interface ToolbarAction {
@ -94,7 +100,6 @@ export function SelectionToolbar({
onKeyDown={handleKeyDown}
placeholder="Enter prompt..."
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoFocus
/>
<button
aria-label="Send prompt"
@ -113,12 +118,16 @@ export function SelectionToolbar({
<button
key={action.id}
onClick={() => onAction(action.id)}
className="flex items-center gap-2.5 px-3 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-muted"
className="flex items-center gap-2.5 px-3 py-1.5 text-left text-foreground text-sm transition-colors hover:bg-muted"
>
<span className="size-4 text-muted-foreground">{action.icon}</span>
<span className="size-4 text-muted-foreground">
{action.icon}
</span>
{action.label}
{action.hint && (
<span className="ml-auto text-xs text-muted-foreground">{action.hint}</span>
<span className="ml-auto text-muted-foreground text-xs">
{action.hint}
</span>
)}
</button>
))}
@ -127,7 +136,7 @@ export function SelectionToolbar({
{/* Context label */}
<div className="border-border border-t px-3 py-1.5">
<span className="font-mono text-xs text-muted-foreground">
<span className="font-mono text-muted-foreground text-xs">
{contextLabel}
</span>
</div>

View file

@ -45,21 +45,25 @@ function snapshotTypeLabel(message: string): string {
if (message.startsWith("[auto]")) return "Auto-save";
if (message.startsWith("[manual]")) return "Save";
if (message.startsWith("[compile]")) return "Compile";
if (message.startsWith("[claude]")) return message.includes("Before") ? "Before Claude" : "After Claude";
if (message.startsWith("[claude]"))
return message.includes("Before") ? "Before Claude" : "After Claude";
if (message.startsWith("[restore]")) return "Restore";
if (message.startsWith("[init]")) return "Initial";
return message;
}
function snapshotTypeBadgeColor(message: string): string {
if (message.startsWith("[claude]")) return "bg-violet-500/15 text-violet-600 dark:text-violet-400";
if (message.startsWith("[restore]")) return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
if (message.startsWith("[manual]")) return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
if (message.startsWith("[compile]")) return "bg-green-500/15 text-green-600 dark:text-green-400";
if (message.startsWith("[claude]"))
return "bg-violet-500/15 text-violet-600 dark:text-violet-400";
if (message.startsWith("[restore]"))
return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
if (message.startsWith("[manual]"))
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
if (message.startsWith("[compile]"))
return "bg-green-500/15 text-green-600 dark:text-green-400";
return "bg-muted text-muted-foreground";
}
// ─── Panel ───
export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
@ -114,7 +118,9 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
// Init history when project opens
useEffect(() => {
if (!projectRoot) return;
init(projectRoot).then(() => loadSnapshots(projectRoot)).catch(console.error);
init(projectRoot)
.then(() => loadSnapshots(projectRoot))
.catch(console.error);
}, [projectRoot, init, loadSnapshots]);
// Infinite scroll
@ -147,7 +153,6 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
[projectRoot, linearSnapshots, reviewingSnapshot, loadDiff, startReview],
);
const handleRestore = useCallback(
async (snapshotId: string) => {
if (!projectRoot) return;
@ -179,7 +184,9 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
if (!projectRoot) {
return (
<div className="flex flex-col items-center gap-2 px-3 py-4 text-center">
<p className="text-xs text-muted-foreground">Open a project to view history.</p>
<p className="text-muted-foreground text-xs">
Open a project to view history.
</p>
</div>
);
}
@ -199,7 +206,7 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
onScroll={handleScroll}
>
{linearSnapshots.length === 0 && !isLoading ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
<div className="px-3 py-4 text-center text-muted-foreground text-xs">
No history yet
</div>
) : (
@ -213,7 +220,9 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
onClick={() => handleClick(snap)}
onRestore={() => handleRestore(snap.id)}
onAddLabel={() => openLabelDialog(snap.id)}
onRemoveLabel={(label) => projectRoot && removeLabel(projectRoot, label)}
onRemoveLabel={(label) =>
projectRoot && removeLabel(projectRoot, label)
}
onCopySha={() => navigator.clipboard.writeText(snap.id)}
/>
))}
@ -238,13 +247,19 @@ export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
placeholder="e.g. Draft v1"
value={labelValue}
onChange={(e) => setLabelValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleAddLabel(); }}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddLabel();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>Cancel</Button>
<Button onClick={handleAddLabel} disabled={!labelValue.trim()}>Add</Button>
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddLabel} disabled={!labelValue.trim()}>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -289,13 +304,13 @@ function SnapshotRow({
<div className="flex items-center gap-1">
<span
className={cn(
"rounded px-1.5 py-0.5 text-xs leading-tight font-medium",
"rounded px-1.5 py-0.5 font-medium text-xs leading-tight",
snapshotTypeBadgeColor(snapshot.message),
)}
>
{snapshotTypeLabel(snapshot.message)}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{formatRelativeTime(snapshot.timestamp)}
</span>
</div>
@ -306,14 +321,17 @@ function SnapshotRow({
{snapshot.labels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-0.5 rounded bg-amber-500/15 px-1.5 py-0.5 text-xs text-amber-600 dark:text-amber-400"
className="inline-flex items-center gap-0.5 rounded bg-amber-500/15 px-1.5 py-0.5 text-amber-600 text-xs dark:text-amber-400"
>
<TagIcon className="size-2" />
{label}
<button
aria-label={`Remove label ${label}`}
className="ml-0.5 rounded-sm opacity-0 hover:text-destructive group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemoveLabel(label); }}
onClick={(e) => {
e.stopPropagation();
onRemoveLabel(label);
}}
>
<XIcon className="size-2" />
</button>
@ -324,8 +342,10 @@ function SnapshotRow({
{/* Changed files summary */}
{hasFiles && (
<div className="mt-0.5 text-xs text-muted-foreground truncate">
{snapshot.changed_files.map((f) => f.split("/").pop()).join(", ")}
<div className="mt-0.5 truncate text-muted-foreground text-xs">
{snapshot.changed_files
.map((f) => f.split("/").pop())
.join(", ")}
</div>
)}
</div>
@ -349,4 +369,3 @@ function SnapshotRow({
</ContextMenu>
);
}

View file

@ -57,21 +57,27 @@ export const MupdfPage = memo(function MupdfPage({
const dpr = window.devicePixelRatio || 1;
const dpi = scale * 72 * dpr;
client.drawPage(docId, pageIndex, dpi).then(async (imageData) => {
if (gen !== renderGenRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = imageData.width;
canvas.height = imageData.height;
const bitmap = await createImageBitmap(imageData);
if (gen !== renderGenRef.current) { bitmap.close(); return; }
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
}).catch((err) => {
if (gen !== renderGenRef.current) return;
log.error(`Render error page ${pageIndex}`, { error: String(err) });
});
client
.drawPage(docId, pageIndex, dpi)
.then(async (imageData) => {
if (gen !== renderGenRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = imageData.width;
canvas.height = imageData.height;
const bitmap = await createImageBitmap(imageData);
if (gen !== renderGenRef.current) {
bitmap.close();
return;
}
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
})
.catch((err) => {
if (gen !== renderGenRef.current) return;
log.error(`Render error page ${pageIndex}`, { error: String(err) });
});
}, [docId, pageIndex, scale, isVisible]);
// Initial render and re-render on dependency changes
@ -83,15 +89,21 @@ export const MupdfPage = memo(function MupdfPage({
const client = getMupdfClient();
const gen = renderGenRef.current;
client.getPageText(docId, pageIndex).then((data) => {
if (gen !== renderGenRef.current) return;
setTextData(data);
}).catch(() => {});
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(() => {});
client
.getPageLinks(docId, pageIndex)
.then((data) => {
if (gen !== renderGenRef.current) return;
setLinks(data);
})
.catch(() => {});
}, [docId, pageIndex, scale, isVisible, renderPage]);
// Re-render canvas when returning from background if content was lost
@ -100,13 +112,19 @@ export const MupdfPage = memo(function MupdfPage({
const canvas = canvasRef.current;
if (!canvas || !isVisible || docId <= 0) return;
if (isCanvasBlank(canvas)) {
log.warn(`Canvas blank after visibility restore, re-rendering page ${pageIndex}`);
log.warn(
`Canvas blank after visibility restore, re-rendering page ${pageIndex}`,
);
renderPage();
}
};
window.addEventListener(APP_VISIBILITY_RESTORED, handleVisibilityRestored);
return () => window.removeEventListener(APP_VISIBILITY_RESTORED, handleVisibilityRestored);
return () =>
window.removeEventListener(
APP_VISIBILITY_RESTORED,
handleVisibilityRestored,
);
}, [docId, pageIndex, scale, isVisible, renderPage]);
return (
@ -128,21 +146,22 @@ export const MupdfPage = memo(function MupdfPage({
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>
)),
{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>
)}
@ -156,12 +175,14 @@ export const MupdfPage = memo(function MupdfPage({
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 + "%",
left: `${(link.x / pageWidth) * 100}%`,
top: `${(link.y / pageHeight) * 100}%`,
width: `${(link.w / pageWidth) * 100}%`,
height: `${(link.h / pageHeight) * 100}%`,
}}
/>
>
<span className="sr-only">Link</span>
</a>
))}
</div>
)}

View file

@ -16,7 +16,12 @@ import {
} from "lucide-react";
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
import { join } from "@tauri-apps/api/path";
import { useDocumentStore, getPdfBytes, getCurrentPdfBytes, hasPdfData } from "@/stores/document-store";
import {
useDocumentStore,
getPdfBytes,
getCurrentPdfBytes,
hasPdfData,
} from "@/stores/document-store";
import { useHistoryStore } from "@/stores/history-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { Button } from "@/components/ui/button";
@ -28,13 +33,29 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { HistoryPanel } from "@/components/workspace/history-panel";
import { compileLatex, synctexEdit, resolveCompileTarget, formatCompileError } from "@/lib/latex-compiler";
import {
compileLatex,
synctexEdit,
resolveCompileTarget,
formatCompileError,
} from "@/lib/latex-compiler";
import { ErrorBoundary } from "react-error-boundary";
import { SelectionToolbar, type ToolbarAction } from "@/components/workspace/editor/selection-toolbar";
import {
SelectionToolbar,
type ToolbarAction,
} from "@/components/workspace/editor/selection-toolbar";
import { save } from "@tauri-apps/plugin-dialog";
import { PdfViewer, type PdfTextSelection, type CaptureResult } from "./pdf-viewer";
import {
PdfViewer,
type PdfTextSelection,
type CaptureResult,
} from "./pdf-viewer";
import { resolveTexRoot } from "@/stores/document-store";
import { createLogger } from "@/lib/debug/logger";
@ -95,8 +116,14 @@ export function PdfPreview() {
const [scale, setScale] = useState<number>(1.0);
const [captureMode, setCaptureMode] = useState(false);
const [fitMode, setFitMode] = useState<FitMode>(null);
const [containerSize, setContainerSize] = useState<{ width: number; height: number } | null>(null);
const [firstPageSize, setFirstPageSize] = useState<{ width: number; height: number } | null>(null);
const [containerSize, setContainerSize] = useState<{
width: number;
height: number;
} | null>(null);
const [firstPageSize, setFirstPageSize] = useState<{
width: number;
height: number;
} | null>(null);
const hasInitialCompile = useRef(false);
const initialized = useDocumentStore((s) => s.initialized);
@ -135,7 +162,9 @@ export function PdfPreview() {
}, [currentRootFileId, pdfData]);
// PDF text selection toolbar
const [pdfSelection, setPdfSelection] = useState<PdfTextSelection | null>(null);
const [pdfSelection, setPdfSelection] = useState<PdfTextSelection | null>(
null,
);
const previewContainerRef = useRef<HTMLDivElement>(null);
const handleTextClick = useCallback(
@ -163,7 +192,8 @@ export function PdfPreview() {
const result = await synctexEdit(projectRoot, page, x, y);
if (!result) return;
const normalize = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, "");
const normalize = (p: string) =>
p.replace(/\\/g, "/").replace(/^\.\//, "");
const normalizedTarget = normalize(result.file);
const targetFile = files.find(
(f) => normalize(f.relativePath) === normalizedTarget,
@ -184,7 +214,10 @@ export function PdfPreview() {
offset += fileLines[i].length + 1;
}
if (result.column > 0) {
offset += Math.min(result.column, fileLines[targetLine - 1]?.length ?? 0);
offset += Math.min(
result.column,
fileLines[targetLine - 1]?.length ?? 0,
);
}
if (needsSwitch) {
@ -212,13 +245,20 @@ export function PdfPreview() {
useEffect(() => {
if (!pdfSelection || !projectRoot) return;
let cancelled = false;
synctexEdit(projectRoot, pdfSelection.pageNumber, pdfSelection.pdfX, pdfSelection.pdfY)
synctexEdit(
projectRoot,
pdfSelection.pageNumber,
pdfSelection.pdfX,
pdfSelection.pdfY,
)
.then((result) => {
if (cancelled || !result) return;
setResolvedSource(result);
})
.catch(() => {});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [pdfSelection, projectRoot]);
const pdfContextLabel = resolvedSource
@ -242,13 +282,19 @@ export function PdfPreview() {
const fileContent = targetFile.content ?? "";
const fileLines = fileContent.split("\n");
const targetLine = Math.max(1, Math.min(resolvedSource.line, fileLines.length));
const targetLine = Math.max(
1,
Math.min(resolvedSource.line, fileLines.length),
);
let offset = 0;
for (let i = 0; i < targetLine - 1; i++) {
offset += fileLines[i].length + 1;
}
if (resolvedSource.column > 0) {
offset += Math.min(resolvedSource.column, fileLines[targetLine - 1]?.length ?? 0);
offset += Math.min(
resolvedSource.column,
fileLines[targetLine - 1]?.length ?? 0,
);
}
if (needsSwitch) {
@ -258,14 +304,17 @@ export function PdfPreview() {
}
}, [resolvedSource, files, setActiveFile, requestJumpToPosition]);
const buildPdfContext = useCallback((text: string) => {
const locationNote = resolvedSource
? `near ${resolvedSource.file}:${resolvedSource.line}`
: pdfSelection
? `PDF page ${pdfSelection.pageNumber}`
: "PDF";
return `[Selected from PDF output, approximate source location: ${locationNote}]\n${text}`;
}, [resolvedSource, pdfSelection]);
const buildPdfContext = useCallback(
(text: string) => {
const locationNote = resolvedSource
? `near ${resolvedSource.file}:${resolvedSource.line}`
: pdfSelection
? `PDF page ${pdfSelection.pageNumber}`
: "PDF";
return `[Selected from PDF output, approximate source location: ${locationNote}]\n${text}`;
},
[resolvedSource, pdfSelection],
);
const handlePdfToolbarSendPrompt = useCallback(
(prompt: string) => {
@ -283,10 +332,22 @@ export function PdfPreview() {
[pdfSelection, pdfContextLabel, resolvedSource, buildPdfContext],
);
const pdfToolbarActions: ToolbarAction[] = useMemo(() => [
{ id: "proofread", label: "Proofread", icon: <SpellCheckIcon className="size-4" /> },
{ id: "navigate", label: "Navigate to source", icon: <FileTextIcon className="size-4" />, hint: "dbl-click" },
], []);
const pdfToolbarActions: ToolbarAction[] = useMemo(
() => [
{
id: "proofread",
label: "Proofread",
icon: <SpellCheckIcon className="size-4" />,
},
{
id: "navigate",
label: "Navigate to source",
icon: <FileTextIcon className="size-4" />,
hint: "dbl-click",
},
],
[],
);
const handlePdfToolbarAction = useCallback(
(actionId: string) => {
@ -296,16 +357,24 @@ export function PdfPreview() {
setPdfSelection(null);
window.getSelection()?.removeAllRanges();
if (actionId === "proofread") {
useClaudeChatStore.getState().sendPrompt("Proofread and fix any errors in this text", {
label,
filePath: resolvedSource?.file ?? "document.pdf",
selectedText: buildPdfContext(sel.text),
});
useClaudeChatStore
.getState()
.sendPrompt("Proofread and fix any errors in this text", {
label,
filePath: resolvedSource?.file ?? "document.pdf",
selectedText: buildPdfContext(sel.text),
});
} else if (actionId === "navigate") {
navigateToSource();
}
},
[pdfSelection, pdfContextLabel, resolvedSource, navigateToSource, buildPdfContext],
[
pdfSelection,
pdfContextLabel,
resolvedSource,
navigateToSource,
buildPdfContext,
],
);
const handlePdfToolbarDismiss = useCallback(() => {
@ -317,10 +386,13 @@ export function PdfPreview() {
if (!pdfSelection || !previewContainerRef.current) return null;
const containerRect = previewContainerRef.current.getBoundingClientRect();
const relTop = pdfSelection.position.top - containerRect.top + 4;
const relLeft = Math.max(8, Math.min(
pdfSelection.position.left - containerRect.left,
containerRect.width - 272,
));
const relLeft = Math.max(
8,
Math.min(
pdfSelection.position.left - containerRect.left,
containerRect.width - 272,
),
);
return { top: relTop, left: relLeft };
})();
@ -338,7 +410,9 @@ export function PdfPreview() {
const { files: allFiles, activeFileId } = useDocumentStore.getState();
const resolved = resolveCompileTarget(activeFileId, allFiles);
if (!resolved) {
setCompileError("No .tex file found in this project. Create a main.tex file to compile.");
setCompileError(
"No .tex file found in this project. Create a main.tex file to compile.",
);
return;
}
const { rootId, targetPath } = resolved;
@ -351,7 +425,19 @@ export function PdfPreview() {
}
};
compile();
}, [initialized, projectRoot, pdfData, isCompiling, compileError, setIsCompiling, setPdfData, setCompileError, saveAllFiles, files, activeFile]);
}, [
initialized,
projectRoot,
pdfData,
isCompiling,
compileError,
setIsCompiling,
setPdfData,
setCompileError,
saveAllFiles,
files,
activeFile,
]);
// Recompute scale when fit mode is active and container/page size changes
useEffect(() => {
@ -366,13 +452,21 @@ export function PdfPreview() {
}
}, [fitMode, containerSize, firstPageSize]);
const zoomIn = () => { setFitMode(null); setScale((s) => Math.min(4, s + 0.1)); };
const zoomOut = () => { setFitMode(null); setScale((s) => Math.max(0.25, s - 0.1)); };
const zoomIn = () => {
setFitMode(null);
setScale((s) => Math.min(4, s + 0.1));
};
const zoomOut = () => {
setFitMode(null);
setScale((s) => Math.max(0.25, s - 0.1));
};
const handleExport = async () => {
const currentPdf = getCurrentPdfBytes();
if (!currentPdf) return;
const mainFile = files.find((f) => f.name === "main.tex" || f.name === "document.tex");
const mainFile = files.find(
(f) => f.name === "main.tex" || f.name === "document.tex",
);
const defaultName = mainFile
? mainFile.name.replace(/\.tex$/, ".pdf")
: "document.pdf";
@ -385,23 +479,29 @@ export function PdfPreview() {
await writeFile(filePath, new Uint8Array(currentPdf));
};
const handleCurrentPageChange = useCallback((page: number) => {
setCurrentPage((prev) => {
if (prev === page) return prev;
if (!isEditingPage) setPageInputValue(String(page));
return page;
});
}, [isEditingPage]);
const handleCurrentPageChange = useCallback(
(page: number) => {
setCurrentPage((prev) => {
if (prev === page) return prev;
if (!isEditingPage) setPageInputValue(String(page));
return page;
});
},
[isEditingPage],
);
const goToPage = useCallback((page: number) => {
const clamped = Math.max(1, Math.min(numPages, page));
scrollToPageRef.current?.(clamped);
}, [numPages]);
const goToPage = useCallback(
(page: number) => {
const clamped = Math.max(1, Math.min(numPages, page));
scrollToPageRef.current?.(clamped);
},
[numPages],
);
const handlePageInputCommit = useCallback(() => {
setIsEditingPage(false);
const parsed = parseInt(pageInputValue, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= numPages) {
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= numPages) {
goToPage(parsed);
} else {
setPageInputValue(String(currentPage));
@ -409,7 +509,10 @@ export function PdfPreview() {
}, [pageInputValue, numPages, currentPage, goToPage]);
const handleLoadSuccess = (pages: number) => setNumPages(pages);
const handleScaleChange = (newScale: number) => { setFitMode(null); setScale(newScale); };
const handleScaleChange = (newScale: number) => {
setFitMode(null);
setScale(newScale);
};
const handleCompile = async () => {
// Read all guard values from the store to avoid stale closures
@ -426,13 +529,20 @@ export function PdfPreview() {
if (!activeEntry || activeEntry.type !== "tex") return;
const resolved = resolveCompileTarget(activeFileId, allFiles);
if (!resolved) {
setCompileError("No .tex file found in this project. Create a main.tex file to compile.");
setCompileError(
"No .tex file found in this project. Create a main.tex file to compile.",
);
return;
}
const { rootId, targetPath: targetFile } = resolved;
// Skip recompile if no edits since last successful compile of this root
const lastGen = state.lastCompiledGenerations.get(rootId);
if (hasPdfData() && lastGen !== undefined && state.contentGeneration === lastGen) return;
if (
hasPdfData() &&
lastGen !== undefined &&
state.contentGeneration === lastGen
)
return;
useHistoryStore.getState().stopReview();
setIsCompiling(true);
state.setPendingRecompile(false);
@ -492,23 +602,28 @@ export function PdfPreview() {
if (pdfData) setCaptureMode((prev) => !prev);
};
window.addEventListener("toggle-capture-mode", handleToggleCapture);
return () => window.removeEventListener("toggle-capture-mode", handleToggleCapture);
return () =>
window.removeEventListener("toggle-capture-mode", handleToggleCapture);
}, [pdfData]);
const renderContent = () => {
if (compileError) {
const errors = [...new Set(
compileError
.split(/\s*!\s*/)
.map((s) => s.trim())
.filter((s) => s.length > 0 && s !== "Compilation failed"),
)];
const errors = [
...new Set(
compileError
.split(/\s*!\s*/)
.map((s) => s.trim())
.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.`,
);
useClaudeChatStore
.getState()
.sendPrompt(
`[Compilation errors]\n${errorList}\n\nFix these LaTeX compilation errors.`,
);
};
return (
@ -517,16 +632,16 @@ export function PdfPreview() {
<div className="mb-4 flex items-center gap-2 text-destructive">
<AlertCircleIcon className="size-5" />
<h2 className="font-semibold text-base">Compilation Failed</h2>
<span className="ml-auto rounded-full bg-destructive/15 px-2 py-0.5 text-xs font-medium">
<span className="ml-auto rounded-full bg-destructive/15 px-2 py-0.5 font-medium text-xs">
{errors.length} {errors.length === 1 ? "error" : "errors"}
</span>
</div>
<div className="rounded-lg border border-destructive/20 bg-background">
<div className="max-h-60 overflow-y-auto divide-y divide-border">
<div className="max-h-60 divide-y divide-border overflow-y-auto">
{errors.map((error, i) => (
<div key={i} className="flex items-start gap-2.5 px-3 py-2.5">
<AlertCircleIcon className="mt-0.5 size-3.5 shrink-0 text-destructive/70" />
<span className="text-sm text-foreground">{error}</span>
<span className="text-foreground text-sm">{error}</span>
</div>
))}
</div>
@ -534,14 +649,14 @@ export function PdfPreview() {
<div className="mt-3 flex items-center gap-2">
<button
onClick={handleFixWithChat}
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 font-medium text-primary-foreground text-xs shadow-sm transition-colors hover:bg-primary/90"
>
<MousePointerClickIcon className="size-3.5" />
Fix with Chat
</button>
<button
onClick={handleCompile}
className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 font-medium text-foreground text-xs transition-colors hover:bg-muted"
>
<RefreshCwIcon className="size-3.5" />
Retry
@ -555,10 +670,19 @@ export function PdfPreview() {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 p-8">
<FileTextIcon className="mb-4 size-16 text-muted-foreground/50" />
<h2 className="mb-2 font-medium text-lg text-muted-foreground">PDF Preview</h2>
<p className="mb-4 text-center text-muted-foreground text-sm">Press +Enter to compile your document</p>
<h2 className="mb-2 font-medium text-lg text-muted-foreground">
PDF Preview
</h2>
<p className="mb-4 text-center text-muted-foreground text-sm">
Press +Enter to compile your document
</p>
{isTexActive && (
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCompile}>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleCompile}
>
<RefreshCwIcon className="size-3.5" />
Compile
</Button>
@ -570,8 +694,12 @@ export function PdfPreview() {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 p-8">
<AlertCircleIcon className="mb-4 size-12 text-destructive" />
<h2 className="mb-2 font-medium text-destructive text-lg">PDF Load Error</h2>
<p className="max-w-md text-center text-muted-foreground text-sm">{pdfError}</p>
<h2 className="mb-2 font-medium text-destructive text-lg">
PDF Load Error
</h2>
<p className="max-w-md text-center text-muted-foreground text-sm">
{pdfError}
</p>
</div>
);
}
@ -591,8 +719,15 @@ export function PdfPreview() {
fallback={
<div className="flex h-full flex-col items-center justify-center gap-3 bg-muted/30 p-8">
<AlertCircleIcon className="size-10 text-destructive" />
<p className="text-sm text-muted-foreground">PDF viewer crashed. Try recompiling.</p>
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCompile}>
<p className="text-muted-foreground text-sm">
PDF viewer crashed. Try recompiling.
</p>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleCompile}
>
<RefreshCwIcon className="size-3.5" />
Recompile
</Button>
@ -603,7 +738,7 @@ export function PdfPreview() {
className={
isActive
? "absolute inset-0 flex flex-col"
: "invisible pointer-events-none absolute inset-0 flex flex-col"
: "pointer-events-none invisible absolute inset-0 flex flex-col"
}
>
<PdfViewer
@ -617,13 +752,25 @@ export function PdfPreview() {
onTextClick={isActive ? handleTextClick : undefined}
onSynctexClick={isActive ? handleSynctexClick : undefined}
onTextSelect={isActive ? handleTextSelect : undefined}
onFirstPageSize={isActive ? (w, h) => setFirstPageSize({ width: w, height: h }) : undefined}
onContainerResize={isActive ? (w, h) => setContainerSize({ width: w, height: h }) : undefined}
onCurrentPageChange={isActive ? handleCurrentPageChange : undefined}
onFirstPageSize={
isActive
? (w, h) => setFirstPageSize({ width: w, height: h })
: undefined
}
onContainerResize={
isActive
? (w, h) => setContainerSize({ width: w, height: h })
: undefined
}
onCurrentPageChange={
isActive ? handleCurrentPageChange : undefined
}
scrollToPageRef={isActive ? scrollToPageRef : undefined}
captureMode={isActive ? captureMode : false}
onCapture={isActive ? handleCapture : undefined}
onCancelCapture={isActive ? () => setCaptureMode(false) : undefined}
onCancelCapture={
isActive ? () => setCaptureMode(false) : undefined
}
/>
</div>
</ErrorBoundary>
@ -634,29 +781,47 @@ export function PdfPreview() {
};
return (
<div ref={previewContainerRef} className="@container/pv relative flex h-full flex-col bg-muted/50">
<div className="flex shrink-0 items-center border-border border-b bg-background px-2 pt-[var(--titlebar-height)] h-[calc(40px+var(--titlebar-height))]">
<div
ref={previewContainerRef}
className="@container/pv relative flex h-full flex-col bg-muted/50"
>
<div className="flex h-[calc(40px+var(--titlebar-height))] shrink-0 items-center border-border border-b bg-background px-2 pt-[var(--titlebar-height)]">
<div className="flex items-center gap-1">
{isSaving && (
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground text-xs font-medium">Saving...</span>
<span className="font-medium text-muted-foreground text-xs">
Saving...
</span>
</div>
)}
{!isSaving && isCompiling && (
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground text-xs font-medium">Compiling...</span>
<span className="font-medium text-muted-foreground text-xs">
Compiling...
</span>
</div>
)}
{!isSaving && !isCompiling && !compileError && isTexActive && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={handleCompile}>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
onClick={handleCompile}
>
<RefreshCwIcon className="size-3.5" />
{pdfData ? "Recompile" : "Compile"}
</Button>
)}
{!isSaving && !isCompiling && compileError && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs text-destructive hover:text-destructive" onClick={handleCompile} disabled={!isTexActive}>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2.5 text-destructive text-xs hover:text-destructive"
onClick={handleCompile}
disabled={!isTexActive}
>
<RefreshCwIcon className="size-3.5" />
Retry
</Button>
@ -666,15 +831,21 @@ export function PdfPreview() {
<div className="flex shrink-0 items-center gap-1">
{pdfData && (
<>
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1} title="Page Up">
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
title="Page Up"
>
<ChevronUpIcon className="size-3.5" />
</Button>
{isEditingPage ? (
<input
autoFocus
type="text"
inputMode="numeric"
className="h-6 w-8 shrink-0 rounded border border-border bg-background text-center text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
className="h-6 w-8 shrink-0 rounded border border-border bg-background text-center text-foreground text-xs outline-none focus:ring-1 focus:ring-ring"
value={pageInputValue}
onChange={(e) => setPageInputValue(e.target.value)}
onBlur={handlePageInputCommit}
@ -688,20 +859,48 @@ export function PdfPreview() {
/>
) : (
<button
className="flex h-6 min-w-[2rem] shrink-0 items-center justify-center rounded px-1 text-xs text-muted-foreground tabular-nums hover:bg-muted"
onClick={() => { setIsEditingPage(true); setPageInputValue(String(currentPage)); }}
className="flex h-6 min-w-[2rem] shrink-0 items-center justify-center rounded px-1 text-muted-foreground text-xs tabular-nums hover:bg-muted"
onClick={() => {
setIsEditingPage(true);
setPageInputValue(String(currentPage));
}}
title="Click to jump to page"
>
{currentPage}
</button>
)}
<span className="shrink-0 whitespace-nowrap text-muted-foreground text-xs">/ {numPages}</span>
<Button variant="ghost" size="icon" className="size-7" onClick={() => goToPage(currentPage + 1)} disabled={currentPage >= numPages} title="Page Down">
<span className="shrink-0 whitespace-nowrap text-muted-foreground text-xs">
/ {numPages}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= numPages}
title="Page Down"
>
<ChevronDownIcon className="size-3.5" />
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<Button variant="ghost" size="icon" className="size-7" onClick={zoomOut} disabled={scale <= 0.25}><MinusIcon className="size-3.5" /></Button>
<Button variant="ghost" size="icon" className="size-7" onClick={zoomIn} disabled={scale >= 4}><PlusIcon className="size-3.5" /></Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={zoomOut}
disabled={scale <= 0.25}
>
<MinusIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={zoomIn}
disabled={scale >= 4}
>
<PlusIcon className="size-3.5" />
</Button>
<Select
value={fitMode ?? scale.toString()}
onValueChange={(v) => {
@ -715,14 +914,22 @@ export function PdfPreview() {
>
<SelectTrigger size="sm" className="h-7! w-auto text-xs">
<SelectValue>
{fitMode === "fit-width" ? "Fit width" : fitMode === "fit-height" ? "Fit height" : `${Math.round(scale * 100)}%`}
{fitMode === "fit-width"
? "Fit width"
: fitMode === "fit-height"
? "Fit height"
: `${Math.round(scale * 100)}%`}
</SelectValue>
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectItem value="fit-width">Fit to width</SelectItem>
<SelectItem value="fit-height">Fit to height</SelectItem>
<SelectSeparator />
{ZOOM_OPTIONS.map((opt) => (<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>))}
{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" />
@ -739,20 +946,31 @@ export function PdfPreview() {
title={`Capture & Ask (${navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"})`}
>
<CrosshairIcon className="size-3.5 shrink-0" />
<span className="hidden @[36rem]/pv:inline">Capture & Ask</span>
<kbd className="pointer-events-none ml-0.5 hidden rounded border border-background/30 bg-background/20 px-1 py-0.5 text-[10px] font-medium leading-none text-background @[36rem]/pv:inline">
<span className="@[36rem]/pv:inline hidden">Capture & Ask</span>
<kbd className="pointer-events-none ml-0.5 @[36rem]/pv:inline hidden rounded border border-background/30 bg-background/20 px-1 py-0.5 font-medium text-[10px] text-background leading-none">
{navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"}
</kbd>
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<Button variant="ghost" size="icon" className="size-7" onClick={handleExport} title="Export PDF">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handleExport}
title="Export PDF"
>
<DownloadIcon className="size-3.5" />
</Button>
</>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="size-7" title="History">
<Button
variant="ghost"
size="icon"
className="size-7"
title="History"
>
<HistoryIcon className="size-3.5" />
</Button>
</PopoverTrigger>
@ -779,14 +997,14 @@ export function PdfPreview() {
<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">
<span className="text-foreground text-xs">
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">
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-medium text-[10px] 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">
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-medium text-[10px] text-muted-foreground">
{navigator.userAgent.includes("Mac") ? "⌘" : "Ctrl+"}X
</kbd>
<span className="text-[10px] text-muted-foreground">to cancel</span>

View file

@ -2,7 +2,10 @@ 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 { getCachedDocument, getOrOpenDocument } from "@/lib/mupdf/pdf-doc-cache";
import {
getCachedDocument,
getOrOpenDocument,
} from "@/lib/mupdf/pdf-doc-cache";
import { MupdfPage } from "./mupdf-page";
import { createLogger } from "@/lib/debug/logger";
import { APP_VISIBILITY_RESTORED } from "@/lib/debug/log-store";
@ -99,7 +102,8 @@ export function PdfViewer({
useEffect(() => {
const handleRestore = () => setFocusGen((g) => g + 1);
window.addEventListener(APP_VISIBILITY_RESTORED, handleRestore);
return () => window.removeEventListener(APP_VISIBILITY_RESTORED, handleRestore);
return () =>
window.removeEventListener(APP_VISIBILITY_RESTORED, handleRestore);
}, []);
// Keep-alive scroll save/restore
@ -127,7 +131,9 @@ export function PdfViewer({
}, [isActive]);
// Capture drag state
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
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);
@ -181,8 +187,17 @@ export function PdfViewer({
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) {
log.error("Invalid PDF data: missing %PDF- header", { length: pdfData.length, firstBytes: Array.from(pdfData.slice(0, 16)) });
if (
pdfData.length < 5 ||
pdfData[0] !== 0x25 ||
pdfData[1] !== 0x50 ||
pdfData[2] !== 0x44 ||
pdfData[3] !== 0x46
) {
log.error("Invalid PDF data: missing %PDF- header", {
length: pdfData.length,
firstBytes: Array.from(pdfData.slice(0, 16)),
});
setLoading(false);
onError?.("Invalid PDF data received. Try recompiling the document.");
return;
@ -241,7 +256,10 @@ export function PdfViewer({
setLoading(false);
if (isFirstLoad.current && syncResult.pageSizes.length > 0) {
onFirstPageSize?.(syncResult.pageSizes[0].width, syncResult.pageSizes[0].height);
onFirstPageSize?.(
syncResult.pageSizes[0].width,
syncResult.pageSizes[0].height,
);
}
isFirstLoad.current = false;
onLoadSuccess?.(syncResult.pageSizes.length);
@ -296,7 +314,10 @@ export function PdfViewer({
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);
const pageNum = parseInt(
el.getAttribute("data-page-number") || "0",
10,
);
if (pageNum === 0) continue;
if (entry.isIntersecting) {
next.add(pageNum);
@ -345,7 +366,10 @@ export function PdfViewer({
const pageEl = target.closest(".mupdf-page") as HTMLElement | null;
if (!pageEl) return;
const pageNum = parseInt(pageEl.getAttribute("data-page-number") || "0", 10);
const pageNum = parseInt(
pageEl.getAttribute("data-page-number") || "0",
10,
);
if (pageNum === 0) return;
const rect = pageEl.getBoundingClientRect();
@ -472,7 +496,9 @@ export function PdfViewer({
const container = containerRef.current;
if (container) scrollToPage(container, page);
};
return () => { if (scrollToPageRef) scrollToPageRef.current = null; };
return () => {
if (scrollToPageRef) scrollToPageRef.current = null;
};
}, [scrollToPageRef, pageSizes]);
// Dismiss selection toolbar on scroll
@ -562,7 +588,11 @@ export function PdfViewer({
return;
}
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:")) {
if (
href.startsWith("http://") ||
href.startsWith("https://") ||
href.startsWith("mailto:")
) {
ask(`Open in browser?\n${href}`, {
title: "External Link",
kind: "info",
@ -602,7 +632,10 @@ export function PdfViewer({
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");
const pageNum = parseInt(
pageEl.getAttribute("data-page-number") || "0",
10,
);
if (!pageNum) return;
setDragPageNum(pageNum);
setDragStart({ x: e.clientX, y: e.clientY });
@ -639,7 +672,9 @@ export function PdfViewer({
const pageEl = containerRef.current?.querySelector(
`.mupdf-page[data-page-number="${dragPageNum}"]`,
) as HTMLElement | null;
const sourceCanvas = pageEl?.querySelector("canvas") as HTMLCanvasElement | null;
const sourceCanvas = pageEl?.querySelector(
"canvas",
) as HTMLCanvasElement | null;
if (!pageEl || !sourceCanvas) {
setDragStart(null);
setDragEnd(null);
@ -692,10 +727,7 @@ export function PdfViewer({
(e: React.MouseEvent) => {
if (!onTextClick) return;
const target = e.target as HTMLElement;
if (
target.tagName === "text" &&
target.closest(".mupdf-text-layer")
) {
if (target.tagName === "text" && target.closest(".mupdf-text-layer")) {
const text = target.textContent?.trim();
if (text && text.length > 2) {
onTextClick(text);

View file

@ -22,7 +22,6 @@ import {
FileCodeIcon,
FileIcon,
FileSpreadsheetIcon,
GripVerticalIcon,
AppWindowIcon,
FlaskConicalIcon,
TerminalIcon,
@ -100,7 +99,11 @@ function parseTableOfContents(content: string): TocItem[] {
const match = line.match(sectionRegex);
if (match) {
const [, type, title] = match;
toc.push({ level: levelMap[type] ?? 2, title: title.trim(), line: index + 1 });
toc.push({
level: levelMap[type] ?? 2,
title: title.trim(),
line: index + 1,
});
}
});
return toc;
@ -179,8 +182,10 @@ function buildFileTree(files: ProjectFile[], folders: string[]): TreeNode[] {
function getFileIcon(file: ProjectFile) {
if (file.type === "image") return <ImageIcon className="size-4 shrink-0" />;
if (file.type === "pdf") return <FileSpreadsheetIcon className="size-4 shrink-0" />;
if (file.type === "style") return <FileCodeIcon className="size-4 shrink-0" />;
if (file.type === "pdf")
return <FileSpreadsheetIcon className="size-4 shrink-0" />;
if (file.type === "style")
return <FileCodeIcon className="size-4 shrink-0" />;
if (file.type === "other") return <FileIcon className="size-4 shrink-0" />;
return <FileTextIcon className="size-4 shrink-0" />;
}
@ -188,7 +193,9 @@ function getFileIcon(file: ProjectFile) {
// ─── App Version (resolved once from Tauri) ───
let _appVersion = "";
getVersion().then((v) => { _appVersion = v; });
getVersion().then((v) => {
_appVersion = v;
});
function useAppVersion() {
const [version, setVersion] = useState(_appVersion);
useEffect(() => {
@ -214,8 +221,10 @@ export function Sidebar() {
const active = s.files.find((f) => f.id === s.activeFileId);
return active?.content ?? "";
});
const requestJumpToPosition = useDocumentStore((s) => s.requestJumpToPosition);
const insertAtCursor = useDocumentStore((s) => s.insertAtCursor);
const requestJumpToPosition = useDocumentStore(
(s) => s.requestJumpToPosition,
);
const _insertAtCursor = useDocumentStore((s) => s.insertAtCursor);
const moveFile = useDocumentStore((s) => s.moveFile);
const moveFolder = useDocumentStore((s) => s.moveFolder);
const closeProject = useDocumentStore((s) => s.closeProject);
@ -239,7 +248,9 @@ export function Sidebar() {
const { type } = event.payload;
if (type === "over" || type === "enter") {
const payload = event.payload as { position: { x: number; y: number } };
const payload = event.payload as {
position: { x: number; y: number };
};
const { x, y } = payload.position;
// Tauri reports physical pixels; elementFromPoint expects logical (CSS) pixels
const logicalX = x / window.devicePixelRatio;
@ -258,12 +269,17 @@ 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 folderEl = el.closest(
"[data-drop-folder]",
) as HTMLElement | null;
const folder = folderEl?.dataset.dropFolder ?? "__root__";
nativeDropTargetRef.current = folder;
setNativeDragOver(folder);
} else if (type === "drop") {
const payload = event.payload as { paths: string[]; position: { x: number; y: number } };
const payload = event.payload as {
paths: string[];
position: { x: number; y: number };
};
const { paths, position } = payload;
const logicalX = position.x / window.devicePixelRatio;
const logicalY = position.y / window.devicePixelRatio;
@ -277,13 +293,16 @@ export function Sidebar() {
return;
}
const targetFolder = nativeDropTargetRef.current === "__root__"
? undefined
: (nativeDropTargetRef.current ?? undefined);
const targetFolder =
nativeDropTargetRef.current === "__root__"
? undefined
: (nativeDropTargetRef.current ?? undefined);
// Mark as handled so chat-composer doesn't also process it
(window as any).__sidebarHandledDrop = true;
setTimeout(() => { (window as any).__sidebarHandledDrop = false; }, 200);
setTimeout(() => {
(window as any).__sidebarHandledDrop = false;
}, 200);
try {
await importFiles(paths, targetFolder);
@ -313,7 +332,9 @@ export function Sidebar() {
}, [importFiles]);
// Track selected folder for paste target
const [pasteTargetFolder, setPasteTargetFolder] = useState<string | undefined>();
const [pasteTargetFolder, setPasteTargetFolder] = useState<
string | undefined
>();
// ─── Cmd+V paste files from OS clipboard ───
useEffect(() => {
@ -349,47 +370,63 @@ export function Sidebar() {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const [activeDrag, setActiveDrag] = useState<{ id: string; type: "file" | "folder"; name: string } | null>(null);
const [activeDrag, setActiveDrag] = useState<{
id: string;
type: "file" | "folder";
name: string;
} | null>(null);
const handleDragStart = useCallback((event: DragStartEvent) => {
const { type, name } = event.active.data.current as { type: "file" | "folder"; name: string };
const { type, name } = event.active.data.current as {
type: "file" | "folder";
name: string;
};
setActiveDrag({ id: event.active.id as string, type, name });
}, []);
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
setActiveDrag(null);
const { active, over } = event;
if (!over) return;
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
setActiveDrag(null);
const { active, over } = event;
if (!over) return;
const draggedPath = active.id as string;
const draggedType = (active.data.current as { type: string }).type;
const targetId = over.id as string;
const targetFolder = targetId === "__root__" ? null : targetId;
const draggedPath = active.id as string;
const draggedType = (active.data.current as { type: string }).type;
const targetId = over.id as string;
const targetFolder = targetId === "__root__" ? null : targetId;
// Don't move if same parent
const draggedParent = draggedPath.includes("/")
? draggedPath.substring(0, draggedPath.lastIndexOf("/"))
: null;
if (targetFolder === draggedParent) return;
// Don't move if same parent
const draggedParent = draggedPath.includes("/")
? draggedPath.substring(0, draggedPath.lastIndexOf("/"))
: null;
if (targetFolder === draggedParent) return;
// Don't move folder into itself or descendant
if (draggedType === "folder" && targetFolder) {
if (targetFolder === draggedPath || targetFolder.startsWith(draggedPath + "/")) return;
}
// Don't move folder into itself or descendant
if (draggedType === "folder" && targetFolder) {
if (
targetFolder === draggedPath ||
targetFolder.startsWith(`${draggedPath}/`)
)
return;
}
try {
if (draggedType === "file") await moveFile(draggedPath, targetFolder);
else await moveFolder(draggedPath, targetFolder);
} catch (err) {
log.error("DnD move failed", { error: String(err) });
}
}, [moveFile, moveFolder]);
try {
if (draggedType === "file") await moveFile(draggedPath, targetFolder);
else await moveFolder(draggedPath, targetFolder);
} catch (err) {
log.error("DnD move failed", { error: String(err) });
}
},
[moveFile, moveFolder],
);
// Dialog state
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [addDialogFolder, setAddDialogFolder] = useState<string | undefined>();
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [folderDialogParent, setFolderDialogParent] = useState<string | undefined>();
const [folderDialogParent, setFolderDialogParent] = useState<
string | undefined
>();
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameFileId, setRenameFileId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
@ -397,7 +434,9 @@ export function Sidebar() {
const [newFolderName, setNewFolderName] = useState("");
// Folder expand/collapse
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
const tree = useMemo(() => buildFileTree(files, folders), [files, folders]);
// Auto-expand parent folders of the active file so it stays visible
@ -410,7 +449,10 @@ export function Sidebar() {
let changed = false;
for (let i = 1; i < parts.length; i++) {
const folder = parts.slice(0, i).join("/");
if (!next.has(folder)) { next.add(folder); changed = true; }
if (!next.has(folder)) {
next.add(folder);
changed = true;
}
}
return changed ? next : prev;
});
@ -427,7 +469,10 @@ export function Sidebar() {
}, []);
// Outline
const toc = useMemo(() => parseTableOfContents(activeFileContent), [activeFileContent]);
const toc = useMemo(
() => parseTableOfContents(activeFileContent),
[activeFileContent],
);
const handleTocClick = useCallback(
(line: number) => {
const lines = activeFileContent.split("\n");
@ -442,7 +487,9 @@ export function Sidebar() {
// Check if a name already exists in the given folder
// Case-insensitive on macOS/Windows (default case-insensitive filesystems)
const isCaseInsensitiveFs = navigator.platform.startsWith("Mac") || navigator.platform.startsWith("Win");
const isCaseInsensitiveFs =
navigator.platform.startsWith("Mac") ||
navigator.platform.startsWith("Win");
const nameExistsIn = useCallback(
(name: string, folder?: string) => {
const targetPath = folder ? `${folder}/${name}` : name;
@ -468,8 +515,11 @@ export function Sidebar() {
// Auto-append .tex if no extension provided
const finalName = /\.\w+$/.test(name) ? name : `${name}.tex`;
const lower = finalName.toLowerCase();
const type: "tex" | "image" =
/\.(png|jpg|jpeg|gif|svg|bmp|webp)$/.test(lower) ? "image" : "tex";
const type: "tex" | "image" = /\.(png|jpg|jpeg|gif|svg|bmp|webp)$/.test(
lower,
)
? "image"
: "tex";
createNewFile(finalName, type, addDialogFolder);
setNewFileName("");
setNameError("");
@ -497,7 +547,23 @@ export function Sidebar() {
filters: [
{
name: "All Files",
extensions: ["tex", "bib", "sty", "cls", "bst", "png", "jpg", "jpeg", "gif", "svg", "bmp", "webp", "pdf", "txt", "md"],
extensions: [
"tex",
"bib",
"sty",
"cls",
"bst",
"png",
"jpg",
"jpeg",
"gif",
"svg",
"bmp",
"webp",
"pdf",
"txt",
"md",
],
},
],
});
@ -555,7 +621,7 @@ export function Sidebar() {
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
{/* Header — padded top for macOS overlay titlebar */}
<div className="relative flex items-center justify-center border-sidebar-border border-b px-3 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
<div className="relative flex h-[calc(48px+var(--titlebar-height))] items-center justify-center border-sidebar-border border-b px-3 pt-[var(--titlebar-height)]">
<div className="flex flex-col items-center">
<span className="font-semibold text-sm">ClaudePrism</span>
<span className="text-muted-foreground text-xs">
@ -579,7 +645,11 @@ export function Sidebar() {
<PanelGroup direction="vertical" className="min-h-0 flex-1">
{/* Files */}
<Panel defaultSize={50} minSize={15}>
<div ref={sidebarFilesRef} className="flex h-full flex-col" data-sidebar-files>
<div
ref={sidebarFilesRef}
className="flex h-full flex-col"
data-sidebar-files
>
<div className="relative flex h-8 shrink-0 items-center justify-center border-sidebar-border border-b px-3">
<div className="flex items-center gap-2">
<FolderIcon className="size-3.5 text-muted-foreground" />
@ -597,7 +667,12 @@ export function Sidebar() {
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-5" title="Add">
<Button
variant="ghost"
size="icon"
className="size-5"
title="Add"
>
<PlusIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
@ -619,7 +694,11 @@ export function Sidebar() {
</DropdownMenu>
</div>
</div>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<ContextMenu>
<ContextMenuTrigger asChild>
<DroppableRoot nativeDragOver={nativeDragOver === "__root__"}>
@ -632,7 +711,9 @@ export function Sidebar() {
expandedFolders={expandedFolders}
onToggleFolder={toggleFolder}
onSelectFile={(id: string) => {
const parent = id.includes("/") ? id.substring(0, id.lastIndexOf("/")) : undefined;
const parent = id.includes("/")
? id.substring(0, id.lastIndexOf("/"))
: undefined;
setPasteTargetFolder(parent);
setActiveFile(id);
}}
@ -667,10 +748,11 @@ export function Sidebar() {
<DragOverlay dropAnimation={null}>
{activeDrag && (
<div className="flex items-center gap-2 rounded-md bg-sidebar px-2 py-1 text-sm shadow-lg ring-1 ring-ring">
{activeDrag.type === "folder"
? <FolderIcon className="size-4 shrink-0" />
: <FileTextIcon className="size-4 shrink-0" />
}
{activeDrag.type === "folder" ? (
<FolderIcon className="size-4 shrink-0" />
) : (
<FileTextIcon className="size-4 shrink-0" />
)}
<span className="truncate">{activeDrag.name}</span>
</div>
)}
@ -782,19 +864,26 @@ export function Sidebar() {
<Input
placeholder="filename.tex"
value={newFileName}
onChange={(e) => { setNewFileName(e.target.value); setNameError(""); }}
onChange={(e) => {
setNewFileName(e.target.value);
setNameError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddFile();
}}
autoFocus
/>
{nameError && <p className="text-destructive text-xs">{nameError}</p>}
{nameError && (
<p className="text-destructive text-xs">{nameError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddFile} disabled={!newFileName.trim()}>Create</Button>
<Button onClick={handleAddFile} disabled={!newFileName.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -811,19 +900,32 @@ export function Sidebar() {
<Input
placeholder="folder name"
value={newFolderName}
onChange={(e) => { setNewFolderName(e.target.value); setNameError(""); }}
onChange={(e) => {
setNewFolderName(e.target.value);
setNameError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateFolder();
}}
autoFocus
/>
{nameError && <p className="text-destructive text-xs">{nameError}</p>}
{nameError && (
<p className="text-destructive text-xs">{nameError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFolderDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setFolderDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>Create</Button>
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim()}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -837,16 +939,24 @@ export function Sidebar() {
<div className="space-y-2 py-4">
<Input
value={renameValue}
onChange={(e) => { setRenameValue(e.target.value); setNameError(""); }}
onChange={(e) => {
setRenameValue(e.target.value);
setNameError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
}}
autoFocus
/>
{nameError && <p className="text-destructive text-xs">{nameError}</p>}
{nameError && (
<p className="text-destructive text-xs">{nameError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setRenameDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleRename}>Rename</Button>
@ -861,7 +971,13 @@ export function Sidebar() {
// ─── dnd-kit helpers ───
function DroppableRoot({ children, nativeDragOver }: { children: React.ReactNode; nativeDragOver?: boolean }) {
function DroppableRoot({
children,
nativeDragOver,
}: {
children: React.ReactNode;
nativeDragOver?: boolean;
}) {
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
return (
<div
@ -877,10 +993,22 @@ function DroppableRoot({ children, nativeDragOver }: { children: React.ReactNode
);
}
function DroppableFolder({ id, children, nativeDragOver }: { id: string; children: React.ReactNode; nativeDragOver?: boolean }) {
function DroppableFolder({
id,
children,
nativeDragOver,
}: {
id: string;
children: React.ReactNode;
nativeDragOver?: boolean;
}) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} data-drop-folder={id} className={cn((isOver || nativeDragOver) && "bg-accent/30 rounded-md")}>
<div
ref={setNodeRef}
data-drop-folder={id}
className={cn((isOver || nativeDragOver) && "rounded-md bg-accent/30")}
>
{children}
</div>
);
@ -925,7 +1053,10 @@ function FileTreeNode({
if (node.type === "folder") {
return (
<DroppableFolder id={node.relativePath} nativeDragOver={nativeDragOver === node.relativePath}>
<DroppableFolder
id={node.relativePath}
nativeDragOver={nativeDragOver === node.relativePath}
>
<DraggableItem id={node.relativePath} type="folder" name={node.name}>
<ContextMenu>
<ContextMenuTrigger asChild>
@ -957,7 +1088,9 @@ function FileTreeNode({
Import File Here
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onRename(node.relativePath, node.name)}>
<ContextMenuItem
onClick={() => onRename(node.relativePath, node.name)}
>
<PencilIcon className="mr-2 size-4" />
Rename
</ContextMenuItem>
@ -1017,7 +1150,10 @@ function FileTreeNode({
{getFileIcon(file)}
<span className="min-w-0 flex-1 truncate">{node.name}</span>
{file.isDirty && (
<span className="ml-auto shrink-0 size-2 rounded-full bg-blue-500" title="Modified" />
<span
className="ml-auto size-2 shrink-0 rounded-full bg-blue-500"
title="Modified"
/>
)}
</button>
</ContextMenuTrigger>
@ -1060,17 +1196,23 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
const checkSkillsStatus = useCallback(async () => {
try {
const globalStatus = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
const globalStatus = await invoke<SkillsStatus>(
"check_skills_installed",
{
projectPath: null,
},
);
if (globalStatus.installed) {
setSkillsStatus(globalStatus);
return;
}
if (projectPath) {
const projectStatus = await invoke<SkillsStatus>("check_skills_installed", {
projectPath,
});
const projectStatus = await invoke<SkillsStatus>(
"check_skills_installed",
{
projectPath,
},
);
setSkillsStatus(projectStatus);
} else {
setSkillsStatus(globalStatus);
@ -1085,22 +1227,31 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
}, [checkSkillsStatus]);
// Lazy import onboarding
const [OnboardingComponent, setOnboardingComponent] = useState<React.ComponentType<{
onClose: () => void;
}> | null>(null);
const [OnboardingComponent, setOnboardingComponent] =
useState<React.ComponentType<{
onClose: () => void;
}> | null>(null);
useEffect(() => {
if (showOnboarding && !OnboardingComponent) {
import("@/components/scientific-skills/scientific-skills-onboarding").then(
(mod) => setOnboardingComponent(() => mod.ScientificSkillsOnboarding)
import(
"@/components/scientific-skills/scientific-skills-onboarding"
).then((mod) =>
setOnboardingComponent(() => mod.ScientificSkillsOnboarding),
);
}
}, [showOnboarding, OnboardingComponent]);
const pythonLabel =
venvReady ? "Active" : uvStatus === "not-installed" ? "Not installed" : uvStatus === "ready" ? "No venv" : "";
const skillsLabel =
skillsStatus?.installed ? `${skillsStatus.skill_count} skills` : "Not installed";
const pythonLabel = venvReady
? "Active"
: uvStatus === "not-installed"
? "Not installed"
: uvStatus === "ready"
? "No venv"
: "";
const skillsLabel = skillsStatus?.installed
? `${skillsStatus.skill_count} skills`
: "Not installed";
return (
<>
@ -1109,33 +1260,60 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
<AppWindowIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium text-xs">Environment</span>
</div>
<div className="px-1 pb-1.5 space-y-0.5">
<div className="space-y-0.5 px-1 pb-1.5">
{/* Python / uv row */}
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50 min-w-0"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50"
onClick={() => setShowUvDialog(true)}
>
<TerminalIcon className={cn("size-3.5 shrink-0", venvReady ? "text-foreground" : "text-muted-foreground")} />
<span className="min-w-0 truncate text-xs flex-1">Python</span>
<span className={cn("shrink-0 text-xs", venvReady ? "text-foreground" : "text-muted-foreground")}>
<TerminalIcon
className={cn(
"size-3.5 shrink-0",
venvReady ? "text-foreground" : "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs">Python</span>
<span
className={cn(
"shrink-0 text-xs",
venvReady ? "text-foreground" : "text-muted-foreground",
)}
>
{pythonLabel}
</span>
</button>
{/* Scientific Skills row */}
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50 min-w-0"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50"
onClick={() => setShowOnboarding(true)}
>
<FlaskConicalIcon className={cn("size-3.5 shrink-0", skillsStatus?.installed ? "text-foreground" : "text-muted-foreground")} />
<span className="min-w-0 truncate text-xs flex-1">Skills</span>
<span className={cn("shrink-0 text-xs", skillsStatus?.installed ? "text-foreground" : "text-muted-foreground")}>
<FlaskConicalIcon
className={cn(
"size-3.5 shrink-0",
skillsStatus?.installed
? "text-foreground"
: "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs">Skills</span>
<span
className={cn(
"shrink-0 text-xs",
skillsStatus?.installed
? "text-foreground"
: "text-muted-foreground",
)}
>
{skillsLabel}
</span>
</button>
</div>
</div>
<UvSetupDialog open={showUvDialog} onClose={() => setShowUvDialog(false)} />
<UvSetupDialog
open={showUvDialog}
onClose={() => setShowUvDialog(false)}
/>
{showOnboarding && OnboardingComponent && (
<OnboardingComponent
@ -1151,21 +1329,33 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) {
// ─── Draggable wrapper ───
function DraggableItem({ id, type, name, children }: { id: string; type: "file" | "folder"; name: string; children: React.ReactNode }) {
function DraggableItem({
id,
type,
name,
children,
}: {
id: string;
type: "file" | "folder";
name: string;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data: { type, name },
});
// Wrap listeners to log pointer events
const wrappedListeners = listeners ? Object.fromEntries(
Object.entries(listeners).map(([key, handler]) => [
key,
(e: React.PointerEvent) => {
(handler as (e: React.PointerEvent) => void)(e);
},
]),
) : {};
const wrappedListeners = listeners
? Object.fromEntries(
Object.entries(listeners).map(([key, handler]) => [
key,
(e: React.PointerEvent) => {
(handler as (e: React.PointerEvent) => void)(e);
},
]),
)
: {};
return (
<div

View file

@ -37,21 +37,23 @@ const MYLIB_KEY = "__my_library__";
export function ZoteroPanel() {
const isAuthenticated = useZoteroStore((s) => s.isAuthenticated);
const username = useZoteroStore((s) => s.username);
const _username = useZoteroStore((s) => s.username);
const isValidating = useZoteroStore((s) => s.isValidating);
const isSyncing = useZoteroStore((s) => s.isSyncing);
const syncProgress = useZoteroStore((s) => s.syncProgress);
const projectRoot = useDocumentStore((s) => s.projectRoot);
const allSyncedCollections = useZoteroStore((s) => s.syncedCollections);
const syncedCollections = projectRoot ? (allSyncedCollections[projectRoot] ?? {}) : {};
const syncedCollections = projectRoot
? (allSyncedCollections[projectRoot] ?? {})
: {};
const error = useZoteroStore((s) => s.error);
const collections = useZoteroStore((s) => s.collections);
const isLoadingCollections = useZoteroStore((s) => s.isLoadingCollections);
const connectWithOAuth = useZoteroStore((s) => s.connectWithOAuth);
const cancelConnect = useZoteroStore((s) => s.cancelConnect);
const disconnect = useZoteroStore((s) => s.disconnect);
const _disconnect = useZoteroStore((s) => s.disconnect);
const revalidate = useZoteroStore((s) => s.revalidate);
const loadCollections = useZoteroStore((s) => s.loadCollections);
const _loadCollections = useZoteroStore((s) => s.loadCollections);
const importCollectionToBib = useZoteroStore((s) => s.importCollectionToBib);
const syncCollectionBib = useZoteroStore((s) => s.syncCollectionBib);
const removeCollection = useZoteroStore((s) => s.removeCollection);
@ -88,7 +90,7 @@ export function ZoteroPanel() {
{/* Syncing progress */}
{isSyncing && (
<div className="mx-2 mb-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<div className="mx-2 mb-0.5 flex items-center gap-1 text-muted-foreground text-xs">
<LoaderIcon className="size-3 animate-spin" />
{syncProgress
? `${syncProgress.loaded}/${syncProgress.total}`
@ -114,7 +116,7 @@ export function ZoteroPanel() {
)}
{isLoadingCollections ? (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1 px-2 py-1 text-muted-foreground text-xs">
<LoaderIcon className="size-3 animate-spin" />
Loading...
</div>
@ -173,7 +175,9 @@ export function ZoteroHeader() {
onClick={loadCollections}
title="Refresh"
>
<RefreshCwIcon className={cn("size-3.5", isLoadingCollections && "animate-spin")} />
<RefreshCwIcon
className={cn("size-3.5", isLoadingCollections && "animate-spin")}
/>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -184,7 +188,9 @@ export function ZoteroHeader() {
<DropdownMenuContent align="end" className="w-44">
<div className="flex items-center gap-2 px-2 py-1">
<UserIcon className="size-3.5 text-muted-foreground" />
<span className="truncate text-xs text-muted-foreground">{username}</span>
<span className="truncate text-muted-foreground text-xs">
{username}
</span>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={disconnect}>
@ -219,7 +225,7 @@ function NotConnectedView({
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
<LinkIcon className="size-4 text-muted-foreground" />
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
<p className="text-[11px] text-muted-foreground leading-relaxed">
Connect Zotero to import references.
</p>
{isValidating ? (
@ -228,22 +234,32 @@ function NotConnectedView({
<LoaderIcon className="size-3 animate-spin" />
Authorizing...
</div>
<button className="text-[10px] text-muted-foreground underline" onClick={onCancel}>
<button
className="text-[10px] text-muted-foreground underline"
onClick={onCancel}
>
Cancel
</button>
</div>
) : (
<div className="flex flex-col items-center gap-1">
<Button size="sm" className="h-6 gap-1 text-[11px]" onClick={onConnect}>
<Button
size="sm"
className="h-6 gap-1 text-[11px]"
onClick={onConnect}
>
<ExternalLinkIcon className="size-3" />
Connect
</Button>
<button className="text-[10px] text-muted-foreground underline" onClick={onApiKey}>
<button
className="text-[10px] text-muted-foreground underline"
onClick={onApiKey}
>
API key
</button>
</div>
)}
{error && <p className="text-destructive text-[10px]">{error}</p>}
{error && <p className="text-[10px] text-destructive">{error}</p>}
</div>
);
}
@ -251,7 +267,7 @@ function NotConnectedView({
// ─── Collection Row ───
function CollectionRow({
collectionKey,
collectionKey: _collectionKey,
name,
icon,
itemCount,
@ -280,16 +296,18 @@ function CollectionRow({
<span className="shrink-0 text-muted-foreground">{icon}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="truncate text-sm text-foreground">{name}</span>
{isSynced && <CheckIcon className="size-2.5 shrink-0 text-muted-foreground" />}
<span className="truncate text-foreground text-sm">{name}</span>
{isSynced && (
<CheckIcon className="size-2.5 shrink-0 text-muted-foreground" />
)}
</div>
{isSynced && (
<p className="truncate text-xs leading-none text-muted-foreground">
<p className="truncate text-muted-foreground text-xs leading-none">
{syncInfo.bibFileName}
</p>
)}
{!isSynced && itemCount !== undefined && (
<p className="text-xs leading-none text-muted-foreground">
<p className="text-muted-foreground text-xs leading-none">
{itemCount} items
</p>
)}
@ -394,7 +412,10 @@ function ZoteroApiKeyDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConnect} disabled={!apiKey.trim() || isValidating}>
<Button
onClick={handleConnect}
disabled={!apiKey.trim() || isValidating}
>
{isValidating ? "Validating..." : "Connect"}
</Button>
</DialogFooter>

View file

@ -9,7 +9,11 @@ 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, resolveCompileTarget, formatCompileError } from "@/lib/latex-compiler";
import {
compileLatex,
resolveCompileTarget,
formatCompileError,
} from "@/lib/latex-compiler";
import { createLogger } from "@/lib/debug/logger";
const log = createLogger("claude-event");
@ -138,34 +142,56 @@ export function useClaudeEvents() {
lastMsgTimeRef.current.set(tabId, now);
// Log ALL message types with gap detection
const contentTypes = msg.message?.content?.map((b: any) => b.type).join(",") ?? "";
const contentTypes =
msg.message?.content?.map((b: any) => b.type).join(",") ?? "";
const gapWarning = Number(gap) > 10 ? ` GAP ${gap}s` : "";
log.debug(`[${tabId}] ${elapsed(tabId)} #${count} type=${msg.type} sub=${msg.subtype ?? ""} content=[${contentTypes}] gap=${gap}s${gapWarning}`);
log.debug(
`[${tabId}] ${elapsed(tabId)} #${count} 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");
const thinkingBlock = msg.message?.content?.find(
(b: any) => b.type === "thinking",
);
if (thinkingBlock) {
log.debug(`[${tabId}] ${elapsed(tabId)} thinking: ${(thinkingBlock.thinking || "").slice(0, 100)}`);
log.debug(
`[${tabId}] ${elapsed(tabId)} thinking: ${(thinkingBlock.thinking || "").slice(0, 100)}`,
);
}
const textBlock = msg.message?.content?.find((b: any) => b.type === "text");
const textBlock = msg.message?.content?.find(
(b: any) => b.type === "text",
);
if (textBlock?.text) {
log.debug(`[${tabId}] ${elapsed(tabId)} text: ${textBlock.text.slice(0, 100)}`);
log.debug(
`[${tabId}] ${elapsed(tabId)} text: ${textBlock.text.slice(0, 100)}`,
);
}
const toolBlock = msg.message?.content?.find((b: any) => b.type === "tool_use");
const toolBlock = msg.message?.content?.find(
(b: any) => b.type === "tool_use",
);
if (toolBlock) {
log.debug(`[${tabId}] ${elapsed(tabId)} tool_use: ${toolBlock.name} ${toolBlock.input?.file_path ?? ""}`);
log.debug(
`[${tabId}] ${elapsed(tabId)} 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);
log.debug(`[${tabId}] ${elapsed(tabId)} tool_result: id=${block.tool_use_id} err=${block.is_error ?? false} len=${preview?.length ?? 0}`);
const preview =
typeof block.content === "string"
? block.content.slice(0, 80)
: JSON.stringify(block.content)?.slice(0, 80);
log.debug(
`[${tabId}] ${elapsed(tabId)} tool_result: id=${block.tool_use_id} err=${block.is_error ?? false} len=${preview?.length ?? 0}`,
);
}
}
}
if (msg.type === "result") {
log.info(`[${tabId}] ${elapsed(tabId)} result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`);
log.info(
`[${tabId}] ${elapsed(tabId)} result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`,
);
}
// Extract session_id from system:init
@ -177,10 +203,17 @@ export function useClaudeEvents() {
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";
log.warn(`[${tabId}] rate_limit: status=${info.status} type=${info.rateLimitType} resets=${resetsAt} overage=${info.overageStatus}`);
const resetsAt = info.resetsAt
? new Date(info.resetsAt * 1000).toLocaleTimeString()
: "unknown";
log.warn(
`[${tabId}] rate_limit: status=${info.status} type=${info.rateLimitType} resets=${resetsAt} overage=${info.overageStatus}`,
);
if (info.status !== "allowed") {
chatStore._setError(tabId, `Rate limited (${info.rateLimitType}). Resets at ${resetsAt}`);
chatStore._setError(
tabId,
`Rate limited (${info.rateLimitType}). Resets at ${resetsAt}`,
);
}
}
return; // rate_limit_event is informational — do not append to messages
@ -240,7 +273,9 @@ export function useClaudeEvents() {
(b: any) => b.type === "tool_use" && b.name === "AskUserQuestion",
);
if (hasAskUser) {
log.info(`[${tabId}] ${elapsed(tabId)} AskUserQuestion detected — cancelling process for user input`);
log.info(
`[${tabId}] ${elapsed(tabId)} AskUserQuestion detected — cancelling process for user input`,
);
cancelledForAskRef.current.set(tabId, true);
invoke("cancel_claude_execution", { tabId }).catch(() => {});
}
@ -251,18 +286,31 @@ export function useClaudeEvents() {
const { tab_id: tabId, success } = payload;
const count = msgCountRef.current.get(tabId) ?? 0;
log.info(`[${tabId}] complete success=${success} (${count} messages) cancelledForAsk=${cancelledForAskRef.current.get(tabId) ?? false}`);
log.info(
`[${tabId}] complete success=${success} (${count} messages) cancelledForAsk=${cancelledForAskRef.current.get(tabId) ?? false}`,
);
const chatStore = useClaudeChatStore.getState();
// Guard against duplicate complete events
const tab = chatStore.tabs.find((t) => t.id === tabId);
if (!tab?.isStreaming) {
log.warn(`[${tabId}] ignoring duplicate complete event (not streaming)`);
log.warn(
`[${tabId}] ignoring duplicate complete event (not streaming)`,
);
return;
}
if (!success && count > 0 && !tab.error && !cancelledForAskRef.current.get(tabId) && !chatStore._cancelledByUser) {
chatStore._setError(tabId, "Claude process exited unexpectedly. This may be due to rate limiting or an API error.");
if (
!success &&
count > 0 &&
!tab.error &&
!cancelledForAskRef.current.get(tabId) &&
!chatStore._cancelledByUser
) {
chatStore._setError(
tabId,
"Claude process exited unexpectedly. This may be due to rate limiting or an API error.",
);
}
// Clean up per-tab state
@ -276,7 +324,9 @@ export function useClaudeEvents() {
const projectPath = useDocumentStore.getState().projectRoot;
if (projectPath) {
try {
await useHistoryStore.getState().createSnapshot(projectPath, "[claude] After Claude edit");
await useHistoryStore
.getState()
.createSnapshot(projectPath, "[claude] After Claude edit");
} catch {
// snapshot failure should not break the flow
}
@ -286,7 +336,12 @@ export function useClaudeEvents() {
await docStore.refreshFiles();
// Auto-recompile after Claude finishes
const { projectRoot, files, activeFileId, isCompiling: alreadyCompiling } = useDocumentStore.getState();
const {
projectRoot,
files,
activeFileId,
isCompiling: alreadyCompiling,
} = useDocumentStore.getState();
if (projectRoot && !alreadyCompiling) {
const resolved = resolveCompileTarget(activeFileId, files);
if (resolved) {
@ -298,7 +353,9 @@ export function useClaudeEvents() {
const pdfData = await compileLatex(projectRoot, targetPath);
useDocumentStore.getState().setPdfData(pdfData, rootId);
} catch (err) {
useDocumentStore.getState().setCompileError(formatCompileError(err), rootId);
useDocumentStore
.getState()
.setCompileError(formatCompileError(err), rootId);
} finally {
useDocumentStore.getState().setIsCompiling(false);
}
@ -321,7 +378,10 @@ export function useClaudeEvents() {
if (!cancelled) handleStreamMessage(event.payload);
},
);
if (cancelled) { unlistenOutput(); return; }
if (cancelled) {
unlistenOutput();
return;
}
listenersRef.current.push(unlistenOutput);
const unlistenComplete = await listen<ClaudeCompletePayload>(
@ -330,7 +390,10 @@ export function useClaudeEvents() {
if (!cancelled) handleComplete(event.payload);
},
);
if (cancelled) { unlistenComplete(); return; }
if (cancelled) {
unlistenComplete();
return;
}
listenersRef.current.push(unlistenComplete);
const unlistenError = await listen<ClaudeErrorPayload>(
@ -339,13 +402,21 @@ export function useClaudeEvents() {
if (!cancelled) {
const { tab_id: tabId, data: payload } = event.payload;
log.warn(`[${tabId}] stderr: ${payload}`);
if (payload.includes("Error") || payload.includes("error") || payload.includes("ECONNREFUSED") || payload.includes("timeout")) {
if (
payload.includes("Error") ||
payload.includes("error") ||
payload.includes("ECONNREFUSED") ||
payload.includes("timeout")
) {
log.error(`[${tabId}] CRITICAL: ${payload}`);
}
}
},
);
if (cancelled) { unlistenError(); return; }
if (cancelled) {
unlistenError();
return;
}
listenersRef.current.push(unlistenError);
})();

View file

@ -14,19 +14,32 @@ export function useKeyboardShortcuts() {
});
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "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) {
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "x" &&
!e.shiftKey &&
!e.altKey
) {
e.preventDefault();
window.dispatchEvent(new CustomEvent("toggle-capture-mode"));
}
// Cmd+Shift+D (macOS) / Ctrl+Shift+D (others): Open debug window
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "d") {
if (
(e.metaKey || e.ctrlKey) &&
e.shiftKey &&
e.key.toLowerCase() === "d"
) {
e.preventDefault();
invoke("open_debug_window").catch(console.error);
}

View file

@ -58,7 +58,12 @@ function _pushEntry(entry: LogEntry) {
interface LogStore {
/** Incremented on each log/clear — subscribers use this to know when to re-read. */
version: number;
log: (level: LogLevel, source: string, message: string, data?: unknown) => void;
log: (
level: LogLevel,
source: string,
message: string,
data?: unknown,
) => void;
getEntries: () => readonly LogEntry[];
getFilteredLogs: (opts?: {
level?: LogLevel;
@ -94,7 +99,7 @@ export const useLogStore = create<LogStore>((set) => ({
// Forward warn/error to Rust stderr via existing js_log command
if (level === "warn" || level === "error") {
const prefix = level === "error" ? "ERROR" : "WARN";
const msg = `[${prefix}][${source}] ${message}${data ? " " + JSON.stringify(data) : ""}`;
const msg = `[${prefix}][${source}] ${message}${data ? ` ${JSON.stringify(data)}` : ""}`;
invoke("js_log", { msg }).catch(() => {});
}
},
@ -172,11 +177,14 @@ export function getGpuRenderer(): string {
if (_gpuRendererCache !== null) return _gpuRendererCache;
try {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
const gl =
canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
if (gl && gl instanceof WebGLRenderingContext) {
const ext = gl.getExtension("WEBGL_debug_renderer_info");
if (ext) {
_gpuRendererCache = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string;
_gpuRendererCache = gl.getParameter(
ext.UNMASKED_RENDERER_WEBGL,
) as string;
return _gpuRendererCache;
}
}

View file

@ -19,7 +19,8 @@ function bonusFor(prev: string, curr: string): number {
if (prev === "/") return SCORE_MATCH_SLASH;
if (prev === "-" || prev === "_" || prev === " ") return SCORE_MATCH_WORD;
if (prev === ".") return SCORE_MATCH_DOT;
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
if (prev === prev.toLowerCase() && curr === curr.toUpperCase())
return SCORE_MATCH_CAPITAL;
return 0;
}
@ -52,12 +53,15 @@ function fuzzyScore(query: string, candidate: string): number {
if (qLower[i] === cLower[j]) {
let score = 0;
if (i === 0) {
score = j === 0
? SCORE_MATCH_CONSECUTIVE
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) + bonusFor(candidate[j - 1], candidate[j]);
score =
j === 0
? SCORE_MATCH_CONSECUTIVE
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) +
bonusFor(candidate[j - 1], candidate[j]);
} else if (j > 0) {
const consecutive = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
const boundary = M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
const boundary =
M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
score = Math.max(consecutive, boundary);
}
D[i][j] = score;
@ -127,7 +131,7 @@ function scoreCommand(cmd: FakeCmd, q: string): number {
// Description: only substring (contains) match to avoid false positives
let descScore = -Infinity;
if (cmd.description && cmd.description.toLowerCase().includes(q.toLowerCase())) {
if (cmd.description?.toLowerCase().includes(q.toLowerCase())) {
descScore = q.length * 0.3;
}
@ -140,20 +144,76 @@ function scoreCommand(cmd: FakeCmd, q: string): number {
// ─── Test data ───
const COMMANDS: FakeCmd[] = [
{ name: "biorxiv-database", full_command: "/biorxiv-database", description: "Efficient database search tool for bioRxiv preprint server." },
{ name: "biopython", full_command: "/biopython", description: "Comprehensive molecular biology toolkit." },
{ name: "bioservices", full_command: "/bioservices", description: "Unified Python interface to 40+ bioinformatics services." },
{ name: "cbioportal-database", full_command: "/cbioportal-database", description: "Query cBioPortal for cancer genomics data." },
{ name: "scikit-bio", full_command: "/scikit-bio", description: "Biological data toolkit." },
{ name: "scvi-tools", full_command: "/scvi-tools", description: "Deep generative models for single-cell omics." },
{ name: "vaex", full_command: "/vaex", description: "Large tabular datasets." },
{ name: "deepchem", full_command: "/deepchem", description: "Molecular ML with diverse featurizers." },
{ name: "market-research-reports", full_command: "/market-research-reports", description: "Market research reports." },
{ name: "matlab", full_command: "/matlab", description: "MATLAB and GNU Octave." },
{ name: "scanpy", full_command: "/scanpy", description: "scRNA-seq analysis." },
{ name: "phylogenetics", full_command: "/phylogenetics", description: "Phylogenetic trees." },
{ name: "perplexity-search", full_command: "/perplexity-search", description: "AI-powered web searches." },
{ name: "latchbio-integration", full_command: "/latchbio-integration", description: "Latch platform for bioinformatics." },
{
name: "biorxiv-database",
full_command: "/biorxiv-database",
description: "Efficient database search tool for bioRxiv preprint server.",
},
{
name: "biopython",
full_command: "/biopython",
description: "Comprehensive molecular biology toolkit.",
},
{
name: "bioservices",
full_command: "/bioservices",
description: "Unified Python interface to 40+ bioinformatics services.",
},
{
name: "cbioportal-database",
full_command: "/cbioportal-database",
description: "Query cBioPortal for cancer genomics data.",
},
{
name: "scikit-bio",
full_command: "/scikit-bio",
description: "Biological data toolkit.",
},
{
name: "scvi-tools",
full_command: "/scvi-tools",
description: "Deep generative models for single-cell omics.",
},
{
name: "vaex",
full_command: "/vaex",
description: "Large tabular datasets.",
},
{
name: "deepchem",
full_command: "/deepchem",
description: "Molecular ML with diverse featurizers.",
},
{
name: "market-research-reports",
full_command: "/market-research-reports",
description: "Market research reports.",
},
{
name: "matlab",
full_command: "/matlab",
description: "MATLAB and GNU Octave.",
},
{
name: "scanpy",
full_command: "/scanpy",
description: "scRNA-seq analysis.",
},
{
name: "phylogenetics",
full_command: "/phylogenetics",
description: "Phylogenetic trees.",
},
{
name: "perplexity-search",
full_command: "/perplexity-search",
description: "AI-powered web searches.",
},
{
name: "latchbio-integration",
full_command: "/latchbio-integration",
description: "Latch platform for bioinformatics.",
},
];
function search(q: string): { name: string; score: number }[] {

View file

@ -16,7 +16,9 @@ export function resolveCompileTarget(
return { rootId, targetPath: rootEntry.relativePath };
}
// Fallback: look for any well-known root tex file
const fallback = files.find((f) => f.name === "main.tex" || f.name === "document.tex");
const fallback = files.find(
(f) => f.name === "main.tex" || f.name === "document.tex",
);
if (fallback) {
return { rootId: fallback.id, targetPath: fallback.relativePath };
}
@ -51,7 +53,9 @@ export async function compileLatex(
});
const result = new Uint8Array(buffer);
log.info(`Compiled ${mainFile} in ${(performance.now() - start).toFixed(0)}ms (${(result.byteLength / 1024).toFixed(0)} KB)`);
log.info(
`Compiled ${mainFile} in ${(performance.now() - start).toFixed(0)}ms (${(result.byteLength / 1024).toFixed(0)} KB)`,
);
return result;
}
@ -74,7 +78,8 @@ export async function synctexEdit(
x,
y,
});
if (result) log.debug(`SyncTeX: page ${page}${result.file}:${result.line}`);
if (result)
log.debug(`SyncTeX: page ${page}${result.file}:${result.line}`);
return result;
} catch (err) {
log.debug("SyncTeX lookup failed", { page, error: String(err) });

View file

@ -1,4 +1,9 @@
import type { StructuredTextData, LinkData, PageSize, WorkerResponse } from "./types";
import type {
StructuredTextData,
LinkData,
PageSize,
WorkerResponse,
} from "./types";
import { createLogger } from "@/lib/debug/logger";
const log = createLogger("mupdf-worker");
@ -12,7 +17,11 @@ export interface MupdfClient {
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>;
renderThumbnail(
docId: number,
pageIndex: number,
targetWidth: number,
): Promise<ArrayBuffer>;
destroy(): void;
}
@ -22,10 +31,9 @@ type PendingRequest = {
};
function createClient(): MupdfClient {
const worker = new Worker(
new URL("./mupdf-worker.ts", import.meta.url),
{ type: "module" },
);
const worker = new Worker(new URL("./mupdf-worker.ts", import.meta.url), {
type: "module",
});
const pending = new Map<number, PendingRequest>();
let nextId = 1;
@ -74,13 +82,23 @@ function createClient(): MupdfClient {
const timer = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`MuPDF worker timeout: ${method} took longer than ${CALL_TIMEOUT_MS}ms`));
reject(
new Error(
`MuPDF worker timeout: ${method} took longer than ${CALL_TIMEOUT_MS}ms`,
),
);
}
}, CALL_TIMEOUT_MS);
pending.set(id, {
resolve: (value: any) => { clearTimeout(timer); resolve(value); },
reject: (error: Error) => { clearTimeout(timer); reject(error); },
resolve: (value: any) => {
clearTimeout(timer);
resolve(value);
},
reject: (error: Error) => {
clearTimeout(timer);
reject(error);
},
});
const transferables: Transferable[] = [];
@ -96,15 +114,18 @@ function createClient(): MupdfClient {
}
return {
openDocument: (buffer, magic = "application/pdf") => call("openDocument", buffer, magic),
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),
getAllPageSizes: (docId) => call("getAllPageSizes", docId),
drawPage: (docId, pageIndex, dpi) => call("drawPage", docId, pageIndex, dpi),
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),
renderThumbnail: (docId, pageIndex, targetWidth) =>
call("renderThumbnail", docId, pageIndex, targetWidth),
destroy: () => worker.terminate(),
};
}

View file

@ -24,7 +24,10 @@ methods.countPages = (docId: number): number => {
return doc.countPages();
};
methods.getPageSize = (docId: number, pageIndex: number): { width: number; height: number } => {
methods.getPageSize = (
docId: number,
pageIndex: number,
): { width: number; height: number } => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const bounds = page.getBounds();
@ -34,7 +37,9 @@ methods.getPageSize = (docId: number, pageIndex: number): { width: number; heigh
};
};
methods.getAllPageSizes = (docId: number): { width: number; height: number }[] => {
methods.getAllPageSizes = (
docId: number,
): { width: number; height: number }[] => {
const doc = documentMap.get(docId)!;
const count = doc.countPages();
const sizes: { width: number; height: number }[] = [];
@ -49,7 +54,11 @@ methods.getAllPageSizes = (docId: number): { width: number; height: number }[] =
return sizes;
};
methods.drawPage = (docId: number, pageIndex: number, dpi: number): ImageData => {
methods.drawPage = (
docId: number,
pageIndex: number,
dpi: number,
): ImageData => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const scale = dpi / 72;
@ -85,13 +94,21 @@ methods.getPageText = (docId: number, pageIndex: number): unknown => {
bbox: block.bbox,
lines: (block.lines || []).map((line: any) => {
let text = "";
let font = { name: "", family: "", size: 12, weight: "normal", style: "normal" };
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(""))
.map((span: any) =>
(span.chars || []).map((ch: any) => ch.c).join(""),
)
.join("");
const firstSpan = spans[0];
@ -162,7 +179,11 @@ methods.getPageLinks = (docId: number, pageIndex: number): unknown[] => {
});
};
methods.renderThumbnail = (docId: number, pageIndex: number, targetWidth: number): ArrayBuffer => {
methods.renderThumbnail = (
docId: number,
pageIndex: number,
targetWidth: number,
): ArrayBuffer => {
const doc = documentMap.get(docId)!;
const page = doc.loadPage(pageIndex);
const bounds = page.getBounds();
@ -177,7 +198,7 @@ methods.renderThumbnail = (docId: number, pageIndex: number, targetWidth: number
};
// RPC message handler
onmessage = (event: MessageEvent) => {
self.onmessage = (event: MessageEvent) => {
const [func, id, args] = event.data as [string, number, unknown[]];
try {
const result = methods[func](...args);

View file

@ -19,7 +19,9 @@ function computeFingerprint(data: Uint8Array): string {
if (len < 16) return `${len}:${Array.from(data).join(",")}`;
// Sample: length + first 8 bytes + middle 8 bytes + last 8 bytes
const first = Array.from(data.subarray(0, 8));
const mid = Array.from(data.subarray(Math.floor(len / 2) - 4, Math.floor(len / 2) + 4));
const mid = Array.from(
data.subarray(Math.floor(len / 2) - 4, Math.floor(len / 2) + 4),
);
const last = Array.from(data.subarray(len - 8));
return `${len}:${first.join(",")}|${mid.join(",")}|${last.join(",")}`;
}
@ -39,7 +41,9 @@ async function evictOldest(): Promise<void> {
const entry = cache.get(oldestKey)!;
cache.delete(oldestKey);
log.debug(`Evicted doc ${entry.docId} (cache size was ${cache.size + 1})`);
await getMupdfClient().closeDocument(entry.docId).catch(() => {});
await getMupdfClient()
.closeDocument(entry.docId)
.catch(() => {});
}
}
@ -68,7 +72,9 @@ export function getCachedDocument(data: Uint8Array): DocCacheResult | null {
* Returns the docId and pageSizes. If the same PDF bytes were already open,
* reuses the existing document (cache hit).
*/
export async function getOrOpenDocument(data: Uint8Array): Promise<DocCacheResult> {
export async function getOrOpenDocument(
data: Uint8Array,
): Promise<DocCacheResult> {
// Reuse synchronous lookup to avoid duplicating fingerprint + cache logic
const hit = getCachedDocument(data);
if (hit) return hit;
@ -77,7 +83,9 @@ export async function getOrOpenDocument(data: Uint8Array): Promise<DocCacheResul
await evictOldest();
const fingerprint = computeFingerprint(data);
log.debug(`Cache miss, opening document (${(data.byteLength / 1024).toFixed(0)} KB)`);
log.debug(
`Cache miss, opening document (${(data.byteLength / 1024).toFixed(0)} KB)`,
);
const client = getMupdfClient();
// Always copy — the original buffer may not be transferable (e.g., from Tauri),
// and transfer detaches the ArrayBuffer which would corrupt the pdfCache reference.
@ -94,7 +102,9 @@ export async function getOrOpenDocument(data: Uint8Array): Promise<DocCacheResul
lastAccess: Date.now(),
});
log.info(`Opened doc ${docId}: ${pageSizes.length} pages, cache size=${cache.size}`);
log.info(
`Opened doc ${docId}: ${pageSizes.length} pages, cache size=${cache.size}`,
);
return { docId, pageSizes, cacheHit: false };
}
@ -103,7 +113,9 @@ export function invalidateDoc(docId: number): void {
for (const [key, entry] of cache) {
if (entry.docId === docId) {
cache.delete(key);
getMupdfClient().closeDocument(docId).catch(() => {});
getMupdfClient()
.closeDocument(docId)
.catch(() => {});
return;
}
}
@ -113,8 +125,8 @@ export function invalidateDoc(docId: number): void {
export async function clearDocCache(): Promise<void> {
const count = cache.size;
const client = getMupdfClient();
const closePromises = [...cache.values()].map(
(entry) => client.closeDocument(entry.docId).catch(() => {}),
const closePromises = [...cache.values()].map((entry) =>
client.closeDocument(entry.docId).catch(() => {}),
);
cache.clear();
await Promise.all(closePromises);

View file

@ -29,7 +29,13 @@ export interface StructuredTextLine {
x: number;
y: number;
text: string;
font: { name: string; family: string; size: number; weight: string; style: string };
font: {
name: string;
family: string;
size: number;
weight: string;
style: string;
};
}
export interface StructuredTextBlock {

View file

@ -16,7 +16,13 @@ import { createLogger } from "@/lib/debug/logger";
const log = createLogger("fs");
export type ProjectFileType = "tex" | "image" | "pdf" | "bib" | "style" | "other";
export type ProjectFileType =
| "tex"
| "image"
| "pdf"
| "bib"
| "style"
| "other";
export interface FsProjectFile {
relativePath: string;
@ -135,9 +141,7 @@ export interface ScanResult {
folders: string[]; // relative paths of all directories
}
export async function scanProjectFolder(
rootPath: string,
): Promise<ScanResult> {
export async function scanProjectFolder(rootPath: string): Promise<ScanResult> {
const files: FsProjectFile[] = [];
const folders: string[] = [];
@ -164,7 +168,9 @@ export async function scanProjectFolder(
try {
const info = await stat(entryPath);
fileSize = info.size;
} catch { /* stat failed — treat as 0 */ }
} catch {
/* stat failed — treat as 0 */
}
}
files.push({
relativePath,
@ -230,7 +236,10 @@ export async function createFileOnDisk(
): Promise<string> {
const fullPath = await join(rootPath, name);
// Ensure parent directory exists
const lastSep = Math.max(fullPath.lastIndexOf("/"), 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 });
@ -275,7 +284,10 @@ 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 = Math.max(fullPath.lastIndexOf("/"), fullPath.lastIndexOf("\\"));
const lastSlash = Math.max(
fullPath.lastIndexOf("/"),
fullPath.lastIndexOf("\\"),
);
if (lastSlash > 0) {
const parentDir = fullPath.substring(0, lastSlash);
if (!(await exists(parentDir))) {
@ -291,7 +303,9 @@ export async function deleteFileFromDisk(absolutePath: string): Promise<void> {
await remove(absolutePath);
}
export async function deleteFolderFromDisk(absolutePath: string): Promise<void> {
export async function deleteFolderFromDisk(
absolutePath: string,
): Promise<void> {
log.debug(`Deleting folder: ${absolutePath}`);
await remove(absolutePath, { recursive: true });
}

View file

@ -38,7 +38,9 @@ export function getTemplatePdfUrl(templateId: string): string {
/**
* Load the static PDF and render page 1 as a thumbnail data URL.
*/
export async function generateThumbnail(templateId: string): Promise<string | null> {
export async function generateThumbnail(
templateId: string,
): Promise<string | null> {
const cached = thumbnailCache.get(templateId);
if (cached) return cached;
@ -68,7 +70,9 @@ export async function generateThumbnail(templateId: string): Promise<string | nu
notify();
return dataUrl;
} catch (err) {
log.warn(`Failed to load preview for ${templateId}`, { error: String(err) });
log.warn(`Failed to load preview for ${templateId}`, {
error: String(err),
});
failedIds.add(templateId);
notify();
return null;

View file

@ -1,6 +1,10 @@
// ─── Template Data Architecture ───
export type TemplateCategory = "academic" | "professional" | "creative" | "starter";
export type TemplateCategory =
| "academic"
| "professional"
| "creative"
| "starter";
export type TemplateSubcategory =
| "papers"
@ -59,7 +63,10 @@ export const SUBCATEGORY_LABELS: Record<TemplateSubcategory, string> = {
blank: "Blank",
};
export const CATEGORY_SUBCATEGORIES: Record<TemplateCategory, TemplateSubcategory[]> = {
export const CATEGORY_SUBCATEGORIES: Record<
TemplateCategory,
TemplateSubcategory[]
> = {
academic: ["papers", "theses", "presentations", "posters"],
professional: ["cv", "letters", "reports"],
creative: ["books", "newsletters"],
@ -75,7 +82,15 @@ const TEMPLATES: TemplateDefinition[] = [
description: "Academic paper with abstract, sections, and references",
category: "academic",
subcategory: "papers",
tags: ["article", "research", "journal", "academic", "science", "abstract", "bibliography"],
tags: [
"article",
"research",
"journal",
"academic",
"science",
"abstract",
"bibliography",
],
icon: "FileText",
documentClass: "article",
mainFileName: "main.tex",
@ -338,7 +353,13 @@ Barret Zoph and Quoc~V Le.
description: "Two-column IEEE conference format with standard sections",
category: "academic",
subcategory: "papers",
tags: ["ieee", "conference", "two-column", "engineering", "computer science"],
tags: [
"ieee",
"conference",
"two-column",
"engineering",
"computer science",
],
icon: "FileText",
documentClass: "IEEEtran",
mainFileName: "main.tex",
@ -3197,7 +3218,7 @@ export const BIB_TEMPLATE = `% Add your references here
// ─── Registry API ───
let _templates = TEMPLATES;
const _templates = TEMPLATES;
export function getAllTemplates(): TemplateDefinition[] {
return _templates;
@ -3207,7 +3228,9 @@ export function getTemplateById(id: string): TemplateDefinition | undefined {
return _templates.find((t) => t.id === id);
}
export function getTemplatesByCategory(category: TemplateCategory): TemplateDefinition[] {
export function getTemplatesByCategory(
category: TemplateCategory,
): TemplateDefinition[] {
return _templates.filter((t) => t.category === category);
}

View file

@ -82,7 +82,9 @@ function extractCitekey(bibtex: string): string {
return match ? match[1] : "";
}
export async function validateApiKey(apiKey: string): Promise<ZoteroCredentials> {
export async function validateApiKey(
apiKey: string,
): Promise<ZoteroCredentials> {
const response = await zoteroFetch(apiKey, "/keys/current");
const data = await response.json();
return {
@ -98,7 +100,10 @@ export async function fetchCollections(
apiKey: string,
userID: string,
): Promise<ZoteroCollection[]> {
const response = await zoteroFetch(apiKey, `/users/${userID}/collections?format=json`);
const response = await zoteroFetch(
apiKey,
`/users/${userID}/collections?format=json`,
);
const data = (await response.json()) as {
key: string;
data: { key: string; name: string; parentCollection: string | false };
@ -146,7 +151,9 @@ export async function importCollection(
if (start === 0) {
total = Number(response.headers.get("Total-Results") ?? 0);
libraryVersion = Number(response.headers.get("Last-Modified-Version") ?? 0);
libraryVersion = Number(
response.headers.get("Last-Modified-Version") ?? 0,
);
}
const items = (await response.json()) as { key: string; bibtex?: string }[];
@ -191,13 +198,19 @@ export async function syncCollection(
// For a specific collection, re-fetch all items and diff against keyMap
// (Zotero API doesn't support `since` scoped to a collection)
const result = await importCollection(apiKey, userID, collectionKey, onProgress);
const result = await importCollection(
apiKey,
userID,
collectionKey,
onProgress,
);
return {
updatedEntries: Object.entries(result.keyMap).map(([key, citekey]) => {
// Extract the bibtex for this citekey from the full bibtex string
const bibtexEntries = result.bibtex.split(/\n(?=@)/);
const entry = bibtexEntries.find((e) => extractCitekey(e) === citekey) ?? "";
const entry =
bibtexEntries.find((e) => extractCitekey(e) === citekey) ?? "";
return { key, citekey, bibtex: entry };
}),
deletedKeys: [],
@ -225,11 +238,16 @@ async function syncFullLibrary(
limit: String(limit),
start: String(start),
});
const response = await zoteroFetch(apiKey, `/users/${userID}/items/top?${params}`);
const response = await zoteroFetch(
apiKey,
`/users/${userID}/items/top?${params}`,
);
if (start === 0) {
total = Number(response.headers.get("Total-Results") ?? 0);
newVersion = Number(response.headers.get("Last-Modified-Version") ?? lastVersion);
newVersion = Number(
response.headers.get("Last-Modified-Version") ?? lastVersion,
);
}
const items = (await response.json()) as { key: string; bibtex?: string }[];
@ -256,7 +274,9 @@ async function syncFullLibrary(
const deletedKeys = deleted.items ?? [];
if (!newVersion || newVersion === lastVersion) {
newVersion = Number(deletedResponse.headers.get("Last-Modified-Version") ?? lastVersion);
newVersion = Number(
deletedResponse.headers.get("Last-Modified-Version") ?? lastVersion,
);
}
return { updatedEntries, deletedKeys, libraryVersion: newVersion };

View file

@ -59,7 +59,12 @@ export interface ClaudeStreamMessage {
export interface TabDraft {
input: string;
pinnedContexts: { label: string; filePath: string; selectedText: string; imageDataUrl?: string }[];
pinnedContexts: {
label: string;
filePath: string;
selectedText: string;
imageDataUrl?: string;
}[];
}
export interface TabState {
@ -149,9 +154,24 @@ interface ClaudeChatState {
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 }[];
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";
@ -162,7 +182,10 @@ interface ClaudeChatState {
setEffortLevel: (level: "low" | "medium" | "high") => void;
// Actions
sendPrompt: (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => Promise<void>;
sendPrompt: (
userPrompt: string,
contextOverride?: { label: string; filePath: string; selectedText: string },
) => Promise<void>;
cancelExecution: () => Promise<void>;
clearMessages: () => void;
newSession: () => void;
@ -233,7 +256,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
anyStreaming: () => get().tabs.some((t) => t.isStreaming),
sendPrompt: async (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => {
sendPrompt: async (
userPrompt: string,
contextOverride?: { label: string; filePath: string; selectedText: string },
) => {
const state = get();
const { activeTabId } = state;
const activeTab = state.tabs.find((t) => t.id === activeTabId);
@ -243,7 +269,11 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
const { sessionId, selectedModel, effortLevel } = state;
const sendStart = performance.now();
log.info("sendPrompt start", { sessionId: !!sessionId, hasContext: !!contextOverride, tab: activeTabId });
log.info("sendPrompt start", {
sessionId: !!sessionId,
hasContext: !!contextOverride,
tab: activeTabId,
});
const docState = useDocumentStore.getState();
const projectPath = docState.projectRoot;
@ -253,7 +283,9 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
}
// Compute context label for display in chat history
const activeFile = docState.files.find((f) => f.id === docState.activeFileId);
const activeFile = docState.files.find(
(f) => f.id === docState.activeFileId,
);
let contextLabel: string | null = null;
if (contextOverride) {
@ -287,7 +319,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
set((s) => {
const tabUpdates: Partial<TabState> = {
messages: [...(s.tabs.find((t) => t.id === activeTabId)?.messages ?? []), userMessage],
messages: [
...(s.tabs.find((t) => t.id === activeTabId)?.messages ?? []),
userMessage,
],
isStreaming: true,
error: null,
};
@ -309,9 +344,13 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
if (projectPath) {
try {
log.debug("creating snapshot...");
await useHistoryStore.getState().createSnapshot(projectPath, "[claude] Before Claude edit");
await useHistoryStore
.getState()
.createSnapshot(projectPath, "[claude] Before Claude edit");
log.debug("snapshot done");
} catch { /* snapshot failure should not block Claude */ }
} catch {
/* snapshot failure should not block Claude */
}
}
// Build prompt with full context for Claude
@ -335,7 +374,10 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
}
prompt = `${ctx}\n\n${userPrompt}`;
}
log.info("invoking CLI", { promptLength: prompt.length, mode: sessionId ? "resume" : "new" });
log.info("invoking CLI", {
promptLength: prompt.length,
mode: sessionId ? "resume" : "new",
});
try {
if (sessionId) {
@ -358,13 +400,20 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
effortLevel,
});
}
log.info(`sendPrompt complete in ${(performance.now() - sendStart).toFixed(0)}ms`);
log.info(
`sendPrompt complete in ${(performance.now() - sendStart).toFixed(0)}ms`,
);
} catch (err: any) {
log.error(`sendPrompt failed after ${(performance.now() - sendStart).toFixed(0)}ms`, { error: String(err) });
set((s) => applyTabUpdate(s, activeTabId, {
isStreaming: false,
error: err?.message || String(err),
}));
log.error(
`sendPrompt failed after ${(performance.now() - sendStart).toFixed(0)}ms`,
{ error: String(err) },
);
set((s) =>
applyTabUpdate(s, activeTabId, {
isStreaming: false,
error: err?.message || String(err),
}),
);
}
},
@ -381,26 +430,30 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
clearMessages: () => {
const { activeTabId } = get();
set((s) => applyTabUpdate(s, activeTabId, {
messages: [],
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
}));
set((s) =>
applyTabUpdate(s, activeTabId, {
messages: [],
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
}),
);
},
newSession: () => {
log.info("Starting new session");
const { activeTabId } = get();
set((s) => applyTabUpdate(s, activeTabId, {
messages: [],
sessionId: null,
error: null,
isStreaming: false,
totalInputTokens: 0,
totalOutputTokens: 0,
title: "New Chat",
}));
set((s) =>
applyTabUpdate(s, activeTabId, {
messages: [],
sessionId: null,
error: null,
isStreaming: false,
totalInputTokens: 0,
totalOutputTokens: 0,
title: "New Chat",
}),
);
},
resumeSession: async (sessionId: string) => {
@ -409,14 +462,16 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
const projectPath = useDocumentStore.getState().projectRoot;
// Reset state with new session ID
set((s) => applyTabUpdate(s, activeTabId, {
messages: [],
sessionId,
error: null,
isStreaming: false,
totalInputTokens: 0,
totalOutputTokens: 0,
}));
set((s) =>
applyTabUpdate(s, activeTabId, {
messages: [],
sessionId,
error: null,
isStreaming: false,
totalInputTokens: 0,
totalOutputTokens: 0,
}),
);
// Load session history from JSONL file
if (projectPath) {
@ -515,7 +570,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
saveDraft: (tabId: string, draft: TabDraft) => {
set((s) => ({
tabs: s.tabs.map((t) => t.id === tabId ? { ...t, draft } : t),
tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, draft } : t)),
}));
},

View file

@ -75,10 +75,19 @@ const LOGIN_STEPS: StepInfo[] = [
{ id: "complete", label: "Authenticated", status: "pending" },
];
const STEP_ORDER_INSTALL = ["downloading", "installing", "verifying", "complete"];
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[] {
function advanceSteps(
steps: StepInfo[],
targetId: string,
order: string[],
): StepInfo[] {
const targetIdx = order.indexOf(targetId);
return steps.map((s) => {
const thisIdx = order.indexOf(s.id);
@ -207,14 +216,18 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
_advanceInstallStep: (stepId: string) => {
set((state) => ({
installSteps: advanceSteps(state.installSteps, stepId, STEP_ORDER_INSTALL),
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
s.status === "active" ? { ...s, status: "error" as const } : s,
),
error,
}));
@ -229,7 +242,7 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
_failCurrentLoginStep: (error: string) => {
set((state) => ({
loginSteps: state.loginSteps.map((s) =>
s.status === "active" ? { ...s, status: "error" as const } : s
s.status === "active" ? { ...s, status: "error" as const } : s,
),
error,
}));

View file

@ -51,7 +51,9 @@ export function getPdfBytes(rootFileId: string): Uint8Array | undefined {
/** Get the current active PDF bytes (convenience for components that don't know the rootId). */
export function getCurrentPdfBytes(): Uint8Array | null {
return _currentPdfRootId ? (_pdfBytesCache.get(_currentPdfRootId) ?? null) : null;
return _currentPdfRootId
? (_pdfBytesCache.get(_currentPdfRootId) ?? null)
: null;
}
/** Check if any PDF data exists for the current root. */
@ -114,11 +116,21 @@ interface DocumentState {
saveFile: (id: string) => Promise<void>;
saveAllFiles: () => Promise<void>;
saveCurrentFile: () => Promise<void>;
createNewFile: (name: string, type: "tex" | "image", folder?: string) => Promise<void>;
createNewFile: (
name: string,
type: "tex" | "image",
folder?: string,
) => Promise<void>;
createFolder: (name: string, parentFolder?: string) => Promise<void>;
importFiles: (sourcePaths: string[], targetFolder?: string) => Promise<string[]>;
importFiles: (
sourcePaths: string[],
targetFolder?: string,
) => Promise<string[]>;
moveFile: (fileId: string, targetFolder: string | null) => Promise<void>;
moveFolder: (folderPath: string, targetFolder: string | null) => Promise<void>;
moveFolder: (
folderPath: string,
targetFolder: string | null,
) => Promise<void>;
reloadFile: (relativePath: string) => Promise<void>;
refreshFiles: () => Promise<void>;
/** Load content for a file that was skipped during project open (large file). */
@ -149,7 +161,9 @@ export function resolveTexRoot(fileId: string, files: ProjectFile[]): string {
if (match) {
const rootPath = match[1].trim();
// Try matching by relative path first, then by filename
const target = files.find((f) => f.relativePath === rootPath) ?? files.find((f) => f.name === rootPath);
const target =
files.find((f) => f.relativePath === rootPath) ??
files.find((f) => f.name === rootPath);
if (target) return target.id;
}
}
@ -166,7 +180,11 @@ function migratePdfBytesKey(oldKey: string, newKey: string) {
}
/** Re-key a Map entry when a file is renamed/moved. */
function migrateCacheKey<V>(map: Map<string, V>, oldKey: string, newKey: string): Map<string, V> {
function migrateCacheKey<V>(
map: Map<string, V>,
oldKey: string,
newKey: string,
): Map<string, V> {
if (!map.has(oldKey)) return map;
const copy = new Map(map);
const val = copy.get(oldKey)!;
@ -186,7 +204,9 @@ function scheduleAutoSave() {
const store = storeRef;
if (!store) return;
const state = store.getState();
const dirtyFiles = state.files.filter((f) => f.isDirty && f.content != null);
const dirtyFiles = state.files.filter(
(f) => f.isDirty && f.content != null,
);
if (dirtyFiles.length > 0) {
await state.saveAllFiles();
}
@ -214,7 +234,8 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
openProject: async (rootPath: string) => {
log.info(`Opening project: ${rootPath}`);
const { files: fsFiles, folders: fsFolders } = await scanProjectFolder(rootPath);
const { files: fsFiles, folders: fsFolders } =
await scanProjectFolder(rootPath);
const projectFiles: ProjectFile[] = [];
for (const f of fsFiles) {
@ -229,8 +250,14 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
};
// Load content for text-based files (skip large non-essential files)
if (f.type === "tex" || f.type === "bib" || f.type === "style" || f.type === "other") {
const isLargeNonEssential = f.type === "other" && f.fileSize > LARGE_FILE_THRESHOLD;
if (
f.type === "tex" ||
f.type === "bib" ||
f.type === "style" ||
f.type === "other"
) {
const isLargeNonEssential =
f.type === "other" && f.fileSize > LARGE_FILE_THRESHOLD;
if (!isLargeNonEssential) {
try {
pf.content = await readTexFileContent(f.absolutePath);
@ -259,8 +286,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
// Find the main tex file
const mainTex =
projectFiles.find((f) => f.name === "main.tex" || f.name === "document.tex") ||
projectFiles.find((f) => f.type === "tex");
projectFiles.find(
(f) => f.name === "main.tex" || f.name === "document.tex",
) || projectFiles.find((f) => f.type === "tex");
clearPdfBytesCache();
set({
@ -364,7 +392,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
lastCompiledGenerations.delete(id);
// If the deleted file was active, show the new active file's cached PDF
const switchingActive = state.activeFileId === id;
const newRootId = switchingActive ? resolveTexRoot(newActiveId, newFiles) : undefined;
const newRootId = switchingActive
? resolveTexRoot(newActiveId, newFiles)
: undefined;
if (switchingActive && newRootId) {
_currentPdfRootId = _pdfBytesCache.has(newRootId) ? newRootId : null;
}
@ -374,18 +404,20 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
compileErrorCache,
lastCompiledGenerations,
...(switchingActive ? { pdfRevision: s.pdfRevision + 1 } : {}),
...(switchingActive && newRootId ? {
compileError: compileErrorCache.get(newRootId) ?? null,
} : {}),
...(switchingActive && newRootId
? {
compileError: compileErrorCache.get(newRootId) ?? null,
}
: {}),
}));
},
deleteFolder: async (folderPath) => {
const state = get();
if (!state.projectRoot) return;
const prefix = folderPath + "/";
const filesToRemove = state.files.filter(
(f) => f.relativePath.startsWith(prefix),
const prefix = `${folderPath}/`;
const filesToRemove = state.files.filter((f) =>
f.relativePath.startsWith(prefix),
);
const remainingFiles = state.files.filter(
(f) => !f.relativePath.startsWith(prefix),
@ -415,7 +447,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
? remainingFiles[0].id
: state.activeFileId;
const switchingActive = newActiveId !== state.activeFileId;
const newRootId = switchingActive ? resolveTexRoot(newActiveId, remainingFiles) : undefined;
const newRootId = switchingActive
? resolveTexRoot(newActiveId, remainingFiles)
: undefined;
if (switchingActive && newRootId) {
_currentPdfRootId = _pdfBytesCache.has(newRootId) ? newRootId : null;
}
@ -432,9 +466,11 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
compileErrorCache,
lastCompiledGenerations,
...(switchingActive ? { pdfRevision: s.pdfRevision + 1 } : {}),
...(switchingActive && newRootId ? {
compileError: compileErrorCache.get(newRootId) ?? null,
} : {}),
...(switchingActive && newRootId
? {
compileError: compileErrorCache.get(newRootId) ?? null,
}
: {}),
}));
},
@ -457,8 +493,16 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
}
migratePdfBytesKey(id, newRelativePath);
set((s) => {
const compileErrorCache = migrateCacheKey(s.compileErrorCache, id, newRelativePath);
const lastCompiledGenerations = migrateCacheKey(s.lastCompiledGenerations, id, newRelativePath);
const compileErrorCache = migrateCacheKey(
s.compileErrorCache,
id,
newRelativePath,
);
const lastCompiledGenerations = migrateCacheKey(
s.lastCompiledGenerations,
id,
newRelativePath,
);
const isActive = s.activeFileId === id;
return {
files: s.files.map((f) =>
@ -491,9 +535,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
updateImageDataUrl: (id, dataUrl) => {
set((state) => ({
files: state.files.map((f) =>
f.id === id ? { ...f, dataUrl } : f,
),
files: state.files.map((f) => (f.id === id ? { ...f, dataUrl } : f)),
}));
},
@ -524,10 +566,16 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
lastCompiledGenerations,
}));
} else {
set((prev) => ({ pdfRevision: prev.pdfRevision + 1, compileError: null }));
set((prev) => ({
pdfRevision: prev.pdfRevision + 1,
compileError: null,
}));
}
} else {
set((prev) => ({ pdfRevision: prev.pdfRevision + 1, compileError: null }));
set((prev) => ({
pdfRevision: prev.pdfRevision + 1,
compileError: null,
}));
}
},
@ -555,7 +603,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
insertAtCursor: (text) => {
const state = get();
const activeFile = getActiveFile(state);
if (!activeFile || (activeFile.type === "image" || activeFile.type === "pdf"))
if (!activeFile || activeFile.type === "image" || activeFile.type === "pdf")
return;
const content = activeFile.content ?? "";
@ -576,7 +624,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
replaceSelection: (start, end, text) => {
const state = get();
const activeFile = getActiveFile(state);
if (!activeFile || (activeFile.type === "image" || activeFile.type === "pdf"))
if (!activeFile || activeFile.type === "image" || activeFile.type === "pdf")
return;
const content = activeFile.content ?? "";
@ -595,7 +643,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
findAndReplace: (find, replace) => {
const state = get();
const activeFile = getActiveFile(state);
if (!activeFile || (activeFile.type === "image" || activeFile.type === "pdf"))
if (!activeFile || activeFile.type === "image" || activeFile.type === "pdf")
return false;
const content = activeFile.content ?? "";
@ -621,15 +669,15 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
await writeTexFileContent(file.absolutePath, file.content);
set((s) => ({
files: s.files.map((f) =>
f.id === id ? { ...f, isDirty: false } : f,
),
files: s.files.map((f) => (f.id === id ? { ...f, isDirty: false } : f)),
}));
},
saveAllFiles: async () => {
const state = get();
const dirtyFiles = state.files.filter((f) => f.isDirty && f.content != null);
const dirtyFiles = state.files.filter(
(f) => f.isDirty && f.content != null,
);
const results = await Promise.allSettled(
dirtyFiles.map((f) => writeTexFileContent(f.absolutePath, f.content!)),
);
@ -653,7 +701,9 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
// Manual save → immediate snapshot
if (state.projectRoot) {
try {
await useHistoryStore.getState().createSnapshot(state.projectRoot, "[manual] Save");
await useHistoryStore
.getState()
.createSnapshot(state.projectRoot, "[manual] Save");
} catch {
// Snapshot failure should not break save
}
@ -670,7 +720,11 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
? `\\documentclass{article}\n\n\\begin{document}\n\n% Your content here\n\n\\end{document}\n`
: "";
const fullPath = await createFileOnDisk(state.projectRoot, relativePath, content);
const fullPath = await createFileOnDisk(
state.projectRoot,
relativePath,
content,
);
set((s) => ({
files: [
@ -709,9 +763,15 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
for (const sourcePath of sourcePaths) {
// Handle both Unix (/) and Windows (\) path separators
const fileName = sourcePath.split(/[/\\]/).pop() || sourcePath;
const targetName = targetFolder ? `${targetFolder}/${fileName}` : fileName;
const targetName = targetFolder
? `${targetFolder}/${fileName}`
: fileName;
// copyFileToProject returns the actual (possibly deduplicated) relative path
const actualName = await copyFileToProject(state.projectRoot, sourcePath, targetName);
const actualName = await copyFileToProject(
state.projectRoot,
sourcePath,
targetName,
);
importedPaths.push(actualName);
}
await state.refreshFiles();
@ -723,26 +783,46 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
const file = state.files.find((f) => f.id === fileId);
if (!file || !state.projectRoot) return;
const desiredPath = targetFolder ? `${targetFolder}/${file.name}` : file.name;
const desiredPath = targetFolder
? `${targetFolder}/${file.name}`
: file.name;
if (desiredPath === file.relativePath) return;
// Auto-deduplicate if a file with the same name exists in the target
const newRelativePath = await getUniqueTargetName(state.projectRoot, desiredPath);
const newRelativePath = await getUniqueTargetName(
state.projectRoot,
desiredPath,
);
const newAbsPath = await join(state.projectRoot, newRelativePath);
await renameFileOnDisk(file.absolutePath, newAbsPath);
const newName = newRelativePath.split("/").pop() || file.name;
migratePdfBytesKey(fileId, newRelativePath);
set((s) => {
const compileErrorCache = migrateCacheKey(s.compileErrorCache, fileId, newRelativePath);
const lastCompiledGenerations = migrateCacheKey(s.lastCompiledGenerations, fileId, newRelativePath);
const compileErrorCache = migrateCacheKey(
s.compileErrorCache,
fileId,
newRelativePath,
);
const lastCompiledGenerations = migrateCacheKey(
s.lastCompiledGenerations,
fileId,
newRelativePath,
);
return {
files: s.files.map((f) =>
f.id === fileId
? { ...f, name: newName, relativePath: newRelativePath, absolutePath: newAbsPath, id: newRelativePath }
? {
...f,
name: newName,
relativePath: newRelativePath,
absolutePath: newAbsPath,
id: newRelativePath,
}
: f,
),
activeFileId: s.activeFileId === fileId ? newRelativePath : s.activeFileId,
activeFileId:
s.activeFileId === fileId ? newRelativePath : s.activeFileId,
compileErrorCache,
lastCompiledGenerations,
};
@ -754,10 +834,12 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
if (!state.projectRoot) return;
const folderName = folderPath.split("/").pop()!;
const newFolderPath = targetFolder ? `${targetFolder}/${folderName}` : folderName;
const newFolderPath = targetFolder
? `${targetFolder}/${folderName}`
: folderName;
if (newFolderPath === folderPath) return;
// Prevent moving a folder into itself
if (newFolderPath.startsWith(folderPath + "/")) return;
if (newFolderPath.startsWith(`${folderPath}/`)) return;
const oldAbsPath = await join(state.projectRoot, folderPath);
const newAbsPath = await join(state.projectRoot, newFolderPath);
@ -787,7 +869,8 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
const { projectRoot, files, activeFileId } = get();
if (!projectRoot) return;
const { files: fsFiles, folders: fsFolders } = await scanProjectFolder(projectRoot);
const { files: fsFiles, folders: fsFolders } =
await scanProjectFolder(projectRoot);
const existingMap = new Map(files.map((f) => [f.relativePath, f]));
const diskPaths = new Set(fsFiles.map((f) => f.relativePath));
@ -802,13 +885,24 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
merged.push(existing);
} else {
const updated = { ...existing, fileSize: fsFile.fileSize };
if (updated.type === "tex" || updated.type === "bib" || updated.type === "style" || updated.type === "other") {
const isLargeNonEssential = updated.type === "other" && fsFile.fileSize > LARGE_FILE_THRESHOLD;
if (
updated.type === "tex" ||
updated.type === "bib" ||
updated.type === "style" ||
updated.type === "other"
) {
const isLargeNonEssential =
updated.type === "other" &&
fsFile.fileSize > LARGE_FILE_THRESHOLD;
// Only reload if it was previously loaded (not a skipped large file)
if (!isLargeNonEssential || updated.content !== undefined) {
try {
updated.content = await readTexFileContent(updated.absolutePath);
} catch { /* keep previous content */ }
updated.content = await readTexFileContent(
updated.absolutePath,
);
} catch {
/* keep previous content */
}
}
}
merged.push(updated);
@ -824,15 +918,28 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
isDirty: false,
fileSize: fsFile.fileSize,
};
const isLargeNonEssential = pf.type === "other" && fsFile.fileSize > LARGE_FILE_THRESHOLD;
if (pf.type === "tex" || pf.type === "bib" || pf.type === "style" || (pf.type === "other" && !isLargeNonEssential)) {
const isLargeNonEssential =
pf.type === "other" && fsFile.fileSize > LARGE_FILE_THRESHOLD;
if (
pf.type === "tex" ||
pf.type === "bib" ||
pf.type === "style" ||
(pf.type === "other" && !isLargeNonEssential)
) {
try {
pf.content = await readTexFileContent(pf.absolutePath);
} catch { /* skip unreadable */ }
} else if (pf.type === "image" && fsFile.fileSize <= LARGE_FILE_THRESHOLD) {
} catch {
/* skip unreadable */
}
} else if (
pf.type === "image" &&
fsFile.fileSize <= LARGE_FILE_THRESHOLD
) {
try {
pf.dataUrl = await readImageAsDataUrl(pf.absolutePath);
} catch { /* skip unreadable */ }
} catch {
/* skip unreadable */
}
}
// PDF files and large files are loaded on-demand
merged.push(pf);
@ -848,7 +955,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
const newActiveId = merged.some((f) => f.id === activeFileId)
? activeFileId
: merged[0]?.id ?? "";
: (merged[0]?.id ?? "");
set((s) => ({
files: merged,
@ -897,9 +1004,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
const state = get();
set({
files: state.files.map((f) =>
f.id === state.activeFileId
? { ...f, content, isDirty: true }
: f,
f.id === state.activeFileId ? { ...f, content, isDirty: true } : f,
),
contentGeneration: state.contentGeneration + 1,
});

View file

@ -31,14 +31,32 @@ interface HistoryState {
reviewingSnapshot: SnapshotInfo | null;
init: (projectRoot: string) => Promise<void>;
createSnapshot: (projectRoot: string, message: string) => Promise<SnapshotInfo | null>;
createSnapshot: (
projectRoot: string,
message: string,
) => Promise<SnapshotInfo | null>;
loadSnapshots: (projectRoot: string) => Promise<void>;
loadMoreSnapshots: (projectRoot: string) => Promise<void>;
selectSnapshot: (id: string | null) => void;
loadDiff: (projectRoot: string, fromId: string, toId: string) => Promise<void>;
getFileAt: (projectRoot: string, snapshotId: string, filePath: string) => Promise<string>;
restoreSnapshot: (projectRoot: string, snapshotId: string) => Promise<SnapshotInfo>;
addLabel: (projectRoot: string, snapshotId: string, label: string) => Promise<void>;
loadDiff: (
projectRoot: string,
fromId: string,
toId: string,
) => Promise<void>;
getFileAt: (
projectRoot: string,
snapshotId: string,
filePath: string,
) => Promise<string>;
restoreSnapshot: (
projectRoot: string,
snapshotId: string,
) => Promise<SnapshotInfo>;
addLabel: (
projectRoot: string,
snapshotId: string,
label: string,
) => Promise<void>;
removeLabel: (projectRoot: string, label: string) => Promise<void>;
startReview: (snapshot: SnapshotInfo) => void;
stopReview: () => void;
@ -65,7 +83,7 @@ export const useHistoryStore = create<HistoryState>()((set, get) => ({
},
init: async (projectRoot) => {
log.debug("Initializing history for " + projectRoot);
log.debug(`Initializing history for ${projectRoot}`);
await invoke("history_init", { projectRoot });
},

View file

@ -19,9 +19,7 @@ interface ProposedChangesState {
changes: ProposedChange[];
// Actions
addChange: (
change: Omit<ProposedChange, "timestamp">
) => void;
addChange: (change: Omit<ProposedChange, "timestamp">) => void;
resolveChange: (id: string) => void;
keepChange: (id: string) => void;
undoChange: (id: string) => Promise<void>;
@ -54,10 +52,7 @@ export const useProposedChangesStore = create<ProposedChangesState>()(
return { changes: newChanges };
}
return {
changes: [
...state.changes,
{ ...change, timestamp: Date.now() },
],
changes: [...state.changes, { ...change, timestamp: Date.now() }],
};
});
},
@ -79,8 +74,8 @@ export const useProposedChangesStore = create<ProposedChangesState>()(
.getState()
.files.find((f) => f.relativePath === change.filePath);
if (file?.content != null) {
writeTexFileContent(change.absolutePath, file.content).catch(
(err) => log.error("Failed to write kept change", { error: String(err) }),
writeTexFileContent(change.absolutePath, file.content).catch((err) =>
log.error("Failed to write kept change", { error: String(err) }),
);
}
@ -128,5 +123,5 @@ export const useProposedChangesStore = create<ProposedChangesState>()(
getChangeForFile: (relativePath) => {
return get().changes.find((c) => c.filePath === relativePath);
},
})
}),
);

View file

@ -4,7 +4,6 @@ import {
type TemplateDefinition,
getAllTemplates,
searchTemplates,
getTemplatesByCategory,
} from "@/lib/template-registry";
interface TemplateState {

View file

@ -123,7 +123,8 @@ export const useUvSetupStore = create<UvSetupState>((set, get) => ({
set({
isInstalling: false,
status: "error",
error: "uv installation failed. Check your internet connection and try again.",
error:
"uv installation failed. Check your internet connection and try again.",
});
}
},

View file

@ -26,7 +26,10 @@ export interface CollectionSyncInfo {
}
/** Synced collections scoped per project path */
type ProjectSyncedCollections = Record<string, Record<string, CollectionSyncInfo>>;
type ProjectSyncedCollections = Record<
string,
Record<string, CollectionSyncInfo>
>;
interface ZoteroState {
// Persisted
@ -51,7 +54,10 @@ interface ZoteroState {
disconnect: () => void;
revalidate: () => Promise<void>;
loadCollections: () => Promise<void>;
importCollectionToBib: (collectionKey: string | null, name: string) => Promise<void>;
importCollectionToBib: (
collectionKey: string | null,
name: string,
) => Promise<void>;
syncCollectionBib: (collectionKey: string | null) => Promise<void>;
removeCollection: (collectionKey: string | null) => void;
}
@ -62,7 +68,10 @@ function storeKey(collectionKey: string | null): string {
}
function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9_\-\s]/g, "").replace(/\s+/g, "-").toLowerCase();
return name
.replace(/[^a-zA-Z0-9_\-\s]/g, "")
.replace(/\s+/g, "-")
.toLowerCase();
}
/** Parse a .bib file into a map of citekey → full entry string */
@ -205,15 +214,22 @@ export const useZoteroStore = create<ZoteroState>()(
set({ isSyncing: sk, syncProgress: null, error: null });
try {
const result = await importCollection(apiKey, userID, collectionKey, (loaded, total) => {
set({ syncProgress: { loaded, total } });
});
const result = await importCollection(
apiKey,
userID,
collectionKey,
(loaded, total) => {
set({ syncProgress: { loaded, total } });
},
);
// Determine .bib file name
const bibFileName = `${sanitizeFileName(name)}.bib`;
// Check if this .bib file already exists in the project
const existingFile = docStore.files.find((f) => f.name === bibFileName);
const existingFile = docStore.files.find(
(f) => f.name === bibFileName,
);
if (existingFile) {
docStore.updateFileContent(existingFile.id, result.bibtex);
} else {
@ -272,15 +288,22 @@ export const useZoteroStore = create<ZoteroState>()(
const syncInfo = projectColls[sk];
if (!syncInfo) return;
const bibFile = docStore.files.find((f) => f.name === syncInfo.bibFileName);
const bibFile = docStore.files.find(
(f) => f.name === syncInfo.bibFileName,
);
if (!bibFile) return;
set({ isSyncing: sk, syncProgress: null, error: null });
try {
const result = await syncCollection(
apiKey, userID, collectionKey, syncInfo.libraryVersion,
(loaded, total) => { set({ syncProgress: { loaded, total } }); },
apiKey,
userID,
collectionKey,
syncInfo.libraryVersion,
(loaded, total) => {
set({ syncProgress: { loaded, total } });
},
);
if (collectionKey) {
@ -294,7 +317,7 @@ export const useZoteroStore = create<ZoteroState>()(
newKeyMap[entry.key] = entry.citekey;
}
}
const updatedContent = entries.join("\n\n") + "\n";
const updatedContent = `${entries.join("\n\n")}\n`;
docStore.updateFileContent(bibFile.id, updatedContent);
set((s) => {
@ -338,7 +361,7 @@ export const useZoteroStore = create<ZoteroState>()(
}
}
const updatedContent = Array.from(entries.values()).join("\n\n") + "\n";
const updatedContent = `${Array.from(entries.values()).join("\n\n")}\n`;
docStore.updateFileContent(bibFile.id, updatedContent);
set((s) => {

@ -1 +1 @@
Subproject commit c3d8dbe5163603bc80a842145697e6bfe24d7a05
Subproject commit 43d9b03108d84a31e36fd0b71f93e442521e0883

7640
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -5,12 +5,14 @@
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", ".wrangler/**", "dist/**", "src-tauri/target/**"],
"env": [
"LATEX_API_URL",
"KV_REST_API_URL",
"KV_REST_API_TOKEN"
]
"outputs": [
".next/**",
"!.next/cache/**",
".wrangler/**",
"dist/**",
"src-tauri/target/**"
],
"env": ["LATEX_API_URL", "KV_REST_API_URL", "KV_REST_API_TOKEN"]
},
"test": {
"dependsOn": ["^build"],