diff --git a/apps/web/src/components/work-os/work-unit-detail.tsx b/apps/web/src/components/work-os/work-unit-detail.tsx index 671249a..d4707f1 100644 --- a/apps/web/src/components/work-os/work-unit-detail.tsx +++ b/apps/web/src/components/work-os/work-unit-detail.tsx @@ -10,6 +10,7 @@ import { TriangleAlert, } from "lucide-react"; +import { formatRelativeTime } from "@/lib/work-os/work-unit-projection"; import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection"; const formatStatus = ( @@ -113,15 +114,32 @@ export const WorkUnitDetail = ({ ) : null} - {/* Signals — no signal-to-issue relation exists yet. Show explicit - unavailable state rather than falsely presenting project Signals. */} -
} title="Signals"> -

- No Signals linked to this Work Unit yet. -

-

- Linked Signals will appear here once attachment is available. -

+ {/* Linked Signals — authenticated via signalIssueAttachments */} +
} + title={`Signals (${detail.signalCount})`} + > + {detail.linkedSignals.length > 0 ? ( +
+ {detail.linkedSignals.map((signal) => ( +
+

{signal.title}

+

+ {signal.summary} +

+

+ {signal.sourceCount} source + {signal.sourceCount === 1 ? "" : "s"} ·{" "} + {formatRelativeTime(signal.createdAt)} +

+
+ ))} +
+ ) : ( +

+ No Signals linked to this Work Unit. +

+ )}
{/* Activity timeline */} diff --git a/apps/web/src/hooks/work-os/use-work-os.ts b/apps/web/src/hooks/work-os/use-work-os.ts index 8f82751..9a306e1 100644 --- a/apps/web/src/hooks/work-os/use-work-os.ts +++ b/apps/web/src/hooks/work-os/use-work-os.ts @@ -14,6 +14,7 @@ import { eventsForIssue, } from "@/lib/work-os/work-unit-projection"; import type { + LinkedSignal, WorkUnitCard, WorkUnitDetail, } from "@/lib/work-os/work-unit-projection"; @@ -28,7 +29,6 @@ export interface SignalItem { export type ComposerMode = "project" | "work-unit"; export interface WorkOsState { - // Project readonly projects: ReturnType["projects"]; readonly selectedProject: ReturnType< typeof useProjectWorkspace @@ -40,7 +40,6 @@ export interface WorkOsState { readonly setRepository: (value: string) => void; readonly connectRepository: () => Promise; - // Work units readonly workUnitCards: readonly WorkUnitCard[]; readonly selectedIssue: ReturnType< typeof useProjectWorkspace @@ -55,20 +54,36 @@ export interface WorkOsState { 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; } +const toLinkedSignals = ( + entries: + | readonly { + readonly signalId: Id<"signals">; + readonly title: string; + readonly summary: string; + readonly sourceCount: number; + readonly createdAt: number; + }[] + | undefined +): readonly LinkedSignal[] => + (entries ?? []).map((entry) => ({ + createdAt: entry.createdAt, + signalId: entry.signalId, + sourceCount: entry.sourceCount, + summary: entry.summary, + title: entry.title, + })); + export const useWorkOs = (): WorkOsState => { const workspace = useProjectWorkspace(); const orgState = usePersonalOrganization(); @@ -76,8 +91,6 @@ export const useWorkOs = (): WorkOsState => { 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) @@ -93,9 +106,7 @@ export const useWorkOs = (): WorkOsState => { 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). + // Project-level signals for the global sidebar panel. const allSignals = useQuery( api.signals.list, orgState.organizationId @@ -116,7 +127,13 @@ export const useWorkOs = (): WorkOsState => { })); }, [allSignals, activeProjectId]); - // Composer mode tracks the selection by default but can be overridden. + // All linked signals for the active project, grouped by issue id. + // Used for card counts and detail display. + const linkedByIssue = useQuery( + api.signals.listLinkedSignalsForProject, + activeProjectId ? { projectId: activeProjectId } : "skip" + ); + const [composerMode, setComposerMode] = useState("project"); const selectIssue = (issueId: Id<"projectIssues"> | null) => { @@ -124,18 +141,21 @@ export const useWorkOs = (): WorkOsState => { setComposerMode(issueId ? "work-unit" : "project"); }; - // Work unit card projections — derived from per-issue events only. const workUnitCards = useMemo(() => { if (!workspace.issues) { return []; } return workspace.issues.map((issue) => { const issueEvents = eventsForIssue(workspace.events, issue._id); - return buildWorkUnitCard({ issue, issueEvents }); + const linked = toLinkedSignals(linkedByIssue?.[String(issue._id)]); + return buildWorkUnitCard({ + issue, + issueEvents, + linkedSignals: linked, + }); }); - }, [workspace.issues, workspace.events]); + }, [workspace.issues, workspace.events, linkedByIssue]); - // Selected work unit detail — same per-issue derivation. const selectedDetail = useMemo(() => { if (!workspace.selectedIssue) { return null; @@ -144,11 +164,15 @@ export const useWorkOs = (): WorkOsState => { workspace.events, workspace.selectedIssue._id ); + const linked = toLinkedSignals( + linkedByIssue?.[String(workspace.selectedIssue._id)] + ); return buildWorkUnitDetail({ issue: workspace.selectedIssue, issueEvents, + linkedSignals: linked, }); - }, [workspace.selectedIssue, workspace.events]); + }, [workspace.selectedIssue, workspace.events, linkedByIssue]); return { composerMode, diff --git a/apps/web/src/lib/work-os/work-unit-projection.test.ts b/apps/web/src/lib/work-os/work-unit-projection.test.ts index 95f9c5d..b9f2f1b 100644 --- a/apps/web/src/lib/work-os/work-unit-projection.test.ts +++ b/apps/web/src/lib/work-os/work-unit-projection.test.ts @@ -10,6 +10,7 @@ import { extractLatestSummary, extractPullRequestFromEvents, } from "./work-unit-projection"; +import type { LinkedSignal } from "./work-unit-projection"; type Issue = Doc<"projectIssues">; type Event = Doc<"projectEvents">; @@ -17,6 +18,17 @@ type Event = Doc<"projectEvents">; const ISSUE_A = "issue-a" as Id<"projectIssues">; const ISSUE_B = "issue-b" as Id<"projectIssues">; +const makeLinkedSignal = ( + overrides: Partial = {} +): LinkedSignal => ({ + createdAt: 5000, + signalId: "sig-1" as Id<"signals">, + sourceCount: 2, + summary: "Three users hit the same error.", + title: "OAuth crash on Safari", + ...overrides, +}); + const makeIssue = (overrides: Partial = {}): Issue => ({ _creationTime: 1, @@ -91,10 +103,11 @@ describe("extractArtifactPaths", () => { }); describe("buildWorkUnitCard", () => { - test("projects a fresh issue with no events", () => { + test("projects a fresh issue with no events and no linked signals", () => { const card = buildWorkUnitCard({ issue: makeIssue(), issueEvents: [], + linkedSignals: [], }); expect(card.title).toBe("Fix Safari OAuth callback"); expect(card.status).toBe("open"); @@ -123,6 +136,7 @@ describe("buildWorkUnitCard", () => { const card = buildWorkUnitCard({ issue: makeIssue({ status: "working" }), issueEvents: events, + linkedSignals: [], }); expect(card.artifactCount).toBe(2); expect(card.stepCount).toBe(2); @@ -137,6 +151,7 @@ describe("buildWorkUnitCard", () => { const card = buildWorkUnitCard({ issue: makeIssue({ status: "needs-input" }), issueEvents: events, + linkedSignals: [], }); expect(card.needsInput).toBe(true); expect(card.stepCount).toBe(3); @@ -157,11 +172,68 @@ describe("buildWorkUnitCard", () => { const card = buildWorkUnitCard({ issue: makeIssue({ status: "completed" }), issueEvents: events, + linkedSignals: [], }); expect(card.hasPullRequest).toBe(true); }); }); +describe("linked signal projection", () => { + test("card signalCount reflects real linked signals", () => { + const card = buildWorkUnitCard({ + issue: makeIssue(), + issueEvents: [], + linkedSignals: [ + makeLinkedSignal(), + makeLinkedSignal({ + signalId: "sig-2" as Id<"signals">, + title: "Build error", + }), + ], + }); + expect(card.signalCount).toBe(2); + }); + + test("card signalCount is 0 when no signals are linked", () => { + const card = buildWorkUnitCard({ + issue: makeIssue(), + issueEvents: [], + linkedSignals: [], + }); + expect(card.signalCount).toBe(0); + }); + + test("detail exposes linked signals with title, summary, and source count", () => { + const detail = buildWorkUnitDetail({ + issue: makeIssue(), + issueEvents: [], + linkedSignals: [ + makeLinkedSignal(), + makeLinkedSignal({ + signalId: "sig-2" as Id<"signals">, + sourceCount: 5, + summary: "CI broke", + }), + ], + }); + expect(detail.signalCount).toBe(2); + expect(detail.linkedSignals).toHaveLength(2); + expect(detail.linkedSignals[0].title).toBe("OAuth crash on Safari"); + expect(detail.linkedSignals[0].sourceCount).toBe(2); + expect(detail.linkedSignals[1].sourceCount).toBe(5); + }); + + test("detail linked signals empty when no attachment exists", () => { + const detail = buildWorkUnitDetail({ + issue: makeIssue(), + issueEvents: [], + linkedSignals: [], + }); + expect(detail.signalCount).toBe(0); + expect(detail.linkedSignals).toEqual([]); + }); +}); + describe("extractLatestSummary", () => { test("returns the most recent agent summary", () => { const events = [ @@ -228,6 +300,19 @@ describe("buildActivityTimeline", () => { expect(timeline[1].kind).toBe("issue.created"); expect(timeline[1].label).toBe("Work created"); }); + + test("maps signal.attached event to activity", () => { + const events = [ + makeEvent( + "signal.attached", + { signalId: "sig-1", signalProblemTitle: "OAuth crash" }, + { createdAt: 3000 } + ), + ]; + const timeline = buildActivityTimeline(events); + expect(timeline[0].label).toBe("Signal linked"); + expect(timeline[0].detail).toContain("OAuth crash"); + }); }); describe("buildWorkUnitDetail", () => { @@ -253,11 +338,12 @@ describe("buildWorkUnitDetail", () => { const detail = buildWorkUnitDetail({ issue: makeIssue({ status: "completed" }), issueEvents: events, + linkedSignals: [makeLinkedSignal()], }); 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.signalCount).toBe(1); expect(detail.hasPullRequest).toBe(true); expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3"); expect(detail.pullRequestNumber).toBe(3); @@ -272,6 +358,12 @@ describe("buildWorkUnitDetail", () => { // --------------------------------------------------------------------------- describe("cross-issue isolation", () => { + const SIGNAL_A = makeLinkedSignal({ signalId: "sig-a" as Id<"signals"> }); + const SIGNAL_B = makeLinkedSignal({ + signalId: "sig-b" as Id<"signals">, + title: "Different signal", + }); + const sharedEvents: Event[] = [ makeEvent( "issue.created", @@ -316,6 +408,7 @@ describe("cross-issue isolation", () => { const cardA = buildWorkUnitCard({ issue: makeIssue({ _id: ISSUE_A, number: 1 }), issueEvents: eventsA, + linkedSignals: [SIGNAL_A], }); expect(cardA.artifactCount).toBe(1); expect(cardA.stepCount).toBe(4); @@ -324,6 +417,7 @@ describe("cross-issue isolation", () => { const cardB = buildWorkUnitCard({ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issueEvents: eventsB, + linkedSignals: [SIGNAL_B], }); expect(cardB.artifactCount).toBe(1); expect(cardB.stepCount).toBe(3); @@ -334,18 +428,17 @@ describe("cross-issue isolation", () => { const cardB = buildWorkUnitCard({ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issueEvents: eventsB, + linkedSignals: [], }); 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."); + expect(extractLatestSummary(eventsA)).toBe("Done for A."); const eventsB = eventsForIssue(sharedEvents, ISSUE_B); - const summaryB = extractLatestSummary(eventsB); - expect(summaryB).toBe("Failed for B."); + expect(extractLatestSummary(eventsB)).toBe("Failed for B."); }); test("detail for issue A lists only issue A's artifact paths", () => { @@ -353,6 +446,7 @@ describe("cross-issue isolation", () => { const detailA = buildWorkUnitDetail({ issue: makeIssue({ _id: ISSUE_A, number: 1 }), issueEvents: eventsA, + linkedSignals: [SIGNAL_A], }); expect(detailA.artifactPaths).toEqual(["work.md"]); expect(detailA.pullRequestNumber).toBe(5); @@ -363,60 +457,28 @@ describe("cross-issue isolation", () => { const detailB = buildWorkUnitDetail({ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issueEvents: eventsB, + linkedSignals: [], }); 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: [], + test("issue A does not inherit issue B's linked signals", () => { + const detailA = buildWorkUnitDetail({ + issue: makeIssue({ _id: ISSUE_A, number: 1 }), + issueEvents: eventsForIssue(sharedEvents, ISSUE_A), + linkedSignals: [SIGNAL_A], }); - // The card never sees projectSignals — signalCount is always 0. - expect(card.signalCount).toBe(0); - expect(projectHasSignals.length).toBeGreaterThan(0); - }); + expect(detailA.signalCount).toBe(1); + expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari"); - 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 }), - ], + const detailB = buildWorkUnitDetail({ + issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), + issueEvents: eventsForIssue(sharedEvents, ISSUE_B), + linkedSignals: [SIGNAL_B], }); - expect(detail.signalCount).toBe(0); - expect(projectHasSignals.length).toBeGreaterThan(0); + expect(detailB.signalCount).toBe(1); + expect(detailB.linkedSignals[0].title).toBe("Different signal"); }); }); diff --git a/apps/web/src/lib/work-os/work-unit-projection.ts b/apps/web/src/lib/work-os/work-unit-projection.ts index a69835c..d05a24c 100644 --- a/apps/web/src/lib/work-os/work-unit-projection.ts +++ b/apps/web/src/lib/work-os/work-unit-projection.ts @@ -4,16 +4,23 @@ 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. +// Counts are derived from issue-scoped evidence: events for artifacts/steps/ +// PRs, and authenticated signalIssueAttachments for linked Signals. We never +// use project-wide artifact lists or signal lists. // --------------------------------------------------------------------------- export type ProjectEvent = Doc<"projectEvents">; export type ProjectIssue = Doc<"projectIssues">; +/** A Signal linked to a Work Unit via signalIssueAttachments. */ +export interface LinkedSignal { + readonly signalId: Id<"signals">; + readonly title: string; + readonly summary: string; + readonly sourceCount: number; + readonly createdAt: number; +} + export interface WorkUnitCard { readonly issueId: Id<"projectIssues">; readonly number: number; @@ -44,6 +51,7 @@ export interface WorkUnitDetail { readonly artifactCount: number; readonly artifactPaths: readonly string[]; readonly signalCount: number; + readonly linkedSignals: readonly LinkedSignal[]; readonly hasPullRequest: boolean; readonly pullRequestUrl: string | null; readonly pullRequestNumber: number | null; @@ -148,6 +156,10 @@ const ACTIVITY_HANDLERS: Record< detail: `Queued #${data.number ?? "?"} for the project manager`, label: "Work queued", }), + "signal.attached": (data) => ({ + detail: `Signal "${getString(data, "signalProblemTitle", "Unknown")}" linked to this Work Unit`, + label: "Signal linked", + }), }; const projectEventToActivity = (event: ProjectEvent): ActivityProjection => { @@ -246,15 +258,17 @@ export const extractArtifactPaths = ( }; // --------------------------------------------------------------------------- -// Card builder — derives all counts from issue-scoped events only +// Card builder — derives counts from issue-scoped events + linked signals // --------------------------------------------------------------------------- export const buildWorkUnitCard = ({ issue, issueEvents, + linkedSignals, }: { readonly issue: ProjectIssue; readonly issueEvents: readonly ProjectEvent[]; + readonly linkedSignals: readonly LinkedSignal[]; }): WorkUnitCard => { const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt); const [latestEvent] = sorted; @@ -272,9 +286,7 @@ export const buildWorkUnitCard = ({ 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, + signalCount: linkedSignals.length, status: issue.status, stepCount: issueEvents.length, summary, @@ -284,15 +296,17 @@ export const buildWorkUnitCard = ({ }; // --------------------------------------------------------------------------- -// Detail builder — same per-issue derivation, plus artifact paths +// Detail builder — same per-issue derivation, plus linked signals // --------------------------------------------------------------------------- export const buildWorkUnitDetail = ({ issue, issueEvents, + linkedSignals, }: { readonly issue: ProjectIssue; readonly issueEvents: readonly ProjectEvent[]; + readonly linkedSignals: readonly LinkedSignal[]; }): WorkUnitDetail => { const activity = buildActivityTimeline(issueEvents); const prInfo = extractPullRequestFromEvents(issueEvents); @@ -305,10 +319,11 @@ export const buildWorkUnitDetail = ({ hasPullRequest: prInfo.hasPR, issue, latestSummary: extractLatestSummary(issueEvents), + linkedSignals, needsInput: issue.status === "needs-input", pullRequestNumber: prInfo.number, pullRequestUrl: prInfo.url, - signalCount: 0, + signalCount: linkedSignals.length, stepCount: issueEvents.length, }; }; diff --git a/packages/backend/convex/signals.ts b/packages/backend/convex/signals.ts index fd21784..bcba426 100644 --- a/packages/backend/convex/signals.ts +++ b/packages/backend/convex/signals.ts @@ -9,7 +9,7 @@ import { type QueryCtx, query, } from "./_generated/server"; -import { requireOrganizationMember } from "./authz"; +import { requireOrganizationMember, requireProjectMember } from "./authz"; const PROBLEM_STATEMENT = v.object({ title: v.string(), @@ -435,3 +435,70 @@ export const get = query({ }; }, }); + +// --------------------------------------------------------------------------- +// Linked Signal query for the Work OS surface. +// --------------------------------------------------------------------------- + +export interface LinkedSignalView { + readonly signalId: Id<"signals">; + readonly title: string; + readonly summary: string; + readonly sourceCount: number; + readonly createdAt: number; +} + +/** + * List all Signal-issue attachments for a project, grouped by issue id. + * User-authenticated: the caller must be a member of the project organization. + * Used by the Work OS card list to show per-issue linked Signal counts. + */ +export const listLinkedSignalsForProject = query({ + args: { + projectId: v.id("projects"), + }, + handler: async (ctx, args): Promise> => { + const { organizationId } = await requireProjectMember(ctx, args.projectId); + const issues = await ctx.db + .query("projectIssues") + .withIndex("by_project_and_number", (q) => + q.eq("projectId", args.projectId) + ) + .take(100); + const result: Record = {}; + for (const issue of issues) { + const attachments = await ctx.db + .query("signalIssueAttachments") + .withIndex("by_issue", (q) => q.eq("issueId", issue._id)) + .collect(); + if (attachments.length === 0) { + continue; + } + const signals = await Promise.all( + attachments.map(async (attachment) => { + const signal = await ctx.db.get(attachment.signalId); + if (!signal || signal.organizationId !== organizationId) { + return null; + } + const sources = await ctx.db + .query("signalSources") + .withIndex("by_signal_and_ordinal", (q) => + q.eq("signalId", signal._id) + ) + .collect(); + return { + createdAt: signal.createdAt, + signalId: signal._id, + sourceCount: sources.length, + summary: signal.problemStatement.summary, + title: signal.problemStatement.title, + } satisfies LinkedSignalView; + }) + ); + result[String(issue._id)] = signals.filter( + (entry): entry is LinkedSignalView => entry !== null + ); + } + return result; + }, +});