- 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
402 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|