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:
-Puter
2026-07-27 16:34:17 +05:30
parent 302fd159df
commit cc47007fa9
101 changed files with 38 additions and 93 deletions

View File

@@ -0,0 +1,40 @@
import { MobileChatComposer } from "@code/ui/components/mobile-chat";
import { LoaderCircle } from "lucide-react";
import type { ReactNode } from "react";
import { useChatComposer } from "@/hooks/chat/use-chat-composer";
import type { ChatComposerProps } from "@/lib/chat/types";
export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
const busy =
status === "connecting" || status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend });
let statusMessage: ReactNode;
if (busy) {
statusMessage = (
<span className="inline-flex items-center gap-1.5">
<LoaderCircle className="size-3 animate-spin" />
{status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
</span>
);
}
return (
<MobileChatComposer
busy={busy}
canSend={composer.canSend}
errorMessage={
error ? error.message || "Message failed to send" : undefined
}
onSubmit={composer.handleSubmit}
statusMessage={statusMessage}
textareaProps={{
...composer.inputProps,
"aria-describedby": error ? "composer-error" : undefined,
"aria-invalid": error ? true : undefined,
disabled: busy,
}}
/>
);
};

View File

@@ -0,0 +1,56 @@
import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import { MobileChatMark } from "@code/ui/components/mobile-chat";
import { SUGGESTIONS } from "@/lib/chat/constants";
import type { ChatConversationProps } from "@/lib/chat/types";
import { ChatMessage } from "./chat-message";
import { ChatThinkingResponse } from "./chat-thinking-response";
export const ChatConversation = ({
historyReady,
messages,
onSuggestion,
status,
}: ChatConversationProps) => {
if (historyReady && messages.length === 0) {
return (
<ConversationEmptyState className="min-h-full items-start justify-end px-0 pt-14 pb-5 text-left">
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
<div className="space-y-1">
<h2 className="text-[22px] font-semibold tracking-[-0.035em]">
What can I help with?
</h2>
<p className="max-w-[320px] text-[14px] leading-5 text-muted-foreground">
Think, write, plan, or explore an idea with Zopu.
</p>
</div>
<div className="mt-3 grid w-full gap-2">
{SUGGESTIONS.map(([title, prompt]) => (
<Button
key={title}
className="group h-auto min-h-14 w-full flex-col items-start justify-center gap-0.5 rounded-[16px] border-[#e7e7e4] bg-[#f7f7f5] px-4 py-3 text-left shadow-none transition-colors active:bg-[#eeeeeb]"
onClick={() => onSuggestion(prompt)}
variant="outline"
>
<span className="font-medium text-foreground">{title}</span>
<span className="text-xs leading-4 font-normal text-muted-foreground whitespace-normal">
{prompt}
</span>
</Button>
))}
</div>
</ConversationEmptyState>
);
}
return (
<div className="flex min-w-0 flex-col gap-5 pb-5">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{status === "submitted" && <ChatThinkingResponse />}
</div>
);
};

View File

@@ -0,0 +1,11 @@
import { MobileChatHeader } from "@code/ui/components/mobile-chat";
import { STATUS_COPY } from "@/lib/chat/constants";
import type { ChatHeaderProps } from "@/lib/chat/types";
export const ChatHeader = ({ status }: ChatHeaderProps) => (
<MobileChatHeader
active={status !== "connecting" && status !== "error"}
statusLabel={STATUS_COPY[status]}
/>
);

View File

@@ -0,0 +1,45 @@
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@code/ui/components/ai-elements/conversation";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
import { ChatComposer } from "./chat-composer";
import { ChatConversation } from "./chat-conversation";
import { ChatHeader } from "./chat-header";
export const ChatPage = () => {
const agent = useChatAgent();
const handleSendMessage = (message: string) => agent.sendMessage(message);
return (
<section className="chat-surface flex h-full min-h-0 flex-col bg-[#fefefe]">
<ChatHeader status={agent.status} />
<main aria-label="Conversation" className="min-h-0 flex-1">
<Conversation className="ai-conversation-mobile h-full">
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) => void handleSendMessage(suggestion)}
status={agent.status}
/>
</ConversationContent>
<ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
/>
</Conversation>
</main>
<ChatComposer
error={agent.error}
onSend={handleSendMessage}
status={agent.status}
/>
</section>
);
};

