From 6a38c1d15c62e4c60dc59d1eb34612a9f2621357 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Fri, 31 Jul 2026 12:45:58 -0400 Subject: [PATCH 1/5] fix: data-loss guards, OS-trash deletes, chat persistence, note-banner opacity - Deletes now route to the OS trash (shell.trashItem) via a pluggable PathRemover; MCP binary keeps hard delete. Falls back to fs delete. - Project delete no longer wipes a same-named project's .md files: slug-collision guard skips the folder rmSync when a survivor shares it (IPC + MCP + post-merge cleanup); MCP delete reordered to DB-first. - Chat threads/messages re-hydrate from SQLite on launch + workspace switch (loadChatFromDb), and the active thread is persisted/restored, so conversations survive restarts/updates even if localStorage is wiped. - ask_questions form is scoped to its thread (tagged with threadId) so it no longer leaks into other chats. - AI-change note banner background sits on var(--background) so scrolling content no longer shows through. --- changelogs/v2.5.18.md | 10 +++ electron/ipc/db-handlers.ts | 12 ++- electron/main.ts | 12 ++- electron/mcp/tools/projects.ts | 16 ++-- electron/notes-files.ts | 4 +- electron/shared/notes-io.test.ts | 116 ++++++++++++++++++++++++++- electron/shared/notes-io.ts | 61 +++++++++++++- src/components/notes/note-editor.tsx | 4 +- src/hooks/useChatStream.ts | 22 ++++- src/lib/constants.ts | 3 + src/store/index.ts | 12 +++ src/store/slices/chat.ts | 66 +++++++++++++++ src/store/slices/ui.ts | 3 + 13 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 changelogs/v2.5.18.md diff --git a/changelogs/v2.5.18.md b/changelogs/v2.5.18.md new file mode 100644 index 000000000..ebf96a99c --- /dev/null +++ b/changelogs/v2.5.18.md @@ -0,0 +1,10 @@ +## Fixes + +- **The AI-change banner on notes is now opaque.** The "AI is editing this note…" / "N lines changed since you last viewed this note" banner pinned to the top of the note editor had a background of `color-mix(… var(--accent) 12%, transparent)` — ~88% see-through — so note content scrolled visibly behind it. The tint now sits on an opaque `var(--background)` base instead of `transparent`, masking the content underneath while keeping the exact same accent colour. + +- **Chat threads now survive app restarts and updates.** Conversations were always written to SQLite, but on the desktop app they were only ever *read back* from Chromium localStorage — which an app update can clear or relocate, orphaning every thread and message in the database with no way to see them. Cairn now re-hydrates chat threads and messages from SQLite on launch (and when switching workspaces), so your conversations persist even if localStorage is wiped. The last-active thread is also remembered and re-selected on restart; if that pointer is lost, the app reuses your existing scoped thread instead of silently starting a blank one. + +- **Clarifying questions no longer leak into the wrong chat.** When the AI asked you questions (the inline question form from `ask_questions`), switching to a different chat thread rendered that same form there too. The pending-question state lived in an unscoped hook `useState` and the `ChatPanel` never remounts on thread switch, so the form followed you around. Pending questions are now tagged with the thread they arrived on and only render while that thread is active — switch away and the form disappears; switch back and it returns. + +- **Deleted notes and projects now go to the OS trash instead of being permanently erased.** When you delete a note or a project from the app (including via the in-app AI chat), Cairn now moves its `.md` file/folder to the system Trash (macOS) or Recycle Bin (Windows) via `shell.trashItem`, so you can restore it from Finder/Explorer if a delete was a mistake. If the OS trash is unavailable, it falls back to a permanent delete. Note: the standalone `cairn-mcp` binary (used by agents while Cairn is closed) still hard-deletes, since a headless process has no desktop trash. +- **Deleting a project no longer wipes another same-named project's notes.** Project note folders on disk are keyed by the project NAME slug (not the id), and project names aren't unique — so if two projects shared a name (e.g. a duplicate "Personal" created by a timed-out, retried request), deleting one ran `fs.rmSync` on the shared folder and destroyed the survivor's `.md` files while its database rows lived on. Project deletion (both the app's IPC handler and the MCP `delete_project` tool) now deletes the database rows first, then only removes the on-disk folder when no surviving project still occupies that slug. The same survivor guard was added to the post-merge source-folder cleanup, and the MCP delete path was reordered to delete the DB rows before touching disk (previously it removed the folder first). diff --git a/electron/ipc/db-handlers.ts b/electron/ipc/db-handlers.ts index 22c54e9df..4d5127393 100644 --- a/electron/ipc/db-handlers.ts +++ b/electron/ipc/db-handlers.ts @@ -168,7 +168,11 @@ export function registerDbHandlers(ctx: DbContext): void { // Delete from DB first so if it fails, the .md files are still intact for recovery. q.deleteProject(ctx.db, id); if (project) { - deleteProjectNotesDir(ctx.workspacePath, project.name); + // The notes folder is keyed by the project NAME slug, not the id, and + // names are not unique. Only remove the folder if no surviving project + // still shares that slug — otherwise we'd wipe a duplicate's .md files. + const survivorNames = q.getProjects(ctx.db).map((p) => p.name); + deleteProjectNotesDir(ctx.workspacePath, project.name, survivorNames); } })); @@ -204,9 +208,11 @@ export function registerDbHandlers(ctx: DbContext): void { } } // Remove the source project's on-disk folder (now that its notes have - // been rewritten under the target). Best-effort. + // been rewritten under the target). Best-effort, and only when no + // surviving project still shares the source name's slug. try { - deleteProjectNotesDir(ctx.workspacePath, result.sourceName); + const survivorNames = q.getProjects(ctx.db).map((p) => p.name); + deleteProjectNotesDir(ctx.workspacePath, result.sourceName, survivorNames); } catch (e) { console.warn("[merge] failed to remove source project folder:", e instanceof Error ? e.message : e); } diff --git a/electron/main.ts b/electron/main.ts index db10074a3..5d772bf87 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -28,7 +28,7 @@ import { registerGitHandlers } from "./ipc/git"; import { registerPiAgentHandler } from "./ipc/pi-agent"; import { readWorkspaceConfig, getDbPathForWorkspace } from "./workspace-config"; import { startFileWatcher, suppressNextChange } from "./file-watcher"; -import { syncNotesFromDisk, writeNoteFile, deleteNoteFile } from "./notes-files"; +import { syncNotesFromDisk, writeNoteFile, deleteNoteFile, setPathRemover } from "./notes-files"; import { markMcpNotificationsRead, getNoteByIdIncludingTombstoned, findNestedConflictCopies } from "./db/queries"; import { getProjectName } from "./ipc/result-helpers"; import { setupProtocol, registerAssetProtocol, setAssetWorkspacePath } from "./lib/protocol"; @@ -221,6 +221,16 @@ app.whenReady().then(async () => { const outDir = path.join(__dirname, "../out"); setupProtocol(outDir); + // Route deleted note files/folders to the OS trash (Finder/Explorer) so a + // user can restore them, instead of a permanent fs delete. shell.trashItem is + // async + main-process-only; we fire-and-forget (the DB row is already gone) + // and fall back to a hard delete if trashing fails (e.g. no desktop trash). + setPathRemover((targetPath) => { + shell.trashItem(targetPath).catch(() => { + try { fs.rmSync(targetPath, { recursive: true, force: true }); } catch { /* ignore */ } + }); + }); + const userDataPath = app.getPath("userData"); // ── Resolve workspace path ──────────────────────────────────────────── diff --git a/electron/mcp/tools/projects.ts b/electron/mcp/tools/projects.ts index afdc85654..b1e5dcc76 100644 --- a/electron/mcp/tools/projects.ts +++ b/electron/mcp/tools/projects.ts @@ -1,13 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import path from "path"; -import fs from "fs"; import Database from "better-sqlite3"; import * as q from "../../db/queries"; import { newId } from "../../db/utils"; import { DEFAULT_COLUMNS } from "../../db/defaults"; import { Snapshot, insertNotification } from "../db"; -import { toSlug } from "../../shared/text-utils"; -import { renameProjectNotesDir } from "../../shared/notes-io"; +import { renameProjectNotesDir, deleteProjectNotesDir } from "../../shared/notes-io"; export function upsert_project(db: Database.Database, snap: Snapshot, workspacePath: string, args: Record) { if (args.projectId) { @@ -62,12 +59,13 @@ export function upsert_project(db: Database.Database, snap: Snapshot, workspaceP export function delete_project(db: Database.Database, snap: Snapshot, workspacePath: string, args: Record) { const project = snap.projects.find((p) => p.id === args.projectId); if (!project) return { error: "Project not found" }; - // Delete notes dir - const notesDir = path.join(workspacePath, toSlug(project.name)); - if (fs.existsSync(notesDir)) { - try { fs.rmSync(notesDir, { recursive: true, force: true }); } catch { /* ignore */ } - } + // Delete the DB rows first, then remove the notes folder — but only if no + // surviving project still shares this name's slug. The folder is keyed by + // the project NAME slug (not the id) and names are not unique, so blindly + // deleting it would destroy a same-named duplicate's .md files (data loss). q.deleteProject(db, args.projectId as string); + const survivorNames = q.getProjects(db).map((p) => p.name); + deleteProjectNotesDir(workspacePath, project.name, survivorNames); insertNotification(db, "delete_project", "Project deleted", `"${project.name}" was deleted`); return { deleted: true, id: args.projectId, name: project.name }; } diff --git a/electron/notes-files.ts b/electron/notes-files.ts index e5297b2e2..249b40a5b 100644 --- a/electron/notes-files.ts +++ b/electron/notes-files.ts @@ -20,6 +20,7 @@ import { renameProjectNotesDir, parseNoteFile, pruneEmptyDirsUpTo, + setPathRemover, CAIRN_FRONTMATTER_KEYS } from "./shared/notes-io"; @@ -38,7 +39,8 @@ export { deleteProjectNotesDir, renameProjectNotesDir, parseNoteFile, - pruneEmptyDirsUpTo + pruneEmptyDirsUpTo, + setPathRemover }; diff --git a/electron/shared/notes-io.test.ts b/electron/shared/notes-io.test.ts index 619ceae67..479f07ced 100644 --- a/electron/shared/notes-io.test.ts +++ b/electron/shared/notes-io.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; import path from "path"; import os from "os"; -import { writeNoteFile, type NoteFileData } from "./notes-io"; +import { writeNoteFile, deleteProjectNotesDir, deleteNoteFile, setPathRemover, type NoteFileData, type PathRemover } from "./notes-io"; let tmpDir: string; @@ -171,3 +171,117 @@ describe("writeNoteFile write strategy", () => { } }); }); + +/** + * Regression tests for the project-delete data-loss bug. + * + * Project note folders on disk are keyed by the project NAME slug, not the id, + * and project names are NOT unique. So two projects sharing a name (e.g. a + * duplicate "Personal" created by a timed-out+retried request) share one + * folder. Deleting one used to `fs.rmSync` the shared folder unconditionally, + * destroying the survivor's .md files. deleteProjectNotesDir must skip the + * delete when a surviving project still occupies the same slug. + */ +describe("deleteProjectNotesDir slug-collision guard", () => { + it("removes the folder when no other project shares the slug", () => { + const dir = path.join(tmpDir, "Personal"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "note.md"), "keep me"); + + deleteProjectNotesDir(tmpDir, "Personal", []); + + expect(fs.existsSync(dir)).toBe(false); + }); + + it("does NOT remove the folder when a surviving project shares the slug", () => { + const dir = path.join(tmpDir, "Personal"); + fs.mkdirSync(dir, { recursive: true }); + const fp = path.join(dir, "note.md"); + fs.writeFileSync(fp, "another project's data"); + + // A same-named duplicate still exists → the shared folder must be preserved. + deleteProjectNotesDir(tmpDir, "Personal", ["Personal"]); + + expect(fs.existsSync(fp)).toBe(true); + expect(fs.readFileSync(fp, "utf-8")).toBe("another project's data"); + }); + + it("guards on the slug, not the exact name (spacing variants collide)", () => { + // "My Project" (double space) and "My Project" both slugify to "My Project". + const dir = path.join(tmpDir, "My Project"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "note.md"), "data"); + + deleteProjectNotesDir(tmpDir, "My Project", ["My Project"]); + + expect(fs.existsSync(dir)).toBe(true); + }); + + it("is a no-op when the folder does not exist", () => { + expect(() => deleteProjectNotesDir(tmpDir, "Nonexistent", [])).not.toThrow(); + }); +}); + +/** + * The Electron main process routes deletes to the OS trash via setPathRemover + * (shell.trashItem) so notes/projects are restorable. The MCP process (no + * Electron shell) keeps the default hard fs delete. These tests verify the + * injection point without depending on a real desktop trash. + */ +describe("setPathRemover — pluggable trash vs hard delete", () => { + afterEach(() => setPathRemover()); // always reset to the fs default + + it("routes note-file deletes through the injected remover", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + expect(fs.existsSync(fp)).toBe(true); + + const trashed: string[] = []; + const remover: PathRemover = (target) => { trashed.push(target); }; + setPathRemover(remover); + + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + // The injected remover was called with the note's path; because our fake + // remover doesn't actually delete, the file is still on disk (proving the + // hard fs.unlinkSync no longer runs directly). + expect(trashed).toEqual([fp]); + expect(fs.existsSync(fp)).toBe(true); + }); + + it("routes project-folder deletes through the injected remover", () => { + const dir = path.join(tmpDir, "My Project"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "note.md"), "data"); + + const trashed: string[] = []; + setPathRemover((target) => { trashed.push(target); }); + + deleteProjectNotesDir(tmpDir, "My Project", []); + + expect(trashed).toEqual([dir]); + expect(fs.existsSync(dir)).toBe(true); // fake remover didn't delete + }); + + it("falls back to a hard delete when the remover throws", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + setPathRemover(() => { throw new Error("trash unavailable"); }); + + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + // Fallback fs delete ran → file is gone despite the trasher failing. + expect(fs.existsSync(fp)).toBe(false); + }); + + it("hard-deletes by default (MCP process, no shell injected)", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + // No setPathRemover call → default hardRemove. + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + expect(fs.existsSync(fp)).toBe(false); + }); +}); diff --git a/electron/shared/notes-io.ts b/electron/shared/notes-io.ts index bfb7999b9..d5d97ac3e 100644 --- a/electron/shared/notes-io.ts +++ b/electron/shared/notes-io.ts @@ -9,6 +9,50 @@ import fs from "fs"; import matter from "gray-matter"; import { toSlug } from "./text-utils"; +// ── Pluggable path removal (OS trash vs hard delete) ────────────────────── +// +// User-initiated note/project deletes should go to the OS trash/recycle bin so +// they can be restored from Finder/Explorer. Electron exposes `shell.trashItem` +// for this, but it is a main-process-only, async API — and this module is also +// bundled into the standalone MCP process, which has no Electron `shell`. +// +// So we keep the sync fs removal as the default, and let the Electron main +// process inject a trasher via `setPathRemover`. Callers stay synchronous; an +// async trasher is fire-and-forget (the DB row is already deleted by the time +// we get here, so the file-watcher can't misread the removal as data loss). +export type PathRemover = (targetPath: string, opts: { recursive: boolean }) => void; + +const hardRemove: PathRemover = (targetPath, opts) => { + if (opts.recursive) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(targetPath); + } +}; + +let pathRemover: PathRemover = hardRemove; + +/** + * Override how deleted note files/folders are removed from disk. The Electron + * main process injects an OS-trash implementation (shell.trashItem) so deletes + * are recoverable. Pass no argument to reset to a hard fs delete (used by the + * MCP process, which has no Electron shell). + */ +export function setPathRemover(remover?: PathRemover): void { + pathRemover = remover ?? hardRemove; +} + +/** Remove a file or folder using the active remover; falls back to a hard + * delete if the remover throws (e.g. trashItem unavailable on a headless box). */ +function removePath(targetPath: string, opts: { recursive: boolean }): void { + if (!fs.existsSync(targetPath)) return; + try { + pathRemover(targetPath, opts); + } catch { + try { hardRemove(targetPath, opts); } catch { /* ignore */ } + } +} + // ── Cairn-owned frontmatter keys ────────────── // Only these keys are managed by Cairn. All other frontmatter fields // (Obsidian tags, aliases, cssclass, date, publish, etc.) are preserved @@ -395,7 +439,7 @@ export function deleteNoteFile( ): void { const fp = findNoteFilePath(workspacePath, projectName, noteId); if (fp) { - try { fs.unlinkSync(fp); } catch { /* ignore */ } + removePath(fp, { recursive: false }); // Deleting the last note in a subfolder empties it — prune the vacated // folder(s), stopping at the project root. pruneEmptyDirsUpTo(path.dirname(fp), projectNotesDir(workspacePath, projectName)); @@ -405,15 +449,24 @@ export function deleteNoteFile( /** * Delete the entire project notes folder (and all .md files inside it). * Called when a project is deleted. + * + * IMPORTANT: the on-disk folder is keyed by the project NAME slug, not the + * project id, and project names are not unique. If another project still + * shares this slug, deleting the folder would destroy that surviving + * project's .md files (data loss). Pass `otherProjectNames` — the names of + * every project that still exists AFTER this one is removed from the DB — so + * we can skip the delete when a sibling still occupies the same folder. */ export function deleteProjectNotesDir( workspacePath: string, projectName: string, + otherProjectNames: string[] = [], ): void { + const slug = toSlug(projectName); + // Guard: never remove a folder that a surviving same-slug project depends on. + if (otherProjectNames.some((n) => toSlug(n) === slug)) return; const dir = projectNotesDir(workspacePath, projectName); - if (fs.existsSync(dir)) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } - } + removePath(dir, { recursive: true }); } /** diff --git a/src/components/notes/note-editor.tsx b/src/components/notes/note-editor.tsx index 614763e54..314e5df2c 100644 --- a/src/components/notes/note-editor.tsx +++ b/src/components/notes/note-editor.tsx @@ -867,7 +867,7 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) {
([]); const [streamingContent, setStreamingContent] = useState(""); const [streamingThought, setStreamingThought] = useState(""); - const [pendingQuestions, setPendingQuestions] = useState(null); + // Pending questions are tagged with the thread they arrived on. The chat + // panel + this hook are a single persistent instance that switches threads by + // changing `threadId` (no remount), so a bare array would render under + // whatever thread is active. Keying by threadId scopes the form to its own + // thread (and restores it if the user switches back). + const [pendingQuestionsState, setPendingQuestionsState] = + useState<{ threadId: string | null; questions: PendingQuestion[] } | null>(null); const [subagents, setSubagents] = useState([]); const threadIdRef = useRef(null); @@ -99,6 +105,12 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { useEffect(() => { threadIdRef.current = threadId; }, [threadId]); + // Only surface pending questions that belong to the currently-active thread. + const pendingQuestions = + pendingQuestionsState && pendingQuestionsState.threadId === threadId + ? pendingQuestionsState.questions + : null; + useEffect(() => { const electron = window.electron; if (!electron) return; @@ -115,7 +127,9 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { if (!isForThisThread(e)) return; if (e.tool === "ask_questions") { const qs = (e.args.questions as PendingQuestion[] | undefined) ?? []; - setPendingQuestions(qs); + // Tag with the thread these questions arrived for, so the form only + // renders in that thread (not whichever thread is active later). + setPendingQuestionsState({ threadId: threadIdRef.current, questions: qs }); } else { if (e.tool === "suggest_connections") { const incoming = (e.args.actions ?? []) as SuggestedAction[]; @@ -322,7 +336,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { pendingActionsRef.current = []; setSubagents([]); subagentsRef.current = []; - setPendingQuestions(null); + setPendingQuestionsState(null); if (threadId) { setThreadUsage(threadId, undefined); } @@ -344,7 +358,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { } function clearQuestions() { - setPendingQuestions(null); + setPendingQuestionsState(null); } return { isLoading, toolCalls, streamingContent, streamingThought, subagents, pendingQuestions, sendStream, stopStream, clearQuestions }; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7aaa616a5..fda8601a7 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -128,6 +128,9 @@ export const AGENT_CONFIG_KEY = "agent-config"; /** localStorage key for the last active project ID. */ export const ACTIVE_PROJECT_KEY = "active-project"; +/** localStorage key for the last active chat thread ID (restored on restart). */ +export const ACTIVE_CHAT_THREAD_KEY = "active-chat-thread"; + /** localStorage key for the persisted chat panel width (px). */ export const CHAT_PANEL_WIDTH_KEY = "chatPanelWidth"; diff --git a/src/store/index.ts b/src/store/index.ts index 0151cbffe..e83c906b6 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -515,6 +515,18 @@ export const useCairnStore = create()( return valid ? saved : (snap.projects?.[0]?.id ?? null); })(), }); + + // Chat threads/messages live in SQLite (write path in the chat slice) but + // aren't in the snapshot above. On the initial hydrate, read them back so + // conversations survive restarts + app updates — Chromium localStorage + // (the only other copy) can be cleared or relocated by an update. On a + // refresh hydrate we skip this to avoid clobbering in-flight chat state. + if (!isRefresh) { + const wsId = get().activeWorkspaceId; + if (wsId) { + void get().loadChatFromDb(wsId); + } + } }, persist() { diff --git a/src/store/slices/chat.ts b/src/store/slices/chat.ts index b1a6ec434..830adee97 100644 --- a/src/store/slices/chat.ts +++ b/src/store/slices/chat.ts @@ -6,6 +6,8 @@ import type { StateCreator } from "zustand"; import type { CairnStore } from "../index"; import type { ChatThread, ChatMessage, ChatToolCallRecord, PendingAction, ID, TokenBreakdown } from "@/types"; import { id, now } from "@/lib/utils"; +import { storage } from "@/lib/storage"; +import { ACTIVE_CHAT_THREAD_KEY } from "@/lib/constants"; import { ipc, ipcAwait, ipcAwaitResult } from "../ipc"; // ── Slice interface ─────────────────────────────────────────────────────────── @@ -17,6 +19,7 @@ export interface ChatSlice { getOrCreateThread: (workspaceId: ID, projectId?: ID) => ChatThread; setActiveChatThreadId: (threadId: string | null) => void; + loadChatFromDb: (workspaceId: ID) => Promise; addMessage: ( threadId: ID, role: ChatMessage["role"], @@ -54,6 +57,69 @@ export const createChatSlice: StateCreator = ( setActiveChatThreadId(threadId) { set({ activeChatThreadId: threadId }); + // Persist so the last-active thread is restored across restarts. SQLite is + // the durable source of truth for the threads themselves (see loadChatFromDb); + // this pointer just remembers which one to re-select. + if (threadId) storage.set(ACTIVE_CHAT_THREAD_KEY, threadId); + else storage.delete(ACTIVE_CHAT_THREAD_KEY); + }, + + /** + * Load chat threads (and their messages) from SQLite into the store, then + * restore the last-active thread pointer. SQLite — not localStorage — is the + * durable store: an app update can clear/relocate Chromium localStorage, but + * every thread + message is written to SQLite on create/send. This re-reads + * them so conversations survive restarts and updates. + * + * Threads/messages already in memory (e.g. an in-flight message not yet + * flushed, or a just-created empty thread) win over the DB copy so we never + * clobber optimistic state. + */ + async loadChatFromDb(workspaceId) { + const threadsRes = await ipcAwaitResult( + (e) => e.chat.threads(workspaceId) as Promise<{ data: ChatThread[] } | { error: string }> + ); + if (!("data" in threadsRes) || !Array.isArray(threadsRes.data)) return; + const dbThreads = threadsRes.data; + + // Pull messages for every thread in parallel. + const messageLists = await Promise.all( + dbThreads.map(async (t) => { + const res = await ipcAwaitResult( + (e) => e.chat.messages(t.id) as Promise<{ data: ChatMessage[] } | { error: string }> + ); + return "data" in res && Array.isArray(res.data) ? res.data : []; + }) + ); + const dbMessages = messageLists.flat(); + + set((s) => { + // Merge threads: keep any in-memory thread not yet in the DB; otherwise + // take the DB row (it's the persisted truth). + const dbThreadIds = new Set(dbThreads.map((t) => t.id)); + const mergedThreads = [ + ...dbThreads, + ...s.chatThreads.filter((t) => !dbThreadIds.has(t.id)), + ]; + + // Merge messages the same way, keyed by message id — in-memory copies of + // messages the DB doesn't have yet (e.g. mid-flush) are preserved. + const dbMsgIds = new Set(dbMessages.map((m) => m.id)); + const mergedMessages = [ + ...dbMessages, + ...s.chatMessages.filter((m) => !dbMsgIds.has(m.id)), + ]; + + return { chatThreads: mergedThreads, chatMessages: mergedMessages }; + }); + get().persist(); + + // Restore the last-active thread if it still exists; otherwise leave it for + // the UI's getOrCreateThread to pick a scoped thread. + const saved = storage.get(ACTIVE_CHAT_THREAD_KEY); + if (saved && get().chatThreads.some((t) => t.id === saved)) { + set({ activeChatThreadId: saved }); + } }, getOrCreateThread(workspaceId, projectId) { diff --git a/src/store/slices/ui.ts b/src/store/slices/ui.ts index 21fff6a71..fad519219 100644 --- a/src/store/slices/ui.ts +++ b/src/store/slices/ui.ts @@ -689,6 +689,9 @@ export const createUISlice: StateCreator = ( activePreviewItem: null, lastContentView: "overview", }); + // Pull this workspace's chat threads/messages from SQLite (the durable + // store) so switching workspaces surfaces their conversations. + get().loadChatFromDb?.(wsId); }, setActiveProject(projId) { From 82a30857e7906d646dbaff9b073d10b40398017b Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Fri, 31 Jul 2026 13:08:12 -0400 Subject: [PATCH 2/5] review: scope survivor queries by workspace, await-capable remover, per-thread questions, guard chat load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review findings: - db-handlers/mcp: scope getProjects survivor check to the project's workspace so a same-named project in another workspace can't block a legitimate folder delete (delete + merge + MCP paths). - notes-io: PathRemover may now return a promise; removePath returns it so callers can await completion. Add hardDeleteNoteFile (sync, bypasses the OS-trash remover) and use it for the move-to-project stale-copy cleanup so the old file is gone before the handler returns (no watcher re-import race). - useChatStream: store pending questions in a per-thread map so a second thread's ask_questions no longer clobbers an unanswered form; entries are cleared per-thread on send/clear. - chat.loadChatFromDb: bail if the workspace switched while awaiting IPC (superseded load); only restore a saved active thread that belongs to the loaded workspace; preserve in-memory lastUsage (chat_threads doesn't persist it) when merging DB threads. Skipped: batched chat.messagesByWorkspace nitpick — no such IPC/query/preload exists; adding one is beyond a minimal review fix. --- electron/ipc/db-handlers.ts | 19 ++++++++---- electron/mcp/tools/projects.ts | 5 +++- electron/notes-files.ts | 2 ++ electron/shared/notes-io.test.ts | 39 ++++++++++++++++++++++++- electron/shared/notes-io.ts | 50 +++++++++++++++++++++++++------- src/hooks/useChatStream.ts | 45 +++++++++++++++++++--------- src/store/slices/chat.ts | 25 ++++++++++++---- 7 files changed, 149 insertions(+), 36 deletions(-) diff --git a/electron/ipc/db-handlers.ts b/electron/ipc/db-handlers.ts index 4d5127393..3f19c273c 100644 --- a/electron/ipc/db-handlers.ts +++ b/electron/ipc/db-handlers.ts @@ -14,7 +14,7 @@ import path from "path"; import { registerIpcHandle, registerIpcOn } from "./registry"; import { handle, getProjectName, type DbContext } from "./result-helpers"; import * as q from "../db/queries"; -import { writeNoteFile, deleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, reconcileProjectFolders, stripMarkdown, findNoteFilePath } from "../notes-files"; +import { writeNoteFile, deleteNoteFile, hardDeleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, reconcileProjectFolders, stripMarkdown, findNoteFilePath } from "../notes-files"; import { suppressNextChange } from "../file-watcher"; import { executeTool as executeMcpTool } from "../mcp/tools"; import { executeReadTool } from "../lib/read-tools"; @@ -171,7 +171,10 @@ export function registerDbHandlers(ctx: DbContext): void { // The notes folder is keyed by the project NAME slug, not the id, and // names are not unique. Only remove the folder if no surviving project // still shares that slug — otherwise we'd wipe a duplicate's .md files. - const survivorNames = q.getProjects(ctx.db).map((p) => p.name); + // Scope survivors to this project's workspace: folders live under this + // workspace's tree, so a same-named project in ANOTHER workspace is not a + // real survivor and must not block the delete. + const survivorNames = q.getProjects(ctx.db, project.workspaceId).map((p) => p.name); deleteProjectNotesDir(ctx.workspacePath, project.name, survivorNames); } })); @@ -209,9 +212,11 @@ export function registerDbHandlers(ctx: DbContext): void { } // Remove the source project's on-disk folder (now that its notes have // been rewritten under the target). Best-effort, and only when no - // surviving project still shares the source name's slug. + // surviving project shares the source name's slug — scoped to the + // target's workspace (both folders live under this workspace's tree). try { - const survivorNames = q.getProjects(ctx.db).map((p) => p.name); + const mergeWsId = q.getProjectById(ctx.db, targetId)?.workspaceId; + const survivorNames = q.getProjects(ctx.db, mergeWsId).map((p) => p.name); deleteProjectNotesDir(ctx.workspacePath, result.sourceName, survivorNames); } catch (e) { console.warn("[merge] failed to remove source project folder:", e instanceof Error ? e.message : e); @@ -404,8 +409,12 @@ export function registerDbHandlers(ctx: DbContext): void { // New file is in place; deleting the stale old copy is best-effort // (a failure here only leaves a duplicate that the watcher would // re-import into the old project — non-fatal, and the DB move stands). + // Use a synchronous HARD delete (not the OS-trash remover): this is a + // move-internal duplicate, not a user delete, so it must not land in + // the Trash and must be gone before we return, or an async trasher + // would race the file-watcher re-importing it into the old project. try { - deleteNoteFile(ctx.workspacePath, oldProjectName, id); + hardDeleteNoteFile(ctx.workspacePath, oldProjectName, id); } catch (e) { console.warn("[notes] failed to remove old-project file after move:", e instanceof Error ? e.message : e); } diff --git a/electron/mcp/tools/projects.ts b/electron/mcp/tools/projects.ts index b1e5dcc76..252a02941 100644 --- a/electron/mcp/tools/projects.ts +++ b/electron/mcp/tools/projects.ts @@ -64,7 +64,10 @@ export function delete_project(db: Database.Database, snap: Snapshot, workspaceP // the project NAME slug (not the id) and names are not unique, so blindly // deleting it would destroy a same-named duplicate's .md files (data loss). q.deleteProject(db, args.projectId as string); - const survivorNames = q.getProjects(db).map((p) => p.name); + // Scope survivors to this project's workspace — a same-named project in + // another workspace lives in a different folder tree and must not block this + // delete. + const survivorNames = q.getProjects(db, project.workspaceId).map((p) => p.name); deleteProjectNotesDir(workspacePath, project.name, survivorNames); insertNotification(db, "delete_project", "Project deleted", `"${project.name}" was deleted`); return { deleted: true, id: args.projectId, name: project.name }; diff --git a/electron/notes-files.ts b/electron/notes-files.ts index 249b40a5b..8dd8ea98d 100644 --- a/electron/notes-files.ts +++ b/electron/notes-files.ts @@ -16,6 +16,7 @@ import { NoteFileData as NoteData, writeNoteFile, deleteNoteFile, + hardDeleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, parseNoteFile, @@ -36,6 +37,7 @@ export { NoteData, writeNoteFile, deleteNoteFile, + hardDeleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, parseNoteFile, diff --git a/electron/shared/notes-io.test.ts b/electron/shared/notes-io.test.ts index 479f07ced..00da4f698 100644 --- a/electron/shared/notes-io.test.ts +++ b/electron/shared/notes-io.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; import path from "path"; import os from "os"; -import { writeNoteFile, deleteProjectNotesDir, deleteNoteFile, setPathRemover, type NoteFileData, type PathRemover } from "./notes-io"; +import { writeNoteFile, deleteProjectNotesDir, deleteNoteFile, hardDeleteNoteFile, setPathRemover, type NoteFileData, type PathRemover } from "./notes-io"; let tmpDir: string; @@ -284,4 +284,41 @@ describe("setPathRemover — pluggable trash vs hard delete", () => { expect(fs.existsSync(fp)).toBe(false); }); + + it("falls back to a hard delete when an ASYNC remover rejects", async () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + // Async trasher that rejects (e.g. shell.trashItem unavailable). removePath + // wraps it in a promise whose .catch performs the hard-delete fallback. + const remover: PathRemover = () => Promise.reject(new Error("no trash")); + setPathRemover(remover); + + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + // Removal completes asynchronously — flush the microtask queue, then assert + // the fallback fs delete ran. + await new Promise((r) => setTimeout(r, 0)); + expect(fs.existsSync(fp)).toBe(false); + }); +}); + +describe("hardDeleteNoteFile — sync delete that bypasses the OS-trash remover", () => { + afterEach(() => setPathRemover()); + + it("deletes synchronously even when an async trasher is injected", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + // An injected (async, fire-and-forget) trasher must NOT be used for the + // move-internal cleanup — hardDeleteNoteFile always does a sync fs delete so + // the stale old-project copy is gone before the move handler returns. + const trashed: string[] = []; + setPathRemover((t) => { trashed.push(t); return Promise.resolve(); }); + + hardDeleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + expect(trashed).toEqual([]); // trasher untouched + expect(fs.existsSync(fp)).toBe(false); // file gone synchronously + }); }); diff --git a/electron/shared/notes-io.ts b/electron/shared/notes-io.ts index d5d97ac3e..b3ecddb7b 100644 --- a/electron/shared/notes-io.ts +++ b/electron/shared/notes-io.ts @@ -17,12 +17,15 @@ import { toSlug } from "./text-utils"; // bundled into the standalone MCP process, which has no Electron `shell`. // // So we keep the sync fs removal as the default, and let the Electron main -// process inject a trasher via `setPathRemover`. Callers stay synchronous; an -// async trasher is fire-and-forget (the DB row is already deleted by the time -// we get here, so the file-watcher can't misread the removal as data loss). -export type PathRemover = (targetPath: string, opts: { recursive: boolean }) => void; - -const hardRemove: PathRemover = (targetPath, opts) => { +// process inject a trasher via `setPathRemover`. The trasher MAY be async +// (shell.trashItem returns a promise); `removePath` returns that promise so +// callers that need the file to actually be gone before continuing (e.g. the +// move-to-project source cleanup) can await it. Fire-and-forget callers can +// ignore the return — the DB row is already deleted, so the file-watcher can't +// misread the removal as data loss. +export type PathRemover = (targetPath: string, opts: { recursive: boolean }) => void | Promise; + +const hardRemove = (targetPath: string, opts: { recursive: boolean }): void => { if (opts.recursive) { fs.rmSync(targetPath, { recursive: true, force: true }); } else { @@ -43,13 +46,18 @@ export function setPathRemover(remover?: PathRemover): void { } /** Remove a file or folder using the active remover; falls back to a hard - * delete if the remover throws (e.g. trashItem unavailable on a headless box). */ -function removePath(targetPath: string, opts: { recursive: boolean }): void { - if (!fs.existsSync(targetPath)) return; + * delete if the remover throws (e.g. trashItem unavailable on a headless box). + * Returns a promise that resolves once removal is complete (the remover may be + * async), so callers that must not race a follow-up can await it. */ +function removePath(targetPath: string, opts: { recursive: boolean }): Promise { + if (!fs.existsSync(targetPath)) return Promise.resolve(); try { - pathRemover(targetPath, opts); + return Promise.resolve(pathRemover(targetPath, opts)).catch(() => { + try { hardRemove(targetPath, opts); } catch { /* ignore */ } + }); } catch { try { hardRemove(targetPath, opts); } catch { /* ignore */ } + return Promise.resolve(); } } @@ -446,6 +454,28 @@ export function deleteNoteFile( } } +/** + * Synchronously HARD-delete a note's .md file (bypassing the OS-trash remover). + * + * Use for internal bookkeeping deletes that are not user "delete" actions — e.g. + * removing the stale copy of a note in its OLD project folder after a + * move-to-project. Such a file is a duplicate, not a deletion, so it must NOT + * land in the user's Trash; and it must be gone before the handler returns, or + * the file-watcher could re-import it into the old project (an async trasher + * would race that). A synchronous fs delete guarantees both. + */ +export function hardDeleteNoteFile( + workspacePath: string, + projectName: string, + noteId: string, +): void { + const fp = findNoteFilePath(workspacePath, projectName, noteId); + if (fp && fs.existsSync(fp)) { + try { fs.unlinkSync(fp); } catch { /* ignore */ } + pruneEmptyDirsUpTo(path.dirname(fp), projectNotesDir(workspacePath, projectName)); + } +} + /** * Delete the entire project notes folder (and all .md files inside it). * Called when a project is deleted. diff --git a/src/hooks/useChatStream.ts b/src/hooks/useChatStream.ts index c74aa12a6..5ba116b14 100644 --- a/src/hooks/useChatStream.ts +++ b/src/hooks/useChatStream.ts @@ -85,13 +85,14 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { const [toolCalls, setToolCalls] = useState([]); const [streamingContent, setStreamingContent] = useState(""); const [streamingThought, setStreamingThought] = useState(""); - // Pending questions are tagged with the thread they arrived on. The chat - // panel + this hook are a single persistent instance that switches threads by - // changing `threadId` (no remount), so a bare array would render under - // whatever thread is active. Keying by threadId scopes the form to its own - // thread (and restores it if the user switches back). - const [pendingQuestionsState, setPendingQuestionsState] = - useState<{ threadId: string | null; questions: PendingQuestion[] } | null>(null); + // Pending questions are keyed by the thread they arrived on. The chat panel + + // this hook are a single persistent instance that switches threads by changing + // `threadId` (no remount), so a single slot would render under whatever thread + // is active and get overwritten when a second thread also asks questions. + // A per-thread map scopes each form to its own thread and preserves an + // unanswered form when the user switches away and back. + const [pendingQuestionsByThread, setPendingQuestionsByThread] = + useState>({}); const [subagents, setSubagents] = useState([]); const threadIdRef = useRef(null); @@ -107,8 +108,8 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { // Only surface pending questions that belong to the currently-active thread. const pendingQuestions = - pendingQuestionsState && pendingQuestionsState.threadId === threadId - ? pendingQuestionsState.questions + threadId && pendingQuestionsByThread[threadId] + ? pendingQuestionsByThread[threadId] : null; useEffect(() => { @@ -127,9 +128,13 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { if (!isForThisThread(e)) return; if (e.tool === "ask_questions") { const qs = (e.args.questions as PendingQuestion[] | undefined) ?? []; - // Tag with the thread these questions arrived for, so the form only - // renders in that thread (not whichever thread is active later). - setPendingQuestionsState({ threadId: threadIdRef.current, questions: qs }); + // Store under the thread these questions arrived for, so the form only + // renders in that thread (not whichever thread is active later) and a + // second thread's questions don't clobber this one's. + const forThread = e.threadId ?? threadIdRef.current; + if (forThread) { + setPendingQuestionsByThread((prev) => ({ ...prev, [forThread]: qs })); + } } else { if (e.tool === "suggest_connections") { const incoming = (e.args.actions ?? []) as SuggestedAction[]; @@ -336,7 +341,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { pendingActionsRef.current = []; setSubagents([]); subagentsRef.current = []; - setPendingQuestionsState(null); + clearQuestionsForThread(threadId); if (threadId) { setThreadUsage(threadId, undefined); } @@ -357,8 +362,20 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { // Keep pendingQuestions — user may still want to answer after stopping } + // Drop a single thread's pending questions (answered/cleared), leaving other + // threads' unanswered forms intact. + function clearQuestionsForThread(tid: string | null) { + if (!tid) return; + setPendingQuestionsByThread((prev) => { + if (!(tid in prev)) return prev; + const next = { ...prev }; + delete next[tid]; + return next; + }); + } + function clearQuestions() { - setPendingQuestionsState(null); + clearQuestionsForThread(threadId); } return { isLoading, toolCalls, streamingContent, streamingThought, subagents, pendingQuestions, sendStream, stopStream, clearQuestions }; diff --git a/src/store/slices/chat.ts b/src/store/slices/chat.ts index 830adee97..0da9f00e8 100644 --- a/src/store/slices/chat.ts +++ b/src/store/slices/chat.ts @@ -93,12 +93,23 @@ export const createChatSlice: StateCreator = ( ); const dbMessages = messageLists.flat(); + // A newer workspace switch may have superseded this (async) load while we + // awaited IPC. If so, bail before touching state so a stale load can't + // overwrite the current workspace's chat or active-thread pointer. + if (get().activeWorkspaceId !== workspaceId) return; + set((s) => { // Merge threads: keep any in-memory thread not yet in the DB; otherwise - // take the DB row (it's the persisted truth). + // take the DB row (it's the persisted truth) — but carry over the + // in-memory `lastUsage`, which chat_threads does not persist and would + // otherwise be lost (blanking the live token-usage gauge). + const memById = new Map(s.chatThreads.map((t) => [t.id, t])); const dbThreadIds = new Set(dbThreads.map((t) => t.id)); const mergedThreads = [ - ...dbThreads, + ...dbThreads.map((t) => { + const mem = memById.get(t.id); + return mem?.lastUsage ? { ...t, lastUsage: mem.lastUsage } : t; + }), ...s.chatThreads.filter((t) => !dbThreadIds.has(t.id)), ]; @@ -114,10 +125,14 @@ export const createChatSlice: StateCreator = ( }); get().persist(); - // Restore the last-active thread if it still exists; otherwise leave it for - // the UI's getOrCreateThread to pick a scoped thread. + // Restore the last-active thread if it still exists AND belongs to the + // workspace we just loaded; otherwise leave it for the UI's + // getOrCreateThread to pick a scoped thread. const saved = storage.get(ACTIVE_CHAT_THREAD_KEY); - if (saved && get().chatThreads.some((t) => t.id === saved)) { + if ( + saved && + get().chatThreads.some((t) => t.id === saved && t.workspaceId === workspaceId) + ) { set({ activeChatThreadId: saved }); } }, From 1fe7d3620d1d848e1c93022801d7874ace51cf4e Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Fri, 31 Jul 2026 14:51:58 -0400 Subject: [PATCH 3/5] Custom slash commands + palette fixes; community command groundwork Feature: custom slash commands (Settings -> Commands) - New workspace-global slash_commands table (migration v31) + queries + db-mappers; command:* IPC handlers + preload API (list/create/update/delete). - New Zustand commands slice (customCommands), refetched on every hydrate so commands created in another window / after a workspace switch stay in sync. - Centralized built-in command registry in src/lib/slash-commands.ts with getCommandsForScope() merge (a custom command overrides a same-named built-in). - Chat panel + coding-agent pane now render merged commands instead of local hardcoded arrays. - New Commands settings section: create/edit/delete custom commands (name/description/insertText/scope) + built-in documentation. Fix: slash-command palette - Tab/click-completing a command whose completion starts with '/' (e.g. /compact) re-detected the trigger and reopened the palette, swallowing the follow-up Enter meant to send. Suppress trigger re-scans for 250ms after a selection, close the palette, and move the cursor to the end so Enter sends. Cleanup: removed the redundant read-only "Project context" snippet panel from Settings -> AI & Chat (no runtime effect). Groundwork: community slash commands - shared/chat/registry-schema.ts parses a new manifest commands[] array (fail-soft, optional so pre-v2 manifests still parse); RegistryCommandEntry added to types + preload. Browse/install UI lands next. Changelog + What's New entry added. --- changelogs/v2.5.18.md | 10 + electron/db/queries.ts | 70 +++ electron/db/schema.ts | 25 + electron/ipc/db-handlers.ts | 6 + electron/lib/community-registry.test.ts | 47 ++ electron/lib/community-registry.ts | 2 +- electron/preload.ts | 15 +- electron/shared/db-mappers.ts | 15 + scripts/features.config.js | 13 + shared/chat/registry-schema.ts | 31 ++ src/components/agent/AgentChatPane.tsx | 33 +- src/components/chat/ChatInput.tsx | 23 + src/components/chat/chat-panel/index.tsx | 32 +- src/components/settings/CommandsSettings.tsx | 433 ++++++++++++++++++ src/components/settings/MCPSettings.tsx | 176 +------ src/components/settings/settings-view.tsx | 4 + .../settings/tools/BrowseCommunityModal.tsx | 2 +- src/lib/slash-commands.ts | 75 +++ src/store/index.ts | 14 + src/store/slices/commands.ts | 102 +++++ src/types/index.ts | 42 ++ 21 files changed, 943 insertions(+), 227 deletions(-) create mode 100644 src/components/settings/CommandsSettings.tsx create mode 100644 src/lib/slash-commands.ts create mode 100644 src/store/slices/commands.ts diff --git a/changelogs/v2.5.18.md b/changelogs/v2.5.18.md index ebf96a99c..75820be50 100644 --- a/changelogs/v2.5.18.md +++ b/changelogs/v2.5.18.md @@ -8,3 +8,13 @@ - **Deleted notes and projects now go to the OS trash instead of being permanently erased.** When you delete a note or a project from the app (including via the in-app AI chat), Cairn now moves its `.md` file/folder to the system Trash (macOS) or Recycle Bin (Windows) via `shell.trashItem`, so you can restore it from Finder/Explorer if a delete was a mistake. If the OS trash is unavailable, it falls back to a permanent delete. Note: the standalone `cairn-mcp` binary (used by agents while Cairn is closed) still hard-deletes, since a headless process has no desktop trash. - **Deleting a project no longer wipes another same-named project's notes.** Project note folders on disk are keyed by the project NAME slug (not the id), and project names aren't unique — so if two projects shared a name (e.g. a duplicate "Personal" created by a timed-out, retried request), deleting one ran `fs.rmSync` on the shared folder and destroyed the survivor's `.md` files while its database rows lived on. Project deletion (both the app's IPC handler and the MCP `delete_project` tool) now deletes the database rows first, then only removes the on-disk folder when no surviving project still occupies that slug. The same survivor guard was added to the post-merge source-folder cleanup, and the MCP delete path was reordered to delete the DB rows before touching disk (previously it removed the folder first). + +- **Slash commands now send on Enter after tab-completing.** When you tab- or click-completed a slash command whose completion is itself a command (e.g. `/compact`), the input re-detected the leading `/` and immediately re-opened the command palette — so the next Enter re-opened the palette instead of sending. Completing a command now suppresses the trigger re-scan briefly and moves the cursor to the end, so the command is ready to send on the next Enter press. + +- **Removed the redundant "Project context" panel from Settings → AI & Chat.** The MCP settings block contained a read-only "Project context" snippet generator (per-project system-prompt / Claude / Cursor config strings for wiring up external MCP clients) that duplicated the main MCP config blocks and confused its purpose. It wrote nothing and had no runtime effect, so it has been removed to simplify the page. + +## Features + +- **Custom slash commands.** You can now create your own slash commands from **Settings → Commands**. Each command has a name (used as `/name`), a description, the text it inserts into the input, and a scope that controls whether it appears in the AI chat panel, the coding-agent input, or both. Custom commands are workspace-global and persist in the local database. The new Commands settings page also documents every built-in command in one place — and a custom command with the same name as a built-in overrides it, so you can tweak what a built-in inserts. (Browsing and installing commands from the cairn-community registry is coming in a follow-up; the groundwork is in place.) + +- **Groundwork for community slash commands.** The cairn-community manifest schema now supports a third entry kind — `command` — alongside MCP servers and HTTP services, and Cairn's registry parser reads the new `commands[]` array from `manifest.json` (v2). Older manifests without a `commands` key still parse. A one-click "Browse Community Commands" installer lands in a follow-up. diff --git a/electron/db/queries.ts b/electron/db/queries.ts index 87533c66b..b0e0b024f 100644 --- a/electron/db/queries.ts +++ b/electron/db/queries.ts @@ -44,6 +44,7 @@ import { toColumn, toCard, toTag, + toSlashCommand, toChatThread, toChatMessage, toMcpNotification, @@ -951,6 +952,75 @@ export function deleteTag(db: Database.Database, id: string) { db.prepare("DELETE FROM tags WHERE id = ?").run(id); } +// ── Slash commands ──────────────────────────── + +export function getSlashCommands(db: Database.Database, workspaceId?: string) { + const rows = workspaceId + ? db.prepare("SELECT * FROM slash_commands WHERE workspace_id = ? ORDER BY name").all(workspaceId) + : db.prepare("SELECT * FROM slash_commands ORDER BY name").all(); + return rows.map(toSlashCommand); +} + +export function createSlashCommand(db: Database.Database, c: { + id: string; + workspaceId: string; + name: string; + description?: string; + insertText?: string; + scope?: string; + source?: string; + communityId?: string; +}) { + const now = ts(); + db.prepare(` + INSERT INTO slash_commands + (id, workspace_id, name, description, insert_text, scope, source, community_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + c.id, + c.workspaceId, + c.name, + c.description ?? "", + c.insertText ?? "", + c.scope ?? "both", + c.source ?? "custom", + c.communityId ?? null, + now, + now + ); + return toSlashCommand(db.prepare("SELECT * FROM slash_commands WHERE id = ?").get(c.id)); +} + +export function updateSlashCommand(db: Database.Database, id: string, patch: { + name?: string; + description?: string; + insertText?: string; + scope?: string; +}) { + const now = ts(); + db.prepare(` + UPDATE slash_commands SET + name = COALESCE(?, name), + description = COALESCE(?, description), + insert_text = COALESCE(?, insert_text), + scope = COALESCE(?, scope), + updated_at = ? + WHERE id = ? + `).run( + patch.name ?? null, + patch.description ?? null, + patch.insertText ?? null, + patch.scope ?? null, + now, + id + ); + return toSlashCommand(db.prepare("SELECT * FROM slash_commands WHERE id = ?").get(id)); +} + +export function deleteSlashCommand(db: Database.Database, id: string) { + db.prepare("DELETE FROM slash_commands WHERE id = ?").run(id); +} + // ── Chat ────────────────────────────────────── export function getChatThreads(db: Database.Database, workspaceId: string) { diff --git a/electron/db/schema.ts b/electron/db/schema.ts index 0eb6711e1..5a114c47a 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -774,6 +774,31 @@ const MIGRATIONS: Migration[] = [ db.exec("ALTER TABLE custom_services ADD COLUMN oauth_config TEXT"); } }, + + // v31: User-defined & community slash commands. Workspace-global custom + // commands that surface in the chat / agent input palettes. Built-in commands + // stay as code constants; only custom + community-installed commands persist + // here. `scope` picks which pane(s) show the command; `source` records + // provenance ('custom' | 'community'); `community_id` links an installed + // command back to its cairn-community manifest entry. Device-local (not synced) + // — commands are a workspace-authoring convenience, not user data. + (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS slash_commands ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + insert_text TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'both', -- 'chat' | 'agent' | 'both' + source TEXT NOT NULL DEFAULT 'custom', -- 'custom' | 'community' + community_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_slash_commands_workspace ON slash_commands(workspace_id); + `); + }, ]; export function applySchema(db: Database.Database): void { diff --git a/electron/ipc/db-handlers.ts b/electron/ipc/db-handlers.ts index 3f19c273c..1a66948b9 100644 --- a/electron/ipc/db-handlers.ts +++ b/electron/ipc/db-handlers.ts @@ -651,4 +651,10 @@ export function registerDbHandlers(ctx: DbContext): void { registerIpcHandle("db:tag:create", (_e, args: Parameters[1]) => handle(() => q.createTag(ctx.db, args))); registerIpcHandle("db:tag:update", (_e, { id, patch }) => handle(() => q.updateTag(ctx.db, id, patch))); registerIpcHandle("db:tag:delete", (_e, { id }) => handle(() => q.deleteTag(ctx.db, id))); + + // ── Slash commands ───────────────────────────────── + registerIpcHandle("db:command:list", (_e, { workspaceId }) => handle(() => q.getSlashCommands(ctx.db, workspaceId))); + registerIpcHandle("db:command:create", (_e, args: Parameters[1]) => handle(() => q.createSlashCommand(ctx.db, args))); + registerIpcHandle("db:command:update", (_e, { id, patch }) => handle(() => q.updateSlashCommand(ctx.db, id, patch))); + registerIpcHandle("db:command:delete", (_e, { id }) => handle(() => q.deleteSlashCommand(ctx.db, id))); } diff --git a/electron/lib/community-registry.test.ts b/electron/lib/community-registry.test.ts index c5d536295..e32f8f1cb 100644 --- a/electron/lib/community-registry.test.ts +++ b/electron/lib/community-registry.test.ts @@ -58,6 +58,22 @@ const VALID = { }, }, ], + commands: [ + { + id: "command-standup", + author: "cairn", + version: "1.0.0", + category: "Automation", + tags: ["standup"], + blurb: "Standup update.", + definition: { + name: "standup", + description: "Standup", + insertText: "Summarise recent activity.", + scope: "chat", + }, + }, + ], }; function fetchResponse(opts: { @@ -91,6 +107,37 @@ describe("parseManifest", () => { expect(m.services[0].definition.name).toBe("Weather"); }); + it("parses community slash commands (manifest v2)", () => { + const m = parseManifest(VALID); + expect(m.commands).toHaveLength(1); + expect(m.commands[0].definition.name).toBe("standup"); + expect(m.commands[0].definition.scope).toBe("chat"); + }); + + it("tolerates a manifest with no commands key (pre-v2)", () => { + const { commands, ...noCommands } = VALID; + void commands; + const m = parseManifest(noCommands); + expect(m.commands).toEqual([]); + }); + + it("drops a command with an invalid name or scope", () => { + const bad = { + ...VALID, + commands: [ + { id: "x", author: "a", version: "1.0.0", tags: [], blurb: "b", + definition: { name: "Bad Name", insertText: "hi", scope: "chat" } }, + { id: "y", author: "a", version: "1.0.0", tags: [], blurb: "b", + definition: { name: "ok", insertText: "hi", scope: "nope" } }, + { id: "z", author: "a", version: "1.0.0", tags: [], blurb: "b", + definition: { name: "good", insertText: "hi", scope: "both" } }, + ], + }; + const m = parseManifest(bad); + expect(m.commands).toHaveLength(1); + expect(m.commands[0].definition.name).toBe("good"); + }); + it("drops an entry with a non-https baseUrl (never trusts an insecure URL)", () => { const bad = structuredClone(VALID); bad.mcpServers[0].definition.baseUrl = "http://insecure.example.com/mcp"; diff --git a/electron/lib/community-registry.ts b/electron/lib/community-registry.ts index 9ca505acf..e970b9f79 100644 --- a/electron/lib/community-registry.ts +++ b/electron/lib/community-registry.ts @@ -143,7 +143,7 @@ export async function fetchManifest(opts?: { force?: boolean }): Promise invoke("db:tag:delete", { id }), }, + // ── Slash commands ─────────────────────────── + command: { + list: (workspaceId?: string) => invoke("db:command:list", { workspaceId }), + create: (args: unknown) => invoke("db:command:create", args), + update: (id: string, patch: unknown) => invoke("db:command:update", { id, patch }), + delete: (id: string) => invoke("db:command:delete", { id }), + }, + // ── Chat ───────────────────────────────────── chat: { threads: (workspaceId: string) => invoke("db:chat:threads", { workspaceId }), diff --git a/electron/shared/db-mappers.ts b/electron/shared/db-mappers.ts index 4f170c446..af709e55a 100644 --- a/electron/shared/db-mappers.ts +++ b/electron/shared/db-mappers.ts @@ -233,6 +233,21 @@ export function toTag(row: any) { }; } +export function toSlashCommand(row: any) { + return { + id: row.id as string, + workspaceId: row.workspace_id as string, + name: row.name as string, + description: (row.description ?? "") as string, + insertText: (row.insert_text ?? "") as string, + scope: (row.scope ?? "both") as "chat" | "agent" | "both", + source: (row.source ?? "custom") as "custom" | "community", + communityId: (row.community_id ?? undefined) as string | undefined, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + }; +} + export function toChatThread(row: any) { return { id: row.id as string, diff --git a/scripts/features.config.js b/scripts/features.config.js index fe7e73628..8b776544b 100644 --- a/scripts/features.config.js +++ b/scripts/features.config.js @@ -243,6 +243,19 @@ const FEATURES = [ "Stays Compatible: Your Obsidian properties (tags, aliases, cssclasses, custom fields) are preserved on every save, and `.obsidian`, `.git`, and attachment folders are skipped.", ], }, + { + id: "v2.5.18-custom-commands", + version: "v2.5.18", + title: "Custom Slash Commands", + category: "AI & Chat", + description: "Create your own slash commands for the chat and coding-agent inputs — and see every built-in documented in one place — from the new Settings → Commands page.", + highlights: [ + "Make Your Own: Give a command a name, a description, and the text it inserts, then trigger it by typing `/` in any chat or agent input.", + "Scoped Where You Want: Choose whether each command shows in the AI chat panel, the coding-agent input, or both.", + "Built-ins, Documented: Every command that ships with Cairn is now listed in one place — and a custom command with the same name overrides what a built-in inserts.", + "Community Coming: Workspace-global commands persist locally today; browsing and installing commands shared by the community lands in a follow-up.", + ], + }, ]; module.exports = { FEATURES }; diff --git a/shared/chat/registry-schema.ts b/shared/chat/registry-schema.ts index 6493c8d91..2c4677abe 100644 --- a/shared/chat/registry-schema.ts +++ b/shared/chat/registry-schema.ts @@ -80,11 +80,27 @@ export interface RegistryServiceEntry extends RegistryEntryMeta { }; } +/** + * A community slash command — a reusable prompt/text snippet surfaced in Cairn's + * chat and/or agent input palettes. Installed workspace-globally; never executes + * code, only inserts `insertText` into the input. + */ +export interface RegistryCommandEntry extends RegistryEntryMeta { + definition: { + name: string; + description?: string; + insertText: string; + scope: "chat" | "agent" | "both"; + }; +} + export interface CommunityManifest { version: number; updatedAt: string; mcpServers: RegistryMcpEntry[]; services: RegistryServiceEntry[]; + /** Community slash commands (manifest v2+). Absent on older manifests. */ + commands: RegistryCommandEntry[]; } // ── validation (mirrors cairn-community/schema.json) ──────────────────────── @@ -187,14 +203,25 @@ const entryMeta = { const mcpEntry = z.object({ ...entryMeta, definition: mcpDefinition }).passthrough(); const serviceEntry = z.object({ ...entryMeta, definition: serviceDefinition }).passthrough(); +const commandDefinition = z.object({ + name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + description: z.string().optional(), + insertText: z.string().min(1), + scope: z.enum(["chat", "agent", "both"]), +}); + +const commandEntry = z.object({ ...entryMeta, definition: commandDefinition }).passthrough(); + // Manifest-level shape only validates the envelope; entries are validated // individually in parseManifest so ONE bad community entry can't blank the // whole catalog (the reject-all behaviour of z.array(z.object(...)) would). +// `commands` is optional so a pre-v2 manifest (no commands key) still parses. const manifestSchema = z.object({ version: z.number(), updatedAt: z.string(), mcpServers: z.array(z.unknown()), services: z.array(z.unknown()), + commands: z.array(z.unknown()).optional(), }); /** Parse + validate an unknown payload into a CommunityManifest, or throw. */ @@ -212,5 +239,9 @@ export function parseManifest(raw: unknown): CommunityManifest { const r = serviceEntry.safeParse(e); return r.success ? [r.data] : []; }), + commands: (m.commands ?? []).flatMap((e) => { + const r = commandEntry.safeParse(e); + return r.success ? [r.data] : []; + }), } as CommunityManifest; } diff --git a/src/components/agent/AgentChatPane.tsx b/src/components/agent/AgentChatPane.tsx index 6f35c887a..e0cf82dea 100644 --- a/src/components/agent/AgentChatPane.tsx +++ b/src/components/agent/AgentChatPane.tsx @@ -8,7 +8,7 @@ * Multi-turn: each new message continues the same session's history. */ -import { useEffect, useLayoutEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from "react"; import { Trash2, CheckCircle, FileText, Zap, Map as MapIcon } from "lucide-react"; import { QuestionForm } from "@/components/chat/chat-panel/QuestionForm"; import { ChatInput, SuggestionItem } from "@/components/chat/ChatInput"; @@ -16,6 +16,7 @@ import type { PendingQuestion } from "@/hooks/useChatStream"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { id } from "@/lib/utils"; +import { getCommandsForScope } from "@/lib/slash-commands"; import { AgentMessageBubble } from "./AgentMessageBubble"; import { PlanTaskList } from "./PlanTaskList"; import { ContextRing } from "./ContextRing"; @@ -83,29 +84,6 @@ function persistPiTranscript(sessionId: string): void { } -const NATIVE_AGENT_SLASH_COMMANDS = [ - { - name: "compact", - description: "Summarise and compact conversation history", - insertText: "/compact", - }, - { - name: "code-review", - description: "Review recent git changes for bugs and style", - insertText: "Please run a code review of the recent changes. Run git diff and analyze it.", - }, - { - name: "spawn-subagent", - description: "Spawn a subagent to work on a task", - insertText: '/spawn-subagent task: "write tests for..."', - }, - { - name: "test", - description: "Run project tests and verify correctness", - insertText: "Run the project tests and report if they pass.", - }, -]; - interface AgentChatPaneProps { session: TerminalSession; isActive: boolean; @@ -141,6 +119,11 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) { projects: s.projects, activeWorkspaceId: s.activeWorkspaceId, }))); + const customCommands = useCairnStore((s) => s.customCommands); + const agentCommands = useMemo( + () => getCommandsForScope("agent", customCommands), + [customCommands] + ); const messages = session.piMessages ?? []; const project = projects.find((p) => p.id === session.projectId); @@ -679,7 +662,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) { isLoading={isLoading} disabled={isLoading} placeholder={session.mode === "plan" ? "Describe what you want to build…" : "Ask the agent…"} - commands={NATIVE_AGENT_SLASH_COMMANDS} + commands={agentCommands} onSearchSuggestions={handleSearchFiles} />

diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 274368af2..7b39331c5 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -63,6 +63,12 @@ export const ChatInput = React.forwardRef( const internalRef = useRef(null); const resolvedRef = (ref as React.RefObject) || internalRef; const fileInputRef = useRef(null); + // After selecting a command/mention we suppress trigger re-scans for a short + // window, otherwise a completed slash command (e.g. "/compact") re-detects the + // '/' at index 0 and re-opens the palette — swallowing the follow-up Enter meant + // to send. A timestamp (not a one-shot bool) covers the multiple events + // (onChange RAF, onKeyUp, onSelect) that fire around a single selection. + const suppressTriggerUntilRef = useRef(0); const [showSuggestions, setShowSuggestions] = useState(false); const [activeIndex, setActiveIndex] = useState(0); @@ -96,6 +102,10 @@ export const ChatInput = React.forwardRef( const checkTrigger = () => { requestAnimationFrame(() => { + // Skip scans briefly after a selection so the palette doesn't immediately reopen. + if (Date.now() < suppressTriggerUntilRef.current) { + return; + } const active = getActiveTrigger(); setTrigger(active); }); @@ -158,8 +168,19 @@ export const ChatInput = React.forwardRef( const before = value.slice(0, trigger.index); const after = value.slice(resolvedRef.current?.selectionEnd ?? value.length); onChange(before + cmd.insertText + after); + // Close the palette and stop trigger scans from reopening it, so a + // completed command is ready to send on the next Enter press. + suppressTriggerUntilRef.current = Date.now() + 250; + setShowSuggestions(false); setTrigger(null); + const newCursorPos = before.length + cmd.insertText.length; resolvedRef.current?.focus(); + setTimeout(() => { + if (resolvedRef.current) { + resolvedRef.current.selectionStart = newCursorPos; + resolvedRef.current.selectionEnd = newCursorPos; + } + }, 0); }; const handleSelectSuggestion = (item: SuggestionItem) => { @@ -168,6 +189,8 @@ export const ChatInput = React.forwardRef( const after = value.slice(resolvedRef.current?.selectionEnd ?? value.length); const insertedText = item.type === "file" ? `\`${item.title}\`` : `[[${item.title}]]`; onChange(before + insertedText + after); + suppressTriggerUntilRef.current = Date.now() + 250; + setShowSuggestions(false); setTrigger(null); resolvedRef.current?.focus(); const newCursorPos = trigger.index + insertedText.length; diff --git a/src/components/chat/chat-panel/index.tsx b/src/components/chat/chat-panel/index.tsx index 4b9a3b416..afb219955 100644 --- a/src/components/chat/chat-panel/index.tsx +++ b/src/components/chat/chat-panel/index.tsx @@ -20,6 +20,7 @@ import { ToolCallIndicator } from "./ToolCallIndicator"; import { QuestionForm } from "./QuestionForm"; import { ChatInput, SuggestionItem } from "../ChatInput"; import { ContextRing } from "@/components/agent/ContextRing"; +import { getCommandsForScope } from "@/lib/slash-commands"; import { cn } from "@/lib/utils"; const GRAPH_SYSTEM_PROMPT = `You are a Knowledge Graph assistant embedded in Cairn, a note-taking and project management app. @@ -53,29 +54,6 @@ You help users build meaningful connections between their notes, tasks, and proj Remember: Suggest connections actively. Call \`suggest_connections\` whenever there is even a potential relationship to explore!`; -const CHAT_SLASH_COMMANDS = [ - { - name: "archive-chat", - description: "Archive conversation as a note & clear chat", - insertText: "/archive-chat", - }, - { - name: "compact", - description: "Summarise and compact conversation history", - insertText: "/compact", - }, - { - name: "board", - description: "Show all task board columns and cards", - insertText: "List the current task board columns and cards.", - }, - { - name: "review-note", - description: "Ask AI to review a note", - insertText: 'Please review my note "[note title]" and suggest improvements.', - }, -]; - interface ChatPanelProps { prefill?: { text: string; autoSend?: boolean } | null; onPrefillConsumed?: () => void; @@ -96,6 +74,7 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP notes, cards, activeChatThreadId, setActiveChatThreadId, setActiveProject, + customCommands, } = useCairnStore(useShallow((s) => ({ chatOpen: s.chatOpen, activeProjectId: s.activeProjectId, @@ -117,12 +96,17 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP activeChatThreadId: s.activeChatThreadId, setActiveChatThreadId: s.setActiveChatThreadId, setActiveProject: s.setActiveProject, + customCommands: s.customCommands, }))); // threadId is driven by the store so the tab bar can switch threads externally const threadId = activeChatThreadId; const [input, setInput] = useState(""); + const chatCommands = useMemo( + () => getCommandsForScope("chat", customCommands), + [customCommands] + ); const [pendingImages, setPendingImages] = useState>([]); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -649,7 +633,7 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP isLoading={isLoading} disabled={isLoading} placeholder={activeView === "graph" ? "Ask about your knowledge graph…" : "Ask about your project…"} - commands={CHAT_SLASH_COMMANDS} + commands={chatCommands} suggestions={mentionSuggestions} pendingImages={pendingImages} onRemoveImage={handleRemoveImage} diff --git a/src/components/settings/CommandsSettings.tsx b/src/components/settings/CommandsSettings.tsx new file mode 100644 index 000000000..6c1822fb3 --- /dev/null +++ b/src/components/settings/CommandsSettings.tsx @@ -0,0 +1,433 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { Plus, X, Pencil, Check, SlashSquare, Users } from "lucide-react"; +import { useCairnStore } from "@/store"; +import { useShallow } from "zustand/react/shallow"; +import { SettingsGroup } from "./shared"; +import { cn } from "@/lib/utils"; +import { ALL_BUILTIN_COMMANDS } from "@/lib/slash-commands"; +import type { CustomSlashCommand, SlashCommandScope } from "@/types"; + +const SCOPE_LABEL: Record = { + chat: "Chat", + agent: "Agent", + both: "Chat + Agent", +}; + +const SCOPE_OPTIONS: SlashCommandScope[] = ["both", "chat", "agent"]; + +/** Slash command names are inserted as `/name`; keep them clean + unambiguous. */ +function sanitizeName(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/^\/+/, "") + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""); +} + +export function CommandsSettings() { + const { + customCommands, + activeWorkspaceId, + fetchCommands, + createCommand, + updateCommand, + deleteCommand, + } = useCairnStore( + useShallow((s) => ({ + customCommands: s.customCommands, + activeWorkspaceId: s.activeWorkspaceId, + fetchCommands: s.fetchCommands, + createCommand: s.createCommand, + updateCommand: s.updateCommand, + deleteCommand: s.deleteCommand, + })) + ); + + useEffect(() => { + if (activeWorkspaceId) void fetchCommands(activeWorkspaceId); + }, [activeWorkspaceId, fetchCommands]); + + const workspaceCommands = useMemo( + () => customCommands.filter((c) => c.workspaceId === activeWorkspaceId), + [customCommands, activeWorkspaceId] + ); + + const existingNames = useMemo( + () => new Set(workspaceCommands.map((c) => c.name)), + [workspaceCommands] + ); + + return ( +

+ { + if (!activeWorkspaceId) return; + void createCommand({ workspaceId: activeWorkspaceId, ...input }); + }} + /> + + + {workspaceCommands.length === 0 ? ( +
+ +

No custom commands yet.

+

+ Add one above — type / in a chat to use it. +

+
+ ) : ( +
+ {workspaceCommands.map((cmd) => ( + void updateCommand(cmd.id, patch)} + onDelete={() => void deleteCommand(cmd.id)} + /> + ))} +
+ )} +
+ + +
+ {ALL_BUILTIN_COMMANDS.map((cmd) => ( +
+ + /{cmd.name} + +
+
{cmd.description}
+
+ + {SCOPE_LABEL[cmd.scope]} + +
+ ))} +
+
+ + +
+ +
+ Browsing and one-click installing community commands from the{" "} + cairn-community registry is coming soon. You can + already create and share your own commands above. +
+
+
+
+ ); +} + +// ── Create form ─────────────────────────────── + +function CreateCommandForm({ + disabled, + existingNames, + onCreate, +}: { + disabled: boolean; + existingNames: Set; + onCreate: (input: { + name: string; + description: string; + insertText: string; + scope: SlashCommandScope; + }) => void; +}) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [insertText, setInsertText] = useState(""); + const [scope, setScope] = useState("both"); + + const cleanName = sanitizeName(name); + const duplicate = cleanName.length > 0 && existingNames.has(cleanName); + const canSubmit = cleanName.length > 0 && insertText.trim().length > 0 && !duplicate; + + function reset() { + setName(""); + setDescription(""); + setInsertText(""); + setScope("both"); + } + + function submit() { + if (!canSubmit) return; + onCreate({ name: cleanName, description: description.trim(), insertText, scope }); + reset(); + setOpen(false); + } + + if (!open) { + return ( + + ); + } + + return ( +
+
+ New command + +
+ + + + + +