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:
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user