Files
zopu-code/repos/web/components/projects/project-workspace-page.tsx
-Puter cc47007fa9 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
2026-07-27 16:34:17 +05:30

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>
);
};