- Archive dormant apps (daemon, desktop, native, tui) and superseded packages/server into repos/ for reference - Archive 21 dead web components (chat page shell, work-os cluster, strays) into repos/web/; keep reused message-rendering primitives in components/chat - Merge @code/work-os into @code/primitives; work.ts absorbed via ./work subpath and barrel export; update all four importers - Add docs/futures.md tracking audio/video (blocked at Flue + provider), web layout cleanup, and message rendering quality - Repoint dev:zopu script to @code/agents (the live Flue server) - Note repos/ reference-code convention in AGENTS.md
908 lines
32 KiB
TypeScript
908 lines
32 KiB
TypeScript
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
|
import { Button } from "@code/ui/components/button";
|
|
import { Input } from "@code/ui/components/input";
|
|
import { Textarea } from "@code/ui/components/textarea";
|
|
import {
|
|
ArrowUpRight,
|
|
Bot,
|
|
CircleAlert,
|
|
ExternalLink,
|
|
FolderGit2,
|
|
GitFork,
|
|
GitPullRequest,
|
|
LoaderCircle,
|
|
Plus,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
|
|
import UserMenu from "@/components/user-menu";
|
|
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
|
|
import type {
|
|
ProjectIssueSummary,
|
|
ProjectLoopView,
|
|
} from "@/lib/projects/project-evidence";
|
|
|
|
interface Project {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly sources: readonly {
|
|
readonly defaultBranch?: string;
|
|
readonly host: string;
|
|
readonly repositoryPath: string;
|
|
readonly url: string;
|
|
}[];
|
|
}
|
|
|
|
const formatStatus = (status: Doc<"projectIssues">["status"]): string => {
|
|
if (status === "needs-input") {
|
|
return "Needs input";
|
|
}
|
|
if (status === "working") {
|
|
return "In progress";
|
|
}
|
|
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
};
|
|
|
|
const statusClasses: Record<Doc<"projectIssues">["status"], string> = {
|
|
completed: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
|
failed: "bg-destructive/15 text-destructive",
|
|
"needs-input": "bg-orange-500/15 text-orange-700 dark:text-orange-300",
|
|
open: "bg-muted text-muted-foreground",
|
|
queued: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
|
working: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
|
};
|
|
|
|
const verificationIconClass = (
|
|
verification: ProjectLoopView["verification"]
|
|
): string => {
|
|
if (verification === "passed") {
|
|
return "text-emerald-600";
|
|
}
|
|
if (verification === "failed" || verification === "blocked") {
|
|
return "text-orange-600";
|
|
}
|
|
return "text-muted-foreground";
|
|
};
|
|
|
|
const checkStateClass = (
|
|
state: ProjectLoopView["verificationChecks"][number]["state"]
|
|
): string => {
|
|
if (state === "complete") {
|
|
return "bg-emerald-500 text-white";
|
|
}
|
|
if (state === "attention") {
|
|
return "bg-orange-500 text-white";
|
|
}
|
|
if (state === "current") {
|
|
return "bg-blue-500 text-white";
|
|
}
|
|
return "bg-muted text-muted-foreground";
|
|
};
|
|
|
|
const LoadingLine = ({ label }: { readonly label: string }) => (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<LoaderCircle className="size-3.5 animate-spin" />
|
|
{label}
|
|
</div>
|
|
);
|
|
|
|
interface RepositoryFormProps {
|
|
readonly busy: boolean;
|
|
readonly onRepositoryChange: (value: string) => void;
|
|
readonly onRepositorySubmit: () => void;
|
|
readonly value: string;
|
|
}
|
|
|
|
const RepositoryForm = ({
|
|
busy,
|
|
onRepositoryChange,
|
|
onRepositorySubmit,
|
|
value,
|
|
}: RepositoryFormProps) => (
|
|
<form
|
|
className="space-y-3 border-b border-border/70 p-4"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onRepositorySubmit();
|
|
}}
|
|
>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Connect project
|
|
</p>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
Bring a public repository into the project loop.
|
|
</p>
|
|
</div>
|
|
<Input
|
|
aria-label="Public Git repository URL"
|
|
onChange={(event) => onRepositoryChange(event.target.value)}
|
|
placeholder="https://github.com/owner/repo"
|
|
required
|
|
value={value}
|
|
/>
|
|
<Button className="w-full" disabled={busy} type="submit">
|
|
{busy ? (
|
|
<LoaderCircle className="animate-spin" data-icon="inline-start" />
|
|
) : (
|
|
<GitFork data-icon="inline-start" />
|
|
)}
|
|
{busy ? "Connecting" : "Connect repository"}
|
|
</Button>
|
|
</form>
|
|
);
|
|
|
|
interface ProjectSidebarProps {
|
|
readonly onProjectSelect: (projectId: Id<"projects">) => void;
|
|
readonly projects: readonly Project[] | undefined;
|
|
readonly selectedId: Id<"projects"> | null;
|
|
}
|
|
|
|
const ProjectSidebar = ({
|
|
onProjectSelect,
|
|
projects,
|
|
selectedId,
|
|
}: ProjectSidebarProps) => {
|
|
const renderProjects = () => {
|
|
if (projects === undefined) {
|
|
return <LoadingLine label="Loading projects" />;
|
|
}
|
|
if (projects.length === 0) {
|
|
return (
|
|
<p className="text-xs leading-5 text-muted-foreground">
|
|
Connect a repository to create the first project workspace.
|
|
</p>
|
|
);
|
|
}
|
|
return (
|
|
<div className="grid gap-1.5">
|
|
{projects.map((project) => {
|
|
const [source] = project.sources;
|
|
const selected = selectedId === project.id;
|
|
return (
|
|
<button
|
|
className={`w-full border px-3 py-2.5 text-left transition-colors ${
|
|
selected
|
|
? "border-foreground/20 bg-foreground text-background"
|
|
: "border-transparent hover:border-border hover:bg-muted/60"
|
|
}`}
|
|
key={project.id}
|
|
onClick={() => onProjectSelect(project.id as Id<"projects">)}
|
|
type="button"
|
|
>
|
|
<span className="block truncate text-sm font-medium">
|
|
{project.name}
|
|
</span>
|
|
<span
|
|
className={`mt-1 block truncate text-[11px] ${
|
|
selected ? "text-background/65" : "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{source
|
|
? `${source.host}/${source.repositoryPath}`
|
|
: "No source"}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<aside className="flex min-h-0 flex-col border-b border-border/70 bg-card/35 md:border-r md:border-b-0">
|
|
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
|
|
<div>
|
|
<p className="text-sm font-semibold">Projects</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Your connected workspaces
|
|
</p>
|
|
</div>
|
|
<FolderGit2 className="size-4 text-muted-foreground" />
|
|
</div>
|
|
<nav
|
|
aria-label="Connected projects"
|
|
className="min-h-0 flex-1 overflow-y-auto p-3"
|
|
>
|
|
{renderProjects()}
|
|
</nav>
|
|
<div className="border-t border-border/70 p-3">
|
|
<div className="flex items-start gap-2 border bg-muted/45 p-3">
|
|
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-lime-600 dark:text-lime-300" />
|
|
<p className="text-[11px] leading-4 text-muted-foreground">
|
|
Project context stays attached to every issue and run.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
interface IssueComposerProps {
|
|
readonly body: string;
|
|
readonly busy: boolean;
|
|
readonly onIssueBodyChange: (value: string) => void;
|
|
readonly onIssueSubmit: () => void;
|
|
readonly onIssueTitleChange: (value: string) => void;
|
|
readonly title: string;
|
|
}
|
|
|
|
const IssueComposer = ({
|
|
body,
|
|
busy,
|
|
onIssueBodyChange,
|
|
onIssueSubmit,
|
|
onIssueTitleChange,
|
|
title,
|
|
}: IssueComposerProps) => (
|
|
<form
|
|
className="border border-border/70 bg-card/60 p-4 shadow-[0_14px_32px_-24px_rgb(0_0_0/0.45)] sm:p-5"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onIssueSubmit();
|
|
}}
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Create work
|
|
</p>
|
|
<h2 className="mt-1 text-base font-semibold tracking-tight">
|
|
What should Zopu move forward?
|
|
</h2>
|
|
</div>
|
|
<div className="grid size-8 shrink-0 place-items-center bg-lime-400 text-black">
|
|
<Plus className="size-4" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-2.5">
|
|
<Input
|
|
aria-label="Issue title"
|
|
onChange={(event) => onIssueTitleChange(event.target.value)}
|
|
placeholder="Outcome or issue title"
|
|
required
|
|
value={title}
|
|
/>
|
|
<Textarea
|
|
aria-label="Issue description"
|
|
onChange={(event) => onIssueBodyChange(event.target.value)}
|
|
placeholder="Describe the desired result, constraints, or evidence to start from."
|
|
required
|
|
rows={3}
|
|
value={body}
|
|
/>
|
|
</div>
|
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-[11px] leading-4 text-muted-foreground">
|
|
The issue becomes durable project work before an agent run starts.
|
|
</p>
|
|
<Button className="w-full sm:w-auto" disabled={busy} type="submit">
|
|
{busy ? (
|
|
<LoaderCircle className="animate-spin" data-icon="inline-start" />
|
|
) : (
|
|
<Plus data-icon="inline-start" />
|
|
)}
|
|
{busy ? "Creating" : "Create issue"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
|
|
interface IssueCardProps {
|
|
readonly issue: Doc<"projectIssues">;
|
|
readonly selected: boolean;
|
|
readonly onIssueSelect: (issueId: Id<"projectIssues">) => void;
|
|
readonly onIssueStart: (
|
|
issueId: Id<"projectIssues">,
|
|
issueNumber: number,
|
|
title: string
|
|
) => void;
|
|
readonly pendingAction: string | null;
|
|
}
|
|
|
|
const IssueCard = ({
|
|
issue,
|
|
onIssueSelect,
|
|
onIssueStart,
|
|
pendingAction,
|
|
selected,
|
|
}: IssueCardProps) => {
|
|
const canStart = issue.status === "open" || issue.status === "failed";
|
|
const startPending = pendingAction === `issue:${issue._id}`;
|
|
|
|
return (
|
|
<article
|
|
className={`border p-4 transition-colors ${
|
|
selected
|
|
? "border-blue-500/35 bg-blue-500/5"
|
|
: "border-border/70 bg-card/45 hover:border-foreground/20"
|
|
}`}
|
|
>
|
|
<button
|
|
className="w-full text-left"
|
|
onClick={() => onIssueSelect(issue._id)}
|
|
type="button"
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Issue #{issue.number}
|
|
</span>
|
|
<span
|
|
className={`rounded-full px-2 py-1 text-[10px] font-semibold ${statusClasses[issue.status]}`}
|
|
>
|
|
{formatStatus(issue.status)}
|
|
</span>
|
|
</div>
|
|
<h3 className="mt-3 text-sm font-semibold leading-5">{issue.title}</h3>
|
|
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
|
{issue.body}
|
|
</p>
|
|
</button>
|
|
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Updated{" "}
|
|
{new Intl.DateTimeFormat(undefined, {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
}).format(issue.updatedAt)}
|
|
</p>
|
|
{canStart ? (
|
|
<Button
|
|
disabled={startPending}
|
|
onClick={() => onIssueStart(issue._id, issue.number, issue.title)}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
{startPending ? (
|
|
<LoaderCircle className="animate-spin" data-icon="inline-start" />
|
|
) : (
|
|
<Bot data-icon="inline-start" />
|
|
)}
|
|
{startPending ? "Starting" : "Start work"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</article>
|
|
);
|
|
};
|
|
|
|
const IssueList = ({
|
|
issues,
|
|
onIssueSelect,
|
|
onIssueStart,
|
|
pendingAction,
|
|
selectedId,
|
|
}: {
|
|
readonly issues: readonly Doc<"projectIssues">[] | undefined;
|
|
readonly onIssueSelect: (issueId: Id<"projectIssues">) => void;
|
|
readonly onIssueStart: IssueCardProps["onIssueStart"];
|
|
readonly pendingAction: string | null;
|
|
readonly selectedId: Id<"projectIssues"> | null;
|
|
}) => {
|
|
if (issues === undefined) {
|
|
return <LoadingLine label="Loading issues" />;
|
|
}
|
|
if (issues.length === 0) {
|
|
return (
|
|
<div className="border border-dashed border-border p-7 text-center">
|
|
<Bot className="mx-auto size-5 text-muted-foreground" />
|
|
<p className="mt-3 text-sm font-medium">
|
|
No issues in this project yet
|
|
</p>
|
|
<p className="mx-auto mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
|
|
Create a small, outcome-shaped issue and it will become the first
|
|
visible loop.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="space-y-3">
|
|
{issues.map((issue) => (
|
|
<IssueCard
|
|
issue={issue}
|
|
key={issue._id}
|
|
onIssueSelect={onIssueSelect}
|
|
onIssueStart={onIssueStart}
|
|
pendingAction={pendingAction}
|
|
selected={issue._id === selectedId}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Stat = ({
|
|
label,
|
|
tone,
|
|
value,
|
|
}: {
|
|
readonly label: string;
|
|
readonly tone: string;
|
|
readonly value: number;
|
|
}) => (
|
|
<div className={`border border-border/70 p-3 ${tone}`}>
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
{label}
|
|
</p>
|
|
<p className="mt-1 text-2xl font-semibold tracking-tight">{value}</p>
|
|
</div>
|
|
);
|
|
|
|
const ProjectStats = ({
|
|
summary,
|
|
}: {
|
|
readonly summary: ProjectIssueSummary;
|
|
}) => (
|
|
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
|
<Stat label="Active" tone="bg-blue-500/5" value={summary.active} />
|
|
<Stat label="Needs you" tone="bg-orange-500/5" value={summary.needsInput} />
|
|
<Stat label="Shipped" tone="bg-emerald-500/5" value={summary.completed} />
|
|
</div>
|
|
);
|
|
|
|
interface IssueDetailProps {
|
|
readonly issue: Doc<"projectIssues"> | undefined;
|
|
readonly onOpenPullRequest: (url: string) => void;
|
|
readonly onIssueStart: (
|
|
issueId: Id<"projectIssues">,
|
|
issueNumber: number,
|
|
title: string
|
|
) => void;
|
|
readonly pendingAction: string | null;
|
|
readonly projectLoop: ProjectLoopView | null;
|
|
}
|
|
|
|
const IssueDetail = ({
|
|
issue,
|
|
onOpenPullRequest,
|
|
onIssueStart,
|
|
pendingAction,
|
|
projectLoop,
|
|
}: IssueDetailProps) => {
|
|
if (!issue || !projectLoop) {
|
|
return (
|
|
<aside className="border border-dashed border-border p-6 lg:sticky lg:top-6">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Selected work
|
|
</p>
|
|
<h2 className="mt-2 text-lg font-semibold">
|
|
Choose an issue to inspect its loop
|
|
</h2>
|
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
Progress, verification, activity, and review artifacts will stay
|
|
together here.
|
|
</p>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
const canStart = issue.status === "open" || issue.status === "failed";
|
|
const startPending = pendingAction === `issue:${issue._id}`;
|
|
|
|
return (
|
|
<aside className="space-y-4 lg:sticky lg:top-6">
|
|
<section className="border border-border/70 bg-card/60 p-5 shadow-[0_14px_32px_-24px_rgb(0_0_0/0.45)]">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Issue #{issue.number}
|
|
</p>
|
|
<h2 className="mt-2 text-xl font-semibold leading-7 tracking-tight">
|
|
{issue.title}
|
|
</h2>
|
|
</div>
|
|
<span
|
|
className={`rounded-full px-2 py-1 text-[10px] font-semibold ${statusClasses[issue.status]}`}
|
|
>
|
|
{formatStatus(issue.status)}
|
|
</span>
|
|
</div>
|
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
|
{issue.body}
|
|
</p>
|
|
|
|
<div className="mt-5">
|
|
<div className="flex items-center justify-between gap-3 text-xs font-medium">
|
|
<span>{projectLoop.progress}% complete</span>
|
|
<span className="text-muted-foreground">
|
|
{projectLoop.status.label}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 h-2 bg-muted">
|
|
<div
|
|
className="h-full bg-blue-600 transition-[width] dark:bg-blue-400"
|
|
style={{ width: `${projectLoop.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 border-t border-border/70 pt-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Current state
|
|
</p>
|
|
<p className="mt-2 text-sm leading-6">{projectLoop.currentState}</p>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-start gap-3 border border-lime-500/25 bg-lime-400/10 p-3">
|
|
<ArrowUpRight className="mt-0.5 size-4 shrink-0 text-lime-700 dark:text-lime-300" />
|
|
<div>
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-lime-800 dark:text-lime-200">
|
|
Next move
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium">{projectLoop.nextAction}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{canStart ? (
|
|
<Button
|
|
className="mt-4 w-full"
|
|
disabled={startPending}
|
|
onClick={() => onIssueStart(issue._id, issue.number, issue.title)}
|
|
type="button"
|
|
>
|
|
{startPending ? (
|
|
<LoaderCircle className="animate-spin" data-icon="inline-start" />
|
|
) : (
|
|
<Bot data-icon="inline-start" />
|
|
)}
|
|
{startPending
|
|
? "Starting project manager"
|
|
: "Start project manager"}
|
|
</Button>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="border border-border/70 bg-card/45 p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Verification
|
|
</p>
|
|
<h3 className="mt-1 text-base font-semibold">
|
|
Evidence earns the next step
|
|
</h3>
|
|
</div>
|
|
<CircleAlert
|
|
className={`size-4 ${verificationIconClass(projectLoop.verification)}`}
|
|
/>
|
|
</div>
|
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
|
{projectLoop.verificationNote}
|
|
</p>
|
|
<div className="mt-4 space-y-2">
|
|
{projectLoop.verificationChecks.map((check) => (
|
|
<div
|
|
className="flex items-start gap-3 border border-border/60 p-3"
|
|
key={check.label}
|
|
>
|
|
<span
|
|
className={`mt-0.5 grid size-4 place-items-center rounded-full ${checkStateClass(check.state)}`}
|
|
>
|
|
<span className="text-[9px]">
|
|
{check.state === "complete" ? "✓" : ""}
|
|
</span>
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium">{check.label}</p>
|
|
<p className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
|
{check.detail}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="border border-border/70 bg-card/45 p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Gitea artifact
|
|
</p>
|
|
<h3 className="mt-1 text-base font-semibold">
|
|
Pull request review
|
|
</h3>
|
|
</div>
|
|
<GitPullRequest className="size-4 text-muted-foreground" />
|
|
</div>
|
|
{projectLoop.pullRequest.state === "available" &&
|
|
projectLoop.pullRequest.reviewUrl ? (
|
|
<div className="mt-4 border border-emerald-500/25 bg-emerald-500/10 p-3">
|
|
<p className="text-sm font-medium">
|
|
{projectLoop.pullRequest.number
|
|
? `PR #${projectLoop.pullRequest.number} is ready for review`
|
|
: "Pull request is ready for review"}
|
|
</p>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
Verification evidence is attached to this project loop.
|
|
</p>
|
|
<Button
|
|
className="mt-3"
|
|
onClick={() =>
|
|
onOpenPullRequest(projectLoop.pullRequest.reviewUrl as string)
|
|
}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ExternalLink data-icon="inline-start" />
|
|
Review changes
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 border border-dashed border-border p-3">
|
|
<p className="text-sm font-medium">Review link pending</p>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
The slot is ready for a Gitea pull request when the agent
|
|
publishes one. The rest of the run remains observable now.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="border border-border/70 bg-card/45 p-5">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="size-3.5 text-lime-600 dark:text-lime-300" />
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Recent activity
|
|
</p>
|
|
</div>
|
|
<div className="mt-4 space-y-4">
|
|
{projectLoop.activity.map((item) => (
|
|
<div className="flex gap-3" key={`${item.label}-${item.time}`}>
|
|
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-foreground/50" />
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium">{item.label}</p>
|
|
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
|
{item.detail}
|
|
</p>
|
|
<p className="mt-1 text-[10px] text-muted-foreground/75">
|
|
{item.time}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export const ProjectWorkspacePage = () => {
|
|
const workspace = useProjectWorkspace();
|
|
const projectSource = workspace.selectedProject?.sources[0];
|
|
const handleIssueBodyChange = workspace.setIssueBody;
|
|
const handleIssueSelect = workspace.setSelectedIssueId;
|
|
const handleIssueStart = workspace.startIssue;
|
|
const handleIssueSubmit = workspace.raiseIssue;
|
|
const handleIssueTitleChange = workspace.setIssueTitle;
|
|
const handleProjectSelect = workspace.setSelectedProjectId;
|
|
const handleRepositoryChange = workspace.setRepository;
|
|
const handleRepositorySubmit = workspace.connectRepository;
|
|
|
|
return (
|
|
<main className="min-h-svh bg-background text-foreground">
|
|
<header className="border-b border-border/70 bg-background/90 backdrop-blur">
|
|
<div className="mx-auto flex max-w-[1540px] items-center justify-between gap-4 px-4 py-3 sm:px-6">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div className="grid size-9 shrink-0 place-items-center bg-lime-400 text-black">
|
|
<span className="text-base font-semibold">Z</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-semibold">Zopu</p>
|
|
<span className="size-1.5 rounded-full bg-emerald-500" />
|
|
<span className="text-xs text-muted-foreground">
|
|
Project loop
|
|
</span>
|
|
</div>
|
|
<p className="truncate text-[11px] text-muted-foreground">
|
|
Connect context, move issues, review outcomes.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<UserMenu />
|
|
</div>
|
|
</header>
|
|
|
|
<div className="mx-auto grid min-h-[calc(100svh-65px)] max-w-[1540px] md:grid-cols-[250px_minmax(0,1fr)]">
|
|
<div className="flex min-h-0 flex-col">
|
|
<RepositoryForm
|
|
busy={workspace.pendingAction === "connect"}
|
|
onRepositoryChange={handleRepositoryChange}
|
|
onRepositorySubmit={handleRepositorySubmit}
|
|
value={workspace.repository}
|
|
/>
|
|
<ProjectSidebar
|
|
onProjectSelect={handleProjectSelect}
|
|
projects={workspace.projects}
|
|
selectedId={workspace.selectedProjectId}
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0 px-4 py-5 sm:px-6 sm:py-7 lg:px-8">
|
|
{workspace.error ? (
|
|
<div className="mb-5 flex items-start gap-3 border border-destructive/35 bg-destructive/10 p-3 text-sm text-destructive">
|
|
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
|
<p>{workspace.error}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{workspace.selectedProject ? (
|
|
<div className="space-y-6">
|
|
<section className="flex flex-col gap-4 border-b border-border/70 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
<span>Project workspace</span>
|
|
<span>/</span>
|
|
<span className="truncate">
|
|
{projectSource?.host ?? "Repository"}
|
|
</span>
|
|
</div>
|
|
<h1 className="mt-2 text-3xl font-semibold sm:text-4xl">
|
|
{workspace.selectedProject.name}
|
|
</h1>
|
|
<p className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
|
|
<FolderGit2 className="size-4" />
|
|
<span className="truncate">
|
|
{projectSource
|
|
? `${projectSource.host}/${projectSource.repositoryPath}`
|
|
: "No repository source"}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
{projectSource ? (
|
|
<Button
|
|
className="w-full sm:w-auto"
|
|
onClick={() =>
|
|
window.open(
|
|
projectSource.url,
|
|
"_blank",
|
|
"noopener,noreferrer"
|
|
)
|
|
}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ExternalLink data-icon="inline-start" />
|
|
Open repository
|
|
</Button>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(300px,0.72fr)]">
|
|
<div className="border border-border/70 bg-blue-500/5 p-5 sm:p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="grid size-7 place-items-center rounded-full bg-blue-600 text-white dark:bg-blue-400 dark:text-blue-950">
|
|
<Bot className="size-3.5" />
|
|
</span>
|
|
<p className="text-sm font-semibold">Work in motion</p>
|
|
</div>
|
|
<span className="rounded-full bg-blue-500/15 px-2 py-1 text-[10px] font-semibold text-blue-700 dark:text-blue-300">
|
|
Context attached
|
|
</span>
|
|
</div>
|
|
<h2 className="mt-7 max-w-xl text-2xl font-semibold tracking-tight sm:text-3xl">
|
|
Make the next useful move visible.
|
|
</h2>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
|
Every issue carries the project context, agent run,
|
|
verification evidence, and review artifact as one durable
|
|
loop.
|
|
</p>
|
|
<div className="mt-6 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
|
|
{[
|
|
["01", "Issue"],
|
|
["02", "Run"],
|
|
["03", "Verify"],
|
|
["04", "Review"],
|
|
].map(([number, label]) => (
|
|
<div className="flex items-center gap-2" key={number}>
|
|
<span className="grid size-5 place-items-center rounded-full bg-background text-[9px] text-foreground">
|
|
{number}
|
|
</span>
|
|
<span>{label}</span>
|
|
{number === "04" ? null : (
|
|
<span className="px-1 text-border">/</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="border border-border/70 bg-card/50 p-4 sm:p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Project pulse
|
|
</p>
|
|
<p className="mt-1 text-sm font-semibold">
|
|
No hidden queue
|
|
</p>
|
|
</div>
|
|
<Sparkles className="size-4 text-lime-600 dark:text-lime-300" />
|
|
</div>
|
|
<p className="mt-4 text-sm leading-6 text-muted-foreground">
|
|
The cards below are the current durable issues. Open one to
|
|
follow the work, not a transient chat session.
|
|
</p>
|
|
<div className="mt-5">
|
|
<ProjectStats summary={workspace.issueSummary} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<IssueComposer
|
|
body={workspace.issueBody}
|
|
busy={workspace.pendingAction === "issue"}
|
|
onIssueBodyChange={handleIssueBodyChange}
|
|
onIssueSubmit={handleIssueSubmit}
|
|
onIssueTitleChange={handleIssueTitleChange}
|
|
title={workspace.issueTitle}
|
|
/>
|
|
|
|
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(350px,0.8fr)]">
|
|
<section
|
|
aria-labelledby="active-work-heading"
|
|
className="min-w-0"
|
|
>
|
|
<div className="mb-3 flex items-end justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
|
Durable queue
|
|
</p>
|
|
<h2
|
|
className="mt-1 text-lg font-semibold"
|
|
id="active-work-heading"
|
|
>
|
|
Issues in this project
|
|
</h2>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{workspace.issueSummary.total} total
|
|
</span>
|
|
</div>
|
|
<IssueList
|
|
issues={workspace.issues}
|
|
onIssueSelect={handleIssueSelect}
|
|
onIssueStart={handleIssueStart}
|
|
pendingAction={workspace.pendingAction}
|
|
selectedId={workspace.selectedIssueId}
|
|
/>
|
|
</section>
|
|
|
|
<IssueDetail
|
|
issue={workspace.selectedIssue}
|
|
onOpenPullRequest={(url) =>
|
|
window.open(url, "_blank", "noopener,noreferrer")
|
|
}
|
|
onIssueStart={handleIssueStart}
|
|
pendingAction={workspace.pendingAction}
|
|
projectLoop={workspace.projectLoop}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<section className="grid min-h-[65svh] place-items-center border border-dashed border-border p-8 text-center">
|
|
<div className="max-w-md">
|
|
<div className="mx-auto grid size-12 place-items-center bg-lime-400 text-black">
|
|
<GitFork className="size-5" />
|
|
</div>
|
|
<h1 className="mt-5 text-2xl font-semibold tracking-tight">
|
|
Connect a project to start the loop
|
|
</h1>
|
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
Import a repository, keep its context attached, then turn a
|
|
clear outcome into an issue that can be started, verified, and
|
|
reviewed.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|