feat(web): Work OS surface with Work Units, Signals, dual-mode composer

Lane C implementation of the Zopu Web Work OS for dogfooding:

- Work Unit card/detail projections built from per-issue events only
  (artifact counts from distinct artifact.updated paths, PR from
  gitea events, activity timeline, agent summaries). Signal linkage
  is explicitly 0/unavailable until Lane A relations are integrated.
- Persistent composer with Project and Work Unit mode switching.
  Project mode sends to the global Zopu agent; Work Unit mode sends
  to the issue-scoped project-manager agent identity.
- Collapsed Work Unit cards show title, summary, signal/step/artifact
  counts, current activity, PR indicator, needs-input indicator.
- Expanded Work Unit detail shows objective, timeline, artifacts,
  PR with directly usable link, needs-input alert, start action.
- Project-level Signals panel in the sidebar (not attributed to any
  issue until a signal-to-issue relation exists).
- Dark theme with calm, Apple-like visual direction.
- 21 projection tests including 5 cross-issue isolation tests and
  2 signal isolation tests proving one issue cannot inherit
  another issue's artifacts, PR, summary, or project signals.
- Mobile-responsive: detail overlay on mobile, sidebar on desktop.

No backend or schema changes. Uses existing Convex contracts and
Flue agent transport throughout.
This commit is contained in:
-Puter
2026-07-24 20:59:48 +05:30
parent 2ce96b0bab
commit 5df8633eae
14 changed files with 2128 additions and 29 deletions

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,219 @@
import { Button } from "@code/ui/components/button";
import {
ArrowUpRight,
Bot,
ExternalLink,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
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}
{/* Signals — no signal-to-issue relation exists yet. Show explicit
unavailable state rather than falsely presenting project Signals. */}
<Section icon={<Radio className="size-3" />} title="Signals">
<p className="text-xs leading-5 text-muted-foreground">
No Signals linked to this Work Unit yet.
</p>
<p className="mt-1 text-[10px] leading-4 text-muted-foreground/60">
Linked Signals will appear here once attachment is available.
</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>
);
};

View File

