2440 lines
68 KiB
TypeScript
2440 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: "Tasks",
|
|
icon: ListTodo,
|
|
blurb: "What needs to happen next.",
|
|
},
|
|
{
|
|
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();
|
|
const intervalId = setInterval(load, 5000);
|
|
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'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: "What should I build first for this project?"
|
|
</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}>Tasks</h3>
|
|
<button
|
|
onClick={() => {
|
|
setCreating(true);
|
|
setSelectedId(null);
|
|
}}
|
|
style={primaryBtn}
|
|
>
|
|
<Plus size={13} /> New task
|
|
</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 tasks yet.
|
|
<span
|
|
style={{
|
|
display: "block",
|
|
marginTop: 8,
|
|
color: INK.mid,
|
|
fontSize: "0.8rem",
|
|
}}
|
|
>
|
|
Ask the AI to break down your first feature — it will create
|
|
scoped tasks automatically.
|
|
</span>
|
|
<span style={promptNudge}>
|
|
Try: "Add user authentication to my app"
|
|
</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 "${task.title}" to an autonomous agent? It will execute this task in the background and commit the results.`,
|
|
)
|
|
)
|
|
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: "Which database should I use for this project?"
|
|
</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,
|
|
};
|