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:
@@ -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'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'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",
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user