@@ -0,0 +1,179 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueAgent } from "@flue/react";
import { useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import type { ChatAgentState } from "@/lib/chat/types";
import {
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
} from "@/lib/work-os/work-unit-projection";
import type {
WorkUnitCard,
WorkUnitDetail,
} from "@/lib/work-os/work-unit-projection";
export interface SignalItem {
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
export type ComposerMode = "project" | "work-unit";
export interface WorkOsState {
// Project
readonly projects: ReturnType<typeof useProjectWorkspace>["projects"];
readonly selectedProject: ReturnType<
typeof useProjectWorkspace
>["selectedProject"];
readonly selectedProjectId: ReturnType<
typeof useProjectWorkspace
>["selectedProjectId"];
readonly repository: string;
readonly setRepository: (value: string) => void;
readonly connectRepository: () => Promise<void>;
// Work units
readonly workUnitCards: readonly WorkUnitCard[];
readonly selectedIssue: ReturnType<
typeof useProjectWorkspace
>["selectedIssue"];
readonly selectedIssueId: Id<"projectIssues"> | null;
readonly selectedDetail: WorkUnitDetail | null;
readonly selectIssue: (issueId: Id<"projectIssues"> | null) => void;
readonly startIssue: ReturnType<typeof useProjectWorkspace>["startIssue"];
readonly raiseIssue: ReturnType<typeof useProjectWorkspace>["raiseIssue"];
readonly issueTitle: string;
readonly setIssueTitle: (value: string) => void;
readonly issueBody: string;
readonly setIssueBody: (value: string) => void;
// Signals (project-scoped, displayed globally — not attributed to any issue)
readonly signals: readonly SignalItem[];
// Chat
readonly composerMode: ComposerMode;
readonly setComposerMode: (mode: ComposerMode) => void;
readonly projectAgent: ChatAgentState;
readonly workUnitAgent: ChatAgentState;
// Meta
readonly pendingAction: string | null;
readonly error: string | null;
}
export const useWorkOs = (): WorkOsState => {
const workspace = useProjectWorkspace();
const orgState = usePersonalOrganization();
const projectAgent = useChatAgent();
const activeProjectId = workspace.selectedProjectId;
// Work-unit-scoped project-manager agent. Stays dormant until an issue is
// selected, then follows that issue identity across re-renders.
const workUnitFlue = useFlueAgent({
id: workspace.selectedIssueId
? String(workspace.selectedIssueId)
: undefined,
live: "sse",
name: "project-manager",
});
const workUnitAgent: ChatAgentState = {
error: workUnitFlue.error,
historyReady: workUnitFlue.historyReady,
messages: workUnitFlue.messages,
sendMessage: workUnitFlue.sendMessage,
status: workspace.selectedIssueId ? workUnitFlue.status : "idle",
};
// Signals scoped to the active project. These are displayed as project-level
// evidence, not attributed to any specific work unit (no signal-to-issue
// relation exists yet).
const allSignals = useQuery(
api.signals.list,
orgState.organizationId
? { organizationId: orgState.organizationId }
: "skip"
);
const signals = useMemo<readonly SignalItem[]>(() => {
if (!allSignals || !activeProjectId) {
return [];
}
return allSignals
.filter((entry) => entry.signal.projectId === activeProjectId)
.map((entry) => ({
createdAt: entry.signal.createdAt,
sourceCount: entry.sources.length,
summary: entry.signal.problemStatement.summary,
title: entry.signal.problemStatement.title,
}));
}, [allSignals, activeProjectId]);
// Composer mode tracks the selection by default but can be overridden.
const [composerMode, setComposerMode] = useState<ComposerMode>("project");
const selectIssue = (issueId: Id<"projectIssues"> | null) => {
workspace.setSelectedIssueId(issueId);
setComposerMode(issueId ? "work-unit" : "project");
};
// Work unit card projections — derived from per-issue events only.
const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => {
if (!workspace.issues) {
return [];
}
return workspace.issues.map((issue) => {
const issueEvents = eventsForIssue(workspace.events, issue._id);
return buildWorkUnitCard({ issue, issueEvents });
});
}, [workspace.issues, workspace.events]);
// Selected work unit detail — same per-issue derivation.
const selectedDetail = useMemo<WorkUnitDetail | null>(() => {
if (!workspace.selectedIssue) {
return null;
}
const issueEvents = eventsForIssue(
workspace.events,
workspace.selectedIssue._id
);
return buildWorkUnitDetail({
issue: workspace.selectedIssue,
issueEvents,
});
}, [workspace.selectedIssue, workspace.events]);
return {
composerMode,
connectRepository: workspace.connectRepository,
error: workspace.error,
issueBody: workspace.issueBody,
issueTitle: workspace.issueTitle,
pendingAction: workspace.pendingAction,
projectAgent,
projects: workspace.projects,
raiseIssue: workspace.raiseIssue,
repository: workspace.repository,
selectIssue,
selectedDetail,
selectedIssue: workspace.selectedIssue,
selectedIssueId: workspace.selectedIssueId,
selectedProject: workspace.selectedProject,
selectedProjectId: activeProjectId,
setComposerMode,
setIssueBody: workspace.setIssueBody,
setIssueTitle: workspace.setIssueTitle,
setRepository: workspace.setRepository,
signals,
startIssue: workspace.startIssue,
workUnitAgent,
workUnitCards,
};
};

View File

@@ -0,0 +1,422 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { describe, expect, test } from "vitest";
import {
buildActivityTimeline,
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
extractArtifactPaths,
extractLatestSummary,
extractPullRequestFromEvents,
} from "./work-unit-projection";
type Issue = Doc<"projectIssues">;
type Event = Doc<"projectEvents">;
const ISSUE_A = "issue-a" as Id<"projectIssues">;
const ISSUE_B = "issue-b" as Id<"projectIssues">;
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
({
_creationTime: 1,
_id: ISSUE_A,
body: "Fix the Safari OAuth callback so users can complete sign-in.",
createdAt: 1000,
number: 1,
projectId: "project-1" as Id<"projects">,
status: "open",
title: "Fix Safari OAuth callback",
updatedAt: 2000,
...overrides,
}) as Issue;
const makeEvent = (
kind: string,
data: Record<string, unknown>,
overrides: Partial<Event> = {}
): Event =>
({
_creationTime: 1,
_id: `evt-${kind}-${overrides.createdAt ?? 1000}` as Id<"projectEvents">,
createdAt: overrides.createdAt ?? 1000,
data,
issueId: ISSUE_A,
kind,
projectId: "project-1" as Id<"projects">,
...overrides,
}) as Event;
describe("eventsForIssue", () => {
test("filters to a specific issue and ignores undefined", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 2000, issueId: ISSUE_B }
),
];
expect(eventsForIssue(events, ISSUE_A)).toHaveLength(1);
expect(eventsForIssue(undefined, ISSUE_A)).toEqual([]);
});
});
describe("extractArtifactPaths", () => {
test("collects distinct paths from artifact.updated events", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 3000 }
),
makeEvent("issue.created", { number: 1 }, { createdAt: 4000 }),
];
expect(extractArtifactPaths(events)).toEqual(["artifacts.md", "work.md"]);
});
test("returns empty for no artifact events", () => {
expect(extractArtifactPaths([])).toEqual([]);
});
});
describe("buildWorkUnitCard", () => {
test("projects a fresh issue with no events", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
});
expect(card.title).toBe("Fix Safari OAuth callback");
expect(card.status).toBe("open");
expect(card.stepCount).toBe(0);
expect(card.currentActivity).toBeNull();
expect(card.needsInput).toBe(false);
expect(card.hasPullRequest).toBe(false);
expect(card.artifactCount).toBe(0);
expect(card.signalCount).toBe(0);
expect(card.summary).toContain("Fix the Safari OAuth");
});
test("counts per-issue artifacts from events, not project-wide", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "working" }),
issueEvents: events,
});
expect(card.artifactCount).toBe(2);
expect(card.stepCount).toBe(2);
});
test("reflects needs-input status and activity from events", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("issue.queued", { number: 1 }, { createdAt: 2000 }),
makeEvent("agent.needs-input", {}, { createdAt: 3000 }),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "needs-input" }),
issueEvents: events,
});
expect(card.needsInput).toBe(true);
expect(card.stepCount).toBe(3);
expect(card.currentActivity).toBe("Needs your input");
});
test("detects PR from gitea event", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"gitea.pull_request.created",
{
pullRequest: { number: 42, url: "https://git.example.com/pulls/42" },
},
{ createdAt: 5000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
});
expect(card.hasPullRequest).toBe(true);
});
});
describe("extractLatestSummary", () => {
test("returns the most recent agent summary", () => {
const events = [
makeEvent("agent.working", {}, { createdAt: 1000 }),
makeEvent(
"agent.completed",
{ summary: "All tests passed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("All tests passed.");
});
test("returns null when no terminal agent event exists", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
];
expect(extractLatestSummary(events)).toBeNull();
});
test("prefers failure error over earlier completion", () => {
const events = [
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 1000 }),
makeEvent(
"agent.failed",
{ error: "Tests failed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("Tests failed.");
});
});
describe("extractPullRequestFromEvents", () => {
test("extracts URL and number from gitea event", () => {
const events = [
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 7, url: "https://git.example.com/pulls/7" } },
{ createdAt: 1000 }
),
];
const info = extractPullRequestFromEvents(events);
expect(info.hasPR).toBe(true);
expect(info.url).toBe("https://git.example.com/pulls/7");
expect(info.number).toBe(7);
});
test("returns false when no PR event exists", () => {
expect(extractPullRequestFromEvents([]).hasPR).toBe(false);
});
});
describe("buildActivityTimeline", () => {
test("sorts newest first and maps labels", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 5000 }),
];
const timeline = buildActivityTimeline(events);
expect(timeline).toHaveLength(2);
expect(timeline[0].kind).toBe("agent.completed");
expect(timeline[0].label).toBe("Work completed");
expect(timeline[1].kind).toBe("issue.created");
expect(timeline[1].label).toBe("Work created");
});
});
describe("buildWorkUnitDetail", () => {
test("aggregates all detail fields from per-issue events", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500 }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 3, url: "https://git.example.com/pulls/3" } },
{ createdAt: 3000 }
),
];
const detail = buildWorkUnitDetail({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
});
expect(detail.stepCount).toBe(4);
expect(detail.artifactCount).toBe(2);
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]);
expect(detail.signalCount).toBe(0);
expect(detail.hasPullRequest).toBe(true);
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
expect(detail.pullRequestNumber).toBe(3);
expect(detail.activity).toHaveLength(4);
expect(detail.activity[0].kind).toBe("gitea.pull_request.created");
});
});
// ---------------------------------------------------------------------------
// Cross-issue isolation: one issue must never inherit another issue's
// artifacts, PR, signals, or activity.
// ---------------------------------------------------------------------------
describe("cross-issue isolation", () => {
const sharedEvents: Event[] = [
makeEvent(
"issue.created",
{ number: 1 },
{ createdAt: 1000, issueId: ISSUE_A }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 2000, issueId: ISSUE_A }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 5, url: "https://git.example.com/pulls/5" } },
{ createdAt: 3000, issueId: ISSUE_A }
),
makeEvent(
"agent.completed",
{ summary: "Done for A." },
{ createdAt: 4000, issueId: ISSUE_A }
),
// Issue B events — completely separate
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 1500, issueId: ISSUE_B }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500, issueId: ISSUE_B }
),
makeEvent(
"agent.failed",
{ error: "Failed for B." },
{ createdAt: 3500, issueId: ISSUE_B }
),
];
test("issue A does not inherit issue B's artifacts", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const cardA = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
});
expect(cardA.artifactCount).toBe(1);
expect(cardA.stepCount).toBe(4);
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
});
expect(cardB.artifactCount).toBe(1);
expect(cardB.stepCount).toBe(3);
});
test("issue A does not inherit issue B's PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
});
expect(cardB.hasPullRequest).toBe(false);
});
test("issue A does not inherit issue B's summary", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const summaryA = extractLatestSummary(eventsA);
expect(summaryA).toBe("Done for A.");
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const summaryB = extractLatestSummary(eventsB);
expect(summaryB).toBe("Failed for B.");
});
test("detail for issue A lists only issue A's artifact paths", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
});
expect(detailA.artifactPaths).toEqual(["work.md"]);
expect(detailA.pullRequestNumber).toBe(5);
});
test("detail for issue B lists only issue B's artifact paths and no PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
});
expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
expect(detailB.hasPullRequest).toBe(false);
expect(detailB.pullRequestUrl).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Signal isolation: a project may have signals, but no Work Unit should
// claim them until a signal-to-issue relation is integrated.
// ---------------------------------------------------------------------------
describe("signal isolation", () => {
test("card signalCount is 0 even when the project has signals", () => {
// Simulate a project that has signals — these are NOT passed to the
// card builder. The card must show 0 regardless.
const projectHasSignals = [
{
sourceCount: 3,
summary: "Three tickets about Safari",
title: "Users report OAuth crash",
},
{
sourceCount: 1,
summary: "CI broke after merge",
title: "Build failure on staging",
},
];
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
});
// The card never sees projectSignals — signalCount is always 0.
expect(card.signalCount).toBe(0);
expect(projectHasSignals.length).toBeGreaterThan(0);
});
test("detail signalCount is 0 even when the project has signals", () => {
const projectHasSignals = [
{
sourceCount: 3,
summary: "Three tickets",
title: "Users report OAuth crash",
},
];
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
],
});
expect(detail.signalCount).toBe(0);
expect(projectHasSignals.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,314 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
// ---------------------------------------------------------------------------
// Work Unit card projection — pure functions that turn Convex documents into
// the collapsed/expanded Work Unit views used by the Work OS surface.
//
// Every count is derived from issue-scoped events only. We never use
// project-wide artifact lists or signal lists, because those would
// incorrectly attribute evidence to every issue in the project. Signal
// linkage is intentionally 0 until a Lane A signal-to-issue relation is
// integrated.
// ---------------------------------------------------------------------------
export type ProjectEvent = Doc<"projectEvents">;
export type ProjectIssue = Doc<"projectIssues">;
export interface WorkUnitCard {
readonly issueId: Id<"projectIssues">;
readonly number: number;
readonly title: string;
readonly status: ProjectIssue["status"];
readonly summary: string;
readonly signalCount: number;
readonly currentActivity: string | null;
readonly stepCount: number;
readonly artifactCount: number;
readonly hasPullRequest: boolean;
readonly needsInput: boolean;
readonly updatedAt: number;
}
export interface WorkUnitActivityItem {
readonly label: string;
readonly detail: string;
readonly time: string;
readonly kind: string;
readonly createdAt: number;
}
export interface WorkUnitDetail {
readonly issue: ProjectIssue;
readonly activity: readonly WorkUnitActivityItem[];
readonly stepCount: number;
readonly artifactCount: number;
readonly artifactPaths: readonly string[];
readonly signalCount: number;
readonly hasPullRequest: boolean;
readonly pullRequestUrl: string | null;
readonly pullRequestNumber: number | null;
readonly needsInput: boolean;
readonly latestSummary: string | null;
}
// ---------------------------------------------------------------------------
// Time formatting
// ---------------------------------------------------------------------------
export const formatRelativeTime = (timestamp: number): string => {
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
};
export const formatClockTime = (timestamp: number): string =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(timestamp);
// ---------------------------------------------------------------------------
// Event filtering
// ---------------------------------------------------------------------------
export const eventsForIssue = (
events: readonly ProjectEvent[] | undefined,
issueId: Id<"projectIssues">
): readonly ProjectEvent[] =>
(events ?? []).filter((e) => e.issueId === issueId);
// ---------------------------------------------------------------------------
// Event-to-activity projection
// ---------------------------------------------------------------------------
interface ActivityProjection {
readonly label: string;
readonly detail: string;
}
const getString = (
data: Record<string, unknown>,
key: string,
fallback: string
): string => (typeof data[key] === "string" ? (data[key] as string) : fallback);
const ACTIVITY_HANDLERS: Record<
string,
(data: Record<string, unknown>, event: ProjectEvent) => ActivityProjection
> = {
"agent.completed": (data) => ({
detail: getString(data, "summary", "Run completed successfully"),
label: "Work completed",
}),
"agent.failed": (data) => ({
detail: getString(data, "error", "The run stopped unexpectedly"),
label: "Run failed",
}),
"agent.needs-input": () => ({
detail: "Waiting for a decision or missing information",
label: "Needs your input",
}),
"agent.working": () => ({
detail: "The project manager is progressing the issue",
label: "Agent working",
}),
"agent.workspace.created": (data) => ({
detail: `Workspace ready on ${getString(data, "branchName", "work branch")}`,
label: "Workspace prepared",
}),
"artifact.updated": (data) => ({
detail: `Evidence revised in ${getString(data, "path", "artifact")} (r${data.revision ?? "?"})`,
label: "Evidence updated",
}),
"gitea.lifecycle.updated": (data) => ({
detail: `${getString(data, "status", "updated")} on ${getString(data, "branch", "branch")}`,
label: "Git lifecycle",
}),
"gitea.pull_request.created": (data) => {
const pr = data.pullRequest as { number?: number } | undefined;
return {
detail: `Pull request #${pr?.number ?? "?"} opened for review`,
label: "Pull request opened",
};
},
"issue.created": (data) => ({
detail: `Issue #${data.number ?? "?"} entered the loop`,
label: "Work created",
}),
"issue.queued": (data) => ({
detail: `Queued #${data.number ?? "?"} for the project manager`,
label: "Work queued",
}),
};
const projectEventToActivity = (event: ProjectEvent): ActivityProjection => {
const data = (event.data ?? {}) as Record<string, unknown>;
const handler = ACTIVITY_HANDLERS[event.kind];
if (handler) {
return handler(data, event);
}
return { detail: event.kind, label: "Activity" };
};
export const buildActivityTimeline = (
issueEvents: readonly ProjectEvent[]
): readonly WorkUnitActivityItem[] =>
[...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.map((event) => {
const projection = projectEventToActivity(event);
return {
createdAt: event.createdAt,
detail: projection.detail,
kind: event.kind,
label: projection.label,
time: formatRelativeTime(event.createdAt),
};
});
// ---------------------------------------------------------------------------
// PR extraction from issue-scoped events
// ---------------------------------------------------------------------------
export interface PullRequestInfo {
readonly hasPR: boolean;
readonly url: string | null;
readonly number: number | null;
}
export const extractPullRequestFromEvents = (
issueEvents: readonly ProjectEvent[]
): PullRequestInfo => {
const prEvent = issueEvents.find(
(e) => e.kind === "gitea.pull_request.created"
);
if (!prEvent) {
return { hasPR: false, number: null, url: null };
}
const data = prEvent.data as {
pullRequest?: { url?: string; number?: number };
};
const pr = data.pullRequest;
return {
hasPR: true,
number: pr?.number ?? null,
url: pr?.url ?? null,
};
};
// ---------------------------------------------------------------------------
// Latest agent summary
// ---------------------------------------------------------------------------
export const extractLatestSummary = (
issueEvents: readonly ProjectEvent[]
): string | null => {
const summaryEvent = [...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.find((e) => e.kind === "agent.completed" || e.kind === "agent.failed");
if (!summaryEvent) {
return null;
}
const data = summaryEvent.data as { summary?: string; error?: string };
if (summaryEvent.kind === "agent.completed") {
return data.summary ?? null;
}
return data.error ?? null;
};
// ---------------------------------------------------------------------------
// Artifact path extraction from issue-scoped artifact.updated events
// ---------------------------------------------------------------------------
export const extractArtifactPaths = (
issueEvents: readonly ProjectEvent[]
): readonly string[] => {
const paths = new Set<string>();
for (const event of issueEvents) {
if (event.kind !== "artifact.updated") {
continue;
}
const data = event.data as { path?: string };
if (typeof data.path === "string") {
paths.add(data.path);
}
}
return [...paths].toSorted();
};
// ---------------------------------------------------------------------------
// Card builder — derives all counts from issue-scoped events only
// ---------------------------------------------------------------------------
export const buildWorkUnitCard = ({
issue,
issueEvents,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
}): WorkUnitCard => {
const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt);
const [latestEvent] = sorted;
const currentActivity = latestEvent
? projectEventToActivity(latestEvent).label
: null;
const prInfo = extractPullRequestFromEvents(issueEvents);
const summary = extractLatestSummary(issueEvents) ?? issue.body.slice(0, 140);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
artifactCount: artifactPaths.length,
currentActivity,
hasPullRequest: prInfo.hasPR,
issueId: issue._id,
needsInput: issue.status === "needs-input",
number: issue.number,
// No signal-to-issue relation exists in the event model yet; Lane A will
// add one. Show 0 rather than falsely claiming project-wide signals.
signalCount: 0,
status: issue.status,
stepCount: issueEvents.length,
summary,
title: issue.title,
updatedAt: issue.updatedAt,
};
};
// ---------------------------------------------------------------------------
// Detail builder — same per-issue derivation, plus artifact paths
// ---------------------------------------------------------------------------
export const buildWorkUnitDetail = ({
issue,
issueEvents,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
}): WorkUnitDetail => {
const activity = buildActivityTimeline(issueEvents);
const prInfo = extractPullRequestFromEvents(issueEvents);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
activity,
artifactCount: artifactPaths.length,
artifactPaths,
hasPullRequest: prInfo.hasPR,
issue,
latestSummary: extractLatestSummary(issueEvents),
needsInput: issue.status === "needs-input",
pullRequestNumber: prInfo.number,
pullRequestUrl: prInfo.url,
signalCount: 0,
stepCount: issueEvents.length,
};
};

