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.
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
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,
|
|
};
|
|
};
|