Slice 1 polish: archive dormant code, merge work-os into primitives, add futures
- 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
This commit is contained in:
907
repos/web/components/projects/project-workspace-page.tsx
Normal file
907
repos/web/components/projects/project-workspace-page.tsx
Normal file
@@ -0,0 +1,907 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user