feat(web): real linked Signal projection from signalIssueAttachments

Replace the deliberate signalCount of 0 with authenticated linked-Signal
data from the Lane A signalIssueAttachments relation.

- Add listLinkedSignalsForProject query (user-authenticated via
  requireProjectMember) returning issue-grouped LinkedSignalView data
  using the existing by_issue index. One query serves both card counts
  and expanded detail.
- Update work-unit-projection builders to accept linkedSignals param;
  LinkedSignal type with signalId, title, summary, sourceCount, createdAt.
- Feed real attachment data into collapsed card signalCount and expanded
  detail linked-signal list with provenance (source count, relative time).
- Add signal.attached event handler to activity timeline projection.
- 32 tests including 4 linked-signal projection tests, 1 signal.attached
  activity mapping test, and cross-issue signal isolation test proving
  issue A cannot inherit issue B's linked signals.
This commit is contained in:
-Puter
2026-07-24 21:21:31 +05:30
parent 5df8633eae
commit fd207eb067
5 changed files with 275 additions and 89 deletions

View File

@@ -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 = ({
</Section>
) : null}
{/* Signals — no signal-to-issue relation exists yet. Show explicit
unavailable state rather than falsely presenting project Signals. */}
<Section icon={<Radio className="size-3" />} title="Signals">
<p className="text-xs leading-5 text-muted-foreground">
No Signals linked to this Work Unit yet.
</p>
<p className="mt-1 text-[10px] leading-4 text-muted-foreground/60">
Linked Signals will appear here once attachment is available.
</p>
{/* Linked Signals — authenticated via signalIssueAttachments */}
<Section
icon={<Radio className="size-3" />}
title={`Signals (${detail.signalCount})`}
>
{detail.linkedSignals.length > 0 ? (
<div className="space-y-3">
{detail.linkedSignals.map((signal) => (
<div key={String(signal.signalId)}>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/60">
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"} ·{" "}
{formatRelativeTime(signal.createdAt)}
</p>
</div>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
No Signals linked to this Work Unit.
</p>
)}
</Section>
{/* Activity timeline */}

View File

@@ -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<typeof useProjectWorkspace>["projects"];
readonly selectedProject: ReturnType<
typeof useProjectWorkspace
@@ -40,7 +40,6 @@ export interface WorkOsState {
readonly setRepository: (value: string) => void;
readonly connectRepository: () => Promise<void>;
// 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<ComposerMode>("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<readonly WorkUnitCard[]>(() => {
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<WorkUnitDetail | null>(() => {
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,

View File

@@ -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> = {}
): 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> = {}): 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");
});
});

View File

@@ -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,
};
};