"use client";
import { AnimatePresence, LayoutGroup, MotionConfig, motion, useReducedMotion } from "motion/react";
import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
BookOpenIcon,
BotIcon,
CheckIcon,
ChevronRightIcon,
ChevronsUpDownIcon,
FileTextIcon,
GlobeIcon,
InboxIcon,
LibraryIcon,
LoaderCircleIcon,
LockIcon,
type LucideIcon,
MenuIcon,
PanelRightIcon,
PaperclipIcon,
PlusIcon,
RotateCwIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
XIcon,
} from "lucide-react";
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Kbd } from "@/components/ui/kbd";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
import { Tooltip, TooltipPopup, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export interface WorkspaceTask {
id: string;
title: string;
state: "running" | "done" | "idle";
time: string;
}
export interface WorkspaceToolCall {
id: string;
tool: string;
detail: string;
icon: LucideIcon;
ms: number;
output?: string;
}
export interface WorkspaceSource {
id: string;
title: string;
domain: string;
cited: number;
tone: string;
}
export interface SplitShellProps {
tasks?: WorkspaceTask[];
toolCalls?: WorkspaceToolCall[];
sources?: WorkspaceSource[];
defaultPanelWidth?: number;
minPanelWidth?: number;
maxPanelWidth?: number;
children?: ReactNode;
className?: string;
}
const spring = { type: "spring", stiffness: 420, damping: 38 } as const;
const defaultTasks: WorkspaceTask[] = [
{ id: "t1", title: "Competitive scan: note apps", state: "running", time: "now" },
{ id: "t2", title: "Draft Q4 pricing memo", state: "done", time: "1h" },
{ id: "t3", title: "Summarise churn interviews", state: "done", time: "Tue" },
{ id: "t4", title: "Onboarding email rewrite", state: "idle", time: "Mon" },
{ id: "t5", title: "Clean up the roadmap doc", state: "idle", time: "Oct 18" },
];
const defaultToolCalls: WorkspaceToolCall[] = [
{ id: "c1", tool: "web.search", detail: "note taking app pricing 2026", icon: SearchIcon, ms: 820, output: "14 results · 6 kept" },
{ id: "c2", tool: "browser.open", detail: "quillpad.app/pricing", icon: GlobeIcon, ms: 1640, output: "Read 3 plan cards, 1 FAQ" },
{ id: "c3", tool: "browser.open", detail: "notably.io/plans", icon: GlobeIcon, ms: 1310, output: "Read 4 plan cards" },
{ id: "c4", tool: "files.read", detail: "churn-interviews.md", icon: FileTextIcon, ms: 410, output: "38 quotes tagged 'price'" },
{ id: "c5", tool: "python.run", detail: "compare_plans.py", icon: SquareTerminalIcon, ms: 2280, output: "Table: 3 apps × 5 columns" },
{ id: "c6", tool: "doc.write", detail: "Draft summary", icon: BookOpenIcon, ms: 1900, output: "412 words" },
];
const defaultSources: WorkspaceSource[] = [
{ id: "s1", title: "Quillpad · Plans and pricing", domain: "quillpad.app", cited: 4, tone: "bg-orange-500/14 text-orange-800 dark:text-orange-400" },
{ id: "s2", title: "Notably plans for teams", domain: "notably.io", cited: 3, tone: "bg-teal-500/14 text-teal-800 dark:text-teal-400" },
{ id: "s3", title: "Driftnote changelog: October", domain: "driftnote.co", cited: 2, tone: "bg-info/14 text-info-foreground" },
{ id: "s4", title: "churn-interviews.md", domain: "Orbit files", cited: 5, tone: "bg-violet-500/18 text-violet-700 dark:text-violet-400" },
{ id: "s5", title: "The state of note apps, 2026", domain: "fieldnotes.blog", cited: 1, tone: "bg-rose-500/14 text-rose-700 dark:text-rose-400" },
];
function Sidebar({ tasks, onClose }: { tasks: WorkspaceTask[]; onClose?: () => void }) {
const [active, setActive] = useState(tasks[0]?.id);
const group = useId();
return (
Orbit
Workspace
{onClose && (
)}
{[
{ label: "Search", icon: SearchIcon, meta: ⌘K },
{ label: "Inbox", icon: InboxIcon, meta: 2 },
{ label: "Library", icon: LibraryIcon },
].map((n) => (
{n.label}
{n.meta}
))}
Tasks
{tasks.length}
{tasks.map((t) => {
const on = active === t.id;
return (
setActive(t.id)}
type="button"
>
{on && (
)}
{t.state === "running" ? (
) : t.state === "done" ? (
) : (
)}
{t.title}
{t.time}
);
})}
DA
Dara Achebe
Northwind Labs
);
}
const compare = [
{ app: "Quillpad", price: "$8", seats: "Unlimited", ai: "Add-on $5", offline: true },
{ app: "Notably", price: "$10", seats: "Up to 50", ai: "Included", offline: false },
{ app: "Driftnote", price: "$6", seats: "Up to 20", ai: "None", offline: true },
];
const quotes = [
{ text: "We paid for Notably's AI on every seat and maybe three people used it.", who: "Ops lead, 30-person agency" },
{ text: "Half our week is on client sites with bad wifi. Offline was the deal breaker.", who: "Producer, events studio" },
];
const enter = { initial: { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.22, ease: [0.23, 1, 0.32, 1] } } as const;
function Cite({ n, label }: { n: number; label: string }) {
return (
}
>
{n}
{label}
);
}
function SkeletonLines({ rows, label }: { rows: number; label: string }) {
return (
{label}
{Array.from({ length: rows }, (_, i) => (
))}
);
}
function SampleThread({ running, step, total }: { running: boolean; step: number; total: number }) {
const [draft, setDraft] = useState("");
const composer = useId();
const scroller = useRef(null);
const reduce = useReducedMotion();
const haveTable = step >= 3;
useEffect(() => {
const el = scroller.current;
if (!el || step === 0) return;
const t = setTimeout(() => {
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 240;
if (nearBottom) el.scrollTo({ top: el.scrollHeight, behavior: reduce ? "auto" : "smooth" });
}, 60);
return () => clearTimeout(t);
}, [step, reduce]);
const haveQuotes = step >= 4;
return (
Scan the three note apps we lose deals to. Pricing, seat limits, AI and offline. Use the churn interviews for
why people leave.
{running ? (
) : (
)}
{running ? `Working · step ${Math.min(step + 1, total)} of ${total}` : `Finished · ${total} steps · 9.4s`}
Notably costs the most per seat but bundles{" "}
AI
,
{" "}
and paying for AI nobody used is the top reason teams left. Quillpad is the closest match on seats and still
works{" "}
offline
.
{haveTable ? (
App
Per seat
Seats
AI
Offline
{compare.map((r) => (
{r.app}
{r.price}
{r.seats}
{r.ai}
{r.offline ? (
) : (
—
)}
))}
{compare.map((r) => (
{r.app}
{r.price}
/ seat
{r.seats} seats · AI {r.ai.toLowerCase()} · {r.offline ? "works offline" : "online only"}
))}
) : (
)}
Why teams left
{haveQuotes ? (
{quotes.map((q) => (
“{q.text}”
{q.who}{" "}
))}
) : (
)}
{!running && (
I saved the write-up to Research. Want a one-slide version for Thursday's pricing review?
Note apps: competitive scan
Doc · 412 words · 5 sources
Open
)}
);
}
function ToolsTab({ calls, step }: { calls: WorkspaceToolCall[]; step: number }) {
const [open, setOpen] = useState(null);
const done = Math.min(step, calls.length);
const elapsed = calls.slice(0, done).reduce((sum, c) => sum + c.ms, 0);
return (
{done === calls.length ? "All steps done" : `Step ${done + 1} of ${calls.length}`}
{(elapsed / 1000).toFixed(1)}s tool time
{calls.map((c, i) => (
))}
{calls.map((c, i) => {
const state = i < done ? "done" : i === done ? "running" : "queued";
const expanded = open === c.id && state === "done";
return (
setOpen(expanded ? null : c.id)}
type="button"
>
{state === "running" ? (
) : (
)}
{c.tool}
{state === "running" ? (
running
) : state === "done" ? (
`${(c.ms / 1000).toFixed(1)}s`
) : (
"queued"
)}
{c.detail}
{expanded && (
→ {c.output ?? "ok"}
)}
);
})}
);
}
function BrowserTab({ reading }: { reading: boolean }) {
const reduce = useReducedMotion();
const plans = [
{ name: "Personal", price: "$0", note: "1 seat" },
{ name: "Team", price: "$8", note: "per seat / mo" },
{ name: "Business", price: "$14", note: "per seat / mo" },
];
return (
Quillpad
Product
Pricing
Blog
Simple plans for every notebook
Billed yearly. Cancel any time.
{plans.map((p, i) => (
{p.name}
{p.price}
{p.note}
{[0, 1, 2].map((k) => (
))}
))}
{reading && (
Reading plan table
)}
{[
["Offline notebooks", true, true, true],
["Shared spaces", false, true, true],
["AI writing", false, "Add-on", "Add-on"],
["SSO & audit log", false, false, true],
].map(([label, ...cells], r) => (
0 && "border-t")} key={String(label)}>
{label}
{cells.map((c, k) => (
{c === true ? (
) : c === false ? (
—
) : (
c
)}
))}
))}
Questions
{["Can I switch plans later?", "Do you offer student pricing?", "What happens to my notes if I cancel?"].map((q) => (
))}
);
}
function SourcesTab({ sources }: { sources: WorkspaceSource[] }) {
const [included, setIncluded] = useState(() => sources.map((s) => s.id));
return (
{included.length} of {sources.length} used in the answer
{sources.map((s, i) => {
const on = included.includes(s.id);
const id = `src-${s.id}`;
return (
{i + 1}
{s.title}
{s.domain} · cited {s.cited}×
setIncluded((x) => (v ? [...x, s.id] : x.filter((y) => y !== s.id)))}
/>
);
})}
Add a link or file
);
}
function WorkPanel({
calls,
sources,
step,
onClose,
}: {
calls: WorkspaceToolCall[];
sources: WorkspaceSource[];
step: number;
onClose: () => void;
}) {
const running = step < calls.length;
const reading = running && calls[step]?.tool.startsWith("browser");
const [tab, setTab] = useState("tools");
return (
setTab(v as string)} value={tab}>
Tools
{calls.length}
Browser
{reading && }
Sources
);
}
export function SplitShell({
tasks = defaultTasks,
toolCalls = defaultToolCalls,
sources = defaultSources,
defaultPanelWidth = 380,
minPanelWidth = 300,
maxPanelWidth = 600,
children,
className,
}: SplitShellProps) {
const rootRef = useRef(null);
const drawerRef = useRef(null);
const [rootWidth, setRootWidth] = useState(1280);
const [panelOpen, setPanelOpen] = useState(true);
const [overlayOpen, setOverlayOpen] = useState(false);
const [drawer, setDrawer] = useState(false);
const [width, setWidth] = useState(defaultPanelWidth);
const [dragging, setDragging] = useState(false);
const [step, setStep] = useState(3);
const panelId = useId();
const drag = useRef<{ x: number; w: number } | null>(null);
const reduce = useReducedMotion();
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => setRootWidth(entry.contentRect.width));
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (drawer) drawerRef.current?.focus();
}, [drawer]);
useEffect(() => {
if (step >= toolCalls.length) return;
const t = setTimeout(() => setStep((s) => s + 1), reduce ? 400 : (toolCalls[step]?.ms ?? 1500) + 1400);
return () => clearTimeout(t);
}, [step, toolCalls, reduce]);
const docked = rootWidth >= 1024;
const sidebarWidth = rootWidth >= 768 ? 240 : 0;
const maxW = Math.max(minPanelWidth, Math.min(maxPanelWidth, rootWidth - sidebarWidth - 400));
const w = Math.min(Math.max(width, minPanelWidth), maxW);
const showPanel = docked ? panelOpen : overlayOpen;
const clamp = useCallback((v: number) => Math.min(Math.max(v, minPanelWidth), maxW), [minPanelWidth, maxW]);
const onPointerDown = (e: PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, w };
setDragging(true);
};
const onPointerMove = (e: PointerEvent) => {
if (!drag.current) return;
setWidth(clamp(drag.current.w + (drag.current.x - e.clientX)));
};
const onPointerUp = () => {
drag.current = null;
setDragging(false);
};
const onHandleKey = (e: KeyboardEvent) => {
const delta = e.shiftKey ? 64 : 16;
if (e.key === "ArrowLeft") setWidth(clamp(w + delta));
else if (e.key === "ArrowRight") setWidth(clamp(w - delta));
else if (e.key === "Home") setWidth(maxW);
else if (e.key === "End") setWidth(minPanelWidth);
else if (e.key === "Enter") setWidth(defaultPanelWidth);
else return;
e.preventDefault();
};
const running = step < toolCalls.length;
const liveId = tasks.find((t) => t.state === "running")?.id;
const liveTasks = tasks.map((t) => (t.id === liveId ? { ...t, state: running ? ("running" as const) : ("done" as const) } : t));
const togglePanel = () => (docked ? setPanelOpen((o) => !o) : setOverlayOpen((o) => !o));
return (
{
if (e.key !== "Escape") return;
if (drawer) setDrawer(false);
else if (overlayOpen) setOverlayOpen(false);
}}
ref={rootRef}
>
{drawer && (
<>
setDrawer(false)}
/>
setDrawer(false)} tasks={liveTasks} />
>
)}
setDrawer(true)}
size="icon-sm"
variant="ghost"
>
Research
Competitive scan: note apps
{running ? (
) : (
)}
{running ? "Running" : "Done"}
{!running && (
setStep(0)} size="sm" variant="ghost">
Re-run
)}
}
>
{showPanel ? "Hide work panel" : "Show work panel"}
{children ?? }
{docked && (
{panelOpen && (
setWidth(defaultPanelWidth)}
onKeyDown={onHandleKey}
onPointerCancel={onPointerUp}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
role="separator"
tabIndex={0}
>
setPanelOpen(false)} sources={sources} step={step} />
{dragging && (
{Math.round(w)}px
)}
)}
)}
{!docked && (
{overlayOpen && (
<>
setOverlayOpen(false)}
/>
setOverlayOpen(false)} sources={sources} step={step} />
>
)}
)}
);
}