Skip to content
Merged
12 changes: 12 additions & 0 deletions changelogs/v2.4.10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## What's new

Moving a note between projects now sticks, and notes get a right-click menu.

### New

- **Right-click a note for its actions.** The note actions menu (Pin/Unpin, Reveal, Archive, Move to folder, Move to project, Delete) now also opens on right-click anywhere on a note row — you no longer have to hover and hit the small ⋯ button. Archived notes get a right-click menu too (Restore / Delete permanently).

### Fixes

- **Project icons in the "Move to project" list.** The move-to-project picker showed some projects' icon as a raw word (e.g. "Briefcase", "Rocket") instead of the actual icon, because it rendered the stored icon *name* as text. It now draws the proper icon, matching the sidebar and everywhere else.
- **Moving a note to another project now persists.** Moving a note from one project to another appeared to work, but the note would re-appear in its original project a moment later. The move was never written to the database — the note-update path had no way to change a note's project — so the note's `.md` file was also left behind in the old project's folder, and the next refresh, file re-scan, or device sync brought the note back where it started. Moving a note now correctly updates its project (and owning workspace), relocates its `.md` file into the new project's folder (removing the old copy), and refreshes its links and search index for the new location. Undo/redo of a move now persists too.
47 changes: 47 additions & 0 deletions electron/db/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
updateNote,
deleteNote,
getNoteById,
moveNoteToProject,
createColumn,
createCard,
updateCard,
Expand Down Expand Up @@ -187,6 +188,52 @@ describe("updateNote", () => {
});
});

