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
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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",
|
||||
@@ -12,7 +18,16 @@ const DISCOVERY_PHASES = [
|
||||
"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 }[] = [
|
||||
@@ -30,6 +45,17 @@ const PRD_SECTIONS: { label: string; phase: string | null }[] = [
|
||||
{ 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;
|
||||
@@ -45,6 +71,35 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||||
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
|
||||
@@ -73,14 +128,14 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||||
if (prdLoading) return;
|
||||
setPrdLoading(true);
|
||||
try {
|
||||
router.push(`/${workspace}/project/${projectId}/prd`);
|
||||
router.push(`/${workspace}/project/${projectId}/tasks`);
|
||||
} finally {
|
||||
setPrdLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMVP = () => {
|
||||
router.push(`/${workspace}/project/${projectId}/build`);
|
||||
router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
|
||||
};
|
||||
|
||||
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
|
||||
@@ -90,29 +145,57 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||||
).length;
|
||||
const totalSections = PRD_SECTIONS.length;
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "row", overflow: "hidden" }}>
|
||||
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]);
|
||||
|
||||
{/* ── Left: Atlas chat ── */}
|
||||
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 }}>
|
||||
|
||||
{/* PRD ready notice — replaces the decision banner once PRD is saved */}
|
||||
{hasPrd && (
|
||||
<div style={{
|
||||
background: "#1a1a1a", padding: "10px 20px",
|
||||
background: JM.primaryGradient,
|
||||
boxShadow: JM.primaryShadow,
|
||||
padding: "10px 20px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexShrink: 0, borderBottom: "1px solid #333",
|
||||
gap: 16, flexShrink: 0,
|
||||
borderBottom: `1px solid rgba(255,255,255,0.12)`,
|
||||
}}>
|
||||
<div style={{ fontSize: "0.8rem", color: "#e8e4dc", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
✦ PRD saved — you can keep refining here or view the full document.
|
||||
<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}/prd`}
|
||||
href={`/${workspace}/project/${projectId}/tasks`}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 7,
|
||||
background: "#fff", color: "#1a1a1a",
|
||||
fontSize: "0.76rem", fontWeight: 600,
|
||||
padding: "6px 14px", borderRadius: 8,
|
||||
background: "#fff", color: JM.ink,
|
||||
fontSize: 12, fontWeight: 600,
|
||||
textDecoration: "none", flexShrink: 0,
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
View PRD →
|
||||
@@ -120,151 +203,499 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision banner — shown when all 6 phases are saved but PRD not yet generated */}
|
||||
{allDone && !dismissed && !hasPrd && (
|
||||
<div style={{
|
||||
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)",
|
||||
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 #333",
|
||||
borderBottom: `1px solid rgba(255,255,255,0.12)`,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#fff", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 2 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#fff", fontFamily: JM.fontDisplay, marginBottom: 2 }}>
|
||||
✦ Discovery complete — what's next?
|
||||
</div>
|
||||
<div style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
All 6 phases captured. Generate your PRD or jump into Build.
|
||||
<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 }}>
|
||||
<div style={{ display: "flex", gap: 8, flexShrink: 0, alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePRD}
|
||||
disabled={prdLoading}
|
||||
style={{
|
||||
padding: "8px 16px", borderRadius: 7, border: "none",
|
||||
background: "#fff", color: "#1a1a1a",
|
||||
fontSize: "0.8rem", fontWeight: 700,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||
transition: "opacity 0.12s",
|
||||
padding: "8px 16px", borderRadius: 8, border: "none",
|
||||
background: "#fff", color: JM.ink,
|
||||
fontSize: 13, fontWeight: 700,
|
||||
fontFamily: JM.fontSans, cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||
>
|
||||
{prdLoading ? "Navigating…" : "Generate PRD →"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMVP}
|
||||
style={{
|
||||
padding: "8px 16px", borderRadius: 7,
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
padding: "8px 16px", borderRadius: 8,
|
||||
border: "1px solid rgba(255,255,255,0.35)",
|
||||
background: "transparent", color: "#fff",
|
||||
fontSize: "0.8rem", fontWeight: 600,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||
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: "#888", fontSize: "1rem", padding: "4px 6px",
|
||||
color: "rgba(255,255,255,0.55)", fontSize: 18, padding: "4px 6px",
|
||||
}}
|
||||
title="Dismiss"
|
||||
>×</button>
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AtlasChat projectId={projectId} projectName={projectName} />
|
||||
<AtlasChat
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
chatContextRefs={chatContextRefs}
|
||||
onRemoveChatContextRef={removeChatContextRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Right: PRD section tracker ── */}
|
||||
{/* ── Right: Teams-style task rail (requirements = PRD sections as tasks) ── */}
|
||||
<div style={{
|
||||
width: 240, flexShrink: 0,
|
||||
background: "#faf8f5",
|
||||
borderLeft: "1px solid #e8e4dc",
|
||||
width: 348, flexShrink: 0,
|
||||
background: "#F4F2FA",
|
||||
borderLeft: `1px solid ${JM.border}`,
|
||||
display: "flex", flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{/* Header */}
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
padding: "14px 16px 10px",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
display: "flex", alignItems: "center",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
flexShrink: 0,
|
||||
padding: "0 8px",
|
||||
gap: 2,
|
||||
background: "#FAF8FF",
|
||||
}}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#1a1a1a", letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
|
||||
PRD Sections
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div style={{ height: 3, background: "#e8e4dc", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 99,
|
||||
background: "#1a1a1a",
|
||||
width: `${Math.round((completedSections / totalSections) * 100)}%`,
|
||||
transition: "width 0.4s ease",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: "0.68rem", color: "#a09a90", marginTop: 5 }}>
|
||||
{completedSections} of {totalSections} sections complete
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0" }}>
|
||||
{PRD_SECTIONS.map(({ label, phase }) => {
|
||||
const isDone = phase === null
|
||||
? allDone // non-functional reqs generated when all done
|
||||
: savedPhaseIds.has(phase);
|
||||
<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 (
|
||||
<div
|
||||
key={label}
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setSidebarTab(t.id)}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
display: "flex", alignItems: "flex-start", gap: 10,
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<div style={{
|
||||
width: 8, height: 8, borderRadius: "50%", flexShrink: 0, marginTop: 4,
|
||||
background: isDone ? "#1a1a1a" : "transparent",
|
||||
border: isDone ? "none" : "1.5px solid #c8c4bc",
|
||||
transition: "all 0.3s",
|
||||
}} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: "0.78rem", fontWeight: isDone ? 600 : 400,
|
||||
color: isDone ? "#1a1a1a" : "#6b6560",
|
||||
lineHeight: 1.3,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
{!isDone && (
|
||||
<div style={{ fontSize: "0.65rem", color: "#a09a90", marginTop: 2, lineHeight: 1.3 }}>
|
||||
Complete this phase in Vibn
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer CTA */}
|
||||
{/* 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: A–Z"}
|
||||
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: "12px 16px", borderTop: "1px solid #e8e4dc", flexShrink: 0 }}>
|
||||
<div style={{ padding: "10px 12px", borderTop: `1px solid ${JM.border}`, flexShrink: 0, background: "#FAF8FF" }}>
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/prd`}
|
||||
href={`/${workspace}/project/${projectId}/tasks`}
|
||||
style={{
|
||||
display: "block", textAlign: "center",
|
||||
padding: "9px 0", borderRadius: 7,
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
fontSize: "0.78rem", fontWeight: 600,
|
||||
padding: "10px 0", borderRadius: 8,
|
||||
background: JM.primaryGradient,
|
||||
color: "#fff",
|
||||
fontSize: 12, fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontFamily: JM.fontSans,
|
||||
boxShadow: JM.primaryShadow,
|
||||
}}
|
||||
>
|
||||
Generate PRD →
|
||||
Open Tasks →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user