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 21:10:47 +05:30
parent 2ce96b0bab
commit 5df8633eae
14 changed files with 2128 additions and 29 deletions
+179
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,
};
};