View File

@@ -0,0 +1,36 @@
import { NavLink } from "react-router";
import { ModeToggle } from "./mode-toggle";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
{ to: "/todos", label: "Todos" },
] as const;
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => {
return (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "font-bold" : "")}
end
>
{label}
</NavLink>
);
})}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react";
export default function Loader() {
return (
<div className="flex h-full items-center justify-center pt-8">
<Loader2 className="animate-spin" />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" />}>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

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

View File

@@ -0,0 +1,43 @@
import { signOutWeb, useWebAuth } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { useNavigate } from "react-router";
export default function UserMenu() {
const navigate = useNavigate();
const auth = useWebAuth();
const user = auth.status === "authenticated" ? auth.user : null;
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
{user?.name}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuGroup>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{user?.email}</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={async () => {
await signOutWeb();
navigate("/login", { replace: true });
}}
>
Sign Out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,53 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { LoaderCircle, MessagesSquare } from "lucide-react";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import type { ChatAgentState } from "@/lib/chat/types";
interface ConversationPanelProps {
readonly agent: ChatAgentState;
readonly title: string;
readonly emptyHint: string;
}
export const ConversationPanel = ({
agent,
emptyHint,
title,
}: ConversationPanelProps) => {
if (agent.status === "connecting" && !agent.historyReady) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<LoaderCircle className="mr-2 size-4 animate-spin" />
Loading conversation
</div>
);
}
if (agent.historyReady && agent.messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<MessagesSquare className="size-6 text-muted-foreground/50" />
<p className="mt-3 text-sm font-medium">{title}</p>
<p className="mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
{emptyHint}
</p>
</div>
);
}
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="min-h-full gap-4 px-4 py-4">
{agent.messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{agent.status === "submitted" ? <ChatThinkingResponse /> : null}
</ConversationContent>
</Conversation>
);
};

View File

@@ -0,0 +1,54 @@
import { Bot, FolderGit2 } from "lucide-react";
interface EmptyStateProps {
readonly onConnectRepository: () => void;
readonly busy: boolean;
readonly repository: string;
readonly onRepositoryChange: (value: string) => void;
}
export const EmptyState = ({
busy,
onConnectRepository,
onRepositoryChange,
repository,
}: EmptyStateProps) => (
<div className="grid min-h-[60vh] place-items-center">
<div className="max-w-md text-center">
<div className="mx-auto grid size-14 place-items-center rounded-2xl bg-foreground text-background">
<FolderGit2 className="size-6" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Connect a project to begin
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Import a repository to start the Work OS loop. Turn a clear outcome into
a Work Unit that Zopu can start, verify, and review.
</p>
<form
className="mx-auto mt-6 max-w-sm space-y-3"
onSubmit={(event) => {
event.preventDefault();
onConnectRepository();
}}
>
<input
aria-label="Public Git repository URL"
className="w-full rounded-xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onRepositoryChange(event.target.value)}
placeholder="https://github.com/owner/repo"
required
value={repository}
/>
<button
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
<Bot className="size-4" />
{busy ? "Connecting" : "Connect repository"}
</button>
</form>
</div>
</div>
);

View File

@@ -0,0 +1,71 @@
import { Radio } from "lucide-react";
import type { SignalItem } from "@/hooks/work-os/use-work-os";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
interface SignalsPanelProps {
readonly signals: readonly SignalItem[];
readonly loading: boolean;
}
export const SignalsPanel = ({ loading, signals }: SignalsPanelProps) => {
if (loading) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">Loading signals</p>
</section>
);
}
if (signals.length === 0) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">
No Signals detected for this project yet.
</p>
</section>
);
}
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<span className="text-[10px] text-muted-foreground/60">
{signals.length} total
</span>
</div>
<div className="space-y-3">
{signals.map((signal) => (
<div
className="rounded-xl border border-border/20 bg-background/30 p-3"
key={`${signal.title}-${signal.createdAt}`}
>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<div className="mt-2 flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span>
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"}
</span>
<span>{formatRelativeTime(signal.createdAt)}</span>
</div>
</div>
))}
</div>
</section>
);
};