describe("moveNoteToProject", () => {
let db: Database.Database;

beforeEach(() => {
db = makeDb();
seedWorkspace(db);
seedProject(db);
// A second project in a different workspace to move into.
createWorkspace(db, { id: "ws2", name: "Other Workspace" });
createProject(db, { id: "proj2", workspaceId: "ws2", name: "Other Project" });
createNote(db, {
id: "note1",
projectId: "proj1",
workspaceId: "ws1",
title: "Movable Note",
content: "content",
});
});

it("persists the new project_id and workspace_id (regression: updateNote drops them)", () => {
const moved = moveNoteToProject(db, "note1", "proj2", "ws2");
expect(moved.projectId).toBe("proj2");
expect(moved.workspaceId).toBe("ws2");

// Re-read from the DB — the move must be durable, not just on the return value.
const reloaded = getNoteById(db, "note1");
expect(reloaded?.projectId).toBe("proj2");
expect(reloaded?.workspaceId).toBe("ws2");
});

it("does NOT re-surface under the old project after the move", () => {
moveNoteToProject(db, "note1", "proj2", "ws2");
const inOldProject = searchNotes(db, { query: "Movable Note", projectId: "proj1" });
expect(inOldProject.find((n) => n.id === "note1")).toBeUndefined();
const inNewProject = searchNotes(db, { query: "Movable Note", projectId: "proj2" });
expect(inNewProject.find((n) => n.id === "note1")).toBeDefined();
});

it("bumps version and updated_at", async () => {
const before = getNoteById(db, "note1");
await new Promise((r) => setTimeout(r, 5));
const moved = moveNoteToProject(db, "note1", "proj2", "ws2");
expect(moved.updatedAt >= (before?.updatedAt ?? "")).toBe(true);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});

// ── Cards CRUD ────────────────────────────────────────────────────────────

describe("createCard", () => {
Expand Down
18 changes: 18 additions & 0 deletions electron/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,24 @@ export function moveNoteFolder(db: Database.Database, id: string, folder: string
return toNote(db.prepare("SELECT * FROM notes WHERE id = ?").get(id));
}

/**
* Move a note to a different project (and its owning workspace).
* Uses a direct SET rather than updateNote()'s COALESCE list, which has no
* project_id/workspace_id columns at all — so a project move sent through
* updateNote() was silently dropped, leaving the row (and its .md file) in the
* old project and letting a DB refresh / file-watcher re-import / sync reconcile
* resurface the note where it started. Callers must also move the .md file
* (delete the old project's copy, write into the new one) — see the
* db:note:moveToProject IPC handler.
*/
export function moveNoteToProject(db: Database.Database, id: string, projectId: string, workspaceId: string) {
const now = ts();
db.prepare(
"UPDATE notes SET project_id = ?, workspace_id = ?, updated_at = ?, version = version + 1 WHERE id = ?",
).run(projectId, workspaceId, now, id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return toNote(db.prepare("SELECT * FROM notes WHERE id = ?").get(id));
}

/**
* Explicitly clear archived_at for a note (cannot use COALESCE for NULL clears).
*/
Expand Down
47 changes: 47 additions & 0 deletions electron/ipc/db-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,53 @@ export function registerDbHandlers(ctx: DbContext): void {
return note;
}));

registerIpcHandle(
"db:note:moveToProject",
(_e, { id, projectId, workspaceId }: { id: string; projectId: string; workspaceId: string }) =>
handle(() => {
// Moving a note between projects was previously routed through
// updateNote(), whose UPDATE has no project_id/workspace_id columns — so
// the move was silently dropped and the note re-surfaced in the old
// project (via a DB refresh, file-watcher re-import, or sync reconcile).
// Persist the move with a dedicated direct-SET query, and — crucially —
// relocate the .md file: writeNoteFile only unlinks a stale file it finds
// *within the target project's* folder, so the old project's copy must be
// deleted explicitly or the file-watcher will re-import it into the old
// project.
const before = q.getNoteById(ctx.db, id);
if (!before) throw new Error(`Note not found: ${id}`);
const oldProjectName = getProjectName(ctx.db, before.projectId);

suppressNextChange(id);
const note = q.moveNoteToProject(ctx.db, id, projectId, workspaceId);

if (note.type !== "dashboard") {
// Remove the note's file from the OLD project folder (its id-matched
// copy), then write it fresh under the NEW project folder.
if (oldProjectName !== getProjectName(ctx.db, projectId)) {
deleteNoteFile(ctx.workspacePath, oldProjectName, id);
}
writeNoteFile(ctx.workspacePath, { ...note, projectName: getProjectName(ctx.db, note.projectId) });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// Membership changed → auto/semantic relationships and the embedding
// index are scoped by workspace, so recompute for the new workspace.
invalidateRelationshipCache(ctx.db, id);
if (note.workspaceId) {
computeAutoRelationships(ctx.db, note.workspaceId, [id]);
void reindexSingleNoteEmbedding(ctx, id, note.workspaceId).then((didReindex) => {
if (!didReindex) return;
try {
computeSemanticRelationships(ctx.db, note.workspaceId, [id]);
} catch (e) {
console.warn("[embeddings] semantic recompute skipped:", e instanceof Error ? e.message : e);
}
}).catch(() => { /* already warned */ });
}
return note;
}),
);

registerIpcHandle("db:note:delete", (_e, { id }) => handle(() => {
const note = q.getNoteById(ctx.db, id);
if (note) {
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe("isWriteChannel", () => {
"db:note:update",
"db:note:delete",
"db:note:moveToFolder",
"db:note:moveToProject",
"db:card:addBlocker",
"db:card:removeBlocker",
"db:cards:archive-done",
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ const api = {
update: (id: string, patch: unknown) => invoke("db:note:update", { id, patch }),
delete: (id: string) => invoke("db:note:delete", { id }),
moveToFolder: (id: string, folder: string) => invoke("db:note:moveToFolder", { id, folder }),
moveToProject: (id: string, projectId: string, workspaceId: string) =>
invoke("db:note:moveToProject", { id, projectId, workspaceId }),
},

// ── Board columns ─────────────────────────────
Expand Down
19 changes: 19 additions & 0 deletions mobile/app/(tabs)/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
Alert,
ActionSheetIOS,
Platform,
AppState,
type AppStateStatus,
} from "react-native";
import Animated, { useSharedValue, withTiming, useAnimatedStyle, interpolate } from "react-native-reanimated";
import { KeyboardStickyView, KeyboardController, KeyboardChatScrollView, KeyboardGestureArea, useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
Expand Down Expand Up @@ -55,6 +57,7 @@ const NOTE_TOOLS: Record<string, "args" | "result"> = {
append_to_note: "args",
patch_note: "args",
rename_note: "args",
move_note_to_project: "args",
};
/** Tools whose result points at a CARD. */
const CARD_TOOLS: Record<string, "args" | "result"> = {
Expand Down Expand Up @@ -127,6 +130,21 @@ export default function ChatScreen() {
// the Chat tab (it's session state otherwise, lost on unmount).
const [usage, setUsage] = useState<ChatUsage | null>(() => loadLastChatUsage());
const scrollRef = useRef<ComponentRef<typeof KeyboardChatScrollView>>(null);
// Resume-repaint key. iOS composites its cached UIKit snapshot over the live
// hierarchy when the app returns from background; the composer's native Liquid
// Glass layer (GlassBar → GlassView) samples a backdrop that stays stale until
// the next paint, leaving translucent vertical "ghost bands" over the
// transformed scroll content (tool chips + avatar column) until the user
// interacts. Bumping this key on the "active" transition remounts that content
// so the stale backdrop is invalidated immediately. Only glass builds are
// affected, so it's a no-op remount elsewhere.
const [resumeKey, setResumeKey] = useState(0);
useEffect(() => {
const sub = AppState.addEventListener("change", (next: AppStateStatus) => {
if (next === "active") setResumeKey((k) => k + 1);
});
return () => sub.remove();
}, []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Whether the user is at/near the bottom of the transcript. Auto-follow on
// content growth only when true, so expanding a past message's reasoning block
// (or other layout changes while scrolled up) doesn't yank the view to the end.
Expand Down Expand Up @@ -402,6 +420,7 @@ export default function ChatScreen() {
style={StyleSheet.absoluteFill}
>
<KeyboardChatScrollView
key={resumeKey}
ref={scrollRef}
style={StyleSheet.absoluteFill}
contentContainerStyle={[styles.list, messages.length === 0 && styles.listEmpty]}
Expand Down
5 changes: 4 additions & 1 deletion mobile/changelogs/v0.1.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Polish for the workspace switcher and Knowledge Graph controls.

### New

- **Long-press a note to move, pin, or delete it**: Press and hold any note in a project's Notes list to open a quick actions menu — **Pin/Unpin**, **Move to project**, **Move to folder**, or **Delete** — mirroring the note menu on desktop. "Move to project" and "Move to folder" open a picker of your other projects / this project's folders. This is the first way to move a note between projects on mobile (there was previously no manual way to do it at all).
- **Project Overview on mobile**: Opening a project now starts on a new **Overview** tab (alongside Notes and Board) that gives you an at-a-glance snapshot — a completion ring, note / open-task / overdue counts, a breakdown of tasks by column and by priority, what's due soon, your pinned and recent notes, and a recent-activity feed. Tapping anything jumps straight to it. This mirrors the desktop project Overview.
- **Drag to reschedule on the calendar**: Long-press a task on the calendar and drag it onto another day to change its due date — the same lift-and-drop gesture as the board. There's also a new **Unscheduled** tray at the bottom listing tasks with no due date: drag one onto a day to schedule it, or drag a dated task into the tray to clear its due date. Available on both the workspace and per-project calendars.

Expand All @@ -16,6 +17,8 @@ Polish for the workspace switcher and Knowledge Graph controls.

### Fixes

- **The assistant can dig much deeper before giving up**: Chat could previously stop after only 8 steps with "I couldn't finish that in a reasonable number of steps." — too few for the assistant's introspective research, which often chains many single reads (open a note, search, read a section, follow a link…). It now allows up to 30 steps, matching the desktop app, so multi-step research and edits complete instead of cutting off early. The message shown if it ever does hit the limit is clearer, and notes any changes already made were saved.
- **No more ghost bands after reopening Chat**: Returning to the Chat screen after the app had been backgrounded could briefly show faint vertical bands over the messages (an iOS snapshot artifact from the frosted composer). Chat now repaints on resume so they no longer appear.
- **Reschedule overdue tasks by dragging**: Overdue chips in the calendar's overdue tray can now be long-pressed and dragged onto a day (or into the Unscheduled tray) to reschedule or clear their due date — same as tasks in the grid.
- **Consistent calendar header**: The Today and Month/Week controls now sit in the top navigation bar on the per-project calendar too, matching the Calendar tab — the calendar looks the same however you open it.
- **Chat attachment icon stays put**: The image-attachment button in the chat composer no longer jumps up out of place after you tap it while the keyboard is open — it's now a plain button (opening a native photo action sheet) that rides the keyboard exactly like the send button, instead of a native menu control that anchored to the screen and drifted when its menu opened.
Expand All @@ -37,7 +40,7 @@ Polish for the workspace switcher and Knowledge Graph controls.

- **Open notes and tasks straight from chat**: Chat is now far more reliable at jumping to what it mentions. When the assistant creates or touches a note or task, its little tool chip is now tappable and takes you straight there. `[[Wikilinks]]` in replies now open **tasks** too (not just notes), and both are matched to the exact item at the moment they're shown — so taps no longer occasionally open the wrong item or quietly do nothing.

- **The assistant can now manage tasks and organise notes**: Chat gained several tools it was missing — it can open a task's full detail, edit a task (including moving it to another column, changing priority, due date or assignee), rename a note (fixing links to it automatically), move notes into folders, list a project's folders, and delete a note or task. Previously it could only read and create/append to notes and tasks.
- **The assistant can now manage tasks and organise notes**: Chat gained several tools it was missing — it can open a task's full detail, edit a task (including moving it to another column, changing priority, due date or assignee), rename a note (fixing links to it automatically), move notes into folders, **move a note to another project**, list a project's folders, and delete a note or task. Previously it could only read and create/append to notes and tasks.

### Under the hood

Expand Down
9 changes: 7 additions & 2 deletions mobile/src/chat/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import {
} from "./providers/types";
import { toolsForAgent, TOOL_MAP } from "./tools";

const MAX_TURNS = 8;
// Max model round-trips per user turn. Each turn is one model call; a turn that
// requests tools runs them all, then loops for the model's follow-up (which sees
// the outputs). Introspective research chains many single-tool reads (get note →
// search → get note range → …), so this must be generous — matched to the
// desktop default (DEFAULT_AGENT_CONFIG.maxSteps in src/lib/constants.ts).
const MAX_TURNS = 30;

export interface AgentEvent {
type: "text-delta" | "reasoning-delta" | "tool" | "final" | "error";
Expand Down Expand Up @@ -236,7 +241,7 @@ export async function runAgent(
}
}

const msg = finalText || "I couldn't finish that in a reasonable number of steps.";
const msg = finalText || `I reached the maximum of ${MAX_TURNS} steps. Any changes made have been saved — try a more focused request.`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
onEvent?.({ type: "final", text: msg, reasoning: reasoning || undefined, usage });
return msg;
}
Expand Down
8 changes: 8 additions & 0 deletions mobile/src/chat/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ export const TOOLS: ToolDef[] = [
jsonSchema: obj({ note_ids: { type: "array", items: S }, folder: S }, ["note_ids", "folder"]),
run: (a) => ({ moved: q.moveNotesToFolder(Array.isArray(a.note_ids) ? a.note_ids.map(str) : [], str(a.folder)) }),
},
{
name: "move_note_to_project",
description:
"Move a note to a different project (call get_cairn_context for project ids). The note's owning workspace is updated automatically. Returns the new project_id/workspace_id, or an error.",
params: '{ "id": string, "project_id": string }',
jsonSchema: obj({ id: S, project_id: S }, ["id", "project_id"]),
run: (a) => q.moveNoteToProject(str(a.id), str(a.project_id)),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{
name: "list_folders",
description: "List all folder paths used by a project's notes.",
Expand Down
Loading