From 5df8633eaed61aba077aec704a7f2a14dbfd6f9f Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:59:48 +0530 Subject: [PATCH 1/3] 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. --- .../components/work-os/conversation-panel.tsx | 53 +++ .../src/components/work-os/empty-state.tsx | 54 +++ .../src/components/work-os/signals-panel.tsx | 71 +++ .../components/work-os/work-os-composer.tsx | 143 ++++++ .../src/components/work-os/work-os-header.tsx | 112 +++++ .../src/components/work-os/work-os-page.tsx | 401 +++++++++++++++++ .../src/components/work-os/work-unit-card.tsx | 131 ++++++ .../components/work-os/work-unit-detail.tsx | 219 +++++++++ apps/web/src/hooks/work-os/use-work-os.ts | 179 ++++++++ .../lib/work-os/work-unit-projection.test.ts | 422 ++++++++++++++++++ .../src/lib/work-os/work-unit-projection.ts | 314 +++++++++++++ apps/web/src/root.tsx | 6 +- apps/web/src/routes/app/dashboard/page.tsx | 4 +- packages/ui/src/styles/globals.css | 48 +- 14 files changed, 2128 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/components/work-os/conversation-panel.tsx create mode 100644 apps/web/src/components/work-os/empty-state.tsx create mode 100644 apps/web/src/components/work-os/signals-panel.tsx create mode 100644 apps/web/src/components/work-os/work-os-composer.tsx create mode 100644 apps/web/src/components/work-os/work-os-header.tsx create mode 100644 apps/web/src/components/work-os/work-os-page.tsx create mode 100644 apps/web/src/components/work-os/work-unit-card.tsx create mode 100644 apps/web/src/components/work-os/work-unit-detail.tsx create mode 100644 apps/web/src/hooks/work-os/use-work-os.ts create mode 100644 apps/web/src/lib/work-os/work-unit-projection.test.ts create mode 100644 apps/web/src/lib/work-os/work-unit-projection.ts diff --git a/apps/web/src/components/work-os/conversation-panel.tsx b/apps/web/src/components/work-os/conversation-panel.tsx new file mode 100644 index 0000000..84cd8fb --- /dev/null +++ b/apps/web/src/components/work-os/conversation-panel.tsx @@ -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 ( +
+ + Loading conversation… +
+ ); + } + + if (agent.historyReady && agent.messages.length === 0) { + return ( +
+ +

{title}

+

+ {emptyHint} +

+
+ ); + } + + return ( + + + {agent.messages.map((message) => ( + + ))} + {agent.status === "submitted" ? : null} + + + ); +}; diff --git a/apps/web/src/components/work-os/empty-state.tsx b/apps/web/src/components/work-os/empty-state.tsx new file mode 100644 index 0000000..fce8fbb --- /dev/null +++ b/apps/web/src/components/work-os/empty-state.tsx @@ -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) => ( +
+
+
+ +
+

+ Connect a project to begin +

+

+ Import a repository to start the Work OS loop. Turn a clear outcome into + a Work Unit that Zopu can start, verify, and review. +

+
{ + event.preventDefault(); + onConnectRepository(); + }} + > + onRepositoryChange(event.target.value)} + placeholder="https://github.com/owner/repo" + required + value={repository} + /> + +
+
+
+); diff --git a/apps/web/src/components/work-os/signals-panel.tsx b/apps/web/src/components/work-os/signals-panel.tsx new file mode 100644 index 0000000..a3ddfd7 --- /dev/null +++ b/apps/web/src/components/work-os/signals-panel.tsx @@ -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 ( +
+
+ + Signals +
+

Loading signals…

+
+ ); + } + + if (signals.length === 0) { + return ( +
+
+ + Signals +
+

+ No Signals detected for this project yet. +

+
+ ); + } + + return ( +
+
+
+ + Signals +
+ + {signals.length} total + +
+
+ {signals.map((signal) => ( +
+

{signal.title}

+

+ {signal.summary} +

+
+ + {signal.sourceCount} source + {signal.sourceCount === 1 ? "" : "s"} + + {formatRelativeTime(signal.createdAt)} +
+
+ ))} +
+
+ ); +}; diff --git a/apps/web/src/components/work-os/work-os-composer.tsx b/apps/web/src/components/work-os/work-os-composer.tsx new file mode 100644 index 0000000..0096792 --- /dev/null +++ b/apps/web/src/components/work-os/work-os-composer.tsx @@ -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; + 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(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) => { + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + void handleSubmit(); + } + }; + + const scopeLabel = + mode === "project" ? "Project" : (workUnitTitle ?? "Work Unit"); + + return ( +
+ {/* Mode switcher */} +
+
+ + +
+ {mode === "work-unit" && workUnitTitle ? ( + + onModeChange("project")} + /> + {workUnitTitle} + + ) : null} + + {scopeLabel} + +
+ + {error ? ( +

+ {error.message || "Message failed to send"} +

+ ) : null} + +
+