Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Generate licenses (required by type check)
run: node scripts/generate-licenses.js

- name: Type check
run: npx tsc --noEmit

Expand Down Expand Up @@ -61,6 +64,15 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Build native SQLite binding for Vitest
# rebuild-native.js also runs @electron/rebuild which needs Electron headers.
# For tests we only need the system Node ABI — rebuild it directly and copy
# to vitest-native/ where the vitest-sqlite-shim.cjs expects to find it.
run: |
npm rebuild better-sqlite3
mkdir -p vitest-native
cp node_modules/better-sqlite3/build/Release/better_sqlite3.node vitest-native/

- name: Run tests
run: npm test

Expand All @@ -87,6 +99,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Generate licenses
run: node scripts/generate-licenses.js

- name: Build
run: npx cross-env ELECTRON_BUILD=true npx next build

Expand Down
1 change: 0 additions & 1 deletion electron/lib/coding-tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/

import { spawn } from "child_process";
import path from "path";

const MAX_OUTPUT_BYTES = 50_000;
const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes
Expand Down
2 changes: 1 addition & 1 deletion electron/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,7 @@ describe("list_recent_activity — ordering and workspace scoping", () => {
});

it("limit parameter caps result count", () => {
const { workspaceId, columnId } = seedBase(db);
const { workspaceId } = seedBase(db);
for (let i = 0; i < 25; i++) {
createNote(db, { id: `n${i}`, projectId: "proj1", workspaceId, title: `Note ${i}` });
}
Expand Down
3 changes: 2 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export default function Home() {
};
} else {
hydrate();
// eslint-disable-next-line react-hooks/set-state-in-effect
setOnboardingState(false);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -211,7 +212,7 @@ export default function Home() {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("cairn:open-chat", handleOpenChat);
};
}, [toggleSearch, toggleChat, toggleSidebar, setView, activeProjectId, createNote, chatOpen, hiddenViews, ORDERED_VIEWS]);
}, [toggleSearch, toggleChat, toggleSidebar, setView, activeProjectId, createNote, chatOpen, hiddenViews, ORDERED_VIEWS, activeView]);