View File

@@ -0,0 +1,143 @@
import { Button } from "@code/ui/components/button";
import { LoaderCircle, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ComposerMode } from "@/hooks/work-os/use-work-os";
interface WorkOsComposerProps {
readonly mode: ComposerMode;
readonly onModeChange: (mode: ComposerMode) => void;
readonly onSend: (message: string) => Promise<void>;
readonly busy: boolean;
readonly error?: Error;
readonly workUnitTitle?: string;
readonly placeholder?: string;
}
export const WorkOsComposer = ({
busy,
error,
mode,
onModeChange,
onSend,
placeholder,
workUnitTitle,
}: WorkOsComposerProps) => {
const [draft, setDraft] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSend = draft.trim().length > 0 && !busy;
useEffect(() => {
if (!busy) {
textareaRef.current?.focus();
}
}, [busy, mode]);
const handleSubmit = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
setDraft("");
try {
await onSend(message);
} finally {
textareaRef.current?.focus();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void handleSubmit();
}
};
const scopeLabel =
mode === "project" ? "Project" : (workUnitTitle ?? "Work Unit");
return (
<div className="border-t border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
{/* Mode switcher */}
<div className="mb-2 flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5">
<button
className={`rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "project"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onModeChange("project")}
type="button"
>
Project
</button>
<button
className={`flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "work-unit"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
disabled={!workUnitTitle}
onClick={() => onModeChange("work-unit")}
type="button"
>
Work Unit
</button>
</div>
{mode === "work-unit" && workUnitTitle ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<X
className="size-3 cursor-pointer"
onClick={() => onModeChange("project")}
/>
<span className="max-w-40 truncate">{workUnitTitle}</span>
</span>
) : null}
<span className="ml-auto text-[10px] text-muted-foreground/50">
{scopeLabel}
</span>
</div>
{error ? (
<p className="mb-2 text-[11px] text-destructive" id="composer-error">
{error.message || "Message failed to send"}
</p>
) : null}
<div className="flex items-end gap-2.5">
<textarea
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label={`Message ${scopeLabel}`}
className="min-h-11 max-h-32 flex-1 resize-none rounded-2xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm leading-5 outline-none transition-colors placeholder:text-muted-foreground/60 focus-visible:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-60"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? `Message ${scopeLabel}`}
ref={textareaRef}
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0 rounded-full"
disabled={!canSend}
onClick={() => void handleSubmit()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { ChevronDown, Circle, FolderGit2, Zap } from "lucide-react";
import UserMenu from "@/components/user-menu";
interface ProjectOption {
readonly id: string;
readonly name: string;
readonly host?: string;
}
interface WorkOsHeaderProps {
readonly projects: readonly ProjectOption[] | undefined;
readonly selectedProject: ProjectOption | null;
readonly onSelectProject: (id: string) => void;
readonly connected: boolean;
readonly onBack?: () => void;
readonly showBack: boolean;
}
const StatusDot = ({ connected }: { readonly connected: boolean }) => (
<span className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Circle
className={`size-2 ${connected ? "fill-emerald-500 text-emerald-500" : "fill-amber-500 text-amber-500"}`}
/>
{connected ? "Connected" : "Connecting"}
</span>
);
export const WorkOsHeader = ({
connected,
onBack,
onSelectProject,
projects,
selectedProject,
showBack,
}: WorkOsHeaderProps) => (
<header className="flex items-center justify-between gap-3 border-b border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
<div className="flex min-w-0 items-center gap-3">
{showBack && onBack ? (
<Button
className="shrink-0"
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronDown className="size-4 rotate-90" />
</Button>
) : null}
<div className="grid size-9 shrink-0 place-items-center rounded-xl bg-foreground text-background">
<Zap className="size-4 fill-current" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tracking-tight">Zopu</p>
<StatusDot connected={connected} />
</div>
{selectedProject ? (
<p className="truncate text-[11px] text-muted-foreground">
{selectedProject.name}
</p>
) : (
<p className="text-[11px] text-muted-foreground">No project</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{projects && projects.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button size="sm" type="button" variant="ghost">
<FolderGit2 className="size-3.5" />
<span className="max-w-32 truncate">
{selectedProject?.name ?? "Select"}
</span>
<ChevronDown className="size-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Projects</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => onSelectProject(project.id)}
>
<FolderGit2 className="size-3.5" />
<span className="truncate">{project.name}</span>
{project.host ? (
<span className="ml-auto text-[10px] text-muted-foreground">
{project.host}
</span>
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<UserMenu />
</div>
</header>
);

View File

@@ -0,0 +1,401 @@
import { CircleAlert, GitFork, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { ConversationPanel } from "@/components/work-os/conversation-panel";
import { EmptyState } from "@/components/work-os/empty-state";
import { SignalsPanel } from "@/components/work-os/signals-panel";
import { WorkOsComposer } from "@/components/work-os/work-os-composer";
import { WorkOsHeader } from "@/components/work-os/work-os-header";
import { WorkUnitCard } from "@/components/work-os/work-unit-card";
import { WorkUnitDetail } from "@/components/work-os/work-unit-detail";
import { useWorkOs } from "@/hooks/work-os/use-work-os";
import type { WorkOsState } from "@/hooks/work-os/use-work-os";
type CardData = WorkOsState["workUnitCards"][number];
type IssueId = WorkOsState["selectedIssueId"];
interface ProjectOption {
readonly host?: string;
readonly id: string;
readonly name: string;
}
const toProjectOption = (project: {
readonly id: string;
readonly name: string;
readonly sources: readonly { readonly host?: string }[];
}): ProjectOption => ({
host: project.sources[0]?.host,
id: project.id,
name: project.name,
});
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>
);
const isAgentBusy = (status: string): boolean =>
status === "connecting" || status === "submitted" || status === "streaming";
const CONVERSATION_COPY = {
projectHint:
"Describe what you want Zopu to work on. Work Units created from the conversation will appear in the right panel.",
projectTitle: "Project conversation",
workUnitHint:
"Send a message to continue this Work Unit. The project manager agent will pick up the existing context.",
workUnitTitle: "Work Unit conversation",
} as const;
interface IssueComposerProps {
readonly body: string;
readonly busy: boolean;
readonly onBodyChange: (value: string) => void;
readonly onSubmit: () => void;
readonly onTitleChange: (value: string) => void;
readonly title: string;
}
const IssueComposer = ({
body,
busy,
onBodyChange,
onSubmit,
onTitleChange,
title,
}: IssueComposerProps) => (
<form
className="rounded-2xl border border-border/40 bg-card/40 p-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<div className="mb-3 flex items-center gap-2">
<div className="grid size-7 place-items-center rounded-lg bg-foreground text-background">
<Plus className="size-3.5" />
</div>
<p className="text-sm font-semibold">Create a Work Unit</p>
</div>
<div className="space-y-2">
<input
aria-label="Work Unit title"
className="w-full rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onTitleChange(event.target.value)}
placeholder="Outcome title"
required
value={title}
/>
<textarea
aria-label="Work Unit description"
className="min-h-20 w-full resize-none rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onBodyChange(event.target.value)}
placeholder="Describe the desired result, constraints, or evidence."
required
rows={3}
value={body}
/>
</div>
<button
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
{busy ? "Creating" : "Create Work Unit"}
</button>
</form>
);
const WorkUnitCards = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-6 text-center">
<GitFork className="mx-auto size-4 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">No Work Units yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Create one above, or describe what you need in the conversation.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
const MobileWorkUnitList = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-4 text-center">
<p className="text-xs text-muted-foreground">
No Work Units yet. Create one or chat with Zopu.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
interface NoProjectProps {
readonly busy: boolean;
readonly onConnectRepository: () => Promise<void>;
readonly onRepositoryChange: (value: string) => void;
readonly projects: WorkOsState["projects"];
readonly repository: string;
}
const NoProjectView = ({
busy,
onConnectRepository,
onRepositoryChange,
projects,
repository,
}: NoProjectProps) => {
if (projects === undefined) {
return (
<div className="grid h-full place-items-center">
<LoadingLine label="Loading projects" />
</div>
);
}
return (
<EmptyState
busy={busy}
onConnectRepository={onConnectRepository}
onRepositoryChange={onRepositoryChange}
repository={repository}
/>
);
};
export const WorkOsPage = () => {
const os = useWorkOs();
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [issueTitle, setIssueTitle] = useState("");
const [issueBody, setIssueBody] = useState("");
const handleSelectIssue = (issueId: IssueId) => {
os.selectIssue(issueId);
setMobileDetailOpen(issueId !== null);
};
const handleStart = () => {
if (!os.selectedIssue) {
return;
}
void os.startIssue(
os.selectedIssue._id,
os.selectedIssue.number,
os.selectedIssue.title
);
};
const handleRaiseIssue = () => {
void os.raiseIssue({ body: issueBody, title: issueTitle });
setIssueTitle("");
setIssueBody("");
};
const handleSend = (message: string): Promise<void> =>
os.composerMode === "work-unit" && os.selectedIssueId
? os.workUnitAgent.sendMessage(message)
: os.projectAgent.sendMessage(message);
const handleModeChange = (mode: "project" | "work-unit") =>
os.setComposerMode(mode);
const handleConnectRepository = os.connectRepository;
const handleRepositoryChange = os.setRepository;
const isWorkUnitMode =
os.composerMode === "work-unit" && !!os.selectedIssueId;
const activeAgent = isWorkUnitMode ? os.workUnitAgent : os.projectAgent;
const conversationTitle = isWorkUnitMode
? CONVERSATION_COPY.workUnitTitle
: CONVERSATION_COPY.projectTitle;
const conversationHint = isWorkUnitMode
? CONVERSATION_COPY.workUnitHint
: CONVERSATION_COPY.projectHint;
const selectedProjectOption = os.selectedProject
? toProjectOption(os.selectedProject)
: null;
const projectOptions = os.projects?.map(toProjectOption) ?? undefined;
const cards = os.workUnitCards ?? [];
const startPending = os.pendingAction === `issue:${os.selectedIssue?._id}`;
const showConversation = !(mobileDetailOpen && os.selectedDetail);
return (
<div className="flex h-svh flex-col bg-background text-foreground">
<WorkOsHeader
connected={!!os.selectedProject}
onBack={() => {
os.selectIssue(null);
setMobileDetailOpen(false);
}}
onSelectProject={() => os.selectIssue(null)}
projects={projectOptions}
selectedProject={selectedProjectOption}
showBack={mobileDetailOpen && !!os.selectedIssue}
/>
{os.error ? (
<div className="flex items-start gap-3 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<p>{os.error}</p>
</div>
) : null}
{os.selectedProject ? (
<main className="flex min-h-0 flex-1">
{/* Left column: conversation + composer */}
<div className="flex min-w-0 flex-1 flex-col">
<div
className={`min-h-0 flex-1 ${showConversation ? "block" : "hidden md:block"}`}
>
<ConversationPanel
agent={activeAgent}
emptyHint={conversationHint}
title={conversationTitle}
/>
</div>
{/* Mobile: detail overlay */}
{mobileDetailOpen && os.selectedDetail ? (
<div className="min-h-0 flex-1 overflow-y-auto md:hidden">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<WorkOsComposer
busy={isAgentBusy(activeAgent.status)}
error={
os.composerMode === "work-unit"
? os.workUnitAgent.error
: os.projectAgent.error
}
mode={os.composerMode}
onModeChange={handleModeChange}
onSend={handleSend}
workUnitTitle={os.selectedIssue?.title}
/>
</div>
{/* Right column: Work Units, signals, detail */}
<aside className="hidden w-[400px] shrink-0 flex-col overflow-y-auto border-l border-border/40 bg-background/50 p-4 md:flex lg:w-[440px]">
<IssueComposer
body={issueBody}
busy={os.pendingAction === "issue"}
onBodyChange={setIssueBody}
onSubmit={handleRaiseIssue}
onTitleChange={setIssueTitle}
title={issueTitle}
/>
<div className="mt-4">
<SignalsPanel
loading={os.signals === undefined}
signals={os.signals}
/>
</div>
{os.selectedDetail ? (
<div className="mt-4">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<div className="mt-4">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<WorkUnitCards
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</aside>
{/* Mobile: Work Unit cards list */}
<div className="border-t border-border/40 p-4 md:hidden">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<MobileWorkUnitList
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</main>
) : (
<main className="flex-1 overflow-y-auto">
<NoProjectView
busy={os.pendingAction === "connect"}
onConnectRepository={handleConnectRepository}
onRepositoryChange={handleRepositoryChange}
projects={os.projects}
repository={os.repository}
/>
</main>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
import { Badge } from "@code/ui/components/badge";
import {
Activity,
ChevronRight,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitCard as WorkUnitCardData } from "@/lib/work-os/work-unit-projection";
const STATUS_LABEL: Record<WorkUnitCardData["status"], string> = {
completed: "Completed",
failed: "Failed",
"needs-input": "Needs input",
open: "Ready",
queued: "Queued",
working: "In progress",
};
const STATUS_TONE: Record<WorkUnitCardData["status"], string> = {
completed: "bg-emerald-500/15 text-emerald-400",
failed: "bg-destructive/15 text-destructive",
"needs-input": "bg-amber-500/15 text-amber-400",
open: "bg-muted text-muted-foreground",
queued: "bg-violet-500/15 text-violet-400",
working: "bg-blue-500/15 text-blue-400",
};
interface IndicatorProps {
readonly icon: React.ReactNode;
readonly label: string;
readonly value: number;
readonly tone?: string;
}
const Indicator = ({ icon, label, tone, value }: IndicatorProps) => (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className={tone}>{icon}</span>
{value}
<span className="sr-only">{label}</span>
</span>
);
interface WorkUnitCardProps {
readonly card: WorkUnitCardData;
readonly selected: boolean;
readonly onSelect: () => void;
}
export const WorkUnitCard = ({
card,
onSelect,
selected,
}: WorkUnitCardProps) => (
<button
className={`w-full cursor-pointer rounded-2xl border p-4 text-left transition-all duration-200 ${
selected
? "border-foreground/20 bg-foreground/5"
: "border-border/40 bg-card/40 hover:border-border/70 hover:bg-card/60"
}`}
onClick={onSelect}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-muted-foreground">
#{card.number}
</p>
<h3 className="mt-0.5 text-sm font-semibold leading-5 tracking-tight">
{card.title}
</h3>
</div>
<Badge
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[card.status]}`}
variant="secondary"
>
{STATUS_LABEL[card.status]}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">
{card.summary}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-border/30 pt-3">
{card.currentActivity ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Activity className="size-3" />
{card.currentActivity}
</span>
) : null}
<Indicator
icon={<Radio className="size-3" />}
label="signals"
value={card.signalCount}
/>
<Indicator
icon={<Layers className="size-3" />}
label="steps"
value={card.stepCount}
/>
<Indicator
icon={<FileText className="size-3" />}
label="artifacts"
value={card.artifactCount}
/>
{card.hasPullRequest ? (
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400">
<GitPullRequest className="size-3" />
PR
</span>
) : null}
{card.needsInput ? (
<span className="inline-flex items-center gap-1 text-[11px] text-amber-400">
<TriangleAlert className="size-3" />
Input needed
</span>
) : null}
<span className="ml-auto inline-flex items-center gap-1">
<span className="text-[11px] text-muted-foreground/70">
{formatRelativeTime(card.updatedAt)}
</span>
<ChevronRight className="size-3.5 text-muted-foreground/50" />
</span>
</div>
</button>
);

View File

@@ -0,0 +1,237 @@
import { Button } from "@code/ui/components/button";
import {
ArrowUpRight,
Bot,
ExternalLink,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection";
const formatStatus = (
status: WorkUnitDetailData["issue"]["status"]
): string => {
if (status === "needs-input") {
return "Needs input";
}
if (status === "working") {
return "In progress";
}
return status.charAt(0).toUpperCase() + status.slice(1);
};
interface WorkUnitDetailProps {
readonly detail: WorkUnitDetailData;
readonly onOpenPullRequest: (url: string) => void;
readonly onStart: () => void;
readonly startPending: boolean;
}
const Section = ({
children,
icon,
title,
}: {
readonly children: React.ReactNode;
readonly icon: React.ReactNode;
readonly title: string;
}) => (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{icon}
{title}
</div>
{children}
</section>
);
export const WorkUnitDetail = ({
detail,
onOpenPullRequest,
onStart,
startPending,
}: WorkUnitDetailProps) => {
const { issue } = detail;
const canStart = issue.status === "open" || issue.status === "failed";
return (
<div className="space-y-4">
{/* Objective */}
<section className="rounded-2xl border border-border/40 bg-card/40 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-medium text-muted-foreground">
Work Unit #{issue.number}
</p>
<h2 className="mt-1 text-lg font-semibold leading-7 tracking-tight">
{issue.title}
</h2>
</div>
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 text-[11px] font-medium">
{formatStatus(issue.status)}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{issue.body}
</p>
{canStart ? (
<Button
className="mt-4 w-full"
disabled={startPending}
onClick={onStart}
type="button"
>
<Bot className="size-4" />
{startPending ? "Starting" : "Start work"}
</Button>
) : null}
</section>
{/* Needs input alert */}
{detail.needsInput ? (
<div className="flex items-start gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">
This work unit needs your input
</p>
<p className="mt-1 text-xs leading-5 text-amber-400/70">
Resolve the open question or decision to unblock the agent.
</p>
</div>
</div>
) : null}
{/* Latest summary */}
{detail.latestSummary ? (
<Section icon={<ArrowUpRight className="size-3" />} title="Latest">
<p className="text-sm leading-6">{detail.latestSummary}</p>
</Section>
) : null}
{/* Linked Signals — authenticated via signalIssueAttachments */}
<Section
icon={<Radio className="size-3" />}
title={`Signals (${detail.signalCount})`}
>
{detail.linkedSignals.length > 0 ? (
<div className="space-y-3">
{detail.linkedSignals.map((signal) => (
<div key={String(signal.signalId)}>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/60">
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"} ·{" "}
{formatRelativeTime(signal.createdAt)}
</p>
</div>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
No Signals linked to this Work Unit.
</p>
)}
</Section>
{/* Activity timeline */}
<Section
icon={<Layers className="size-3" />}
title={`Timeline (${detail.stepCount} steps)`}
>
{detail.activity.length > 0 ? (
<div className="space-y-4">
{detail.activity.map((item) => (
<div
className="flex gap-3"
key={`${item.kind}-${item.createdAt}`}
>
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-foreground/40" />
<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-0.5 text-[10px] text-muted-foreground/60">
{item.time}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No activity recorded yet.
</p>
)}
</Section>
{/* Artifacts */}
<Section
icon={<FileText className="size-3" />}
title={`Artifacts (${detail.artifactCount})`}
>
{detail.artifactPaths.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detail.artifactPaths.map((path) => (
<span
className="inline-flex items-center gap-1.5 rounded-lg bg-muted/60 px-2.5 py-1 text-[11px]"
key={path}
>
<FileText className="size-3 text-muted-foreground" />
{path}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No artifacts produced yet.
</p>
)}
</Section>
{/* Pull request */}
{detail.hasPullRequest ? (
<Section
icon={<GitPullRequest className="size-3" />}
title="Pull request"
>
{detail.pullRequestUrl ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-sm font-medium text-emerald-300">
{detail.pullRequestNumber
? `PR #${detail.pullRequestNumber} ready for review`
: "Pull request ready"}
</p>
<Button
className="mt-3"
onClick={() =>
detail.pullRequestUrl &&
onOpenPullRequest(detail.pullRequestUrl)
}
size="sm"
type="button"
variant="outline"
>
<ExternalLink className="size-3.5" />
Review changes
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground">
A pull request was opened but the URL is unavailable.
</p>
)}
</Section>
) : null}
</div>
);
};