View File

@@ -68,7 +68,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
<Meta />
<Links />
</head>
<body className="bg-[#11110f]">
<body className="bg-[#0e0e0d]">
{children}
<ScrollRestoration />
<Scripts />
@@ -81,8 +81,8 @@ const App = () => (
<AuthenticatedFlueProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
forcedTheme="light"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>

View File

@@ -1,5 +1,5 @@
import { ProjectWorkspacePage } from "@/components/projects/project-workspace-page";
import { WorkOsPage } from "@/components/work-os/work-os-page";
export default function Dashboard() {
return <ProjectWorkspacePage />;
return <WorkOsPage />;
}

View File

@@ -42,37 +42,37 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--background: oklch(0.13 0.003 90);
--foreground: oklch(0.93 0.002 90);
--card: oklch(0.17 0.004 90);
--card-foreground: oklch(0.93 0.002 90);
--popover: oklch(0.17 0.004 90);
--popover-foreground: oklch(0.93 0.002 90);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--primary-foreground: oklch(0.17 0.004 90);
--secondary: oklch(0.22 0.004 90);
--secondary-foreground: oklch(0.93 0.002 90);
--muted: oklch(0.22 0.004 90);
--muted-foreground: oklch(0.62 0.004 90);
--accent: oklch(0.27 0.005 90);
--accent-foreground: oklch(0.93 0.002 90);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.28 0.004 90);
--input: oklch(0.22 0.004 90);
--ring: oklch(0.5 0.004 90);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar: oklch(0.17 0.004 90);
--sidebar-foreground: oklch(0.93 0.002 90);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-primary-foreground: oklch(0.93 0.002 90);
--sidebar-accent: oklch(0.22 0.004 90);
--sidebar-accent-foreground: oklch(0.93 0.002 90);
--sidebar-border: oklch(0.28 0.004 90);
--sidebar-ring: oklch(0.5 0.004 90);
}
@theme inline {