"use client"; import { useState, useTransition } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { BriefcaseIcon, PinIcon, PlusIcon, SearchIcon, ShieldIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/page-header"; import { SaveIndicator } from "@/components/save-indicator"; import { useAutosave } from "@/hooks/use-autosave"; import { cn } from "@/lib/utils"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { createNoteAction, deleteNoteAction, updateNoteAction, } from "@/server/actions"; import { StickyNoteIcon } from "lucide-react"; type Note = { id: string; title: string; body: string; tags: string[]; pinned: boolean; kind: "NOTE" | "GUARDRAIL"; /** For a standing rule: whether it fits in the briefing every client reads. */ inBriefing: boolean; /** The job this note is filed against, if any. */ roleId: string | null; }; type RoleOption = { id: string; label: string }; /** Sent down from the panel so each card can offer "about a job" without its own fetch. */ const NO_ROLE = "none"; /** * A title that reads like an instruction. Deliberately narrow — it only ever * produces a suggestion, never a change — but narrow matters anyway, because a * suggestion on every third note is one people learn to ignore. */ const RULE_LIKE = /\b(?:rules?|guardrails?|never|do not|don'?t|must not|off[- ]limits)\b/i; export function NotesPanel({ notes, briefing, roles, }: { notes: Note[]; /** Room for standing rules in the briefing, and how much of it is used. */ briefing: { budget: number; used: number }; roles: RoleOption[]; }) { const [pending, startTransition] = useTransition(); const [removed, setRemoved] = useState>(new Set()); const [query, setQuery] = useState(""); const add = () => { startTransition(async () => { await createNoteAction({ title: "Untitled note" }); toast.success("Note added"); }); }; const kept = notes.filter((note) => !removed.has(note.id)); const needle = query.trim().toLowerCase(); const visible = needle ? kept.filter((note) => `${note.title}\n${note.body}\n${note.tags.join(" ")}`.toLowerCase().includes(needle), ) : kept; // Standing rules first and on their own, because they are a different kind // of thing: every connected client is handed them before it does anything. const rules = visible.filter((note) => note.kind === "GUARDRAIL"); const others = visible.filter((note) => note.kind !== "GUARDRAIL"); const overflowing = rules.filter((note) => !note.inBriefing).length; return (

Anything that isn't tied to one job: STAR stories, interview answers, references, salary history, the thing you always forget to mention.

{kept.length > 6 && (
setQuery(event.target.value)} placeholder="Find a note" aria-label="Find a note" className="h-9 w-44 pl-8 md:h-8" />
)}
{rules.length > 0 && (

Standing rules {rules.length}

{overflowing > 0 ? `${overflowing} too long to travel with every connection — clients are told to fetch ${overflowing === 1 ? "it" : "them"}. ` : "Every connected client gets these before it does anything. "} {briefing.used.toLocaleString()} of {briefing.budget.toLocaleString()} {" "} characters used. One short rule per note fits best.

{rules.map((note) => ( setRemoved((prev) => new Set(prev).add(note.id))} /> ))}
)} {kept.length > 0 && visible.length === 0 ? (

Nothing matches “{query.trim()}”.

) : kept.length === 0 ? ( New note } /> ) : others.length === 0 ? null : (
{others.map((note) => ( setRemoved((prev) => new Set(prev).add(note.id))} /> ))}
)}
); } function NoteCard({ note, roles, onRemoved, }: { note: Note; roles: RoleOption[]; onRemoved: () => void; }) { const [values, setValues] = useState({ title: note.title, body: note.body, pinned: note.pinned, kind: note.kind, roleId: note.roleId, }); const { state, push } = useAutosave((next) => updateNoteAction(note.id, next)); const isRule = values.kind === "GUARDRAIL"; // Only while it is still a note: once it is a rule, the suggestion is done. const readsLikeRule = !isRule && RULE_LIKE.test(values.title); const set = (patch: Partial) => { const next = { ...values, ...patch }; setValues(next); push(next); }; return ( {isRule && (note.inBriefing || note.kind !== "GUARDRAIL" ? (
Sent to every AI on connect
) : ( // Measured on the server against the real briefing, so it is // right as of the last save rather than while typing.
Too long to send with every connection. Clients are told it exists and to fetch it; a shorter rule, or several short ones, reaches them from the first message.
))} {readsLikeRule && (
This reads like a rule, but it is a note: Claude only sees it if it goes looking.
)}
set({ title: event.target.value })} className="h-auto border-0 bg-transparent px-0 py-0 text-[15px] font-semibold shadow-none focus-visible:ring-0" placeholder="Note title" />