Files
vibn-frontend/components/project-main/FreshIdeaMain.tsx
Mark Henderson 6f79a88abd fix(gitea-bot): add write:organization scope so bot can create repos
Without this the bot PAT 403s on POST /orgs/{org}/repos, which is
the single most important operation — creating new project repos
inside the workspace's Gitea org.

Made-with: Cursor
2026-04-21 11:05:55 -07:00

706 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { AtlasChat } from "@/components/AtlasChat";
import { useRouter, useParams } from "next/navigation";
import Link from "next/link";
import { ArrowUpDown, Filter, LayoutPanelLeft, Search } from "lucide-react";
import { JM, JV } from "@/components/project-creation/modal-theme";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
const DISCOVERY_PHASES = [
"big_picture",
"users_personas",
"features_scope",
"business_model",
"screens_data",
"risks_questions",
] as const;
const PHASE_DISPLAY: Record<string, string> = {
big_picture: "Big picture",
users_personas: "Users & personas",
features_scope: "Features & scope",
business_model: "Business model",
screens_data: "Screens & data",
risks_questions: "Risks & questions",
};
// Maps discovery phases → the PRD sections they populate
const PRD_SECTIONS: { label: string; phase: string | null }[] = [
{ label: "Executive Summary", phase: "big_picture" },
{ label: "Problem Statement", phase: "big_picture" },
{ label: "Vision & Success Metrics", phase: "big_picture" },
{ label: "Users & Personas", phase: "users_personas" },
{ label: "User Flows", phase: "users_personas" },
{ label: "Feature Requirements", phase: "features_scope" },
{ label: "Screen Specs", phase: "features_scope" },
{ label: "Business Model", phase: "business_model" },
{ label: "Integrations & Dependencies", phase: "screens_data" },
{ label: "Non-Functional Reqs", phase: "features_scope" },
{ label: "Risks & Mitigations", phase: "risks_questions" },
{ label: "Open Questions", phase: "risks_questions" },
];
type SidebarTab = "tasks" | "phases";
type GroupBy = "none" | "phase" | "status";
function sectionDone(
phase: string | null,
savedPhaseIds: Set<string>,
allDone: boolean
): boolean {
return phase === null ? allDone : savedPhaseIds.has(phase);
}
interface FreshIdeaMainProps {
projectId: string;
projectName: string;
}
export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const [savedPhaseIds, setSavedPhaseIds] = useState<Set<string>>(new Set());
const [allDone, setAllDone] = useState(false);
const [prdLoading, setPrdLoading] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [hasPrd, setHasPrd] = useState(false);
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("tasks");
const [sectionSearch, setSectionSearch] = useState("");
const [phaseScope, setPhaseScope] = useState<string>("all");
const [groupBy, setGroupBy] = useState<GroupBy>("none");
const [pendingOnly, setPendingOnly] = useState(false);
const [sortAlpha, setSortAlpha] = useState(false);
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
const addSectionToChat = useCallback((label: string, phase: string | null) => {
setChatContextRefs(prev => {
const next: ChatContextRef = { kind: "section", label, phaseId: phase };
const k = contextRefKey(next);
if (prev.some(r => contextRefKey(r) === k)) return prev;
return [...prev, next];
});
}, []);
const addPhaseToChat = useCallback((phaseId: string, label: string) => {
setChatContextRefs(prev => {
const next: ChatContextRef = { kind: "phase", phaseId, label };
const k = contextRefKey(next);
if (prev.some(r => contextRefKey(r) === k)) return prev;
return [...prev, next];
});
}, []);
const removeChatContextRef = useCallback((key: string) => {
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
}, []);
useEffect(() => {
// Check if PRD already exists on the project
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => { if (d.project?.prd) setHasPrd(true); })
.catch(() => {});
const poll = () => {
fetch(`/api/projects/${projectId}/save-phase`)
.then(r => r.json())
.then(d => {
const ids = new Set<string>((d.phases ?? []).map((p: { phase: string }) => p.phase));
setSavedPhaseIds(ids);
const done = DISCOVERY_PHASES.every(id => ids.has(id));
setAllDone(done);
})
.catch(() => {});
};
poll();
const interval = setInterval(poll, 8_000);
return () => clearInterval(interval);
}, [projectId]);
const handleGeneratePRD = async () => {
if (prdLoading) return;
setPrdLoading(true);
try {
router.push(`/${workspace}/project/${projectId}/tasks`);
} finally {
setPrdLoading(false);
}
};
const handleMVP = () => {
router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
};
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
const completedSections = PRD_SECTIONS.filter(({ phase }) =>
phase === null ? allDone : savedPhaseIds.has(phase)
).length;
const totalSections = PRD_SECTIONS.length;
const filteredSections = useMemo(() => {
const q = sectionSearch.trim().toLowerCase();
let rows = PRD_SECTIONS.map((s, index) => ({ ...s, index }));
if (q) {
rows = rows.filter(r => r.label.toLowerCase().includes(q));
}
if (phaseScope !== "all") {
rows = rows.filter(r => r.phase === phaseScope);
}
if (pendingOnly) {
rows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
}
if (sortAlpha) {
rows = [...rows].sort((a, b) => a.label.localeCompare(b.label));
} else {
rows = [...rows].sort((a, b) => a.index - b.index);
}
return rows;
}, [sectionSearch, phaseScope, pendingOnly, sortAlpha, savedPhaseIds, allDone]);
const effectiveGroupBy: GroupBy = sidebarTab === "phases" ? "phase" : groupBy;
return (
<div style={{
height: "100%", display: "flex", flexDirection: "row", overflow: "hidden",
fontFamily: JM.fontSans,
}}>
{/* ── Left: Atlas chat (Justine describe column) ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{hasPrd && (
<div style={{
background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
padding: "10px 20px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexShrink: 0,
borderBottom: `1px solid rgba(255,255,255,0.12)`,
}}>
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.92)", fontFamily: JM.fontSans }}>
PRD saved keep refining here or open the full document.
</div>
<Link
href={`/${workspace}/project/${projectId}/tasks`}
style={{
padding: "6px 14px", borderRadius: 8,
background: "#fff", color: JM.ink,
fontSize: 12, fontWeight: 600,
textDecoration: "none", flexShrink: 0,
fontFamily: JM.fontSans,
}}
>
View PRD
</Link>
</div>
)}
{allDone && !dismissed && !hasPrd && (
<div style={{
background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
padding: "14px 20px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexShrink: 0, flexWrap: "wrap",
borderBottom: `1px solid rgba(255,255,255,0.12)`,
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: "#fff", fontFamily: JM.fontDisplay, marginBottom: 2 }}>
Discovery complete what&apos;s next?
</div>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.75)", fontFamily: JM.fontSans }}>
All 6 phases captured. Generate your PRD or open the MVP plan flow.
</div>
</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0, alignItems: "center" }}>
<button
type="button"
onClick={handleGeneratePRD}
disabled={prdLoading}
style={{
padding: "8px 16px", borderRadius: 8, border: "none",
background: "#fff", color: JM.ink,
fontSize: 13, fontWeight: 700,
fontFamily: JM.fontSans, cursor: "pointer",
}}
>
{prdLoading ? "Navigating…" : "Generate PRD →"}
</button>
<button
type="button"
onClick={handleMVP}
style={{
padding: "8px 16px", borderRadius: 8,
border: "1px solid rgba(255,255,255,0.35)",
background: "transparent", color: "#fff",
fontSize: 13, fontWeight: 600,
fontFamily: JM.fontSans, cursor: "pointer",
}}
>
Plan MVP
</button>
<button
type="button"
onClick={() => setDismissed(true)}
style={{
background: "none", border: "none", cursor: "pointer",
color: "rgba(255,255,255,0.55)", fontSize: 18, padding: "4px 6px",
}}
title="Dismiss"
>
×
</button>
</div>
</div>
)}
<AtlasChat
projectId={projectId}
projectName={projectName}
chatContextRefs={chatContextRefs}
onRemoveChatContextRef={removeChatContextRef}
/>
</div>
{/* ── Right: Teams-style task rail (requirements = PRD sections as tasks) ── */}
<div style={{
width: 348, flexShrink: 0,
background: "#F4F2FA",
borderLeft: `1px solid ${JM.border}`,
display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
{/* Tab bar */}
<div style={{
display: "flex", alignItems: "center",
borderBottom: `1px solid ${JM.border}`,
flexShrink: 0,
padding: "0 8px",
gap: 2,
background: "#FAF8FF",
}}>
<span style={{ display: "flex", padding: "10px 6px", color: JM.muted }} title="Panel">
<LayoutPanelLeft size={16} strokeWidth={1.75} />
</span>
{([
{ id: "tasks" as const, label: "Tasks" },
{ id: "phases" as const, label: "Phases" },
]).map(t => {
const active = sidebarTab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setSidebarTab(t.id)}
style={{
padding: "10px 12px 8px",
border: "none",
background: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? JM.ink : JM.muted,
fontFamily: JM.fontSans,
borderBottom: active ? `2px solid ${JM.indigo}` : "2px solid transparent",
marginBottom: -1,
}}
>
{t.label}
</button>
);
})}
</div>
{/* Search + tools */}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "8px 10px",
borderBottom: `1px solid ${JM.border}`,
background: "#FAF8FF",
}}>
<Search size={15} strokeWidth={1.75} color={JM.muted} style={{ flexShrink: 0 }} />
<input
type="search"
value={sectionSearch}
onChange={e => setSectionSearch(e.target.value)}
placeholder="Search sections…"
aria-label="Search sections"
style={{
flex: 1, minWidth: 0,
border: "none", background: "transparent",
fontSize: 12, fontFamily: JM.fontSans,
color: JM.ink, outline: "none",
}}
/>
<button
type="button"
title={sortAlpha ? "Sort: document order" : "Sort: AZ"}
onClick={() => setSortAlpha(s => !s)}
style={{
border: "none", background: sortAlpha ? JV.violetTint : "transparent",
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
}}
>
<ArrowUpDown size={15} strokeWidth={1.75} />
</button>
<button
type="button"
title={pendingOnly ? "Show all sections" : "Pending only"}
onClick={() => setPendingOnly(p => !p)}
style={{
border: "none", background: pendingOnly ? JV.violetTint : "transparent",
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
}}
>
<Filter size={15} strokeWidth={1.75} />
</button>
</div>
{/* Scope + group (Tasks tab only shows group pills; Phases tab locks grouping) */}
<div style={{
padding: "8px 10px 10px",
borderBottom: `1px solid ${JM.border}`,
background: "#F7F5FC",
}}>
<select
value={phaseScope}
onChange={e => setPhaseScope(e.target.value)}
aria-label="Filter by discovery phase"
style={{
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${JM.border}`,
background: "#fff",
fontSize: 12,
fontFamily: JM.fontSans,
color: JM.ink,
marginBottom: 8,
cursor: "pointer",
}}
>
<option value="all">All sections</option>
{DISCOVERY_PHASES.map(p => (
<option key={p} value={p}>{PHASE_DISPLAY[p]}</option>
))}
</select>
{sidebarTab === "tasks" && (
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
<span style={{ fontSize: 10, fontWeight: 600, color: JM.muted, fontFamily: JM.fontSans }}>
Group by
</span>
{([
{ id: "none" as const, label: "None" },
{ id: "phase" as const, label: "Phase" },
{ id: "status" as const, label: "Status" },
]).map(opt => {
const on = groupBy === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => setGroupBy(opt.id)}
style={{
padding: "4px 10px",
borderRadius: 999,
border: `1px solid ${on ? JM.indigo : JM.border}`,
background: on ? JV.violetTint : "#fff",
fontSize: 11,
fontWeight: on ? 600 : 500,
color: on ? JM.indigo : JM.mid,
fontFamily: JM.fontSans,
cursor: "pointer",
}}
>
{opt.label}
</button>
);
})}
</div>
)}
{sidebarTab === "phases" && (
<div style={{ fontSize: 11, color: JM.muted, fontFamily: JM.fontSans }}>
Grouped by discovery phase
</div>
)}
</div>
{/* Progress summary */}
<div style={{
padding: "10px 12px",
borderBottom: `1px solid ${JM.border}`,
background: "#F4F2FA",
flexShrink: 0,
}}>
<div style={{ height: 3, background: "#E0E7FF", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", borderRadius: 99,
background: JM.primaryGradient,
width: `${Math.round((completedSections / totalSections) * 100)}%`,
transition: "width 0.4s ease",
}} />
</div>
<div style={{ fontSize: 10, color: JM.muted, marginTop: 6, fontFamily: JM.fontSans }}>
{completedSections} of {totalSections} sections · Requirements task
</div>
<div style={{ fontSize: 10, color: JM.indigo, marginTop: 5, fontFamily: JM.fontSans, opacity: 0.9 }}>
Click a section row or phase header to attach it to your next message.
</div>
</div>
{/* Task list */}
<div style={{ flex: 1, overflowY: "auto", background: "#F4F2FA" }}>
{(() => {
const rows = filteredSections;
if (rows.length === 0) {
return (
<div style={{
padding: "28px 16px",
textAlign: "center",
fontSize: 12,
color: JM.muted,
fontFamily: JM.fontSans,
lineHeight: 1.5,
}}>
No sections match your search or filters.
</div>
);
}
const renderRow = (label: string, phase: string | null, key: string) => {
const isDone = sectionDone(phase, savedPhaseIds, allDone);
const phaseSlug = phase ? phase.replace(/_/g, "-") : "prd";
const phaseLine = phase ? PHASE_DISPLAY[phase] ?? phase : "PRD";
return (
<button
key={key}
type="button"
title="Add this section to chat context for Vibn"
onClick={() => addSectionToChat(label, phase)}
style={{
padding: "10px 12px",
borderBottom: `1px solid rgba(229,231,235,0.85)`,
borderTop: "none",
borderLeft: "none",
borderRight: "none",
display: "flex", gap: 10, alignItems: "flex-start",
background: isDone ? "rgba(237,233,254,0.55)" : "transparent",
width: "100%",
textAlign: "left",
cursor: "pointer",
font: "inherit",
}}
>
<div style={{
width: 22, height: 22, borderRadius: "50%", flexShrink: 0, marginTop: 1,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700,
background: isDone ? JM.indigo : "#fff",
border: isDone ? "none" : `1.5px solid ${JM.border}`,
color: isDone ? "#fff" : "transparent",
fontFamily: JM.fontSans,
}}>
{isDone ? "✓" : ""}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: JM.ink,
lineHeight: 1.3,
fontFamily: JM.fontSans,
}}>
{label}
</div>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginTop: 4,
}}>
<span style={{
fontSize: 11,
fontWeight: 500,
color: JM.indigo,
fontFamily: JM.fontSans,
}}>
{phaseSlug}
</span>
<span style={{
fontSize: 10,
fontWeight: 600,
color: isDone ? "#059669" : JM.muted,
fontFamily: JM.fontSans,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}>
{isDone ? "Done" : "Pending"}
</span>
</div>
<div style={{
fontSize: 10,
color: JM.muted,
marginTop: 3,
fontFamily: JM.fontSans,
lineHeight: 1.35,
}}>
Discovery · {phaseLine}
{!isDone ? " · complete in chat" : ""}
</div>
</div>
</button>
);
};
if (effectiveGroupBy === "none") {
return rows.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`));
}
if (effectiveGroupBy === "phase") {
const byPhase = new Map<string, typeof rows>();
for (const r of rows) {
const pk = r.phase ?? "null";
if (!byPhase.has(pk)) byPhase.set(pk, []);
byPhase.get(pk)!.push(r);
}
const order = [...DISCOVERY_PHASES, "null"];
return order.flatMap(pk => {
const list = byPhase.get(pk);
if (!list?.length) return [];
const header = pk === "null" ? "Final" : PHASE_DISPLAY[pk] ?? pk;
const phaseClickable = pk !== "null";
return [
phaseClickable ? (
<button
key={`h-${pk}`}
type="button"
title={`Add discovery phase "${header}" to chat context`}
onClick={() => addPhaseToChat(pk, header)}
style={{
display: "block",
width: "100%",
padding: "8px 12px 6px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
border: "none",
borderBottom: `1px solid ${JM.border}`,
cursor: "pointer",
textAlign: "left",
}}
>
{header}
</button>
) : (
<div
key={`h-${pk}`}
style={{
padding: "8px 12px 4px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
borderBottom: `1px solid ${JM.border}`,
}}
>
{header}
</div>
),
...list.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`)),
];
});
}
const doneRows = rows.filter(r => sectionDone(r.phase, savedPhaseIds, allDone));
const todoRows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
const statusBlocks: ReactNode[] = [];
if (todoRows.length > 0) {
statusBlocks.push(
<div
key="h-todo"
style={{
padding: "8px 12px 4px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
borderBottom: `1px solid ${JM.border}`,
}}
>
To do
</div>
);
todoRows.forEach(r => {
statusBlocks.push(renderRow(r.label, r.phase, `todo-${r.label}-${r.index}`));
});
}
if (doneRows.length > 0) {
statusBlocks.push(
<div
key="h-done"
style={{
padding: "8px 12px 4px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
borderBottom: `1px solid ${JM.border}`,
}}
>
Done
</div>
);
doneRows.forEach(r => {
statusBlocks.push(renderRow(r.label, r.phase, `done-${r.label}-${r.index}`));
});
}
return statusBlocks;
})()}
</div>
{allDone && (
<div style={{ padding: "10px 12px", borderTop: `1px solid ${JM.border}`, flexShrink: 0, background: "#FAF8FF" }}>
<Link
href={`/${workspace}/project/${projectId}/tasks`}
style={{
display: "block", textAlign: "center",
padding: "10px 0", borderRadius: 8,
background: JM.primaryGradient,
color: "#fff",
fontSize: 12, fontWeight: 600,
textDecoration: "none",
fontFamily: JM.fontSans,
boxShadow: JM.primaryShadow,
}}
>
Open Tasks
</Link>
</div>
)}
</div>
</div>
);
}