This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page-v1.tsx

2442 lines
68 KiB
TypeScript

"use client";
/**
* Plan tab — the home of the project's thinking.
*
* Sub-areas (always visible, even when empty, so a founder learns the
* model on a brand-new project):
* 1. Vision — the elevator pitch + audience
* 2. Ideas — the "park-it" bin for raw thoughts
* 3. Tasks — what needs to happen next (open / done)
* 4. Decisions — log of "we chose X over Y because Z"
*
* Same UI rhythm as Infrastructure: section tiles in a horizontal bar
* (with counts), full-width detail panel below. Everything writes through
* `/api/projects/[projectId]/plan` and persists under
* `fs_projects.data.plan`. The AI chat reads the plan as context so
* decisions don't get re-litigated.
*/
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
Loader2,
AlertCircle,
MessageSquare,
ListTodo,
GitBranch,
Target,
Plus,
Trash2,
Check,
RotateCcw,
Pencil,
X,
Eye,
FileText,
} from "lucide-react";
interface Idea {
id: string;
text: string;
createdAt: string;
}
type TaskStatus = "open" | "in_progress" | "review" | "done" | "blocked";
interface Task {
id: string;
title: string;
description?: string;
status: TaskStatus;
agent?: {
runId: string;
startedAt: string;
finishedAt?: string;
status: "queued" | "running" | "succeeded" | "failed";
} | null;
createdAt: string;
startedAt?: string;
doneAt?: string;
// Legacy single-line tasks (pre-markdown migration) carry text instead of title.
text?: string;
}
interface Decision {
id: string;
title: string;
choice: string;
why?: string;
createdAt: string;
}
interface Plan {
vision?: string;
brief?: string;
brief_filename?: string; // stored as `vision` server-side, surfaced as "Objective" in the UI
ideas: Idea[]; // legacy bin — no longer surfaced; kept on the model for backward compat
tasks: Task[];
decisions: Decision[];
}
interface Session {
id: string;
title: string;
summary: string | null;
messageCount: number;
updatedAt: string;
createdAt: string;
}
type Section = "objective" | "sessions" | "tasks" | "decisions";
interface SectionDef {
key: Section;
label: string;
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
blurb: string;
}
const SECTIONS: SectionDef[] = [
{
key: "objective",
label: "Objective",
icon: Target,
blurb: "What you're building, who it's for, and why.",
},
{
key: "tasks",
label: "Features",
icon: ListTodo,
blurb: "High-level capabilities to be delegated to the AI.",
},
{
key: "decisions",
label: "Decisions",
icon: GitBranch,
blurb: "Choices already made — so we stop re-litigating them.",
},
{
key: "sessions",
label: "Sessions",
icon: MessageSquare,
blurb: "Past chat sessions for this project.",
},
];
// ──────────────────────────────────────────────────
// Page
// ──────────────────────────────────────────────────
export default function PlanTab() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [plan, setPlan] = useState<Plan | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState<Section>("objective");
const [sessionCount, setSessionCount] = useState(0);
const load = useCallback(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/plan`, {
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setPlan(data.plan);
setError(null);
} catch (e: any) {
setError(e?.message ?? "Failed to load plan");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
load();
// Only poll the plan API periodically if we aren't currently editing or saving
// something, otherwise we risk overwriting user input or causing layout thrashing.
const intervalId = setInterval(load, 15000); // Backed off from 5s to 15s to reduce API spam
return () => clearInterval(intervalId);
}, [load]);
if (loading && !plan) {
return (
<div style={pageWrap}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
color: INK.mid,
}}
>
<Loader2 size={16} className="animate-spin" /> Loading plan
</div>
</div>
);
}
if (error || !plan) {
return (
<div style={pageWrap}>
<div style={errorBox}>
<AlertCircle
size={14}
style={{ marginRight: 6, verticalAlign: -2 }}
/>
{error ?? "No plan data"}
</div>
</div>
);
}
const counts: Record<Section, number> = {
objective: plan.vision ? 1 : 0,
sessions: sessionCount,
tasks: plan.tasks.filter((t) => t.status !== "done").length,
decisions: plan.decisions.length,
};
return (
<div style={pageWrap}>
<style jsx global>{`
.vibn-prose {
font-family: "Outfit", "Inter", ui-sans-serif, sans-serif;
color: #1a1a1a;
font-size: 0.95rem;
line-height: 1.65;
}
.vibn-prose > :first-child {
margin-top: 0;
}
.vibn-prose > :last-child {
margin-bottom: 0;
}
.vibn-prose h1,
.vibn-prose h2,
.vibn-prose h3,
.vibn-prose h4 {
font-family: "Newsreader", "Lora", Georgia, serif;
font-weight: 500;
letter-spacing: -0.01em;
margin: 1.4em 0 0.5em;
line-height: 1.25;
color: #1a1a1a;
}
.vibn-prose h1 {
font-size: 1.7rem;
font-weight: 400;
letter-spacing: -0.02em;
}
.vibn-prose h2 {
font-size: 1.3rem;
}
.vibn-prose h3 {
font-size: 1.05rem;
}
.vibn-prose h4 {
font-size: 0.95rem;
}
.vibn-prose p,
.vibn-prose ul,
.vibn-prose ol,
.vibn-prose blockquote {
margin: 0.75em 0;
}
.vibn-prose ul,
.vibn-prose ol {
padding-left: 1.4em;
}
.vibn-prose li {
margin: 0.25em 0;
}
.vibn-prose li > p {
margin: 0.25em 0;
}
.vibn-prose blockquote {
border-left: 3px solid #e8e4dc;
padding: 0.1em 0 0.1em 14px;
color: #5f5e5a;
font-style: italic;
}
.vibn-prose code {
font-family: "JetBrains Mono", "SF Mono", ui-monospace, monospace;
font-size: 0.85em;
background: #f3eee4;
padding: 1px 5px;
border-radius: 4px;
}
.vibn-prose pre {
font-family: "JetBrains Mono", "SF Mono", ui-monospace, monospace;
font-size: 0.84rem;
line-height: 1.55;
background: #1a1a1a;
color: #f6f4f0;
padding: 14px 16px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.vibn-prose pre code {
background: transparent;
color: inherit;
padding: 0;
font-size: inherit;
}
.vibn-prose a {
color: #1a1a1a;
text-decoration: underline;
text-decoration-color: #c8c2b6;
text-underline-offset: 3px;
}
.vibn-prose a:hover {
text-decoration-color: #1a1a1a;
}
.vibn-prose hr {
border: none;
border-top: 1px solid #e8e4dc;
margin: 1.6em 0;
}
.vibn-prose table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 0.88rem;
}
.vibn-prose th,
.vibn-prose td {
border: 1px solid #e8e4dc;
padding: 8px 12px;
text-align: left;
}
.vibn-prose th {
background: #fafaf6;
font-weight: 600;
}
.vibn-prose img {
max-width: 100%;
border-radius: 6px;
}
`}</style>
<div style={planStack}>
<nav style={planNav} aria-label="Plan sections">
<h2 style={heading}>Plan</h2>
<div style={tileRow}>
{SECTIONS.map((def) => (
<SectionTile
key={def.key}
def={def}
count={counts[def.key]}
active={active === def.key}
onClick={() => setActive(def.key)}
/>
))}
</div>
</nav>
<section style={planMain}>
<div style={panel}>
{active === "objective" && (
<VisionPanel
plan={plan}
projectId={projectId}
onChange={setPlan}
/>
)}
{active === "sessions" && (
<SessionsPanel
workspace={workspace}
projectId={projectId}
onCount={setSessionCount}
/>
)}
{active === "tasks" && (
<TasksPanel
plan={plan}
projectId={projectId}
onChange={setPlan}
/>
)}
{active === "decisions" && (
<DecisionsPanel
plan={plan}
projectId={projectId}
onChange={setPlan}
/>
)}
</div>
</section>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Left rail tile
// ──────────────────────────────────────────────────
function SectionTile({
def,
count,
active,
onClick,
}: {
def: SectionDef;
count: number;
active: boolean;
onClick: () => void;
}) {
const Icon = def.icon;
return (
<button
type="button"
onClick={onClick}
style={{
...tileButton,
background: active ? "#fff" : "transparent",
borderColor: active ? INK.border : "transparent",
boxShadow: active ? "0 1px 3px rgba(0,0,0,0.04)" : "none",
}}
aria-pressed={active}
>
<Icon size={14} style={{ color: INK.mid, flexShrink: 0 }} />
<span style={{ ...tileLabel, color: active ? INK.ink : INK.mid }}>
{def.label}
</span>
<span style={countPill}>{count}</span>
</button>
);
}
// ──────────────────────────────────────────────────
// Vision
// ──────────────────────────────────────────────────
function BriefPanel({
plan,
projectId,
onChange,
}: {
plan: Plan;
projectId: string;
onChange: (p: Plan) => void;
}) {
const [draft, setDraft] = useState(plan.brief ?? "");
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(!plan.brief);
const [editorView, setEditorView] = useState<"write" | "preview">("write");
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(plan.brief ?? "");
setDirty(false);
}, [plan.brief]);
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "brief", text: draft }),
});
if (res.ok) {
onChange({ ...plan, brief: draft });
setDirty(false);
setEditing(false);
}
} finally {
setSaving(false);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// For now we just read text files (txt, md, csv).
// In the future you can send PDFs to an extraction API.
const reader = new FileReader();
reader.onload = async (event) => {
const content = event.target?.result as string;
setDraft(content);
setDirty(true);
setEditing(true);
};
reader.readAsText(file);
};
if (!editing && plan.brief) {
return (
<div className="vibn-enter" style={{ position: "relative" }}>
<button
onClick={() => setEditing(true)}
style={{
position: "absolute",
top: 0,
right: 0,
padding: "6px 12px",
borderRadius: 6,
background: "transparent",
border: "1px solid #e8e4dc",
fontSize: "0.75rem",
color: "#6b6560",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<Pencil style={{ width: 12, height: 12 }} /> Edit
</button>
<div style={{ padding: "8px 0" }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => (
<p
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
marginBottom: 16,
}}
{...props}
/>
),
h1: ({ node, ...props }) => (
<h1
style={{
fontSize: "1.4rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 24,
marginBottom: 16,
}}
{...props}
/>
),
h2: ({ node, ...props }) => (
<h2
style={{
fontSize: "1.2rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 20,
marginBottom: 12,
}}
{...props}
/>
),
h3: ({ node, ...props }) => (
<h3
style={{
fontSize: "1.05rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 16,
marginBottom: 10,
}}
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
paddingLeft: 24,
marginBottom: 16,
listStyleType: "disc",
}}
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
paddingLeft: 24,
marginBottom: 16,
listStyleType: "decimal",
}}
{...props}
/>
),
li: ({ node, ...props }) => (
<li style={{ marginBottom: 6 }} {...props} />
),
}}
>
{plan.brief}
</ReactMarkdown>
</div>
</div>
);
}
return (
<div className="vibn-enter">
{!plan.brief && (
<div style={emptyVisionBox}>
<p
style={{
margin: 0,
marginBottom: 12,
fontSize: "0.9rem",
color: "#444441",
lineHeight: 1.55,
}}
>
No project brief uploaded. You can paste your PRD, requirements
document, or notes below. The AI will read this to understand the
full scope of your project.
</p>
<div style={{ display: "flex", gap: "12px" }}>
<label
style={{
...primaryBtn,
cursor: "pointer",
display: "inline-flex",
}}
>
Upload Text File (.md, .txt)
<input
type="file"
accept=".txt,.md,.csv"
style={{ display: "none" }}
onChange={handleFileUpload}
/>
</label>
<button
onClick={() => setEditing(true)}
style={{
padding: "8px 16px",
borderRadius: 7,
background: "transparent",
color: "#1a1a1a",
border: "1px solid #1a1a1a",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Paste Manually
</button>
</div>
</div>
)}
{editing && (
<div
style={{
background: "#fff",
border: "1px solid #e8e4dc",
borderRadius: 10,
overflow: "hidden",
marginTop: plan.brief ? 0 : 24,
}}
>
<div
style={{
display: "flex",
borderBottom: "1px solid #e8e4dc",
background: "#faf8f5",
}}
>
<button
onClick={() => setEditorView("write")}
style={{
...tabStyle(editorView === "write" ? "write" : ""),
borderRadius: 0,
border: "none",
borderRight: "1px solid #e8e4dc",
borderBottom:
editorView === "write"
? "1px solid transparent"
: "1px solid #e8e4dc",
background: editorView === "write" ? "#fff" : "transparent",
}}
>
Write
</button>
<button
onClick={() => setEditorView("preview")}
style={{
...tabStyle(editorView === "preview" ? "preview" : ""),
borderRadius: 0,
border: "none",
borderRight: "1px solid #e8e4dc",
borderBottom:
editorView === "preview"
? "1px solid transparent"
: "1px solid #e8e4dc",
background: editorView === "preview" ? "#fff" : "transparent",
}}
>
Preview
</button>
<div style={{ flex: 1, borderBottom: "1px solid #e8e4dc" }} />
</div>
{editorView === "write" ? (
<textarea
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setDirty(true);
}}
placeholder="Paste your PRD or project scope here..."
style={{
width: "100%",
minHeight: 300,
padding: 20,
border: "none",
outline: "none",
fontSize: "0.9rem",
lineHeight: 1.6,
fontFamily: "var(--font-inter), sans-serif",
color: "#1a1a1a",
resize: "vertical",
}}
/>
) : (
<div
style={{
padding: 20,
minHeight: 300,
maxHeight: 500,
overflowY: "auto",
}}
>
{draft ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => (
<p
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
marginBottom: 16,
}}
{...props}
/>
),
h1: ({ node, ...props }) => (
<h1
style={{
fontSize: "1.4rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 24,
marginBottom: 16,
}}
{...props}
/>
),
h2: ({ node, ...props }) => (
<h2
style={{
fontSize: "1.2rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 20,
marginBottom: 12,
}}
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
paddingLeft: 24,
marginBottom: 16,
listStyleType: "disc",
}}
{...props}
/>
),
li: ({ node, ...props }) => (
<li style={{ marginBottom: 6 }} {...props} />
),
}}
>
{draft}
</ReactMarkdown>
) : (
<p style={{ color: "#a09a90", fontStyle: "italic", margin: 0 }}>
Nothing to preview.
</p>
)}
</div>
)}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 12,
padding: "12px 20px",
borderTop: "1px solid #e8e4dc",
background: "#faf8f5",
}}
>
{plan.brief && (
<button
onClick={() => {
setDraft(plan.brief ?? "");
setDirty(false);
setEditing(false);
}}
style={{
padding: "8px 16px",
borderRadius: 6,
background: "transparent",
border: "1px solid #e8e4dc",
color: "#6b6560",
fontSize: "0.82rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Cancel
</button>
)}
<button
onClick={handleSave}
disabled={!dirty || saving}
style={{ ...primaryBtn, opacity: !dirty || saving ? 0.5 : 1 }}
>
{saving ? (
<Loader2
style={{ width: 14, height: 14 }}
className="animate-spin"
/>
) : null}
{saving ? "Saving..." : "Save Brief"}
</button>
</div>
</div>
)}
</div>
);
}
function VisionPanel({
plan,
projectId,
onChange,
}: {
plan: Plan;
projectId: string;
onChange: (p: Plan) => void;
}) {
const [draft, setDraft] = useState(plan.vision ?? "");
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(!plan.vision);
// While editing, toggle between raw markdown source and rendered preview.
const [editorView, setEditorView] = useState<"write" | "preview">("write");
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(plan.vision ?? "");
setDirty(false);
}, [plan.vision]);
const save = async () => {
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "vision", text: draft }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
setDirty(false);
setEditing(false);
} finally {
setSaving(false);
}
};
const cancel = () => {
setDraft(plan.vision ?? "");
setDirty(false);
setEditing(false);
};
return (
<div>
<div style={visionHeaderRow}>
<h3 style={panelTitle}>Objective</h3>
{!editing && plan.vision && (
<button onClick={() => setEditing(true)} style={ghostBtn}>
<Pencil size={12} /> Edit
</button>
)}
</div>
{editing ? (
<>
<div style={editorTabs}>
<button
type="button"
onClick={() => setEditorView("write")}
style={editorView === "write" ? editorTabActive : editorTab}
>
<FileText size={12} /> Write
</button>
<button
type="button"
onClick={() => setEditorView("preview")}
style={editorView === "preview" ? editorTabActive : editorTab}
>
<Eye size={12} /> Preview
</button>
<span style={{ flex: 1 }} />
{dirty && <span style={dirtyDot}> Unsaved</span>}
</div>
{editorView === "write" ? (
<textarea
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setDirty(true);
}}
placeholder={visionPlaceholder}
style={visionTextarea}
spellCheck
/>
) : (
<div style={visionPreviewBox}>
{draft.trim() ? (
<div className="vibn-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{draft}
</ReactMarkdown>
</div>
) : (
<div style={{ color: INK.muted, fontStyle: "italic" }}>
Nothing to preview yet.
</div>
)}
</div>
)}
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button
onClick={save}
disabled={saving || !dirty}
style={primaryBtn}
>
{saving ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}
Save
</button>
<button onClick={cancel} style={ghostBtn}>
<X size={13} /> Cancel
</button>
</div>
</>
) : plan.vision ? (
<div className="vibn-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{plan.vision}
</ReactMarkdown>
</div>
) : (
<div style={emptyVisionBox}>
<p
style={{
margin: 0,
marginBottom: 12,
fontSize: "0.9rem",
color: INK.mid,
lineHeight: 1.55,
}}
>
No objective yet. Write a few paragraphs about the problem, the
people you&apos;re building it for, and the shape of the solution.
The AI reads this on every chat so it stays anchored to your real
intent.
</p>
<button
onClick={() => {
setEditing(true);
setEditorView("write");
}}
style={primaryBtn}
>
<Plus size={13} /> Start writing
</button>
</div>
)}
</div>
);
}
const visionPlaceholder = `# What we're building
A short paragraph on the problem and who feels it.
## Who it's for
Describe the user — their context, what they currently do, and why it's painful.
## What success looks like
What changes when this exists?
`;
// ──────────────────────────────────────────────────
// Sessions — read-only log of past chat threads scoped to this project.
// Title is auto-generated by the chat backend on first message; summary
// (when present) is filled in by the AI at end-of-thread. Clicking a row
// jumps the user back into that conversation in the side chat panel.
// ──────────────────────────────────────────────────
function SessionsPanel({
workspace,
projectId,
onCount,
}: {
workspace: string;
projectId: string;
onCount: (n: number) => void;
}) {
const [sessions, setSessions] = useState<Session[] | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(
`/api/chat/threads?workspace=${encodeURIComponent(workspace)}&projectId=${encodeURIComponent(projectId)}`,
{
credentials: "include",
},
)
.then((r) =>
r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
)
.then((d) => {
if (cancelled) return;
const list: Session[] = d.threads ?? [];
setSessions(list);
onCount(list.length);
})
.catch((e) => {
if (!cancelled) setErr(e?.message ?? "Failed to load sessions");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [workspace, projectId, onCount]);
return (
<div>
<PanelHeader
title="Sessions"
hint="Past chat sessions on this project. Each one captures what was attempted, decided, or shipped."
/>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: INK.mid,
fontSize: "0.85rem",
padding: "8px 0",
}}
>
<Loader2 size={13} className="animate-spin" /> Loading sessions
</div>
) : err ? (
<div style={errorBox}>
<AlertCircle
size={13}
style={{ marginRight: 6, verticalAlign: -2 }}
/>
{err}
</div>
) : !sessions || sessions.length === 0 ? (
<div style={emptyBox}>
No sessions yet.{" "}
<span style={{ display: "block", marginTop: 8, color: INK.mid }}>
Open the chat and ask anything each conversation becomes a session
entry here with an AI-generated summary.
</span>
<span style={promptNudge}>
Try: &quot;What should I build first for this project?&quot;
</span>
</div>
) : (
<ul style={list}>
{sessions.map((s) => (
<li key={s.id} style={{ ...sessionRow, padding: 0 }}>
<a
href={`/${workspace}/project/${projectId}/sessions/${s.id}`}
style={{
display: "flex",
alignItems: "flex-start",
gap: 12,
textDecoration: "none",
color: "inherit",
width: "100%",
padding: "12px 14px",
borderRadius: 8,
transition: "background 0.15s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.03)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "transparent")
}
>
<MessageSquare
size={14}
style={{ color: INK.mid, marginTop: 2, flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={sessionTitle}>{s.title}</div>
{s.summary && <div style={sessionSummary}>{s.summary}</div>}
<div style={sessionMeta}>
<span>
{s.messageCount}{" "}
{s.messageCount === 1 ? "message" : "messages"}
</span>
<span style={{ color: INK.muted }}>·</span>
<span>{relativeTime(s.updatedAt)}</span>
</div>
</div>
</a>
</li>
))}
</ul>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Tasks
// ──────────────────────────────────────────────────
// Tasks. Each task is a *scoped unit of work* with a markdown spec —
// a feature, a refactor, an investigation. The list-detail layout
// mirrors how engineers think: pick one off the queue, see the full
// brief, work it (or delegate it). Phase 2 will wire the "Delegate"
// button to a background agent runner that executes the task spec
// against the MCP and writes progress back here.
function taskTitle(t: Task): string {
return (t.title || t.text || "Untitled task").trim();
}
const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
open: "Open",
in_progress: "In progress",
review: "In review",
done: "Done",
blocked: "Blocked",
};
const TASK_STATUS_COLOR: Record<TaskStatus, string> = {
open: "#5f5e5a",
in_progress: "#1d6cd0",
review: "#f1a80a",
done: "#1f8c4d",
blocked: "#b94a3a",
};
function TasksPanel({
plan,
projectId,
onChange,
}: {
plan: Plan;
projectId: string;
onChange: (p: Plan) => void;
}) {
const tasks = plan.tasks;
const [selectedId, setSelectedId] = useState<string | null>(
() => tasks[0]?.id ?? null,
);
const [creating, setCreating] = useState(false);
const [filter, setFilter] = useState<"all" | "active" | "done">("active");
// Keep selection valid when the underlying list changes (delete / add).
useEffect(() => {
if (!selectedId && tasks.length > 0) {
setSelectedId(tasks[0].id);
return;
}
if (selectedId && !tasks.some((t) => t.id === selectedId)) {
setSelectedId(tasks[0]?.id ?? null);
}
}, [tasks, selectedId]);
const visible = tasks.filter((t) => {
if (filter === "all") return true;
if (filter === "done") return t.status === "done";
return t.status !== "done";
});
const selected = tasks.find((t) => t.id === selectedId) ?? null;
return (
<div>
<div style={visionHeaderRow}>
<h3 style={panelTitle}>Features</h3>
<button
onClick={() => {
setCreating(true);
setSelectedId(null);
}}
style={primaryBtn}
>
<Plus size={13} /> New Feature
</button>
</div>
<div style={taskFilterRow}>
{(["active", "done", "all"] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
style={filter === f ? taskFilterPillActive : taskFilterPill}
>
{f === "active" ? "Active" : f === "done" ? "Done" : "All"} ·{" "}
{f === "active"
? tasks.filter((t) => t.status !== "done").length
: f === "done"
? tasks.filter((t) => t.status === "done").length
: tasks.length}
</button>
))}
</div>
<div style={taskSplit}>
<div style={taskListCol}>
{visible.length === 0 && !creating ? (
<div style={{ ...emptyBox, marginTop: 0 }}>
{tasks.length === 0 ? (
<>
No features defined yet.
<span
style={{
display: "block",
marginTop: 8,
color: INK.mid,
fontSize: "0.8rem",
}}
>
Ask the AI to break down your objective it will create
scoped features automatically.
</span>
<span style={promptNudge}>
Try: &quot;Draft the features for this app&quot;
</span>
</>
) : (
"Nothing in this view."
)}
</div>
) : (
<div>
{(() => {
const groups: Record<string, typeof visible> = {};
for (const t of visible) {
const title = taskTitle(t);
const match = title.match(/^\[(.*?)\]\s*(.*)$/);
const groupName = match ? match[1] : "Uncategorized";
const cleanTitle = match ? match[2] : title;
if (!groups[groupName]) groups[groupName] = [];
groups[groupName].push({ ...t, title: cleanTitle });
}
const groupKeys = Object.keys(groups).sort((a, b) => {
if (a === "Uncategorized") return 1;
if (b === "Uncategorized") return -1;
return a.localeCompare(b);
});
return groupKeys.map(groupKey => (
<div key={groupKey} style={{ marginBottom: 16 }}>
<div style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "var(--fg-mute)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
marginLeft: 2,
}}>
{groupKey}
</div>
<ul style={taskList}>
{groups[groupKey].map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => {
setSelectedId(t.id);
setCreating(false);
}}
style={selectedId === t.id ? taskItemActive : taskItem}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span
style={{
...taskStatusDot,
background: TASK_STATUS_COLOR[t.status],
}}
/>
<span style={taskItemTitle}>{t.title}</span>
</div>
<div style={taskItemMeta}>
<span>{TASK_STATUS_LABEL[t.status]}</span>
<span style={{ color: INK.muted }}>·</span>
<span>
{relativeTime(t.doneAt ?? t.startedAt ?? t.createdAt)}
</span>
</div>
</button>
</li>
))}
</ul>
</div>
));
})()}
</div>
)}
</div>
<div style={taskDetailCol}>
{creating ? (
<TaskComposer
projectId={projectId}
onCancel={() => setCreating(false)}
onCreated={(plan, newId) => {
onChange(plan);
setCreating(false);
setSelectedId(newId);
}}
/>
) : selected ? (
<TaskDetail
task={selected}
projectId={projectId}
onChange={onChange}
/>
) : (
<div style={{ ...emptyBox, marginTop: 0 }}>
Pick a task on the left, or create a new one.
</div>
)}
</div>
</div>
</div>
);
}
// ─────────────────────────────────────
// Task detail — view + inline edit + status + delegate stub
// ─────────────────────────────────────
function TaskDetail({
task,
projectId,
onChange,
}: {
task: Task;
projectId: string;
onChange: (p: Plan) => void;
}) {
const [editing, setEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState(taskTitle(task));
const [descDraft, setDescDraft] = useState(task.description ?? "");
const [editorView, setEditorView] = useState<"write" | "preview">("write");
const [saving, setSaving] = useState(false);
// Reset drafts whenever the selected task changes.
useEffect(() => {
setTitleDraft(taskTitle(task));
setDescDraft(task.description ?? "");
setEditing(false);
}, [task.id, task.title, task.description, task]);
const patch = async (body: Record<string, unknown>) => {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "task", id: task.id, ...body }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
};
const save = async () => {
setSaving(true);
try {
await patch({ title: titleDraft.trim(), description: descDraft });
setEditing(false);
} finally {
setSaving(false);
}
};
const remove = async () => {
const r = await fetch(
`/api/projects/${projectId}/plan?kind=task&id=${task.id}`,
{ method: "DELETE" },
);
const d = await r.json();
if (d.plan) onChange(d.plan);
};
return (
<div style={taskDetailWrap}>
<div style={taskDetailHeader}>
{editing ? (
<input
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
placeholder="Task title"
style={{ ...input, fontSize: "1.05rem", fontWeight: 600 }}
/>
) : (
<h4 style={taskDetailTitle}>{taskTitle(task)}</h4>
)}
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
{!editing && (
<button onClick={() => setEditing(true)} style={ghostBtn}>
<Pencil size={12} /> Edit
</button>
)}
{!editing && (
<button onClick={remove} style={ghostBtn} title="Delete task">
<Trash2 size={12} />
</button>
)}
</div>
</div>
<div style={taskStatusRow}>
<span style={taskStatusLabel}>Status</span>
<select
value={task.status}
onChange={(e) => patch({ status: e.target.value })}
style={taskStatusSelect}
>
<option value="open">Open</option>
<option value="in_progress">In progress</option>
<option value="review">In review</option>
<option value="blocked">Blocked</option>
<option value="done">Done</option>
</select>
<span style={{ flex: 1 }} />
<button
type="button"
disabled={saving}
onClick={async () => {
if (
!confirm(
`Delegate feature "${task.title}" to an autonomous agent? It will execute this in the background, test it, and deploy.`,
)
)
return;
setSaving(true);
try {
const r = await fetch(
`/api/projects/${projectId}/agent/sessions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appName: "frontend",
appPath: ".",
task: `Task: ${task.title}\n\n${task.description || ""}`,
}),
},
);
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${r.status}`);
}
alert(
"Agent spawned successfully! It is now working in the background.",
);
} catch (err) {
alert(
`Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setSaving(false);
}
}}
style={{
...delegateBtn,
opacity: saving ? 0.5 : 1,
cursor: saving ? "wait" : "pointer",
}}
title="Delegate this task to a background AI agent"
>
<Sparkle /> Delegate to agent
</button>
</div>
{editing ? (
<>
<div style={editorTabs}>
<button
type="button"
onClick={() => setEditorView("write")}
style={editorView === "write" ? editorTabActive : editorTab}
>
<FileText size={12} /> Write
</button>
<button
type="button"
onClick={() => setEditorView("preview")}
style={editorView === "preview" ? editorTabActive : editorTab}
>
<Eye size={12} /> Preview
</button>
</div>
{editorView === "write" ? (
<textarea
value={descDraft}
onChange={(e) => setDescDraft(e.target.value)}
placeholder={taskPlaceholder}
style={visionTextarea}
spellCheck
/>
) : (
<div style={visionPreviewBox}>
{descDraft.trim() ? (
<div className="vibn-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{descDraft}
</ReactMarkdown>
</div>
) : (
<div style={{ color: INK.muted, fontStyle: "italic" }}>
Nothing to preview yet.
</div>
)}
</div>
)}
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button
onClick={save}
disabled={saving || !titleDraft.trim()}
style={primaryBtn}
>
{saving ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}{" "}
Save
</button>
<button
onClick={() => {
setTitleDraft(taskTitle(task));
setDescDraft(task.description ?? "");
setEditing(false);
}}
style={ghostBtn}
>
<X size={13} /> Cancel
</button>
</div>
</>
) : task.description?.trim() ? (
<div className="vibn-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{task.description}
</ReactMarkdown>
</div>
) : (
<div style={{ ...emptyBox, marginTop: 0 }}>
No spec yet. Click <em>Edit</em> and describe what success looks like
for this task context, acceptance criteria, gotchas. Markdown
supported.
</div>
)}
</div>
);
}
// ─────────────────────────────────────
// Task composer (new)
// ─────────────────────────────────────
function TaskComposer({
projectId,
onCancel,
onCreated,
}: {
projectId: string;
onCancel: () => void;
onCreated: (plan: Plan, taskId: string) => void;
}) {
const [title, setTitle] = useState("");
const [desc, setDesc] = useState("");
const [editorView, setEditorView] = useState<"write" | "preview">("write");
const [saving, setSaving] = useState(false);
const create = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "task",
title: title.trim(),
description: desc,
}),
});
const d = await r.json();
if (d.plan?.tasks?.[0]?.id) onCreated(d.plan, d.plan.tasks[0].id);
} finally {
setSaving(false);
}
};
return (
<div style={taskDetailWrap}>
<div style={taskDetailHeader}>
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What is this task? (e.g. 'Migrate auth to NextAuth v5')"
style={{ ...input, fontSize: "1.05rem", fontWeight: 600 }}
/>
</div>
<div style={editorTabs}>
<button
type="button"
onClick={() => setEditorView("write")}
style={editorView === "write" ? editorTabActive : editorTab}
>
<FileText size={12} /> Write
</button>
<button
type="button"
onClick={() => setEditorView("preview")}
style={editorView === "preview" ? editorTabActive : editorTab}
>
<Eye size={12} /> Preview
</button>
</div>
{editorView === "write" ? (
<textarea
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder={taskPlaceholder}
style={visionTextarea}
spellCheck
/>
) : (
<div style={visionPreviewBox}>
{desc.trim() ? (
<div className="vibn-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{desc}</ReactMarkdown>
</div>
) : (
<div style={{ color: INK.muted, fontStyle: "italic" }}>
Nothing to preview yet.
</div>
)}
</div>
)}
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button
onClick={create}
disabled={saving || !title.trim()}
style={primaryBtn}
>
{saving ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}{" "}
Create task
</button>
<button onClick={onCancel} style={ghostBtn}>
<X size={13} /> Cancel
</button>
</div>
</div>
);
}
const taskPlaceholder = `## Goal
What does success look like? One paragraph.
## Context
Why this matters now. Any background the agent or future-you needs.
## Acceptance criteria
- [ ] First observable thing that proves it's done
- [ ] Second
- [ ] ...
## Notes / gotchas
Edge cases, related decisions, links.
`;
function Sparkle() {
// tiny inline glyph — avoids pulling another lucide icon for a stub button
return (
<span
aria-hidden
style={{
display: "inline-block",
width: 12,
height: 12,
fontSize: 12,
lineHeight: 1,
}}
>
</span>
);
}
// ──────────────────────────────────────────────────
// Decisions
// ──────────────────────────────────────────────────
function DecisionsPanel({
plan,
projectId,
onChange,
}: {
plan: Plan;
projectId: string;
onChange: (p: Plan) => void;
}) {
const [adding, setAdding] = useState(false);
const [title, setTitle] = useState("");
const [choice, setChoice] = useState("");
const [why, setWhy] = useState("");
const [saving, setSaving] = useState(false);
const add = async () => {
if (!title.trim() || !choice.trim()) return;
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "decision",
title: title.trim(),
choice: choice.trim(),
why: why.trim() || undefined,
}),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
setTitle("");
setChoice("");
setWhy("");
setAdding(false);
} finally {
setSaving(false);
}
};
const remove = async (id: string) => {
const r = await fetch(
`/api/projects/${projectId}/plan?kind=decision&id=${id}`,
{ method: "DELETE" },
);
const d = await r.json();
if (d.plan) onChange(d.plan);
};
return (
<div>
<PanelHeader
title="Decisions"
hint="Once a question is settled, log it here. The AI reads decisions so it stops asking you to re-decide."
/>
{adding ? (
<div style={decisionForm}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Question or topic (e.g. 'Database choice')"
style={input}
/>
<input
value={choice}
onChange={(e) => setChoice(e.target.value)}
placeholder="What we chose (e.g. 'Postgres')"
style={input}
/>
<textarea
value={why}
onChange={(e) => setWhy(e.target.value)}
placeholder="Why (optional)"
rows={3}
style={textarea}
/>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={add}
disabled={saving || !title.trim() || !choice.trim()}
style={primaryBtn}
>
{saving ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}{" "}
Save
</button>
<button
onClick={() => {
setAdding(false);
setTitle("");
setChoice("");
setWhy("");
}}
style={ghostBtn}
>
<X size={13} /> Cancel
</button>
</div>
</div>
) : (
<button onClick={() => setAdding(true)} style={primaryBtn}>
<Plus size={13} /> Log a decision
</button>
)}
{plan.decisions.length === 0 ? (
<div style={{ ...emptyBox, marginTop: 14 }}>
No decisions yet the AI logs these automatically when you settle on
something in chat.
<span style={promptNudge}>
Try: &quot;Which database should I use for this project?&quot;
</span>
</div>
) : (
<ul style={{ ...list, marginTop: 14 }}>
{plan.decisions.map((d) => (
<li key={d.id} style={decisionRow}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={decisionTitle}>{d.title}</div>
<div style={decisionChoice}> {d.choice}</div>
{d.why && <div style={decisionWhy}>{d.why}</div>}
<div style={{ ...timestampStyle, marginTop: 4 }}>
{relativeTime(d.createdAt)}
</div>
</div>
<button
onClick={() => remove(d.id)}
style={iconBtn}
title="Delete"
>
<Trash2 size={13} />
</button>
</li>
))}
</ul>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function PanelHeader({ title, hint }: { title: string; hint: string }) {
return (
<div style={{ marginBottom: 14 }}>
<h3 style={panelTitle}>{title}</h3>
<p style={panelHint}>{hint}</p>
</div>
);
}
function relativeTime(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`;
return new Date(iso).toLocaleDateString();
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
fontSerif: '"Newsreader", "Lora", Georgia, serif',
} as const;
const pageWrap: React.CSSProperties = {
padding: "28px 48px 48px",
fontFamily: INK.fontSans,
color: INK.ink,
};
/** Vertical stack: horizontal section tabs, then full-width content. */
const planStack: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 16,
maxWidth: "min(1400px, 100%)",
margin: "0 auto",
minHeight: 0,
flex: 1,
};
const planNav: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "10px 14px",
rowGap: 10,
};
const tileRow: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 8,
flex: "1 1 200px",
minWidth: 0,
};
const planMain: React.CSSProperties = {
minWidth: 0,
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
};
const heading: React.CSSProperties = {
fontSize: "0.72rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
margin: 0,
flexShrink: 0,
};
const tileButton: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 8,
width: "auto",
padding: "8px 14px",
border: "1px solid transparent",
borderRadius: 8,
cursor: "pointer",
font: "inherit",
color: "inherit",
transition: "background 0.12s, border-color 0.12s",
whiteSpace: "nowrap",
};
const tileLabel: React.CSSProperties = { fontSize: "0.82rem", fontWeight: 600 };
const countPill: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const panel: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 22,
flex: 1,
minHeight: 0,
overflowY: "auto",
};
const panelTitle: React.CSSProperties = {
fontFamily: INK.fontSerif,
fontSize: "1.4rem",
fontWeight: 400,
letterSpacing: "-0.02em",
margin: 0,
};
const panelHint: React.CSSProperties = {
fontSize: "0.82rem",
color: INK.mid,
marginTop: 4,
lineHeight: 1.5,
};
const subHeading: React.CSSProperties = {
fontSize: "0.68rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
margin: "14px 0 8px",
};
const visionHeaderRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
marginBottom: 18,
};
const editorTabs: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 4,
borderBottom: `1px solid ${INK.border}`,
marginBottom: 12,
paddingBottom: 0,
};
const editorTab: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
fontSize: "0.78rem",
fontWeight: 500,
background: "transparent",
color: INK.mid,
border: "none",
borderBottom: "2px solid transparent",
cursor: "pointer",
fontFamily: "inherit",
marginBottom: -1,
};
const editorTabActive: React.CSSProperties = {
...editorTab,
color: INK.ink,
fontWeight: 600,
borderBottomColor: INK.ink,
};
const dirtyDot: React.CSSProperties = {
fontSize: "0.72rem",
color: "#b76b00",
fontWeight: 500,
};
const visionTextarea: React.CSSProperties = {
width: "100%",
minHeight: 360,
padding: "14px 16px",
fontSize: "0.92rem",
lineHeight: 1.6,
fontFamily: '"JetBrains Mono", "SF Mono", ui-monospace, monospace',
border: `1px solid ${INK.border}`,
borderRadius: 8,
background: "#fdfcf9",
color: INK.ink,
resize: "vertical",
outline: "none",
boxSizing: "border-box",
};
const visionPreviewBox: React.CSSProperties = {
minHeight: 360,
padding: "16px 20px",
border: `1px solid ${INK.border}`,
borderRadius: 8,
background: "#fff",
boxSizing: "border-box",
};
const emptyVisionBox: React.CSSProperties = {
padding: "22px 24px",
border: `1px dashed ${INK.borderSoft}`,
borderRadius: 10,
background: "#fafaf6",
};
const addRow: React.CSSProperties = {
display: "flex",
gap: 8,
marginBottom: 14,
};
const input: React.CSSProperties = {
flex: 1,
padding: "9px 12px",
fontSize: "0.88rem",
border: `1px solid ${INK.border}`,
borderRadius: 8,
fontFamily: "inherit",
background: "#fff",
color: INK.ink,
};
const textarea: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
fontSize: "0.9rem",
border: `1px solid ${INK.border}`,
borderRadius: 8,
fontFamily: "inherit",
background: "#fff",
color: INK.ink,
resize: "vertical",
lineHeight: 1.5,
};
const list: React.CSSProperties = {
listStyle: "none",
padding: 0,
margin: 0,
display: "flex",
flexDirection: "column",
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
overflow: "hidden",
};
const listRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "11px 14px",
fontSize: "0.88rem",
borderBottom: `1px solid ${INK.borderSoft}`,
background: "#fff",
};
// Tasks (master/detail)
const taskFilterRow: React.CSSProperties = {
display: "flex",
gap: 6,
marginBottom: 14,
};
const taskFilterPill: React.CSSProperties = {
padding: "5px 11px",
fontSize: "0.74rem",
fontWeight: 500,
background: "transparent",
color: INK.mid,
border: `1px solid ${INK.border}`,
borderRadius: 999,
cursor: "pointer",
fontFamily: "inherit",
};
const taskFilterPillActive: React.CSSProperties = {
...taskFilterPill,
background: INK.ink,
color: "#fff",
borderColor: INK.ink,
};
const taskSplit: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(220px, 280px) 1fr",
gap: 18,
alignItems: "stretch",
minHeight: 320,
};
const taskListCol: React.CSSProperties = {
minWidth: 0,
borderRight: `1px solid ${INK.borderSoft}`,
paddingRight: 14,
maxHeight: 540,
overflowY: "auto",
};
const taskDetailCol: React.CSSProperties = {
minWidth: 0,
paddingLeft: 4,
};
const taskList: React.CSSProperties = {
listStyle: "none",
padding: 0,
margin: 0,
display: "flex",
flexDirection: "column",
gap: 4,
};
const taskItem: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 4,
width: "100%",
padding: "10px 12px",
background: "transparent",
border: `1px solid transparent`,
borderRadius: 8,
cursor: "pointer",
textAlign: "left",
fontFamily: "inherit",
color: "inherit",
};
const taskItemActive: React.CSSProperties = {
...taskItem,
background: "#fff",
border: `1px solid ${INK.border}`,
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
};
const taskItemTitle: React.CSSProperties = {
fontSize: "0.88rem",
fontWeight: 600,
color: INK.ink,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
};
const taskStatusDot: React.CSSProperties = {
display: "inline-block",
width: 8,
height: 8,
borderRadius: 999,
flexShrink: 0,
};
const taskItemMeta: React.CSSProperties = {
display: "flex",
gap: 6,
alignItems: "center",
fontSize: "0.7rem",
color: INK.muted,
paddingLeft: 16,
};
const taskDetailWrap: React.CSSProperties = {
display: "flex",
flexDirection: "column",
minHeight: 0,
};
const taskDetailHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 12,
};
const taskDetailTitle: React.CSSProperties = {
fontFamily: INK.fontSerif,
fontSize: "1.25rem",
fontWeight: 400,
letterSpacing: "-0.01em",
margin: 0,
};
const taskStatusRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
marginBottom: 14,
background: "#fafaf6",
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
};
const taskStatusLabel: React.CSSProperties = {
fontSize: "0.72rem",
textTransform: "uppercase",
letterSpacing: "0.1em",
color: INK.muted,
fontWeight: 600,
};
const taskStatusSelect: React.CSSProperties = {
padding: "6px 10px",
fontSize: "0.82rem",
border: `1px solid ${INK.border}`,
borderRadius: 6,
background: "#fff",
color: INK.ink,
fontFamily: "inherit",
cursor: "pointer",
};
const delegateBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
fontSize: "0.78rem",
fontWeight: 500,
background: "#fff",
color: INK.mid,
border: `1px solid ${INK.border}`,
borderRadius: 6,
cursor: "not-allowed",
fontFamily: "inherit",
opacity: 0.7,
};
const sessionRow: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 12,
padding: "12px 14px",
borderBottom: `1px solid ${INK.borderSoft}`,
background: "#fff",
};
const sessionTitle: React.CSSProperties = {
fontSize: "0.9rem",
fontWeight: 600,
color: INK.ink,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const sessionSummary: React.CSSProperties = {
fontSize: "0.82rem",
color: INK.mid,
marginTop: 4,
lineHeight: 1.5,
};
const sessionMeta: React.CSSProperties = {
display: "flex",
gap: 8,
alignItems: "center",
fontSize: "0.72rem",
color: INK.muted,
marginTop: 6,
};
const decisionRow: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 14px",
borderBottom: `1px solid ${INK.borderSoft}`,
background: "#fff",
};
const decisionTitle: React.CSSProperties = {
fontSize: "0.88rem",
fontWeight: 600,
color: INK.ink,
};
const decisionChoice: React.CSSProperties = {
fontSize: "0.88rem",
color: INK.ink,
marginTop: 2,
};
const decisionWhy: React.CSSProperties = {
fontSize: "0.82rem",
color: INK.mid,
marginTop: 6,
lineHeight: 1.5,
};
const decisionForm: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 8,
marginBottom: 14,
};
const timestampStyle: React.CSSProperties = {
fontSize: "0.7rem",
color: INK.muted,
flexShrink: 0,
};
const primaryBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
fontSize: "0.82rem",
fontWeight: 600,
background: "#1a1a1a",
color: "#fff",
border: "none",
borderRadius: 8,
cursor: "pointer",
fontFamily: "inherit",
};
const ghostBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
fontSize: "0.82rem",
fontWeight: 500,
background: "transparent",
color: INK.mid,
border: `1px solid ${INK.border}`,
borderRadius: 8,
cursor: "pointer",
fontFamily: "inherit",
};
const iconBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
padding: 0,
background: "transparent",
color: INK.muted,
border: "none",
borderRadius: 6,
cursor: "pointer",
flexShrink: 0,
};
const checkbox: React.CSSProperties = {
width: 22,
height: 22,
padding: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
background: "transparent",
border: "none",
cursor: "pointer",
flexShrink: 0,
color: INK.mid,
};
const emptyBox: React.CSSProperties = {
padding: "16px 18px",
fontSize: "0.85rem",
color: INK.mid,
background: "#fafaf6",
border: `1px dashed ${INK.borderSoft}`,
borderRadius: 8,
lineHeight: 1.5,
textAlign: "center",
};
const promptNudge: React.CSSProperties = {
display: "block",
marginTop: 10,
background: "#f3eee4",
borderRadius: 5,
padding: "5px 10px",
fontSize: "0.76rem",
color: INK.mid,
fontStyle: "italic",
};
const errorBox: React.CSSProperties = {
padding: "12px 14px",
fontSize: "0.85rem",
color: "#7a1f15",
background: "#fbe9e7",
border: `1px solid #f4c2bc`,
borderRadius: 8,
};