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
@@ -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",
};