Files
zopu-code/repos/web/components/work-os/work-os-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

402 lines
13 KiB
TypeScript

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