diff --git a/desktop/src/apps/NotesApp.test.tsx b/desktop/src/apps/NotesApp.test.tsx
index f364a92d0..f723f76c7 100644
--- a/desktop/src/apps/NotesApp.test.tsx
+++ b/desktop/src/apps/NotesApp.test.tsx
@@ -1,6 +1,6 @@
import { render, screen, act, waitFor, within, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { NotesApp, TodoApp } from "./NotesApp";
+import { NotesApp } from "./NotesApp";
function mockFetch(
resolver: (url: string, init?: RequestInit) => { ok: boolean; status?: number; body: unknown },
@@ -33,14 +33,6 @@ const noteDoc = {
archived_at: null,
};
-const listDoc = {
- id: "list-1",
- kind: "list" as const,
- title: "Sprint backlog",
- updated_at: past(10),
- archived_at: null,
-};
-
describe("NotesApp", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -62,23 +54,6 @@ describe("NotesApp", () => {
expect(screen.getByRole("button", { name: /create one/i })).toBeTruthy();
});
- it("renders only notes, filtering out lists that share the same API", async () => {
- vi.stubGlobal(
- "fetch",
- mockFetch(() => ({ ok: true, body: [noteDoc, listDoc] })),
- );
- render();
- await flush();
-
- await waitFor(() =>
- expect(screen.getByText("Project kickoff")).toBeTruthy(),
- );
- // The list doc lives in the same store but NotesApp must hide it.
- expect(screen.queryByText("Sprint backlog")).toBeNull();
- // The relative updated time is rendered from updated_at.
- expect(screen.getByText(/30m ago/i)).toBeTruthy();
- });
-
it("loads the selected note's detail via /api/notes/:id", async () => {
const fetchMock = mockFetch((url) => {
if (url === "/api/notes") {
@@ -178,27 +153,3 @@ describe("NotesApp", () => {
});
});
-describe("TodoApp", () => {
- beforeEach(() => {
- vi.restoreAllMocks();
- });
-
- it("renders only lists, filtering out notes that share the same API", async () => {
- vi.stubGlobal(
- "fetch",
- mockFetch(() => ({ ok: true, body: [noteDoc, listDoc] })),
- );
- render();
- await flush();
-
- await waitFor(() => expect(screen.getByText("Sprint backlog")).toBeTruthy());
- expect(screen.queryByText("Project kickoff")).toBeNull();
- });
-
- it("shows the todo empty state when there are no lists", async () => {
- vi.stubGlobal("fetch", mockFetch(() => ({ ok: true, body: [] })));
- render();
- await flush();
- await waitFor(() => expect(screen.getByText(/no lists yet/i)).toBeTruthy());
- });
-});
diff --git a/desktop/src/apps/NotesApp.tsx b/desktop/src/apps/NotesApp.tsx
index 7b6f599c4..8b1391d89 100644
--- a/desktop/src/apps/NotesApp.tsx
+++ b/desktop/src/apps/NotesApp.tsx
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
StickyNote,
- ListChecks,
Plus,
Clock,
Users,
@@ -23,7 +22,7 @@ import { Button, Textarea } from "@/components/ui";
interface NoteDoc {
id: string;
- kind: "note" | "list";
+ kind: "note";
title: string;
updated_at: string;
archived_at: string | null;
@@ -47,7 +46,7 @@ interface NoteMember {
interface NoteDetail {
id: string;
- kind: "note" | "list";
+ kind: "note";
title: string;
updated_at: string;
entries: NoteEntry[];
@@ -89,12 +88,11 @@ const ACTION_OPTIONS = [
] as const;
// ---- Kind config ----
-// Notes and lists share one component, one store, and one API; they differ only
-// in copy, icon, and whether entries carry a done checkbox. A config object
-// keeps both variants in sync instead of forking ~1000 lines.
+// NotesApp is now notes-only (todos live in TodoApp). The config object
+// keeps copy and behaviour centralised instead of scattering magic strings.
interface DocKindConfig {
- kind: "note" | "list";
+ kind: "note";
appName: string; // header + listing aria
icon: LucideIcon;
noun: string; // doc-level aria: New {noun}, Create {noun}, Share {noun}
@@ -119,19 +117,6 @@ const NOTES_CONFIG: DocKindConfig = {
showDone: false,
};
-const TODO_CONFIG: DocKindConfig = {
- kind: "list",
- appName: "Todo",
- icon: ListChecks,
- noun: "list",
- titlePlaceholder: "List title...",
- addPlaceholder: "Add a task...",
- emptyDocs: "No lists yet.",
- emptyEntries: "Nothing here yet. Add your first task above.",
- selectPrompt: "Select a list to get started.",
- showDone: true,
-};
-
// ---- Sub-components ----
function MemberBadge({ member }: { member: NoteMember }) {
@@ -334,7 +319,7 @@ function SharePanel({ doc, onClose }: { doc: NoteDetail; onClose: () => void })
@@ -1171,7 +1156,3 @@ function DocsApp({ config }: { config: DocKindConfig }) {
export function NotesApp({ windowId: _windowId }: { windowId: string }) {
return ;
}
-
-export function TodoApp({ windowId: _windowId }: { windowId: string }) {
- return ;
-}
diff --git a/desktop/src/apps/TodoApp.test.tsx b/desktop/src/apps/TodoApp.test.tsx
new file mode 100644
index 000000000..dff67d57c
--- /dev/null
+++ b/desktop/src/apps/TodoApp.test.tsx
@@ -0,0 +1,474 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import React from "react";
+
+vi.mock("@/components/ui", () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ ...rest
+ }: React.ButtonHTMLAttributes & { children?: React.ReactNode }) => (
+
+ ),
+}));
+
+import { TodoApp } from "./TodoApp";
+
+// ---- Test data ----
+
+const NOW_TS = Math.floor(Date.now() / 1000);
+
+const TODO_LIST_A = {
+ id: "tl-abc",
+ owner_user_id: "user-1",
+ title: "Groceries",
+ created_at: NOW_TS - 3600,
+ updated_at: NOW_TS - 600,
+ archived_at: null,
+};
+
+const TODO_LIST_B = {
+ id: "tl-def",
+ owner_user_id: "user-1",
+ title: "Work Tasks",
+ created_at: NOW_TS - 7200,
+ updated_at: NOW_TS - 300,
+ archived_at: null,
+};
+
+const TODO_DETAIL = {
+ ...TODO_LIST_A,
+ items: [
+ {
+ id: "ti-1",
+ list_id: "tl-abc",
+ text: "Buy milk",
+ done: false,
+ position: 0,
+ due_at: null,
+ remind_at: null,
+ author: "user-1",
+ created_at: NOW_TS - 300,
+ updated_at: NOW_TS - 300,
+ },
+ {
+ id: "ti-2",
+ list_id: "tl-abc",
+ text: "Get bread",
+ done: false,
+ position: 1,
+ due_at: NOW_TS + 86400, // tomorrow
+ remind_at: null,
+ author: "",
+ created_at: NOW_TS - 200,
+ updated_at: NOW_TS - 200,
+ },
+ {
+ id: "ti-3",
+ list_id: "tl-abc",
+ text: "Pick up laundry",
+ done: false,
+ position: 2,
+ due_at: NOW_TS - 3600, // 1 hour ago — overdue
+ remind_at: null,
+ author: "",
+ created_at: NOW_TS - 100,
+ updated_at: NOW_TS - 50,
+ },
+ {
+ id: "ti-4",
+ list_id: "tl-abc",
+ text: "Already done",
+ done: true,
+ position: 3,
+ due_at: null,
+ remind_at: null,
+ author: "",
+ created_at: NOW_TS - 50,
+ updated_at: NOW_TS - 25,
+ },
+ ],
+};
+
+function makeFetch(overrides: Record = {}) {
+ return vi.fn(async (url: string, init?: RequestInit) => {
+ const u = String(url);
+ const method = (init?.method ?? "GET").toUpperCase();
+
+ // List lists
+ if (u === "/api/todo" && method === "GET") {
+ return {
+ ok: true,
+ json: async () =>
+ overrides["GET /api/todo"] ?? [TODO_LIST_A, TODO_LIST_B],
+ };
+ }
+
+ // Create list
+ if (u === "/api/todo" && method === "POST") {
+ const created = overrides["POST /api/todo"] ?? {
+ id: "tl-new",
+ owner_user_id: "user-1",
+ title: "New List",
+ created_at: NOW_TS,
+ updated_at: NOW_TS,
+ archived_at: null,
+ };
+ return { ok: true, json: async () => created };
+ }
+
+ // Get list detail
+ if (u === "/api/todo/tl-abc" && method === "GET") {
+ return {
+ ok: true,
+ json: async () => overrides["GET /api/todo/tl-abc"] ?? TODO_DETAIL,
+ };
+ }
+
+ // Add item
+ if (u === "/api/todo/tl-abc/items" && method === "POST") {
+ const body = JSON.parse((init?.body as string) ?? "{}");
+ return {
+ ok: true,
+ json: async () => ({
+ id: "ti-new",
+ list_id: "tl-abc",
+ text: body.text,
+ done: false,
+ position: 3,
+ due_at: null,
+ remind_at: null,
+ author: "",
+ created_at: NOW_TS,
+ updated_at: NOW_TS,
+ }),
+ };
+ }
+
+ // Patch item (toggle done, edit text)
+ if (u.startsWith("/api/todo/tl-abc/items/") && method === "PATCH") {
+ const body = JSON.parse((init?.body as string) ?? "{}");
+ const itemId = u.split("/").pop();
+ return {
+ ok: true,
+ json: async () => ({
+ id: itemId,
+ list_id: "tl-abc",
+ text: body.text ?? "Buy milk",
+ done: body.done ?? false,
+ position: 0,
+ due_at: null,
+ remind_at: null,
+ author: "",
+ created_at: NOW_TS - 300,
+ updated_at: NOW_TS,
+ }),
+ };
+ }
+
+ // Delete item
+ if (u.startsWith("/api/todo/tl-abc/items/") && method === "DELETE") {
+ return { ok: true, json: async () => ({ ok: true }) };
+ }
+
+ // Reorder
+ if (u === "/api/todo/tl-abc/items/reorder" && method === "PUT") {
+ return { ok: true, json: async () => ({ ok: true }) };
+ }
+
+ return { ok: true, json: async () => ({}) };
+ });
+}
+
+describe("TodoApp", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // ---- List rendering ----
+
+ it("renders the todo lists from GET /api/todo", async () => {
+ global.fetch = makeFetch() as typeof fetch;
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Groceries")).toBeDefined();
+ expect(screen.getByText("Work Tasks")).toBeDefined();
+ });
+ });
+
+ it("shows the Todo header and new-list button", async () => {
+ global.fetch = makeFetch() as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Todo")).toBeDefined());
+ expect(screen.getByLabelText("New list")).toBeDefined();
+ });
+
+ it("shows the empty state when there are no lists", async () => {
+ global.fetch = makeFetch({ "GET /api/todo": [] }) as typeof fetch;
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByText(/no lists yet/i)).toBeDefined(),
+ );
+ expect(screen.getByText(/create one/i)).toBeDefined();
+ });
+
+ // ---- Create list ----
+
+ it("creates a list via POST /api/todo and shows it", async () => {
+ const fetchMock = makeFetch({
+ "POST /api/todo": {
+ id: "tl-shopping",
+ owner_user_id: "user-1",
+ title: "Shopping",
+ created_at: NOW_TS,
+ updated_at: NOW_TS,
+ archived_at: null,
+ },
+ });
+ global.fetch = fetchMock as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+
+ // Open create form
+ fireEvent.click(screen.getByLabelText("New list"));
+ const input = await screen.findByLabelText("New list title");
+ fireEvent.change(input, { target: { value: "Shopping" } });
+ fireEvent.click(screen.getByLabelText("Create list"));
+
+ await waitFor(() => {
+ const postCalls = (
+ fetchMock.mock.calls as [string, RequestInit?][]
+ ).filter(
+ ([u, init]) =>
+ String(u) === "/api/todo" &&
+ (init?.method ?? "GET").toUpperCase() === "POST",
+ );
+ expect(postCalls.length).toBeGreaterThan(0);
+ const body = JSON.parse(postCalls[0]![1]!.body as string);
+ expect(body.title).toBe("Shopping");
+ });
+
+ await waitFor(() => expect(screen.getByText("Shopping")).toBeDefined());
+ });
+
+ // ---- Item display ----
+
+ it("shows items when a list is selected", async () => {
+ global.fetch = makeFetch() as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Buy milk")).toBeDefined();
+ expect(screen.getByText("Get bread")).toBeDefined();
+ });
+ });
+
+ it("splits incomplete and completed items into sections", async () => {
+ global.fetch = makeFetch() as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Incomplete tasks")).toBeDefined();
+ expect(screen.getByLabelText("Completed tasks")).toBeDefined();
+ expect(screen.getByText(/completed/i)).toBeDefined();
+ });
+
+ // Completed item should show in completed section
+ const completedSection = screen.getByLabelText("Completed tasks");
+ expect(completedSection).toBeDefined();
+ // "Already done" has done:true — should be in the completed section
+ expect(screen.getByText("Already done")).toBeDefined();
+ // "Pick up laundry" has done:false — should be in the incomplete section
+ const incompleteSection = screen.getByLabelText("Incomplete tasks");
+ expect(incompleteSection.textContent).toContain("Pick up laundry");
+ });
+
+ // ---- Toggle done (optimistic) ----
+
+ it("toggles a task done via PATCH and shows optimistic update", async () => {
+ const fetchMock = makeFetch();
+ global.fetch = fetchMock as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined());
+
+ // Click the checkbox for "Buy milk"
+ const checkboxes = screen.getAllByLabelText("Mark task done");
+ fireEvent.click(checkboxes[0]!);
+
+ await waitFor(() => {
+ const patchCalls = (
+ fetchMock.mock.calls as [string, RequestInit?][]
+ ).filter(
+ ([u, init]) =>
+ String(u).startsWith("/api/todo/tl-abc/items/ti-1") &&
+ (init?.method ?? "GET").toUpperCase() === "PATCH",
+ );
+ expect(patchCalls.length).toBeGreaterThan(0);
+ const body = JSON.parse(patchCalls[0]![1]!.body as string);
+ expect(body.done).toBe(true);
+ });
+ });
+
+ // ---- Delete item ----
+
+ it("deletes an item via DELETE", async () => {
+ const fetchMock = makeFetch();
+ global.fetch = fetchMock as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined());
+
+ // Find delete button (hover-revealed, but still in DOM)
+ const deleteButtons = screen.getAllByLabelText("Delete task");
+ fireEvent.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ const delCalls = (
+ fetchMock.mock.calls as [string, RequestInit?][]
+ ).filter(
+ ([u, init]) =>
+ String(u).startsWith("/api/todo/tl-abc/items/ti-1") &&
+ (init?.method ?? "GET").toUpperCase() === "DELETE",
+ );
+ expect(delCalls.length).toBeGreaterThan(0);
+ });
+ });
+
+ // ---- Add item ----
+
+ it("adds a new item via POST /items", async () => {
+ const fetchMock = makeFetch();
+ global.fetch = fetchMock as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined());
+
+ const input = screen.getByLabelText("New task text");
+ fireEvent.change(input, { target: { value: "Buy eggs" } });
+ fireEvent.click(screen.getByLabelText("Add task"));
+
+ await waitFor(() => {
+ const postCalls = (
+ fetchMock.mock.calls as [string, RequestInit?][]
+ ).filter(
+ ([u, init]) =>
+ String(u) === "/api/todo/tl-abc/items" &&
+ (init?.method ?? "GET").toUpperCase() === "POST",
+ );
+ expect(postCalls.length).toBeGreaterThan(0);
+ const body = JSON.parse(postCalls[0]![1]!.body as string);
+ expect(body.text).toBe("Buy eggs");
+ });
+ });
+
+ // ---- Due date display + overdue highlighting ----
+
+ it("displays due dates and highlights overdue items", async () => {
+ global.fetch = makeFetch() as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => {
+ // "Get bread" has a tomorrow due date — should show but not overdue
+ expect(screen.getByText("Get bread")).toBeDefined();
+ // Overdue text indicator for "Pick up laundry" (overdue + done — shows in completed section)
+ expect(screen.getByText(/overdue/i)).toBeDefined();
+ });
+ });
+
+ // ---- Move up/down ----
+
+ it("reorders items via PUT /reorder", async () => {
+ const fetchMock = makeFetch();
+ global.fetch = fetchMock as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined());
+
+ // "Buy milk" is position 0, so move-up should be disabled.
+ // Move it down (to pos 1)
+ const moveDownButtons = screen.getAllByLabelText("Move task down");
+ fireEvent.click(moveDownButtons[0]!);
+
+ await waitFor(() => {
+ const reorderCalls = (
+ fetchMock.mock.calls as [string, RequestInit?][]
+ ).filter(
+ ([u, init]) =>
+ String(u) === "/api/todo/tl-abc/items/reorder" &&
+ (init?.method ?? "GET").toUpperCase() === "PUT",
+ );
+ expect(reorderCalls.length).toBeGreaterThan(0);
+ const body = JSON.parse(reorderCalls[0]![1]!.body as string);
+ expect(body.items).toBeDefined();
+ expect(Array.isArray(body.items)).toBe(true);
+ });
+ });
+
+ // ---- Inline edit ----
+
+ it("edits an item inline via PATCH", async () => {
+ const fetchMock = makeFetch();
+ global.fetch = fetchMock as typeof fetch;
+ render();
+
+ await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
+ fireEvent.click(screen.getByText("Groceries"));
+
+ await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined());
+
+ // Click edit button
+ const editButtons = screen.getAllByLabelText("Edit task");
+ fireEvent.click(editButtons[0]!);
+
+ // Edit the text
+ const textarea = await screen.findByLabelText("Edit task text");
+ fireEvent.change(textarea, { target: { value: "Buy almond milk" } });
+ fireEvent.click(screen.getByLabelText("Save edit"));
+
+ await waitFor(() => {
+ const patchCalls = (
+ fetchMock.mock.calls as [string, RequestInit?][]
+ ).filter(
+ ([u, init]) =>
+ String(u).startsWith("/api/todo/tl-abc/items/ti-1") &&
+ (init?.method ?? "GET").toUpperCase() === "PATCH",
+ );
+ expect(patchCalls.length).toBeGreaterThan(0);
+ const body = JSON.parse(patchCalls[0]![1]!.body as string);
+ expect(body.text).toBe("Buy almond milk");
+ });
+ });
+});
diff --git a/desktop/src/apps/TodoApp.tsx b/desktop/src/apps/TodoApp.tsx
new file mode 100644
index 000000000..bb8d34f8c
--- /dev/null
+++ b/desktop/src/apps/TodoApp.tsx
@@ -0,0 +1,899 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import {
+ ListChecks,
+ Plus,
+ Clock,
+ Square,
+ CheckSquare,
+ ChevronLeft,
+ Pencil,
+ Trash2,
+ Check,
+ X,
+ ChevronUp,
+ ChevronDown,
+ AlertCircle,
+} from "lucide-react";
+import { Button } from "@/components/ui";
+import { withCsrf } from "@/lib/csrf";
+
+// ---- Types ----
+
+interface TodoList {
+ id: string;
+ owner_user_id: string;
+ title: string;
+ created_at: number;
+ updated_at: number;
+ archived_at: number | null;
+}
+
+interface TodoItem {
+ id: string;
+ list_id: string;
+ text: string;
+ done: boolean;
+ position: number;
+ due_at: number | null;
+ remind_at: number | null;
+ author: string;
+ created_at: number;
+ updated_at: number;
+}
+
+interface TodoDetail extends TodoList {
+ items: TodoItem[];
+}
+
+// ---- Helpers ----
+
+function relativeTime(ts: number): string {
+ const diff = Date.now() - ts * 1000;
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ return `${days}d ago`;
+}
+
+function formatDueDate(ts: number): string {
+ const d = new Date(ts * 1000);
+ const now = new Date();
+ const isToday =
+ d.getFullYear() === now.getFullYear() &&
+ d.getMonth() === now.getMonth() &&
+ d.getDate() === now.getDate();
+ const tomorrow = new Date(now);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const isTomorrow =
+ d.getFullYear() === tomorrow.getFullYear() &&
+ d.getMonth() === tomorrow.getMonth() &&
+ d.getDate() === tomorrow.getDate();
+
+ const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ if (isToday) return `Today ${time}`;
+ if (isTomorrow) return `Tomorrow ${time}`;
+ return d.toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function isOverdue(ts: number): boolean {
+ return ts * 1000 < Date.now();
+}
+
+// ---- TodoItemRow ----
+
+function TodoItemRow({
+ item,
+ sectionItems,
+ onToggleDone,
+ onEditSave,
+ onDelete,
+ onMove,
+}: {
+ item: TodoItem;
+ sectionItems: TodoItem[];
+ onToggleDone: (id: string, done: boolean) => void;
+ onEditSave: (id: string, text: string) => Promise;
+ onDelete: (id: string) => void;
+ onMove: (id: string, direction: -1 | 1) => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(item.text);
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ async function save() {
+ if (!draft.trim() || draft === item.text) {
+ setEditing(false);
+ return;
+ }
+ setSaving(true);
+ setSaveError(null);
+ try {
+ await onEditSave(item.id, draft.trim());
+ setEditing(false);
+ } catch (e) {
+ setSaveError(
+ e instanceof Error ? e.message : "Could not save the edit.",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const overdue = item.due_at && isOverdue(item.due_at) && !item.done;
+ const sectionIdx = sectionItems.findIndex((i) => i.id === item.id);
+
+ return (
+
+
+ {editing ? (
+
+ ) : (
+ <>
+ {/* Checkbox */}
+
+
+ {/* Text + due date */}
+
+
+ {item.text}
+
+ {item.due_at && (
+
+
+ {formatDueDate(item.due_at)}
+ {overdue && " · Overdue"}
+
+ )}
+
+
+ {/* Action buttons (visible on hover) */}
+
+ {/* Move up */}
+
+ {/* Move down */}
+
+ {/* Edit */}
+
+ {/* Delete */}
+
+
+ >
+ )}
+
+ {item.author && (
+
+ {item.author} · {relativeTime(item.created_at)}
+
+ )}
+
+ );
+}
+
+// ---- TodoDetailPane ----
+
+function TodoDetailPane({
+ listId,
+ onBack,
+}: {
+ listId: string;
+ onBack: () => void;
+}) {
+ const [doc, setDoc] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [newText, setNewText] = useState("");
+ const [adding, setAdding] = useState(false);
+ const pendingToggles = useRef>(new Set());
+
+ const loadReqRef = useRef(0);
+ const loadDoc = useCallback(async () => {
+ const myReq = ++loadReqRef.current;
+ try {
+ const r = await fetch(`/api/todo/${listId}`);
+ if (!r.ok) throw new Error("Could not load list.");
+ const raw = await r.json();
+ const data: TodoDetail = {
+ ...raw,
+ items: Array.isArray(raw.items) ? raw.items : [],
+ };
+ if (loadReqRef.current === myReq) {
+ setDoc(data);
+ setError(null);
+ }
+ } catch (e) {
+ if (loadReqRef.current === myReq)
+ setError(e instanceof Error ? e.message : "Could not load list.");
+ } finally {
+ if (loadReqRef.current === myReq) setLoading(false);
+ }
+ }, [listId]);
+
+ useEffect(() => {
+ setLoading(true);
+ setError(null);
+ setDoc(null);
+ loadDoc();
+ }, [loadDoc]);
+
+ async function addItem() {
+ if (!newText.trim() || !doc || adding) return;
+ setAdding(true);
+ try {
+ const r = await fetch(
+ `/api/todo/${doc.id}/items`,
+ withCsrf({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: newText.trim() }),
+ }),
+ );
+ if (!r.ok) throw new Error("Could not add task.");
+ setNewText("");
+ setError(null);
+ await loadDoc();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Could not add task.");
+ } finally {
+ setAdding(false);
+ }
+ }
+
+ async function deleteItem(itemId: string) {
+ if (!doc) return;
+ try {
+ const r = await fetch(
+ `/api/todo/${doc.id}/items/${itemId}`,
+ withCsrf({ method: "DELETE" }),
+ );
+ if (!r.ok) throw new Error("Could not delete task.");
+ setError(null);
+ setDoc((prev) =>
+ prev
+ ? { ...prev, items: prev.items.filter((i) => i.id !== itemId) }
+ : prev,
+ );
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Could not delete task.");
+ }
+ }
+
+ async function editItem(itemId: string, text: string) {
+ if (!doc) return;
+ const r = await fetch(
+ `/api/todo/${doc.id}/items/${itemId}`,
+ withCsrf({
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text }),
+ }),
+ );
+ if (!r.ok) throw new Error("Could not edit task.");
+ setDoc((prev) =>
+ prev
+ ? {
+ ...prev,
+ items: prev.items.map((i) =>
+ i.id === itemId ? { ...i, text } : i,
+ ),
+ }
+ : prev,
+ );
+ }
+
+ async function toggleDone(itemId: string, done: boolean) {
+ if (!doc || pendingToggles.current.has(itemId)) return;
+ pendingToggles.current.add(itemId);
+ // Optimistic update
+ setDoc((prev) =>
+ prev
+ ? {
+ ...prev,
+ items: prev.items.map((i) =>
+ i.id === itemId ? { ...i, done } : i,
+ ),
+ }
+ : prev,
+ );
+ try {
+ const r = await fetch(
+ `/api/todo/${doc.id}/items/${itemId}`,
+ withCsrf({
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ done }),
+ }),
+ );
+ if (!r.ok) throw new Error("Could not update task.");
+ setError(null);
+ } catch (e) {
+ // Revert on failure
+ setDoc((prev) =>
+ prev
+ ? {
+ ...prev,
+ items: prev.items.map((i) =>
+ i.id === itemId ? { ...i, done: !done } : i,
+ ),
+ }
+ : prev,
+ );
+ setError(e instanceof Error ? e.message : "Could not update task.");
+ } finally {
+ pendingToggles.current.delete(itemId);
+ }
+ }
+
+ async function moveItem(itemId: string, direction: -1 | 1, sectionItems: TodoItem[]) {
+ if (!doc) return;
+ const docItems: TodoItem[] = Array.isArray(doc.items) ? doc.items : [];
+ const sectionIdx = sectionItems.findIndex((i) => i.id === itemId);
+ if (sectionIdx < 0) return;
+ const newSectionIdx = sectionIdx + direction;
+ if (newSectionIdx < 0 || newSectionIdx >= sectionItems.length) return;
+
+ // Swap the two items within the full array by their ids
+ const itemA = sectionItems[sectionIdx]!;
+ const itemB = sectionItems[newSectionIdx]!;
+ const fullIdxA = docItems.findIndex((i) => i.id === itemA.id);
+ const fullIdxB = docItems.findIndex((i) => i.id === itemB.id);
+ if (fullIdxA < 0 || fullIdxB < 0) return;
+
+ const newItems = [...docItems];
+ [newItems[fullIdxA], newItems[fullIdxB]] = [newItems[fullIdxB]!, newItems[fullIdxA]!];
+ const reordered = newItems.map((item, i) => ({
+ ...item,
+ position: i,
+ }));
+
+ // Optimistic update
+ setDoc((prev) => (prev ? { ...prev, items: reordered } : prev));
+
+ try {
+ const r = await fetch(
+ `/api/todo/${doc.id}/items/reorder`,
+ withCsrf({
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ items: reordered.map((i) => ({ id: i.id, position: i.position })),
+ }),
+ }),
+ );
+ if (!r.ok) throw new Error("Could not reorder tasks.");
+ setError(null);
+ } catch (e) {
+ // Revert
+ await loadDoc();
+ setError(e instanceof Error ? e.message : "Could not reorder tasks.");
+ }
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!doc) {
+ return (
+
+
+
+
+ {error ?? "List not found."}
+
+
+
+ );
+ }
+
+ // Separate incomplete and completed items
+ const items: TodoItem[] = Array.isArray(doc.items) ? doc.items : [];
+ const incomplete = items.filter((i) => !i.done);
+ const complete = items.filter((i) => i.done);
+
+ return (
+
+ {/* Header */}
+
+
+
+ {doc.title}
+
+
+
+ {/* Body */}
+
+
+ {/* Error banner */}
+ {error && (
+
+ )}
+
+ {/* Add task input */}
+
+
setNewText(e.target.value)}
+ placeholder="Add a task..."
+ aria-label="New task text"
+ maxLength={20000}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ void addItem();
+ }
+ }}
+ className="flex-1 rounded-lg border border-shell-border bg-shell-surface px-3 py-2 text-sm text-shell-text placeholder:text-shell-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent/40"
+ />
+
+
+
+ {/* Incomplete items */}
+ {incomplete.length > 0 && (
+
+ {incomplete.map((item) => (
+ moveItem(id, dir, incomplete)}
+ />
+ ))}
+
+ )}
+
+ {/* Completed items (collapsed by default) */}
+ {complete.length > 0 && (
+
+
+ Completed ({complete.length})
+
+
+ {complete.map((item) => (
+ moveItem(id, dir, complete)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Empty state */}
+ {items.length === 0 && (
+
+
+
+ Nothing here yet. Add your first task above.
+
+
+ )}
+
+
+
+ );
+}
+
+// ---- TodoListItem ----
+
+function TodoListItem({
+ list,
+ selected,
+ onClick,
+}: {
+ list: TodoList;
+ selected: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+// ---- CreateTodoForm ----
+
+function CreateTodoForm({
+ onCreated,
+ onCancel,
+}: {
+ onCreated: (list: TodoList) => void;
+ onCancel: () => void;
+}) {
+ const [title, setTitle] = useState("");
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState(null);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ async function create() {
+ if (!title.trim() || creating) return;
+ setCreating(true);
+ setError(null);
+ try {
+ const r = await fetch(
+ "/api/todo",
+ withCsrf({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: title.trim() }),
+ }),
+ );
+ if (!r.ok) throw new Error("Could not create list.");
+ const doc: TodoList = await r.json();
+ setCreating(false);
+ onCreated(doc);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Could not create list.");
+ setCreating(false);
+ }
+ }
+
+ return (
+
+
setTitle(e.target.value)}
+ placeholder="List title..."
+ aria-label="New list title"
+ maxLength={255}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ void create();
+ }
+ if (e.key === "Escape") onCancel();
+ }}
+ className="w-full rounded-lg border border-shell-border bg-shell-surface px-3 py-2 text-sm text-shell-text placeholder:text-shell-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent/40"
+ />
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ );
+}
+
+// ---- Main TodoApp ----
+
+export function TodoApp({ windowId: _windowId }: { windowId: string }) {
+ const [lists, setLists] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [listLoadError, setListLoadError] = useState(null);
+ const [selectedId, setSelectedId] = useState(null);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const loadLists = useCallback(async () => {
+ try {
+ const r = await fetch("/api/todo");
+ if (!r.ok) throw new Error("Could not load lists.");
+ const data: unknown = await r.json();
+ setLists(Array.isArray(data) ? (data as TodoList[]) : []);
+ setListLoadError(null);
+ } catch (e) {
+ setListLoadError(e instanceof Error ? e.message : "Could not load lists.");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadLists();
+ }, [loadLists]);
+
+ function handleCreated(doc: TodoList) {
+ setLists((prev) => [doc, ...prev]);
+ setSelectedId(doc.id);
+ setShowCreate(false);
+ }
+
+ return (
+
+ {/* Left pane: list of todo lists */}
+
+ {/* Header */}
+
+
+
+ Todo
+
+
+
+
+ {/* Create form */}
+ {showCreate && (
+
+ setShowCreate(false)}
+ />
+
+ )}
+
+ {/* List body */}
+
+ {loading ? (
+
Loading...
+ ) : listLoadError ? (
+
+ {listLoadError}
+
+ ) : lists.length === 0 ? (
+
+
+
+ No lists yet.
+
+
+
+ ) : (
+
+ {lists.map((list) => (
+ setSelectedId(list.id)}
+ />
+ ))}
+
+ )}
+
+
+
+ {/* Right pane: detail */}
+
+ {selectedId ? (
+
setSelectedId(null)}
+ />
+ ) : (
+
+
+
+ Select a list to get started.
+
+
+ )}
+
+
+ );
+}
diff --git a/desktop/src/apps/__tests__/NotesApp.test.tsx b/desktop/src/apps/__tests__/NotesApp.test.tsx
index 5a4d00e91..48808d30f 100644
--- a/desktop/src/apps/__tests__/NotesApp.test.tsx
+++ b/desktop/src/apps/__tests__/NotesApp.test.tsx
@@ -23,7 +23,7 @@ vi.mock("@/components/ui", () => ({
),
}));
-import { NotesApp, TodoApp } from "../NotesApp";
+import { NotesApp } from "../NotesApp";
const NOTE_LIST = [
{ id: "note-1", kind: "note", title: "My First Note", updated_at: new Date().toISOString(), archived_at: null },
@@ -206,79 +206,3 @@ describe("NotesApp", () => {
});
});
});
-
-// ---- Todo (list) variant ----
-
-const MIXED_LIST = [
- { id: "note-1", kind: "note", title: "My First Note", updated_at: new Date().toISOString(), archived_at: null },
- { id: "list-1", kind: "list", title: "Groceries", updated_at: new Date().toISOString(), archived_at: null },
-];
-
-const LIST_DETAIL = {
- id: "list-1",
- kind: "list",
- title: "Groceries",
- updated_at: new Date().toISOString(),
- entries: [
- { id: "task-1", text: "Buy milk", done: false, author: null, created_at: new Date().toISOString() },
- ],
- members: [],
-};
-
-function makeListFetch() {
- return vi.fn(async (url: string, init?: RequestInit) => {
- const u = String(url);
- const method = (init?.method ?? "GET").toUpperCase();
-
- if (u === "/api/notes" && method === "GET") {
- return { ok: true, json: async () => MIXED_LIST };
- }
- if (u === "/api/notes/list-1" && method === "GET") {
- return { ok: true, json: async () => LIST_DETAIL };
- }
- if (u.startsWith("/api/notes/list-1/entries/task-1") && method === "PATCH") {
- return { ok: true, json: async () => ({ ok: true }) };
- }
- return { ok: true, json: async () => ({}) };
- });
-}
-
-describe("TodoApp", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("shows the Todo header and only list-kind docs", async () => {
- global.fetch = makeListFetch() as typeof fetch;
- render();
-
- await waitFor(() => expect(screen.getByText("Todo")).toBeDefined());
- // A list doc shows; a note doc is filtered out.
- await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
- expect(screen.queryByText("My First Note")).toBeNull();
- expect(screen.getByLabelText("New list")).toBeDefined();
- });
-
- it("toggles a task done via PATCH /entries/{id}", async () => {
- const fetchMock = makeListFetch();
- global.fetch = fetchMock as typeof fetch;
- render();
-
- await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined());
- fireEvent.click(screen.getByText("Groceries"));
-
- await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined());
- fireEvent.click(screen.getByLabelText("Mark task done"));
-
- await waitFor(() => {
- const patch = (fetchMock.mock.calls as [string, RequestInit?][]).filter(
- ([u, init]) =>
- String(u) === "/api/notes/list-1/entries/task-1" &&
- (init?.method ?? "GET").toUpperCase() === "PATCH",
- );
- expect(patch.length).toBeGreaterThan(0);
- const body = JSON.parse(patch[0]![1]!.body as string);
- expect(body.done).toBe(true);
- });
- });
-});
diff --git a/desktop/src/registry/app-registry.ts b/desktop/src/registry/app-registry.ts
index a34453574..7b55cc616 100644
--- a/desktop/src/registry/app-registry.ts
+++ b/desktop/src/registry/app-registry.ts
@@ -66,7 +66,7 @@ const apps: AppManifest[] = [
{ id: "notification-archive", name: "Archive", icon: "archive", category: "platform", component: () => import("@/apps/NotificationArchiveApp").then((m) => ({ default: m.NotificationArchiveApp })), defaultSize: { w: 800, h: 600 }, minSize: { w: 500, h: 400 }, singleton: true, pinned: false, launchpadOrder: 16.65 },
{ id: "observatory", name: "Observatory", icon: "radar", category: "platform", component: () => import("@/apps/ObservatoryApp").then((m) => ({ default: m.ObservatoryApp })), defaultSize: { w: 620, h: 600 }, minSize: { w: 420, h: 400 }, singleton: true, pinned: false, launchpadOrder: 16.7 },
{ id: "notes", name: "Notes", icon: "sticky-note", category: "platform", component: () => import("@/apps/NotesApp").then((m) => ({ default: m.NotesApp })), defaultSize: { w: 860, h: 620 }, minSize: { w: 520, h: 400 }, singleton: true, pinned: false, launchpadOrder: 16.8 },
- { id: "todo", name: "Todo", icon: "list-checks", category: "platform", component: () => import("@/apps/NotesApp").then((m) => ({ default: m.TodoApp })), defaultSize: { w: 860, h: 620 }, minSize: { w: 520, h: 400 }, singleton: true, pinned: false, launchpadOrder: 16.85 },
+ { id: "todo", name: "Todo", icon: "list-checks", category: "platform", component: () => import("@/apps/TodoApp").then((m) => ({ default: m.TodoApp })), defaultSize: { w: 860, h: 620 }, minSize: { w: 520, h: 400 }, singleton: true, pinned: false, launchpadOrder: 16.85 },
{ id: "hub", name: "Hub", icon: "users", category: "platform", component: () => import("@/apps/HubApp/HubApp").then((m) => ({ default: m.HubApp })), defaultSize: { w: 760, h: 640 }, minSize: { w: 480, h: 420 }, singleton: true, pinned: false, launchpadOrder: 16.9 },
// OS apps
diff --git a/docs/design/notes-todo-split.md b/docs/design/notes-todo-split.md
new file mode 100644
index 000000000..fce2285d7
--- /dev/null
+++ b/docs/design/notes-todo-split.md
@@ -0,0 +1,398 @@
+# DESIGN: Split Notes and Todo into Separate Apps (#1923)
+
+## Summary
+
+Split the current shared Notes/Todo implementation into two fully independent
+apps with distinct data models, API surfaces, and frontend components.
+
+**Current state:** One component (`DocsApp` in `NotesApp.tsx`) serving two app
+IDs (`notes`, `todo`) via config switching (`NOTES_CONFIG` / `TODO_CONFIG`).
+One API (`/api/notes`) over one store (`SharedDocsStore`) differentiated by
+`kind` field (`note` vs `list`).
+
+**Target state:** `NotesApp.tsx` + `/api/notes` + `NotesStore` for free-text
+notes. `TodoApp.tsx` + `/api/todo` + `TodoStore` for checklist-oriented lists
+with ordering, completion, and optional due/reminder dates.
+
+---
+
+## 1. Storage Design
+
+### Decision: Split into separate stores
+
+**Rationale:** The two use cases diverge enough that a single schema stretched
+to cover both would be awkward. Todo needs per-item ordering (`position`),
+completion tracking, and future due-date/reminder support. Notes will evolve
+toward rich text, diagrams, and block-based content. Keeping them in one table
+forces every Notes row to carry unused `done`/`position`/`due_at` columns and
+every Todo row to carry block-type metadata it won't use.
+
+The shared collaboration model (members, permissions, agent triggers, revision
+history) will be **extracted into a shared base** rather than duplicated.
+
+### 1a. TodoStore (new)
+
+Dedicated SQLite store at `tinyagentos/todo/todo_store.py`. Schema:
+
+```sql
+CREATE TABLE todo_lists (
+ id TEXT PRIMARY KEY,
+ owner_user_id TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ created_at REAL NOT NULL,
+ updated_at REAL NOT NULL,
+ archived_at REAL
+);
+
+CREATE TABLE todo_items (
+ id TEXT PRIMARY KEY,
+ list_id TEXT NOT NULL REFERENCES todo_lists(id),
+ text TEXT NOT NULL DEFAULT '',
+ done INTEGER NOT NULL DEFAULT 0,
+ position INTEGER NOT NULL DEFAULT 0,
+ due_at REAL, -- optional deadline
+ remind_at REAL, -- optional reminder timestamp
+ author TEXT NOT NULL DEFAULT '',
+ created_at REAL NOT NULL,
+ updated_at REAL NOT NULL
+);
+CREATE INDEX idx_todo_items_list ON todo_items(list_id, position);
+
+-- Share the existing member + revision tables from SharedDocsStore
+-- via a shared_collaboration module (see 1c).
+```
+
+Key differences from current shared_doc_entries:
+- `position` column enables drag-to-reorder
+- `due_at` / `remind_at` for future due-date/reminder features
+- `updated_at` tracked per-item (needed for sync/last-modified display)
+- No generic `kind` column — every row is a todo item
+
+### 1b. NotesStore (refactored from SharedDocsStore)
+
+Keep `tinyagentos/notes/shared_docs_store.py` but rename/refactor:
+
+- Remove `kind` column from `shared_docs` — all docs are notes now
+- Remove `done` from `shared_doc_entries` — notes don't have completion
+- Rename `shared_doc_entries` → `note_blocks` (optional; can defer)
+- The existing entry revision history is preserved **via the shared
+ collaboration module** (see 1c) — it's useful for both apps without
+ duplication
+
+Schema (existing, minus `kind` and `done`):
+
+```sql
+CREATE TABLE notes (
+ id TEXT PRIMARY KEY,
+ owner_user_id TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ created_at REAL NOT NULL,
+ updated_at REAL NOT NULL,
+ archived_at REAL
+);
+
+CREATE TABLE note_entries (
+ id TEXT PRIMARY KEY,
+ note_id TEXT NOT NULL REFERENCES notes(id),
+ text TEXT NOT NULL DEFAULT '',
+ author TEXT NOT NULL DEFAULT '',
+ created_at REAL NOT NULL
+);
+
+-- Member + revision tables shared via shared_collaboration module
+```
+
+### 1c. Shared Collaboration Module (extracted, not duplicated)
+
+Both stores use identical member/permission/revision patterns. Extract these
+into a reusable module at `tinyagentos/collaboration/`:
+
+```
+tinyagentos/collaboration/
+ __init__.py
+ member_store.py -- member CRUD, permission check, discuss_channel
+ revision_store.py -- immutable revision log with diff/snapshot
+ constants.py -- VALID_PERMISSIONS, VALID_ACTIONS
+```
+
+Each store (`todo_store.py`, `notes_store.py`) composes the collaboration
+tables into its own schema string and delegates member/revision operations.
+
+**Why not one generic store:** The todo and notes row shapes differ enough
+(`position`, `due_at` vs no such columns) that a single "generic doc" table
+would require nullable-everything columns and runtime kind-checking. Separate
+stores with shared collaboration plumbing give us type-safe schemas without
+duplicating the member/revision logic.
+
+---
+
+## 2. API Surface
+
+### 2a. Todo API (`/api/todo`)
+
+New route module at `tinyagentos/routes/todo.py`, registered as an APIRouter
+in `create_app()`.
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| GET | `/api/todo` | List user's todo lists (non-archived) |
+| POST | `/api/todo` | Create a new todo list |
+| GET | `/api/todo/{list_id}` | Get list with items (ordered by position) |
+| PATCH | `/api/todo/{list_id}` | Rename or archive list |
+| POST | `/api/todo/{list_id}/items` | Add item (appended at end) |
+| PATCH | `/api/todo/{list_id}/items/{item_id}` | Toggle done, set due date, edit text |
+| DELETE | `/api/todo/{list_id}/items/{item_id}` | Delete item |
+| PUT | `/api/todo/{list_id}/items/reorder` | Batch reorder: `{items: [{id, position}, ...]}` |
+| GET | `/api/todo/{list_id}/members` | List members |
+| POST | `/api/todo/{list_id}/members` | Add member (user or agent) |
+| DELETE | `/api/todo/{list_id}/members/{type}/{id}` | Remove member |
+
+Pydantic models:
+
+```python
+class CreateTodoListIn(BaseModel):
+ title: str = ""
+
+class PatchTodoListIn(BaseModel):
+ title: str | None = None
+ archived: bool | None = None
+
+class AddTodoItemIn(BaseModel):
+ text: str
+ due_at: str | None = None # ISO-8601 timestamp
+ remind_at: str | None = None # ISO-8601 timestamp
+
+class PatchTodoItemIn(BaseModel):
+ text: str | None = None
+ done: bool | None = None
+ due_at: str | None = None
+ remind_at: str | None = None
+
+class ReorderItemsIn(BaseModel):
+ items: list[ReorderEntry]
+
+class ReorderEntry(BaseModel):
+ id: str
+ position: int
+```
+
+**Reorder contract:** The reorder endpoint receives a complete ordering of
+every item in the list — each `id` must appear exactly once, `position`
+values must be unique (no ties), and the entire batch is applied atomically
+within a single transaction. Partial or invalid payloads (missing items,
+duplicate positions, unknown ids) are rejected with `400 Bad Request` and
+make no changes to the stored order.
+
+**Timestamp conversion:** `due_at` and `remind_at` arrive as ISO-8601
+strings (e.g. `"2026-07-18T14:00:00Z"`) from the API. The route handler
+converts them to SQLite REAL (Unix epoch seconds) via
+`datetime.fromisoformat()` (Python ≥ 3.11, which this project requires,
+handles the `Z` suffix natively as UTC). The store writes
+the float timestamp directly into the `REAL` column, matching the existing
+`created_at`/`updated_at` convention used throughout the codebase. On read,
+the store returns the float; the route serializes it back to ISO-8601 for
+the response model.
+
+### 2b. Notes API (`/api/notes`)
+
+Keep existing routes at `/api/notes` with minimal changes:
+
+- Remove `kind` from `CreateDocIn` (all docs are notes now)
+- Remove `PatchEntryIn.done` (notes don't have completion)
+- Entry history endpoints preserved as-is
+- Member endpoints preserved as-is
+
+```python
+class CreateNoteIn(BaseModel): # was CreateDocIn
+ title: str = ""
+
+class AddEntryIn(BaseModel): # unchanged
+ text: str
+
+class EditEntryTextIn(BaseModel): # unchanged
+ text: str
+```
+
+### 2c. Agent tools
+
+| Current Tool | After (rename) | Purpose |
+|------|---------|-------|
+| `notes_list_shared_docs` | → `notes_list_docs` | Lists the agent's notes |
+| `notes_add_entry` | → `notes_add_entry` | Appends to a note |
+| `notes_set_done` | → **removed** from notes | Notes don't have done |
+| (new) `todo_list_lists` | — | Lists the agent's todo lists |
+| (new) `todo_add_item` | — | Appends a todo item |
+| (new) `todo_set_done` | — | Toggles completion on a todo item |
+
+---
+
+## 3. Component Split
+
+### 3a. Current: One component, two configs
+
+`NotesApp.tsx` (1177 lines) exports both `NotesApp` and `TodoApp`. They share
+`DocsApp`, `NoteDetailPane`, `EntryRow`, `SharePanel`, `EntryHistoryPanel`,
+and `CreateNoteForm` — differentiated only by the `DocKindConfig` object.
+
+### 3b. Target: Two separate component trees
+
+```
+desktop/src/apps/
+ NotesApp.tsx ← free-text notes component
+ NotesApp.test.tsx
+ TodoApp.tsx ← checklist-oriented todo component
+ TodoApp.test.tsx
+```
+
+**Shared UI primitives extracted to `desktop/src/apps/notes-shared/`:**
+
+- `MemberBadge.tsx` — unchanged
+- `SharePanel.tsx` — shared via members API (different endpoint per app)
+- `EntryHistoryPanel.tsx` — revision viewer, unchanged internally
+
+**NotesApp specifics:**
+- No done checkboxes
+- No reorder drag handles
+- Textarea-based entry input (existing)
+- Future: rich text editor, diagram canvas, link embeds
+
+**TodoApp specifics:**
+- Checkbox per item (Square/CheckSquare, optimistic toggle)
+- Drag handles for reorder (future: `@dnd-kit` or native HTML5 drag)
+- Due date display + date picker
+- "Add task" input bar (single-line + Enter to add)
+- Visual separation: completed items moved to bottom or collapsible section
+
+### 3c. App registry
+
+No changes to `app-registry.ts` needed — the two app IDs (`notes`, `todo`)
+already exist and will continue to lazy-import their respective components:
+
+```typescript
+// Before (both from same chunk):
+{ id: "notes", component: () => import("@/apps/NotesApp").then(m => ({ default: m.NotesApp })) }
+{ id: "todo", component: () => import("@/apps/NotesApp").then(m => ({ default: m.TodoApp })) }
+
+// After (separate chunks):
+{ id: "notes", component: () => import("@/apps/NotesApp").then(m => ({ default: m.NotesApp })) }
+{ id: "todo", component: () => import("@/apps/TodoApp").then(m => ({ default: m.TodoApp })) }
+```
+
+---
+
+## 4. Migration Plan
+
+### 4a. Database migration
+
+A one-time migration script at `tinyagentos/migrations/migrate_notes_todo_split.py`
+(or as a store `_post_init` migration):
+
+**Step 1:** Create `todo_lists` and `todo_items` tables.
+
+**Step 2:** Copy existing `shared_docs` with `kind = 'list'` into `todo_lists`:
+```sql
+INSERT INTO todo_lists (id, owner_user_id, title, created_at, updated_at, archived_at)
+SELECT id, owner_user_id, title, created_at, updated_at, archived_at
+FROM shared_docs WHERE kind = 'list';
+```
+
+**Step 3:** Copy entries:
+```sql
+INSERT INTO todo_items (id, list_id, text, done, position, author, created_at, updated_at)
+SELECT e.id, e.doc_id, e.text, e.done,
+ (SELECT COUNT(*) FROM shared_doc_entries e2
+ WHERE e2.doc_id = e.doc_id AND e2.created_at <= e.created_at) - 1,
+ e.author, e.created_at, e.created_at
+FROM shared_doc_entries e
+JOIN shared_docs d ON d.id = e.doc_id
+WHERE d.kind = 'list';
+```
+
+`position` is derived from `created_at` order — items retain their existing
+chronological order after migration.
+
+**Step 4:** Copy members for migrated lists into new todo_members table.
+
+**Step 5:** Do NOT delete the source data — leave `shared_docs` rows in place
+as a safety net. The migration is additive. A future cleanup PR can remove
+kind=list rows after the split is stable.
+
+**Step 6:** Remove `kind` column from `shared_docs` (or set all remaining to
+`note`). Drop the `done` column from `shared_doc_entries` after confirming
+no Todo consumers remain on the old tables.
+
+### 4b. Rollout strategy
+
+1. **Phase 1 (C1):** Backend split — `TodoStore`, `routes/todo.py`, migration
+ script. Old `/api/notes` still serves both kinds during transition.
+2. **Phase 2 (C2):** Frontend split — `TodoApp.tsx` hits new `/api/todo`
+ endpoints. `NotesApp.tsx` hits `/api/notes`. Both apps fully functional.
+3. **Phase 3 (C3):** Agent tools — `todo_list_lists`, `todo_add_item`,
+ `todo_set_done`. Remove `notes_set_done`.
+4. **Phase 4 (C4):** Cleanup — remove `kind` from Notes API, drop kind=list
+ support from `/api/notes`, remove old `shared_doc_entries.done` column.
+
+---
+
+## 5. App Registry Changes
+
+Minimal — the two app IDs (`notes`, `todo`) remain but now point to separate
+component chunks:
+
+```typescript
+{ id: "notes", name: "Notes", icon: "sticky-note", category: "platform",
+ component: () => import("@/apps/NotesApp").then(m => ({ default: m.NotesApp })),
+ defaultSize: { w: 860, h: 620 }, minSize: { w: 520, h: 400 },
+ singleton: true, pinned: false, launchpadOrder: 16.8 },
+
+{ id: "todo", name: "Todo", icon: "list-checks", category: "platform",
+ component: () => import("@/apps/TodoApp").then(m => ({ default: m.TodoApp })),
+ defaultSize: { w: 860, h: 620 }, minSize: { w: 520, h: 400 },
+ singleton: true, pinned: false, launchpadOrder: 16.85 },
+```
+
+---
+
+## 6. Child Task Breakdown (C1–C4)
+
+### C1: Backend — TodoStore + routes/todo.py + migration
+**Assignee:** `skald-engineer-taos`
+- Create `tinyagentos/todo/todo_store.py` with TodoStore (BaseStore subclass)
+- Create `tinyagentos/routes/todo.py` with full CRUD + reorder endpoints
+- Register router in `app.py`'s `create_app()`
+- Create migration script or `_post_init` migration
+- Write `tests/todo/test_todo_routes.py`
+
+### C2: Frontend — TodoApp.tsx + NotesApp refactor
+**Assignee:** `skald-engineer-taos`
+- Create `desktop/src/apps/TodoApp.tsx` (checklist UI, drag handles, due dates)
+- Extract shared primitives to `desktop/src/apps/notes-shared/`
+- Refactor `NotesApp.tsx` to remove Todo config, keep Notes only
+- Update app-registry imports
+- Write/update tests
+
+### C3: Agent tools — todo tools + notes cleanup
+**Assignee:** `skald-engineer-taos`
+- Create `tinyagentos/tools/todo_tools.py` with `todo_list_lists`, `todo_add_item`, `todo_set_done`
+- Remove `notes_set_done` from `notes_tools.py`
+- Register new tools in tool registry
+- Write `tests/todo/test_todo_tools.py`
+
+### C4: Cleanup — remove kind, drop done column, final consolidation
+**Assignee:** `skald-engineer-taos`
+- Remove `kind` from Notes API (CreateDocIn, routes, store)
+- Drop `done` column from `shared_doc_entries` (schema migration)
+- Remove kind=list filtering from Notes frontend
+- Update integration tests
+- Run full test gate
+
+---
+
+## 7. Risks and Mitigations
+
+| Risk | Mitigation |
+|------|------------|
+| Migration breaks existing lists | Additive-only migration; old data preserved; both APIs run side-by-side during transition |
+| Frontend regressions | Separate test files for each app; existing tests pass before merge |
+| Agent tools silently break | Tool registry registration is atomic; old tool stays registered until C4 explicitly removes it |
+| Duplicated collaboration logic | Extracted to shared `collaboration/` module in C1; both stores compose it |
diff --git a/tests/notes/test_notes_routes.py b/tests/notes/test_notes_routes.py
index b3ad50a5d..e6e1eeb7b 100644
--- a/tests/notes/test_notes_routes.py
+++ b/tests/notes/test_notes_routes.py
@@ -116,12 +116,6 @@ async def test_create_and_get_doc(client):
assert resp2.json()["id"] == doc_id
-@pytest.mark.asyncio
-async def test_invalid_kind_returns_400(client):
- resp = await client.post("/api/notes", json={"kind": "spreadsheet", "title": "x"})
- assert resp.status_code == 400
-
-
@pytest.mark.asyncio
async def test_patch_doc_title_and_archive(client):
doc = (await client.post("/api/notes", json={"kind": "list", "title": "Old"})).json()
diff --git a/tinyagentos/notes/shared_docs_store.py b/tinyagentos/notes/shared_docs_store.py
index 5ea7fbb2d..8700b4764 100644
--- a/tinyagentos/notes/shared_docs_store.py
+++ b/tinyagentos/notes/shared_docs_store.py
@@ -1,12 +1,12 @@
-"""SQLite-backed store for shared notes and lists.
-
-A document is a note (``kind="note"``) or a list (``kind="list"``). Both are
-ordered collections of entries; a list entry additionally tracks a ``done``
-flag. A document has members: the owner (a user) plus any number of agents,
-each agent share carrying a ``standing_instruction`` that says what the agent
-should do when a new entry appears. The reaction itself is dispatched by the
-route layer; this store only records the data and reports which agents to
-notify after an entry is added.
+"""SQLite-backed store for shared notes.
+
+A document is a note (``kind="note"``). Each note is an ordered collection
+of entries. Todo lists have been split into their own module (``tinyagentos.todo``)
+and use a separate store. A document has members: the owner (a user) plus any
+number of agents, each agent share carrying a ``standing_instruction`` that says
+what the agent should do when a new entry appears. The reaction itself is
+dispatched by the route layer; this store only records the data and reports
+which agents to notify after an entry is added.
Append-only-friendly: archiving sets ``archived_at`` rather than deleting, so
nothing is truly lost (#103).
diff --git a/tinyagentos/routes/notes.py b/tinyagentos/routes/notes.py
index 2f23d7ef9..2b7e925b9 100644
--- a/tinyagentos/routes/notes.py
+++ b/tinyagentos/routes/notes.py
@@ -1,9 +1,12 @@
-"""Shared notes and lists REST API.
+"""Shared notes REST API.
-Documents (notes and lists) are created by a user, who can invite agent
-members. Each agent member carries a standing_instruction that describes
-what the agent should do when a new entry is added. When an entry is
-added, the route posts a message to each agent's DM channel (best-effort).
+Notes are created by a user, who can invite agent members. Each agent
+member carries a standing_instruction that describes what the agent should
+do when a new entry is added. When an entry is added, the route posts a
+message to each agent's DM channel (best-effort).
+
+Todo lists have been split into their own API at ``/api/todo`` using a
+dedicated ``TodoStore`` (``tinyagentos.todo``).
"""
from __future__ import annotations
@@ -35,7 +38,6 @@
# --------------------------------------------------------------------- models
class CreateDocIn(BaseModel):
- kind: str
title: str = ""
@@ -127,7 +129,7 @@ async def create_doc(
):
store = _get_store(request)
try:
- doc = await store.create_doc(user.user_id, body.kind, body.title)
+ doc = await store.create_doc(user.user_id, "note", body.title)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
return doc
diff --git a/tinyagentos/routes/todo.py b/tinyagentos/routes/todo.py
index 132504ebe..f959e8e08 100644
--- a/tinyagentos/routes/todo.py
+++ b/tinyagentos/routes/todo.py
@@ -308,7 +308,10 @@ async def reorder_items(
return err
items = [{"id": e.id, "position": e.position} for e in body.items]
- await store.reorder_items(list_id, items)
+ try:
+ await store.reorder_items(list_id, items)
+ except ValueError as e:
+ return JSONResponse({"error": str(e)}, status_code=400)
return JSONResponse({"ok": True})
diff --git a/tinyagentos/todo/todo_store.py b/tinyagentos/todo/todo_store.py
index 7a27eb43e..710b5fc3e 100644
--- a/tinyagentos/todo/todo_store.py
+++ b/tinyagentos/todo/todo_store.py
@@ -240,14 +240,47 @@ async def delete_item(self, item_id: str) -> None:
await self._db.commit()
async def reorder_items(self, list_id: str, items: list[dict]) -> None:
- """Batch-update positions. Each item: {id: str, position: int}."""
+ """Batch-update positions atomically within a transaction.
+
+ Each item: {id: str, position: int}. All items must belong to
+ *list_id* and positions must be unique — invalid payloads raise
+ ValueError before any writes.
+ """
+ # Validate that all items are present in the list
+ cur = await self._db.execute(
+ "SELECT id FROM todo_items WHERE list_id = ?", (list_id,)
+ )
+ existing_ids = {row[0] for row in await cur.fetchall()}
+ payload_ids = {item["id"] for item in items}
+ if payload_ids != existing_ids:
+ raise ValueError(
+ "Reorder payload must include exactly every item in the list"
+ )
+
+ # Validate unique, contiguous positions
+ positions = [item["position"] for item in items]
+ if len(set(positions)) != len(positions):
+ raise ValueError("Positions must be unique")
+ sorted_positions = sorted(positions)
+ if sorted_positions != list(range(len(items))):
+ raise ValueError(
+ "Positions must be contiguous starting from 0"
+ )
+
now = time.time()
- for item in items:
+ await self._db.execute("BEGIN")
+ try:
+ for item in items:
+ await self._db.execute(
+ "UPDATE todo_items SET position = ?, updated_at = ?"
+ " WHERE id = ? AND list_id = ?",
+ (item["position"], now, item["id"], list_id),
+ )
await self._db.execute(
- "UPDATE todo_items SET position = ?, updated_at = ? WHERE id = ? AND list_id = ?",
- (item["position"], now, item["id"], list_id),
+ "UPDATE todo_lists SET updated_at = ? WHERE id = ?",
+ (now, list_id),
)
- await self._db.execute(
- "UPDATE todo_lists SET updated_at = ? WHERE id = ?", (now, list_id)
- )
- await self._db.commit()
+ await self._db.commit()
+ except Exception:
+ await self._db.execute("ROLLBACK")
+ raise