From 95c4072ec1d36bc93701f5e99f39489dab8ef202 Mon Sep 17 00:00:00 2001 From: delibae Date: Mon, 16 Mar 2026 17:04:36 +0900 Subject: [PATCH] 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) --- apps/desktop/scripts/generate-previews.ts | 11 +- .../src-tauri/capabilities/desktop.json | 16 +- apps/desktop/src/App.tsx | 5 +- .../lib/tauri-error-handling.test.ts | 2 +- .../src/__tests__/lib/tauri-fs.test.ts | 89 +- .../__tests__/lib/template-registry.test.ts | 7 +- .../stores/claude-setup-store.test.ts | 6 +- .../__tests__/stores/document-store.test.ts | 108 +- .../__tests__/stores/multi-tab-merge.test.ts | 89 +- .../__tests__/stores/project-store.test.ts | 8 +- .../stores/proposed-changes-store.test.ts | 8 +- .../__tests__/stores/template-store.test.ts | 8 +- .../src/__tests__/stores/zotero-store.test.ts | 5 +- .../src/__tests__/tauri-conf-csp.test.ts | 4 +- .../assistant-ui/tooltip-icon-button.tsx | 2 - .../components/claude-chat/chat-composer.tsx | 459 +- .../components/claude-chat/chat-messages.tsx | 93 +- .../components/claude-chat/chat-tab-bar.tsx | 14 +- .../claude-chat/claude-chat-drawer.tsx | 79 +- .../claude-chat/markdown-renderer.tsx | 67 +- .../claude-chat/proposed-changes-panel.tsx | 6 +- .../claude-chat/session-selector.tsx | 15 +- .../claude-chat/slash-command-picker.tsx | 221 +- .../components/claude-chat/tool-widgets.tsx | 250 +- apps/desktop/src/components/claude-setup.tsx | 99 +- .../src/components/debug/debug-page.tsx | 110 +- .../desktop/src/components/error-fallback.tsx | 12 +- .../desktop/src/components/project-picker.tsx | 75 +- .../desktop/src/components/project-wizard.tsx | 169 +- .../scientific-skills/install-progress.tsx | 9 +- .../scientific-skills-onboarding.tsx | 84 +- .../template-gallery/category-sidebar.tsx | 12 +- .../template-gallery/template-card.tsx | 44 +- .../template-gallery/template-gallery.tsx | 23 +- .../template-gallery/template-preview.tsx | 250 +- apps/desktop/src/components/ui/badge.tsx | 8 +- .../src/components/ui/context-menu.tsx | 5 +- apps/desktop/src/components/ui/dialog.tsx | 2 - .../src/components/ui/dropdown-menu.tsx | 2 - apps/desktop/src/components/ui/label.tsx | 2 - apps/desktop/src/components/ui/select.tsx | 2 - apps/desktop/src/components/ui/separator.tsx | 2 - apps/desktop/src/components/ui/sheet.tsx | 2 - apps/desktop/src/components/ui/sonner.tsx | 2 - apps/desktop/src/components/ui/tabs.tsx | 2 +- apps/desktop/src/components/ui/toggle.tsx | 2 - apps/desktop/src/components/ui/tooltip.tsx | 2 - apps/desktop/src/components/uv-setup.tsx | 33 +- .../workspace/editor/editor-toolbar.tsx | 8 +- .../workspace/editor/image-preview.tsx | 125 +- .../workspace/editor/lang-bibtex.ts | 10 +- .../workspace/editor/latex-editor.tsx | 907 +- .../workspace/editor/problems-panel.tsx | 17 +- .../workspace/editor/search-panel.tsx | 2 - .../workspace/editor/selection-toolbar.tsx | 21 +- .../components/workspace/history-panel.tsx | 61 +- .../workspace/preview/mupdf-page.tsx | 111 +- .../workspace/preview/pdf-preview.tsx | 432 +- .../workspace/preview/pdf-viewer.tsx | 64 +- .../src/components/workspace/sidebar.tsx | 406 +- .../src/components/workspace/zotero-panel.tsx | 59 +- apps/desktop/src/hooks/use-claude-events.ts | 125 +- .../src/hooks/use-keyboard-shortcuts.ts | 19 +- apps/desktop/src/lib/debug/log-store.ts | 16 +- apps/desktop/src/lib/fuzzy-search.test.ts | 100 +- apps/desktop/src/lib/latex-compiler.ts | 11 +- apps/desktop/src/lib/mupdf/mupdf-client.ts | 45 +- apps/desktop/src/lib/mupdf/mupdf-worker.ts | 35 +- apps/desktop/src/lib/mupdf/pdf-doc-cache.ts | 28 +- apps/desktop/src/lib/mupdf/types.ts | 8 +- apps/desktop/src/lib/tauri/fs.ts | 30 +- .../desktop/src/lib/template-preview-cache.ts | 8 +- apps/desktop/src/lib/template-registry.ts | 35 +- apps/desktop/src/lib/zotero-api.ts | 36 +- apps/desktop/src/stores/claude-chat-store.ts | 139 +- apps/desktop/src/stores/claude-setup-store.ts | 23 +- apps/desktop/src/stores/document-store.ts | 231 +- apps/desktop/src/stores/history-store.ts | 30 +- .../src/stores/proposed-changes-store.ts | 15 +- apps/desktop/src/stores/template-store.ts | 1 - apps/desktop/src/stores/uv-setup-store.ts | 3 +- apps/desktop/src/stores/zotero-store.ts | 47 +- apps/landing | 2 +- pnpm-lock.yaml | 7640 ++++++++++++++++- turbo.json | 14 +- 85 files changed, 11649 insertions(+), 1740 deletions(-) diff --git a/apps/desktop/scripts/generate-previews.ts b/apps/desktop/scripts/generate-previews.ts index 351e0fb..a954267 100644 --- a/apps/desktop/scripts/generate-previews.ts +++ b/apps/desktop/scripts/generate-previews.ts @@ -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.", + ); } } diff --git a/apps/desktop/src-tauri/capabilities/desktop.json b/apps/desktop/src-tauri/capabilities/desktop.json index ba6d080..47424fa 100644 --- a/apps/desktop/src-tauri/capabilities/desktop.json +++ b/apps/desktop/src-tauri/capabilities/desktop.json @@ -1,14 +1,6 @@ { "identifier": "desktop-capability", - "platforms": [ - "macOS", - "windows", - "linux" - ], - "windows": [ - "main" - ], - "permissions": [ - "updater:default" - ] -} \ No newline at end of file + "platforms": ["macOS", "windows", "linux"], + "windows": ["main"], + "permissions": ["updater:default"] +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9ae998f..e122c7e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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]); diff --git a/apps/desktop/src/__tests__/lib/tauri-error-handling.test.ts b/apps/desktop/src/__tests__/lib/tauri-error-handling.test.ts index d09bfbf..8fc0c49 100644 --- a/apps/desktop/src/__tests__/lib/tauri-error-handling.test.ts +++ b/apps/desktop/src/__tests__/lib/tauri-error-handling.test.ts @@ -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.", ); }); diff --git a/apps/desktop/src/__tests__/lib/tauri-fs.test.ts b/apps/desktop/src/__tests__/lib/tauri-fs.test.ts index 13dff28..b20e24d 100644 --- a/apps/desktop/src/__tests__/lib/tauri-fs.test.ts +++ b/apps/desktop/src/__tests__/lib/tauri-fs.test.ts @@ -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 { diff --git a/apps/desktop/src/__tests__/lib/template-registry.test.ts b/apps/desktop/src/__tests__/lib/template-registry.test.ts index 9ced33f..23c1143 100644 --- a/apps/desktop/src/__tests__/lib/template-registry.test.ts +++ b/apps/desktop/src/__tests__/lib/template-registry.test.ts @@ -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); } }); diff --git a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts index a459d8b..da6733a 100644 --- a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts @@ -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); diff --git a/apps/desktop/src/__tests__/stores/document-store.test.ts b/apps/desktop/src/__tests__/stores/document-store.test.ts index 887fc05..43b9e76 100644 --- a/apps/desktop/src/__tests__/stores/document-store.test.ts +++ b/apps/desktop/src/__tests__/stores/document-store.test.ts @@ -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.", ); }); }); diff --git a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts index 761a384..6f743e1 100644 --- a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts +++ b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts @@ -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", () => { diff --git a/apps/desktop/src/__tests__/stores/project-store.test.ts b/apps/desktop/src/__tests__/stores/project-store.test.ts index 694a74c..acfd4a0 100644 --- a/apps/desktop/src/__tests__/stores/project-store.test.ts +++ b/apps/desktop/src/__tests__/stores/project-store.test.ts @@ -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", + ); }); }); diff --git a/apps/desktop/src/__tests__/stores/proposed-changes-store.test.ts b/apps/desktop/src/__tests__/stores/proposed-changes-store.test.ts index 917d4e3..7c3b4c8 100644 --- a/apps/desktop/src/__tests__/stores/proposed-changes-store.test.ts +++ b/apps/desktop/src/__tests__/stores/proposed-changes-store.test.ts @@ -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(); }); }); diff --git a/apps/desktop/src/__tests__/stores/template-store.test.ts b/apps/desktop/src/__tests__/stores/template-store.test.ts index 5a2011f..88fe0d6 100644 --- a/apps/desktop/src/__tests__/stores/template-store.test.ts +++ b/apps/desktop/src/__tests__/stores/template-store.test.ts @@ -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, + ); }); }); }); diff --git a/apps/desktop/src/__tests__/stores/zotero-store.test.ts b/apps/desktop/src/__tests__/stores/zotero-store.test.ts index cb75280..fc8202c 100644 --- a/apps/desktop/src/__tests__/stores/zotero-store.test.ts +++ b/apps/desktop/src/__tests__/stores/zotero-store.test.ts @@ -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 { diff --git a/apps/desktop/src/__tests__/tauri-conf-csp.test.ts b/apps/desktop/src/__tests__/tauri-conf-csp.test.ts index f0fbbd6..5e50045 100644 --- a/apps/desktop/src/__tests__/tauri-conf-csp.test.ts +++ b/apps/desktop/src/__tests__/tauri-conf-csp.test.ts @@ -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. diff --git a/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx b/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx index 64601d6..94f0414 100644 --- a/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx +++ b/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx @@ -1,5 +1,3 @@ - - import { ComponentPropsWithRef, forwardRef } from "react"; import { Slottable } from "@radix-ui/react-slot"; diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index c2f7c28..c283062 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -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 ; - if (file.type === "pdf") return ; - if (file.type === "style") return ; - if (file.type === "other") return ; + if (file.type === "image") + return ; + if (file.type === "pdf") + return ( + + ); + if (file.type === "style") + return ; + if (file.type === "other") + return ; return ; } @@ -49,7 +82,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const [modelPickerOpen, setModelPickerOpen] = useState(false); const modelPickerRef = useRef(null); const modelButtonRef = useRef(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>(async () => {}); + const handleFileDropRef = useRef<(paths: string[]) => Promise>( + 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) => { 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( -
- {/* Models */} -
-
Model
- {([ - { id: "sonnet" as const, name: "Sonnet", desc: "Fast, efficient for most tasks", icon: }, - { id: "opus" as const, name: "Opus", desc: "Most capable, complex reasoning", icon: }, - { id: "haiku" as const, name: "Haiku", desc: "Fastest, simple tasks", icon: }, - { id: "opusplan" as const, name: "OpusPlan", desc: "Opus for planning, Sonnet for execution", icon: }, - ]).map((m) => ( - - ))} -
- -
- - {/* Effort level */} -
-
- Effort - - {effortLevel === "low" ? "Low" : effortLevel === "medium" ? "Medium" : "High"} - -
-
- {(["low", "medium", "high"] as const).map((level) => ( + {modelPickerOpen && + createPortal( +
+ {/* Models */} +
+
+ Model +
+ {[ + { + id: "sonnet" as const, + name: "Sonnet", + desc: "Fast, efficient for most tasks", + icon: , + }, + { + id: "opus" as const, + name: "Opus", + desc: "Most capable, complex reasoning", + icon: , + }, + { + id: "haiku" as const, + name: "Haiku", + desc: "Fastest, simple tasks", + icon: , + }, + { + id: "opusplan" as const, + name: "OpusPlan", + desc: "Opus for planning, Sonnet for execution", + icon: , + }, + ].map((m) => ( ))}
-
-
, - document.body, - )} +
+ + {/* Effort level */} +
+
+ + Effort + + + {effortLevel === "low" + ? "Low" + : effortLevel === "medium" + ? "Medium" + : "High"} + +
+
+ {(["low", "medium", "high"] as const).map((level) => ( + + ))} +
+
+
, + document.body, + )} {/* @ mention dropdown */} - {slashQuery === null && mentionQuery !== null && mentionFiles.length > 0 && ( -
- {mentionFiles.map((file, i) => { - const parts = file.relativePath.split("/"); - const fileName = parts.pop()!; - const dirPath = parts.length > 0 ? parts.join("/") + "/" : ""; - return ( - - ); - })} -
- )} + {slashQuery === null && + mentionQuery !== null && + mentionFiles.length > 0 && ( +
+ {mentionFiles.map((file, i) => { + const parts = file.relativePath.split("/"); + const fileName = parts.pop()!; + const dirPath = parts.length > 0 ? `${parts.join("/")}/` : ""; + return ( + + ); + })} +
+ )}
= ({ isOpen }) => { /> diff --git a/apps/desktop/src/components/claude-chat/chat-messages.tsx b/apps/desktop/src/components/claude-chat/chat-messages.tsx index 077f508..76c0c71 100644 --- a/apps/desktop/src/components/claude-chat/chat-messages.tsx +++ b/apps/desktop/src/components/claude-chat/chat-messages.tsx @@ -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 (
- - - + + +
Thinking... {elapsed >= 3 && ( - {elapsed}s + + {elapsed}s + )}
@@ -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) => ( - + ))} {isStreaming && } @@ -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 }) => {
-
{title}
+
{title}
{errors.map((e, i) => (
- {e.message} + + {e.message} + {e.location && ( - {e.location} + + {e.location} + )}
))} @@ -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 }) => {
{contextLabel && ( - + {contextLabel} )} @@ -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 ( - - ); + return ; } return null; })} diff --git a/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx b/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx index aee5d1f..ac3c78c 100644 --- a/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx +++ b/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx @@ -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 ( -
+
{tabs.map((tab) => ( 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 ? ( <> -
+
{expanded && truncated && ( -
-
+        
+
             {truncated}
           
diff --git a/apps/desktop/src/components/claude-chat/proposed-changes-panel.tsx b/apps/desktop/src/components/claude-chat/proposed-changes-panel.tsx index c02cd7f..5d5b745 100644 --- a/apps/desktop/src/components/claude-chat/proposed-changes-panel.tsx +++ b/apps/desktop/src/components/claude-chat/proposed-changes-panel.tsx @@ -27,7 +27,7 @@ export const ProposedChangesPanel: FC = ({
Proposed Changes {totalChanges > 1 && ( - + {changeIndex + 1}/{totalChanges} files )} @@ -39,7 +39,7 @@ export const ProposedChangesPanel: FC = ({
) : sessions.length === 0 ? ( -
+
No previous sessions
) : ( @@ -132,7 +131,7 @@ export function SessionSelector() { >
{session.title} - + {formatRelativeTime(session.last_modified)}
diff --git a/apps/desktop/src/components/claude-chat/slash-command-picker.tsx b/apps/desktop/src/components/claude-chat/slash-command-picker.tsx index ab8ae16..c2b813b 100644 --- a/apps/desktop/src/components/claude-chat/slash-command-picker.tsx +++ b/apps/desktop/src/components/claude-chat/slash-command-picker.tsx @@ -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 ; - if (command.has_bash_commands) return ; - if (command.has_file_references) return ; - if (command.scope === "project") return ; - if (command.scope === "user") return ; - if (command.scope === "default") return ; + if (command.scope === "skill") + return ( + + ); + if (command.has_bash_commands) + return ; + if (command.has_file_references) + return ; + if (command.scope === "project") + return ( + + ); + if (command.scope === "user") + return ; + if (command.scope === "default") + return ; return ; } @@ -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 ( -
+
{body.split("\n").map((line, i) => { const trimmed = line.trimEnd(); if (trimmed.startsWith("# ")) { - return

{trimmed.slice(2)}

; + return ( +

+ {trimmed.slice(2)} +

+ ); } if (trimmed.startsWith("## ")) { - return

{trimmed.slice(3)}

; + return ( +

+ {trimmed.slice(3)} +

+ ); } if (trimmed.startsWith("### ")) { - return
{trimmed.slice(4)}
; + return ( +
+ {trimmed.slice(4)} +
+ ); } if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { - return
{trimmed.slice(2)}
; + return ( +
+ {trimmed.slice(2)} +
+ ); } if (trimmed.startsWith("```")) { - return
{trimmed}
; + return ( +
+ {trimmed} +
+ ); } if (trimmed === "") { return
; @@ -292,7 +362,11 @@ export const SlashCommandPicker: FC = ({ const [activeTab, setActiveTab] = useState("skills"); const [showPreview, setShowPreview] = useState(false); const listRef = useRef(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 = ({ 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 = { skills: "Skills", default: "Default", custom: "Custom" }; + const labels: Record = { + 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 = ({ // 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 = ({ 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 = ({ onMouseEnter={() => setSelectedIndex(index)} > {getCommandIcon(cmd)} - {cmd.full_command} + {cmd.full_command} {cmd.description && ( - + {cmd.description} )} @@ -482,29 +565,34 @@ export const SlashCommandPicker: FC = ({ if (isSearching) { return (
- - No results for "{query}" + + + No results for "{query}" +
); } const hints: Record = { skills: ( -

+

Install scientific skills from the sidebar menu.

), default: null, custom: ( -

- Add commands in .claude/commands/ or ~/.claude/commands/ +

+ Add commands in .claude/commands/ or{" "} + ~/.claude/commands/

), }; return (
- No commands available + + No commands available + {hints[activeTab]}
); @@ -514,7 +602,7 @@ export const SlashCommandPicker: FC = ({
{isLoading && (
- Loading... + Loading...
)} @@ -526,7 +614,7 @@ export const SlashCommandPicker: FC = ({
{searchGroups.map((group) => (
-

+

{group.label}

@@ -559,16 +647,18 @@ export const SlashCommandPicker: FC = ({ }} > {/* Left side: list */} -
+
{/* Header */} -
-
+
+
- + {isSearching ? `Search: "${query}"` : "Commands"}
@@ -590,7 +680,7 @@ export const SlashCommandPicker: FC = ({ - {selectedCommand.full_command} + + {selectedCommand.full_command} +
{/* Preview body */} diff --git a/apps/desktop/src/components/claude-chat/tool-widgets.tsx b/apps/desktop/src/components/claude-chat/tool-widgets.tsx index 960cde5..067deff 100644 --- a/apps/desktop/src/components/claude-chat/tool-widgets.tsx +++ b/apps/desktop/src/components/claude-chat/tool-widgets.tsx @@ -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 = ({ toolUse, toolResult }) => { const name = toolUse.name?.toLowerCase() || ""; - if (name === "write") return ; - if (name === "edit" || name === "multiedit") return ; - if (name === "read") return ; - if (name === "bash") return ; - if (name === "glob") return ; - if (name === "grep") return ; - if (name === "askuserquestion") return ; - if (name === "todowrite") return ; + if (name === "write") + return ; + if (name === "edit" || name === "multiedit") + return ; + if (name === "read") + return ; + if (name === "bash") + return ; + if (name === "glob") + return ; + if (name === "grep") + return ; + if (name === "askuserquestion") + return ; + if (name === "todowrite") + return ; - return ; + return ( + + ); }; // ─── Status Icon ─── @@ -48,24 +64,31 @@ const StatusIcon: FC<{ result?: ContentBlock }> = ({ result }) => { // Tool was cancelled (stop pressed) — show stopped state return ; } - return ; + return ( + + ); } if (result.is_error) { - return !; + return !; } return ; }; // ─── Write Widget ─── -const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => { +const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ + input, + result, +}) => { return (
{result ? "Wrote" : "Writing"}{" "} - {input?.file_path} + + {input?.file_path} +
); @@ -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 } {result ? "Edited" : "Editing"}{" "} - {input?.file_path} + + {input?.file_path} + - {(input?.old_string || input?.edits) && ( - expanded - ? - : - )} + {(input?.old_string || input?.edits) && + (expanded ? ( + + ) : ( + + ))} {expanded && input?.old_string && ( -
-
- {truncate(input.old_string, 200)}
-
+ {truncate(input.new_string, 200)}
+
+
+ - {truncate(input.old_string, 200)} +
+
+ + {truncate(input.new_string, 200)} +
)}
@@ -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 (
{result ? "Read" : "Reading"}{" "} - {input?.file_path} + + {input?.file_path} +
); @@ -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 (
@@ -136,16 +178,19 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result } > - $ {truncate(command, 80)} - {result && ( - expanded - ? - : - )} + + $ {truncate(command, 80)} + + {result && + (expanded ? ( + + ) : ( + + ))} {expanded && resultContent && ( -
-
+        
+
             {truncate(resultContent, 2000)}
           
@@ -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 (
@@ -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 (
@@ -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 ( -
+
{needsUserAnswer ? ( @@ -243,22 +300,24 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu {headerLabel}
-
+
{questions.map((q: any, qIdx: number) => (
{q.header && ( - + {q.header} )} -

{q.question}

+

{q.question}

{q.options?.map((opt: any, oIdx: number) => ( {expanded && todos.length > 0 && ( -
+
{todos.map((todo, idx) => (
= ({ input, res {todo.status === "in_progress" - ? (todo.activeForm || todo.content) + ? todo.activeForm || todo.content : todo.content}
@@ -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 }> = ( {result ? "Ran" : "Running"} {name} - {expanded - ? - : } + {expanded ? ( + + ) : ( + + )} {expanded && input && ( -
-
+        
+
             {JSON.stringify(input, null, 2)}
           
@@ -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 ( -
+
{expanded && ( -
-
+        
+
             {trimmed}
           
@@ -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; } diff --git a/apps/desktop/src/components/claude-setup.tsx b/apps/desktop/src/components/claude-setup.tsx index ea27edc..2beea59 100644 --- a/apps/desktop/src/components/claude-setup.tsx +++ b/apps/desktop/src/components/claude-setup.tsx @@ -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("install-complete", (event) => { - if (cancelled) return; - clearTimeout(timer); - useClaudeSetupStore.getState()._finishInstall(event.payload); - }); + const unlistenComplete = await listen( + "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("login-output", (event) => { + const unlistenOutput = await listen("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("login-complete", (event) => { - if (cancelled) return; - clearTimeout(timer); - useClaudeSetupStore.getState()._finishLogin(event.payload); - }); + const unlistenComplete = await listen( + "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() {
@@ -418,22 +429,18 @@ export function ClaudeSetup() {
-

Sign in to Claude

-

+

Sign in to Claude

+

Authenticate with your Anthropic account to continue.

{version && ( -

+

Claude Code {version} installed

)} - diff --git a/apps/desktop/src/components/debug/debug-page.tsx b/apps/desktop/src/components/debug/debug-page.tsx index e0c8221..51ba532 100644 --- a/apps/desktop/src/components/debug/debug-page.tsx +++ b/apps/desktop/src/components/debug/debug-page.tsx @@ -44,7 +44,9 @@ export function DebugPage() { // Fetch system info once useEffect(() => { - invoke("get_system_info").then(setSystemInfo).catch(() => {}); + invoke("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() {
-

Debug

+

Debug

@@ -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() {
-
+
{filteredEntries.length === 0 && ( -

No log entries

+

+ No log entries +

)} {filteredEntries.map((entry, i) => (
- {formatTime(entry.timestamp)} - + + {formatTime(entry.timestamp)} + + {entry.level} - [{entry.source}] - {entry.message} + + [{entry.source}] + + + {entry.message} +
))}
@@ -194,7 +222,9 @@ export function DebugPage() { {tab === "system" && (
-

System Information

+

+ System Information +

{systemInfo ? (
@@ -203,13 +233,18 @@ export function DebugPage() {
) : ( -

Loading...

+

Loading...

)} -

Browser / WebView

+

+ Browser / WebView +

- +
@@ -217,22 +252,35 @@ export function DebugPage() { {tab === "visibility" && (
-

Visibility State

+

+ Visibility State +

+ -
-

Recent Visibility Logs

-
+

+ Recent Visibility Logs +

+
{getVisibilityLogs().map((entry, i) => (
- {formatTime(entry.timestamp)}{" "} + + {formatTime(entry.timestamp)} + {" "} {entry.message}
))} @@ -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 (
- {label}: + {label}: {value}
); diff --git a/apps/desktop/src/components/error-fallback.tsx b/apps/desktop/src/components/error-fallback.tsx index 9045e47..84f0978 100644 --- a/apps/desktop/src/components/error-fallback.tsx +++ b/apps/desktop/src/components/error-fallback.tsx @@ -3,15 +3,15 @@ import type { FallbackProps } from "react-error-boundary"; export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { return (
-
-

+
+

Something went wrong

-

+

An unexpected error occurred. You can try again or reload the app.

-
+        
           {error instanceof Error
             ? `${error.message}${error.stack ? `\n\n${error.stack}` : ""}`
             : String(error)}
@@ -21,14 +21,14 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
           
           
diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx
index e8dea03..ff9a876 100644
--- a/apps/desktop/src/components/project-picker.tsx
+++ b/apps/desktop/src/components/project-picker.tsx
@@ -12,8 +12,6 @@ import {
   SparklesIcon,
   CheckCircle2Icon,
   CircleIcon,
-  TerminalIcon,
-  FlaskConicalIcon,
   DownloadIcon,
   Loader2Icon,
   RefreshCwIcon,
@@ -80,10 +78,7 @@ export function ProjectPicker() {
 
   if (wizardMode) {
     return (
-       setWizardMode(null)}
-      />
+       setWizardMode(null)} />
     );
   }
 
@@ -106,7 +101,9 @@ export function ProjectPicker() {
 
         {!isClaudeReady ?  : }
 
-        
+
- + Recommended @@ -238,7 +233,7 @@ function EnvironmentStatus() { const _finishUvInstall = useUvSetupStore((s) => s._finishInstall); const [skillsStatus, setSkillsStatus] = useState(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 void; - }> | null>(null); + const [OnboardingComponent, setOnboardingComponent] = + useState 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 ( <> -
+
{/* Claude Code — always ready here */} setShowSkillsOnboarding(true) } + ? { + label: "Install", + onClick: () => setShowSkillsOnboarding(true), + } : undefined } /> @@ -355,7 +356,7 @@ function StatusRow({ action?: { label: string; onClick?: () => void; loading?: boolean }; }) { return ( -
+
{ok ? ( ) : ( @@ -363,20 +364,20 @@ function StatusRow({ )} {label} - + {detail} {action && ( ); case "downloading": return ( -
+
Downloading... {updateStatus.percent}%
@@ -429,7 +430,7 @@ function VersionBadge({ case "installing": return ( -
+
Installing...
@@ -437,7 +438,7 @@ function VersionBadge({ case "ready": return ( -
+
Update complete — restarting...
@@ -445,15 +446,15 @@ function VersionBadge({ case "checking": return ( -
- - v{version} — checking for updates... +
+ v{version} — checking + for updates...
); case "error": return ( -
+
v{version} · Choose a Template @@ -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 (
{/* Header */} -
- New Document @@ -255,9 +331,12 @@ function ScratchForm({ onBack }: { onBack: () => void }) { {/* Purpose */}
- + + What are you writing? +

- Describe your document and Claude will generate tailored content. + Describe your document and Claude will generate tailored + content.