diff --git a/desktop/src/apps/NotesApp.test.tsx b/desktop/src/apps/NotesApp.test.tsx index f364a92d0..73bc28d9e 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 { render, screen, act, waitFor, 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 }, @@ -177,28 +177,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..d64f80c80 100644 --- a/desktop/src/apps/NotesApp.tsx +++ b/desktop/src/apps/NotesApp.tsx @@ -1,110 +1,19 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { - StickyNote, - ListChecks, - Plus, - Clock, - Users, - Bot, - History, - Pencil, - Trash2, - Check, - X, - Square, - CheckSquare, - AlertCircle, - ChevronLeft, - type LucideIcon, -} from "lucide-react"; -import { Button, Textarea } from "@/components/ui"; - -// ---- Types ---- - -interface NoteDoc { - id: string; - kind: "note" | "list"; - title: string; - updated_at: string; - archived_at: string | null; -} - -interface NoteEntry { - id: string; - text: string; - done: boolean; - author: string | null; - created_at: string; -} - -interface NoteMember { - member_type: "user" | "agent"; - member_id: string; - permission: "viewer" | "contributor" | "editor"; - action: "research" | "plan" | "critique" | "build" | "discuss" | null; - standing_instruction: string | null; -} - -interface NoteDetail { - id: string; - kind: "note" | "list"; - title: string; - updated_at: string; - entries: NoteEntry[]; - members: NoteMember[]; -} - -interface EntryRevision { - rev_index: number; - text: string; - edited_at?: string; - author?: string; -} - -// ---- Helpers ---- - -function relativeTime(ts: string): string { - const diff = Date.now() - Date.parse(ts); - 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`; -} - -const PERMISSION_LABELS: Record = { - viewer: "Viewer", - contributor: "Contributor", - editor: "Editor", -}; - -const ACTION_OPTIONS = [ - { value: "research", label: "Research" }, - { value: "plan", label: "Plan" }, - { value: "critique", label: "Critique" }, - { value: "build", label: "Build" }, - { value: "discuss", label: "Discuss" }, -] 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. - -interface DocKindConfig { - kind: "note" | "list"; - appName: string; // header + listing aria - icon: LucideIcon; - noun: string; // doc-level aria: New {noun}, Create {noun}, Share {noun} - titlePlaceholder: string; - addPlaceholder: string; - emptyDocs: string; - emptyEntries: string; - selectPrompt: string; - showDone: boolean; -} +import { StickyNote } from "lucide-react"; +import { DocsApp, type DocKindConfig } from "./notes-shared-base"; + +/** + * Notes-only configuration for the DocsApp component. + * + * Notes are free-text documents with rich text, diagram, link, and embed + * capabilities. They do not show done checkboxes — that affordance belongs + * to the Todo (list) variant. + * + * Data migration note: + * Existing docs with kind="note" continue to work without changes. + * Docs previously created with kind="list" are handled by TodoApp + * (see TodoApp.tsx), which uses the dedicated /api/todo store. + * No database migration is required for Notes. + */ const NOTES_CONFIG: DocKindConfig = { kind: "note", @@ -119,1059 +28,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 }) { - return ( - - {member.member_type === "agent" ? ( - - ) : ( - - )} - {member.member_id} - - ); -} - -// History panel for a single entry -function EntryHistoryPanel({ docId, entryId, onClose }: { - docId: string; - entryId: string; - onClose: () => void; -}) { - const [revisions, setRevisions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [viewingRev, setViewingRev] = useState<{ index: number; text: string } | null>(null); - - useEffect(() => { - let alive = true; - setLoading(true); - fetch(`/api/notes/${docId}/entries/${entryId}/history`) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Could not load history.")))) - .then((data: EntryRevision[]) => { - if (alive) setRevisions(Array.isArray(data) ? data : []); - }) - .catch((e: Error) => { if (alive) setError(e.message); }) - .finally(() => { if (alive) setLoading(false); }); - return () => { alive = false; }; - }, [docId, entryId]); - - const viewReqRef = useRef(0); - async function viewAt(index: number) { - const myReq = ++viewReqRef.current; - try { - const r = await fetch(`/api/notes/${docId}/entries/${entryId}/history/at/${index}`); - if (!r.ok) throw new Error("Could not load revision."); - const data: EntryRevision = await r.json(); - if (viewReqRef.current === myReq) setViewingRev({ index, text: data.text }); - } catch (e) { - if (viewReqRef.current === myReq) - setError(e instanceof Error ? e.message : "Error loading revision."); - } - } - - return ( -
-
- - - Revision history - - -
- - {loading &&

Loading...

} - {error && ( -

{error}

- )} - - {!loading && revisions.length === 0 && !error && ( -

No revision history.

- )} - - {revisions.length > 0 && ( -
    - {revisions.map((rev) => ( -
  1. -
    - - Rev {rev.rev_index + 1} - {rev.edited_at ? ` · ${relativeTime(rev.edited_at)}` : ""} - {rev.author ? ` · ${rev.author}` : ""} - - {rev.text} -
    - -
  2. - ))} -
- )} - - {viewingRev && ( -
-

- Rev {viewingRev.index + 1} (read-only) -

-

{viewingRev.text}

- -
- )} -
- ); -} - -// Share dialog rendered as an inline panel at the bottom of the detail pane -function SharePanel({ doc, onClose }: { doc: NoteDetail; onClose: () => void }) { - const [members, setMembers] = useState(doc.members ?? []); - const [memberType, setMemberType] = useState<"user" | "agent">("user"); - const [memberId, setMemberId] = useState(""); - const [permission, setPermission] = useState("viewer"); - const [action, setAction] = useState(null); - const [instruction, setInstruction] = useState(""); - const [adding, setAdding] = useState(false); - const [removingId, setRemovingId] = useState(null); - const [error, setError] = useState(null); - - async function addMember() { - if (!memberId.trim()) return; - setAdding(true); - setError(null); - try { - const body: Record = { - member_type: memberType, - member_id: memberId.trim(), - permission, - action: memberType === "agent" ? action : null, - standing_instruction: memberType === "agent" && instruction.trim() ? instruction.trim() : null, - }; - const r = await fetch(`/api/notes/${doc.id}/members`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!r.ok) { - const d = await r.json().catch(() => ({})); - throw new Error(typeof d?.detail === "string" ? d.detail : "Could not add member."); - } - const fresh: NoteMember = await r.json(); - setMembers((prev) => { - const without = prev.filter((m) => m.member_id !== fresh.member_id); - return [...without, fresh]; - }); - setMemberId(""); - setInstruction(""); - setAction(null); - } catch (e) { - setError(e instanceof Error ? e.message : "Could not add member."); - } finally { - setAdding(false); - } - } - - async function removeMember(id: string) { - setRemovingId(id); - setError(null); - try { - const r = await fetch(`/api/notes/${doc.id}/members/${encodeURIComponent(id)}`, { - method: "DELETE", - }); - if (!r.ok) throw new Error("Could not remove member."); - setMembers((prev) => prev.filter((m) => m.member_id !== id)); - } catch (e) { - setError(e instanceof Error ? e.message : "Could not remove member."); - } finally { - setRemovingId(null); - } - } - - return ( -
-
- - - Share - - -
- - {/* Current members */} - {members.length > 0 && ( -
-

Members

-
    - {members.map((m) => ( -
  • - - - {PERMISSION_LABELS[m.permission]} - - {m.action && ( - - {m.action} - - )} - -
  • - ))} -
-
- )} - - {/* Add member form */} -
-

Add member

- - {/* Member type toggle */} -
- {(["user", "agent"] as const).map((t) => ( - - ))} -
- - {/* Member ID */} - setMemberId(e.target.value)} - placeholder={memberType === "agent" ? "agent-slug" : "user@example.com"} - aria-label="Member ID or email" - 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" - /> - - {/* Permission: segmented control (3 levels) */} -
-

Permission

-
- {(["viewer", "contributor", "editor"] as const).map((p) => ( - - ))} -
-
- - {/* Agent-only: action picker + standing instruction */} - {memberType === "agent" && ( - <> -
-

Action

-
- {ACTION_OPTIONS.map((opt) => ( - - ))} -
-
- -