dashboard: REPOS tier tab + Repos page shell

Inserts a 6th tier tab between AGENT and INFRA (Tier type + TIER_TABS +
rail icon). Wires two new sidebar/canvas components with the same
list+detail pattern as loops/research:

- RepoList: header (provider count · repo count), + button opens the
  connection wizard, groups repos by provider connection (empty state
  prompts the user to connect the first). Fetches /api/repos/connections
  and /api/repos — those routes land in tasks #12-14.
- RepoCanvas: repo detail (name, owner, private badge, description,
  stars/forks/branch/updated, clone URL with copy, open-on-provider
  link, last-synced footer). Empty + loading + error placeholders.
- RepoConnectionWizardStub: minimal 'coming next' modal so the + button
  is wired end-to-end; real wizard replaces it in task #15.

Sidebar header branch updated so the tier renders its own header. Build
is clean; the sidebar is fully functional once the backend endpoints
respond.
This commit is contained in:
Omar Sobh
2026-07-07 14:44:50 -07:00
parent 806ba869e5
commit 076f7724ca
4 changed files with 772 additions and 2 deletions
@@ -27,6 +27,9 @@ import { ResearchList } from "./ResearchList";
import { ResearchCanvas } from "./ResearchCanvas"; import { ResearchCanvas } from "./ResearchCanvas";
import { LoopsList } from "./LoopsList"; import { LoopsList } from "./LoopsList";
import { LoopsCanvas } from "./LoopsCanvas"; import { LoopsCanvas } from "./LoopsCanvas";
import { RepoList } from "./RepoList";
import { RepoCanvas } from "./RepoCanvas";
import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu";
import { ToolPanel, type ToolKey } from "./ToolPanel"; import { ToolPanel, type ToolKey } from "./ToolPanel";
import { InfraNav, FleetConsole, FleetStatusBar, FleetPill } from "./fleet/FleetConsole"; import { InfraNav, FleetConsole, FleetStatusBar, FleetPill } from "./fleet/FleetConsole";
@@ -47,7 +50,7 @@ import { DeviceSizeToggle } from "@/components/computer/DeviceSizeToggle";
const COMPUTER_WIDTH: Record<DeviceSize, string> = { phone: "448px", tablet: "67%", full: "100%" }; const COMPUTER_WIDTH: Record<DeviceSize, string> = { phone: "448px", tablet: "67%", full: "100%" };
type Tier = "world" | "research" | "loops" | "claw" | "infra"; type Tier = "world" | "research" | "loops" | "claw" | "repos" | "infra";
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
// Gradient palette for structure nodes that don't carry their own (companies, // Gradient palette for structure nodes that don't carry their own (companies,
@@ -88,6 +91,8 @@ const railIcon: Record<Tier, React.ReactNode> = {
// Circular arrow ⟳ with an inner dot — loops. // Circular arrow ⟳ with an inner dot — loops.
loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>), loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>),
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>), claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
// Branching lines forking off a trunk — repositories.
repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>),
infra: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="3.5" y="4" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="3.5" y="11" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="6.4" cy="6.5" r="1" fill="currentColor" /><circle cx="6.4" cy="13.5" r="1" fill="currentColor" /></svg>), infra: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="3.5" y="4" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="3.5" y="11" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="6.4" cy="6.5" r="1" fill="currentColor" /><circle cx="6.4" cy="13.5" r="1" fill="currentColor" /></svg>),
}; };
const TIER_TABS: { key: Tier; label: string }[] = [ const TIER_TABS: { key: Tier; label: string }[] = [
@@ -95,6 +100,7 @@ const TIER_TABS: { key: Tier; label: string }[] = [
{ key: "research", label: "RESEARCH" }, { key: "research", label: "RESEARCH" },
{ key: "loops", label: "LOOPS" }, { key: "loops", label: "LOOPS" },
{ key: "claw", label: "AGENT" }, { key: "claw", label: "AGENT" },
{ key: "repos", label: "REPOS" },
{ key: "infra", label: "INFRA" }, { key: "infra", label: "INFRA" },
]; ];
const companyIcon = (size: number) => ( const companyIcon = (size: number) => (
@@ -210,6 +216,10 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
// Loops tier: same pattern. // Loops tier: same pattern.
const [loopsSel, setLoopsSel] = useState<string | null>(null); const [loopsSel, setLoopsSel] = useState<string | null>(null);
const [loopsRefresh, setLoopsRefresh] = useState(0); const [loopsRefresh, setLoopsRefresh] = useState(0);
// Repos tier: selected repo + refresh key + wizard modal flag.
const [repoSel, setRepoSel] = useState<string | null>(null);
const [repoRefresh, setRepoRefresh] = useState(0);
const [repoWizardOpen, setRepoWizardOpen] = useState(false);
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard. // Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
const [infraSel, setInfraSel] = useState<string | null>("local"); const [infraSel, setInfraSel] = useState<string | null>("local");
const [infraConnectOpen, setInfraConnectOpen] = useState(false); const [infraConnectOpen, setInfraConnectOpen] = useState(false);
@@ -470,6 +480,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
isResearch = tier === "research", isResearch = tier === "research",
isLoops = tier === "loops", isLoops = tier === "loops",
isClaw = tier === "claw", isClaw = tier === "claw",
isRepos = tier === "repos",
isInfra = tier === "infra"; isInfra = tier === "infra";
const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents))); const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents)));
@@ -591,7 +602,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button> <button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
</div> </div>
) : isInfra || isResearch || isLoops ? null : ( ) : isInfra || isResearch || isLoops || isRepos ? null : (
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "flex-start", gap: 8 }}> <div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "flex-start", gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{allAgents.length} AGENT{allAgents.length === 1 ? "" : "S"}</div> <div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{allAgents.length} AGENT{allAgents.length === 1 ? "" : "S"}</div>
@@ -626,6 +637,13 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
setLoopsRefresh((n) => n + 1); setLoopsRefresh((n) => n + 1);
}} }}
/> />
) : isRepos ? (
<RepoList
selectedId={repoSel}
onSelect={setRepoSel}
onAdd={() => setRepoWizardOpen(true)}
refreshKey={repoRefresh}
/>
) : isInfra ? ( ) : isInfra ? (
<InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} /> <InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} />
) : ( ) : (
@@ -670,6 +688,8 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
setLoopsRefresh((n) => n + 1); setLoopsRefresh((n) => n + 1);
}} }}
/> />
) : isRepos ? (
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
) : isWorld ? ( ) : isWorld ? (
<> <>
{/* Graph stage — condensed by the right slide-out's width. */} {/* Graph stage — condensed by the right slide-out's width. */}
@@ -856,6 +876,15 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
{/* MASTER PLANNER — the "+" opens a chat with Opus 4.8 that proposes and {/* MASTER PLANNER — the "+" opens a chat with Opus 4.8 that proposes and
scaffolds a whole team of agents. */} scaffolds a whole team of agents. */}
{deployOpen ? <MasterPlannerModal onClose={() => setDeployOpen(false)} /> : null} {deployOpen ? <MasterPlannerModal onClose={() => setDeployOpen(false)} /> : null}
{repoWizardOpen ? (
<RepoConnectionWizardStub
onClose={() => setRepoWizardOpen(false)}
onCreated={() => {
setRepoWizardOpen(false);
setRepoRefresh((n) => n + 1);
}}
/>
) : null}
{historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null} {historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null}
{(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()} {(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()}
{reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null} {reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null}
@@ -0,0 +1,327 @@
"use client";
// Canvas for a selected repo. Renders detail (name, owner, description,
// default branch, private/public, stars/forks, updated_at, clone URL with copy
// button, external link) plus a placeholder actions row for future work
// ("run agent against this repo", "clone into workspace drive", etc.).
import { useEffect, useState } from "react";
import { Copy, ExternalLink, GitBranch, Lock, Star } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
interface RepoDetail {
id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
private: boolean;
description: string | null;
default_branch: string | null;
clone_url: string | null;
html_url: string | null;
stars: number;
forks: number;
updated_at: string | null;
last_synced_at: string | null;
}
export function RepoCanvas({
selectedId,
refreshKey,
}: {
selectedId: string | null;
refreshKey: number;
}) {
const [repo, setRepo] = useState<RepoDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
if (!selectedId) {
setRepo(null);
return;
}
setLoading(true);
setError(null);
try {
const r = await fetch(`/api/repos/${selectedId}`);
if (!r.ok) throw new Error(`load failed (${r.status})`);
const d = (await r.json()) as RepoDetail;
if (!cancelled) setRepo(d);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "load failed");
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [selectedId, refreshKey]);
if (!selectedId) return <Placeholder />;
if (loading && !repo) return <PlaceholderText>Loading repo…</PlaceholderText>;
if (error) return <PlaceholderText color="#ff8a7a">{error}</PlaceholderText>;
if (!repo) return <Placeholder />;
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "auto",
background: "radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)",
padding: 32,
}}
>
<div style={{ maxWidth: 820, margin: "0 auto", display: "flex", flexDirection: "column", gap: 24 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
}}
>
<span style={{ color: "#c98af0", fontWeight: 700 }}>{repo.provider}</span>
<span style={{ opacity: 0.5 }}>·</span>
<span>{repo.owner}</span>
{repo.private ? (
<>
<span style={{ opacity: 0.5 }}>·</span>
<span style={{ color: "#c8a464", display: "inline-flex", alignItems: "center", gap: 4 }}>
<Lock size={11} /> private
</span>
</>
) : null}
</div>
<h1 style={{ fontSize: 30, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.02em", margin: 0 }}>
{repo.name}
</h1>
{repo.description ? (
<p style={{ fontFamily: mono, fontSize: 13, color: "#b5b5bd", margin: 0, lineHeight: 1.6 }}>
{repo.description}
</p>
) : null}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 12 }}>
<Stat icon={<GitBranch size={13} />} label="Default branch" value={repo.default_branch ?? "—"} />
<Stat icon={<Star size={13} />} label="Stars" value={String(repo.stars)} />
<Stat icon={<GitBranch size={13} />} label="Forks" value={String(repo.forks)} />
<Stat
icon={<GitBranch size={13} />}
label="Updated"
value={repo.updated_at ? new Date(repo.updated_at).toLocaleDateString() : "—"}
/>
</div>
{repo.clone_url ? (
<Section header="Clone URL">
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderRadius: 8,
background: "#101014",
border: "1px solid rgba(255,255,255,.06)",
fontFamily: mono,
fontSize: 12,
color: "#eaeaee",
}}
>
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{repo.clone_url}
</span>
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(repo.clone_url ?? "");
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}}
aria-label="Copy clone URL"
style={{
flex: "none",
width: 30,
height: 30,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: copied ? "#7fd0a0" : "#cfcfd5",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Copy size={13} />
</button>
</div>
</Section>
) : null}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{repo.html_url ? (
<a
href={repo.html_url}
target="_blank"
rel="noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 12.5,
fontWeight: 600,
textDecoration: "none",
}}
>
<ExternalLink size={12} /> Open on {repo.provider}
</a>
) : null}
{/* Future: "Run agent against this repo" + "Clone to shared drive" */}
</div>
<p style={{ ...hintStyle, textAlign: "center", marginTop: 12 }}>
{repo.last_synced_at
? `Last synced ${new Date(repo.last_synced_at).toLocaleString()}`
: "Not synced yet"}
</p>
</div>
</div>
);
}
function Section({ header, children }: { header: string; children: React.ReactNode }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#5a5a62",
textTransform: "uppercase",
}}
>
{header}
</div>
{children}
</div>
);
}
function Stat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "10px 12px",
borderRadius: 8,
background: "rgba(255,255,255,.03)",
border: "1px solid rgba(255,255,255,.06)",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 9.5,
letterSpacing: ".12em",
textTransform: "uppercase",
color: "#6a6a72",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{icon}
{label}
</span>
<span style={{ fontSize: 14, fontWeight: 700, color: "#eaeaee" }}>{value}</span>
</div>
);
}
function Placeholder() {
return (
<div
style={{
position: "absolute",
inset: 0,
background: "radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 48,
}}
>
<div style={{ maxWidth: 420, textAlign: "center" }}>
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", marginBottom: 10 }}>
Repositories
</div>
<div style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", lineHeight: 1.6 }}>
Pick a repo on the left, or hit the + in the sidebar to connect a
GitHub, Gitea or GitLab account and pull an organization&apos;s repos.
</div>
</div>
</div>
);
}
function PlaceholderText({
children,
color = "#8a8a92",
}: {
children: React.ReactNode;
color?: string;
}) {
return (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: mono,
fontSize: 12,
color,
}}
>
{children}
</div>
);
}
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
};
@@ -0,0 +1,102 @@
"use client";
// Placeholder wizard shell. The real provider connection flow lands in task
// #15 (frontend) once the /api/repos/connections routes exist. This exists
// so the tier shell has a working + button — it just tells the user the
// feature is coming.
import { X } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function RepoConnectionWizardStub({
onClose,
onCreated: _onCreated,
}: {
onClose: () => void;
onCreated: () => void;
}) {
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 100,
background: "rgba(0,0,0,.62)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Connect a provider"
style={{
width: "100%",
maxWidth: 460,
borderRadius: 16,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
padding: 22,
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}>
Connect a provider
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
width: 28,
height: 28,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={14} />
</button>
</div>
<p style={{ fontFamily: mono, fontSize: 12, color: "#b5b5bd", lineHeight: 1.6, margin: 0 }}>
The provider-connection flow is landing next. It&apos;ll ask for the
provider (GitHub / Gitea / GitLab), a personal access token, and an
optional owner override — then pull every repo the token can see.
</p>
<button
type="button"
onClick={onClose}
style={{
marginTop: 6,
padding: "9px 0",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 12.5,
fontWeight: 600,
cursor: "pointer",
}}
>
Got it
</button>
</div>
</div>
);
}
@@ -0,0 +1,312 @@
"use client";
// Sidebar for the REPOS tier. Renders the workspace's repo connections and
// their pulled repos grouped underneath. The + button opens the connection
// wizard. Empty state prompts the user to connect their first provider.
//
// v1 shows a placeholder until the backend routes land — see tasks #12–14.
import { useEffect, useState } from "react";
import { GitBranch, Plus } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export interface RepoSummary {
id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
private: boolean;
description: string | null;
default_branch: string | null;
stars: number;
forks: number;
updated_at: string | null;
}
export interface ConnectionSummary {
id: string;
provider: string;
owner: string | null;
status: string;
created_at: string;
}
export function RepoList({
selectedId,
onSelect,
onAdd,
refreshKey,
}: {
selectedId: string | null;
onSelect: (id: string | null) => void;
onAdd: () => void;
refreshKey: number;
}) {
const [connections, setConnections] = useState<ConnectionSummary[]>([]);
const [repos, setRepos] = useState<RepoSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const [cRes, rRes] = await Promise.all([
fetch("/api/repos/connections"),
fetch("/api/repos"),
]);
if (cancelled) return;
if (cRes.ok) setConnections(await cRes.json());
if (rRes.ok) setRepos(await rRes.json());
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "load failed");
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [refreshKey]);
const byConn = new Map<string, RepoSummary[]>();
for (const r of repos) {
const arr = byConn.get(r.connection_id) ?? [];
arr.push(r);
byConn.set(r.connection_id, arr);
}
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<div
style={{
padding: "16px 16px 12px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#5a5a62",
marginBottom: 6,
}}
>
{connections.length} PROVIDER{connections.length === 1 ? "" : "S"} · {repos.length} REPO{repos.length === 1 ? "" : "S"}
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>
Repositories
</div>
</div>
<button
type="button"
onClick={onAdd}
title="Connect a provider"
aria-label="Connect a provider"
style={{
flex: "none",
width: 34,
height: 34,
borderRadius: 9,
border: "1px solid rgba(255,111,97,.4)",
background: "rgba(255,111,97,.08)",
color: "#ff8a7a",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Plus size={16} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 12 }}>
{loading && connections.length === 0 ? (
<p style={hintStyle}>Loading providers…</p>
) : connections.length === 0 ? (
<EmptyState onAdd={onAdd} />
) : (
connections.map((c) => {
const list = byConn.get(c.id) ?? [];
return (
<div key={c.id} style={{ display: "flex", flexDirection: "column", marginBottom: 14 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "0 4px 6px",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
textTransform: "uppercase",
color: "#c98af0",
}}
>
{c.provider}
</span>
{c.owner ? (
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: "#8a8a92",
}}
>
{c.owner}
</span>
) : null}
<span style={{ flex: 1 }} />
<span
style={{
fontFamily: mono,
fontSize: 10,
color: c.status === "connected" ? "#7fd0a0" : "#c8a464",
}}
>
{c.status}
</span>
</div>
{list.length === 0 ? (
<p style={{ ...hintStyle, padding: "6px 4px" }}>
No repos synced yet.
</p>
) : (
<ul style={{ margin: 0, padding: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 4 }}>
{list.map((r) => {
const active = selectedId === r.id;
return (
<li key={r.id}>
<button
type="button"
onClick={() => onSelect(r.id)}
style={{
width: "100%",
textAlign: "left",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 10px",
borderRadius: 8,
border: active
? "1px solid rgba(255,111,97,.4)"
: "1px solid transparent",
background: active ? "rgba(255,111,97,.08)" : "transparent",
color: "#eaeaee",
fontFamily: "inherit",
fontSize: 12.5,
cursor: "pointer",
}}
>
<GitBranch size={12} style={{ color: active ? "#ff8a7a" : "#5a5a62" }} />
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{r.name}
</span>
{r.private ? (
<span
style={{
fontFamily: mono,
fontSize: 9,
color: "#c8a464",
padding: "1px 5px",
borderRadius: 4,
background: "rgba(200,164,100,.1)",
}}
>
priv
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
})
)}
{error ? (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
) : null}
</div>
</div>
);
}
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div
style={{
padding: "20px 12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: 10,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: "rgba(255,111,97,.08)",
border: "1px dashed rgba(255,111,97,.4)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "#ff8a7a",
}}
>
<GitBranch size={18} />
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#eaeaee" }}>
No providers connected
</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.5 }}>
Connect GitHub, Gitea or GitLab to pull all repos an organization owns —
each becomes a card you can inspect from here.
</div>
<button
type="button"
onClick={onAdd}
style={{
marginTop: 4,
padding: "8px 14px",
borderRadius: 8,
border: 0,
background: "#ff6f61",
color: "#1a0d0b",
fontSize: 12.5,
fontWeight: 700,
cursor: "pointer",
}}
>
Connect a provider
</button>
</div>
);
}
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
};