// Still loading
if (onboardingState === null) {
Expand Down
1 change: 1 addition & 0 deletions src/components/agent/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function DiffViewer({ cwd }: DiffViewerProps) {

useEffect(() => {
isMounted.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchDiff();
const id = setInterval(fetchDiff, POLL_INTERVAL);
return () => { isMounted.current = false; clearInterval(id); };
Expand Down
2 changes: 2 additions & 0 deletions src/components/agent/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function FileTree({ project }: FileTreeProps) {
const codeDirectory = project?.codeDirectory ?? null;

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!codeDirectory) { setRootEntries(null); return; }
setError(null);
(window.electron?.agent.readDir(codeDirectory) as Promise<DirEntry[]> | undefined)
Expand Down Expand Up @@ -194,6 +195,7 @@ export function FileTree({ project }: FileTreeProps) {
// Run search whenever query changes
useEffect(() => {
if (!searchActive || !codeDirectory || !searchQuery.trim()) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearchResults([]);
return;
}
Expand Down
1 change: 1 addition & 0 deletions src/components/agent/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function ImageViewer({ filePath }: ImageViewerProps) {
const [error, setError] = useState<string | null>(null);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSrc(null);
setError(null);
if (!window.electron) return;
Expand Down
4 changes: 3 additions & 1 deletion src/components/agent/PiAgentPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function PiAgentPane({ session, isActive }: PiAgentPaneProps) {
updatePiSubagentToolCall,
addPiSubagent,
appendPiSubagentToken,
finalisePiSubagentMessage,
finalisePiSubagentMessage: _finalisePiSubagentMessage,
addPiSubagentToolCall,
completePiSubagent,
stepPiSubagent,
Expand Down Expand Up @@ -127,9 +127,11 @@ export function PiAgentPane({ session, isActive }: PiAgentPaneProps) {
const firedInitial = useRef(false);

// Scroll to bottom on new messages
/* eslint-disable react-hooks/exhaustive-deps */
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length, messages[messages.length - 1]?.content?.length]);
/* eslint-enable react-hooks/exhaustive-deps */

// Focus input when pane becomes active
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/agent/PiMessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function ToolOutputPanel({ name, output }: { name: string; output: string }) {
);
}

function ToolChip({ tc, index }: { tc: { callId?: string; name: string; label: string; running?: boolean; ok: boolean; output?: string; cairnRef?: { type: "note" | "task"; id: string; title: string } }; index: number }) {
function ToolChip({ tc, index: _index }: { tc: { callId?: string; name: string; label: string; running?: boolean; ok: boolean; output?: string; cairnRef?: { type: "note" | "task"; id: string; title: string } }; index: number }) {
const [expanded, setExpanded] = useState(false);

// While running — show a spinner chip, not expandable
Expand Down
1 change: 1 addition & 0 deletions src/components/agent/SpawnAgentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function SpawnAgentModal({ card, open, onClose }: SpawnAgentModalProps) {
useEffect(() => {
if (!open) return;
const defaultAgent = agents.find((a) => a.isDefault) ?? agents[0];
// eslint-disable-next-line react-hooks/set-state-in-effect
if (defaultAgent) setSelectedAgentId(defaultAgent.id);
if (card) {
const desc = card.description ? `\n\n${card.description}` : "";
Expand Down
1 change: 1 addition & 0 deletions src/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export function ChatPanel({ prefill, onPrefillConsumed }: ChatPanelProps = {}) {
// Pre-fill input when opened via cairn:open-chat event
useEffect(() => {
if (prefill) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setInput(prefill);
onPrefillConsumed?.();
setTimeout(() => inputRef.current?.focus(), 50);
Expand Down
2 changes: 1 addition & 1 deletion src/components/flow/NodeEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function NodeEditModal({ nodeId, type, data, onSave, onClose }: NodeEditM
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [fields, setFields] = useState<Record<string, any>>(data);

// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/set-state-in-effect
useEffect(() => { setFields(data); }, [nodeId]);

function set(key: string, value: string) {
Expand Down
1 change: 1 addition & 0 deletions src/components/flow/flow-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ function IdeaFlowCanvas() {
}
}, [activeProjectId, setNodes, setEdges, fitView]);

// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { loadFlow(true); }, [loadFlow]);

// Clear the suppress-reload timer on unmount to avoid setting state on a stale ref.
Expand Down
1 change: 1 addition & 0 deletions src/components/graph/KnowledgeGraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function KnowledgeGraphView() {

// Clear graph search when leaving force/radial
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (graphLayout !== "force" && graphLayout !== "radial") setGraphSearch("");
}, [graphLayout]);

Expand Down
1 change: 1 addition & 0 deletions src/components/kanban/column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function KanbanColumn({

useEffect(() => {
if (renaming) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRenameValue(column.name);
renameInputRef.current?.focus();
renameInputRef.current?.select();
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/project-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ export function ProjectOverview() {

useEffect(() => {
if (editOpen && project) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditIcon(project.icon ?? "");
setEditDesc(project.description ?? "");
}
}, [editOpen, project]);

// Keep codeDirInput in sync when project changes externally
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setCodeDirInput(project?.codeDirectory ?? "");
}, [project?.codeDirectory]);

Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ function ProjectItem({ project, isActive, isExpanded, onToggleExpand, onSelectPr

useEffect(() => {
if (renaming) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRenameValue(project.name);
renameInputRef.current?.focus();
renameInputRef.current?.select();
Expand Down Expand Up @@ -368,6 +369,7 @@ function DueDateDot({ dueDate }: { dueDate: string }) {
const { diffDays, dueDateLabel } = useMemo(() => {
const due = new Date(dueDate);
return {
// eslint-disable-next-line react-hooks/purity
diffDays: Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
dueDateLabel: due.toLocaleDateString(),
};
Expand Down
1 change: 1 addition & 0 deletions src/components/layout/title-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function TitleBar() {

useEffect(() => {
if (window.electron) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPlatform(window.electron.platform ?? "linux");
}
}, []);
Expand Down
1 change: 1 addition & 0 deletions src/components/notes/WikilinkPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function WikilinkPicker({
.slice(0, 12);

// Reset active index when filtered list changes
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setActiveIdx(0); }, [query]);

// Keyboard navigation
Expand Down
1 change: 1 addition & 0 deletions src/components/notes/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function DashboardView({ note }: DashboardViewProps) {

// Rebuild srcdoc when note content or theme changes
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSrcdoc(buildSrcdoc(note.content ?? "", projectId, workspaceId, themeInjection));
setErrors([]);
}, [note.id, note.content, projectId, workspaceId, themeInjection]);
Expand Down
21 changes: 12 additions & 9 deletions src/components/notes/markdown-pipeline.bench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,20 @@ function fmt(r: BenchResult): string {
}

// ── Perf ceilings (ms, p95) — fail on catastrophic regression only ────────────
// Values are calibrated for GitHub Actions shared runners, which are ~3× slower
// than a typical dev machine. The intent is to catch catastrophic regressions
// only — not to enforce tight latency budgets.

const CEILINGS: Record<string, Record<FixtureName, number>> = {
"remark-parse": { small: 5, medium: 10, large: 30 },
"remark-gfm+math": { small: 5, medium: 15, large: 50 },
"remark-callout": { small: 2, medium: 5, large: 20 },
"remark-promoteDisplay": { small: 2, medium: 5, large: 15 },
"remark→hast": { small: 5, medium: 15, large: 50 },
"rehype-captureLatex": { small: 2, medium: 5, large: 15 },
"rehype-katex": { small: 20, medium: 60, large: 200 },
"rehype-mergedPass": { small: 5, medium: 15, large: 50 },
"full-pipeline": { small: 30, medium: 80, large: 300 },
"remark-parse": { small: 15, medium: 30, large: 90 },
"remark-gfm+math": { small: 15, medium: 30, large: 80 },
"remark-callout": { small: 10, medium: 15, large: 60 },
"remark-promoteDisplay": { small: 10, medium: 15, large: 45 },
"remark→hast": { small: 15, medium: 45, large: 150 },
"rehype-captureLatex": { small: 10, medium: 15, large: 45 },
"rehype-katex": { small: 60, medium: 180, large: 600 },
"rehype-mergedPass": { small: 15, medium: 45, large: 150 },
"full-pipeline": { small: 90, medium: 240, large: 900 },
};

// ── Processors for building pre-stage inputs ──────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions src/components/notes/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export function NoteEditor({ note }: NoteEditorProps) {
const [wordCount, setWordCount] = useState(() => countWords(note.content ?? ""));
// Reset when switching notes
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setWordCount(countWords(note.content ?? ""));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id]);
Expand Down Expand Up @@ -503,6 +504,7 @@ export function NoteEditor({ note }: NoteEditorProps) {

// Sync local title when switching to a different note.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLocalTitle(note.title);
titleRef.current = note.title;
}, [note.id]); // eslint-disable-line react-hooks/exhaustive-deps
Expand Down
1 change: 1 addition & 0 deletions src/components/notes/notes-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export function NotesView() {
setActiveNoteId(notes[0].id);
} else if (!activeNoteId && notes.length > 0) {
// No active note yet — select the first one
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveNoteId(notes[0].id);
}
prevNoteCountRef.current = notes.length;
Expand Down
1 change: 1 addition & 0 deletions src/components/onboarding/StepMCP.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function StepMCP({ onBack, onNext }: Props) {
window.electron.mcpServerPath().then(setMcpBin).catch(() => {});
} else {
// Web / dev fallback
// eslint-disable-next-line react-hooks/set-state-in-effect
setMcpBin("/path/to/cairn-mcp");
}
}, []);
Expand Down
2 changes: 2 additions & 0 deletions src/components/search/search-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function SearchPanel() {
useEffect(() => {
if (searchOpen) {
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/set-state-in-effect
setQuery("");
setResults([]);
setFocused(0);
Expand All @@ -94,6 +95,7 @@ export function SearchPanel() {
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (searchTimer.current) clearTimeout(searchTimer.current);
// eslint-disable-next-line react-hooks/set-state-in-effect
if (query.trim().length < 1) { setResults([]); return; }
searchTimer.current = setTimeout(() => {
setResults(searchAll(query));
Expand Down
1 change: 1 addition & 0 deletions src/components/settings/MCPSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function MCPServerSettings() {
useEffect(() => {
if (typeof window !== "undefined" && window.electron) {
window.electron.mcpServerPath().then((p) => setMcpServerPath(p));
// eslint-disable-next-line react-hooks/set-state-in-effect
setPlatform(window.electron.platform ?? null);
}
}, []);
Expand Down