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, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection"; import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection";
const formatStatus = ( const formatStatus = (
@@ -113,15 +114,32 @@ export const WorkUnitDetail = ({
</Section> </Section>
) : null} ) : null}
{/* Signals — no signal-to-issue relation exists yet. Show explicit {/* Linked Signals — authenticated via signalIssueAttachments */}
unavailable state rather than falsely presenting project Signals. */} <Section
<Section icon={<Radio className="size-3" />} title="Signals"> icon={<Radio className="size-3" />}
<p className="text-xs leading-5 text-muted-foreground"> title={`Signals (${detail.signalCount})`}
No Signals linked to this Work Unit yet. >
</p> {detail.linkedSignals.length > 0 ? (
<p className="mt-1 text-[10px] leading-4 text-muted-foreground/60"> <div className="space-y-3">
Linked Signals will appear here once attachment is available. {detail.linkedSignals.map((signal) => (
</p> <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> </Section>
{/* Activity timeline */} {/* Activity timeline */}

View File

@@ -14,6 +14,7 @@ import {
eventsForIssue, eventsForIssue,
} from "@/lib/work-os/work-unit-projection"; } from "@/lib/work-os/work-unit-projection";
import type { import type {
LinkedSignal,
WorkUnitCard, WorkUnitCard,
WorkUnitDetail, WorkUnitDetail,
} from "@/lib/work-os/work-unit-projection"; } from "@/lib/work-os/work-unit-projection";
@@ -28,7 +29,6 @@ export interface SignalItem {
export type ComposerMode = "project" | "work-unit"; export type ComposerMode = "project" | "work-unit";
export interface WorkOsState { export interface WorkOsState {
// Project
readonly projects: ReturnType<typeof useProjectWorkspace>["projects"]; readonly projects: ReturnType<typeof useProjectWorkspace>["projects"];
readonly selectedProject: ReturnType< readonly selectedProject: ReturnType<
typeof useProjectWorkspace typeof useProjectWorkspace
@@ -40,7 +40,6 @@ export interface WorkOsState {
readonly setRepository: (value: string) => void; readonly setRepository: (value: string) => void;
readonly connectRepository: () => Promise<void>; readonly connectRepository: () => Promise<void>;
// Work units
readonly workUnitCards: readonly WorkUnitCard[]; readonly workUnitCards: readonly WorkUnitCard[];
readonly selectedIssue: ReturnType< readonly selectedIssue: ReturnType<
typeof useProjectWorkspace typeof useProjectWorkspace
@@ -55,20 +54,36 @@ export interface WorkOsState {
readonly issueBody: string; readonly issueBody: string;
readonly setIssueBody: (value: string) => void; readonly setIssueBody: (value: string) => void;
// Signals (project-scoped, displayed globally — not attributed to any issue)
readonly signals: readonly SignalItem[]; readonly signals: readonly SignalItem[];
// Chat
readonly composerMode: ComposerMode; readonly composerMode: ComposerMode;
readonly setComposerMode: (mode: ComposerMode) => void; readonly setComposerMode: (mode: ComposerMode) => void;
readonly projectAgent: ChatAgentState; readonly projectAgent: ChatAgentState;
readonly workUnitAgent: ChatAgentState; readonly workUnitAgent: ChatAgentState;
// Meta
readonly pendingAction: string | null; readonly pendingAction: string | null;
readonly error: 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 => { export const useWorkOs = (): WorkOsState => {
const workspace = useProjectWorkspace(); const workspace = useProjectWorkspace();
const orgState = usePersonalOrganization(); const orgState = usePersonalOrganization();
@@ -76,8 +91,6 @@ export const useWorkOs = (): WorkOsState => {
const activeProjectId = workspace.selectedProjectId; 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({ const workUnitFlue = useFlueAgent({
id: workspace.selectedIssueId id: workspace.selectedIssueId
? String(workspace.selectedIssueId) ? String(workspace.selectedIssueId)
@@ -93,9 +106,7 @@ export const useWorkOs = (): WorkOsState => {
status: workspace.selectedIssueId ? workUnitFlue.status : "idle", status: workspace.selectedIssueId ? workUnitFlue.status : "idle",
}; };
// Signals scoped to the active project. These are displayed as project-level // Project-level signals for the global sidebar panel.
// evidence, not attributed to any specific work unit (no signal-to-issue
// relation exists yet).
const allSignals = useQuery( const allSignals = useQuery(
api.signals.list, api.signals.list,
orgState.organizationId orgState.organizationId
@@ -116,7 +127,13 @@ export const useWorkOs = (): WorkOsState => {
})); }));
}, [allSignals, activeProjectId]); }, [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 [composerMode, setComposerMode] = useState<ComposerMode>("project");
const selectIssue = (issueId: Id<"projectIssues"> | null) => { const selectIssue = (issueId: Id<"projectIssues"> | null) => {
@@ -124,18 +141,21 @@ export const useWorkOs = (): WorkOsState => {
setComposerMode(issueId ? "work-unit" : "project"); setComposerMode(issueId ? "work-unit" : "project");
}; };
// Work unit card projections — derived from per-issue events only.
const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => { const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => {
if (!workspace.issues) { if (!workspace.issues) {
return []; return [];
} }
return workspace.issues.map((issue) => { return workspace.issues.map((issue) => {
const issueEvents = eventsForIssue(workspace.events, issue._id); 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>(() => { const selectedDetail = useMemo<WorkUnitDetail | null>(() => {
if (!workspace.selectedIssue) { if (!workspace.selectedIssue) {
return null; return null;
@@ -144,11 +164,15 @@ export const useWorkOs = (): WorkOsState => {
workspace.events, workspace.events,
workspace.selectedIssue._id workspace.selectedIssue._id
); );
const linked = toLinkedSignals(
linkedByIssue?.[String(workspace.selectedIssue._id)]
);
return buildWorkUnitDetail({ return buildWorkUnitDetail({
issue: workspace.selectedIssue, issue: workspace.selectedIssue,
issueEvents, issueEvents,
linkedSignals: linked,
}); });
}, [workspace.selectedIssue, workspace.events]); }, [workspace.selectedIssue, workspace.events, linkedByIssue]);
return { return {
composerMode, composerMode,

View File

@@ -10,6 +10,7 @@ import {
extractLatestSummary, extractLatestSummary,
extractPullRequestFromEvents, extractPullRequestFromEvents,
} from "./work-unit-projection"; } from "./work-unit-projection";
import type { LinkedSignal } from "./work-unit-projection";
type Issue = Doc<"projectIssues">; type Issue = Doc<"projectIssues">;
type Event = Doc<"projectEvents">; type Event = Doc<"projectEvents">;
@@ -17,6 +18,17 @@ type Event = Doc<"projectEvents">;
const ISSUE_A = "issue-a" as Id<"projectIssues">; const ISSUE_A = "issue-a" as Id<"projectIssues">;
const ISSUE_B = "issue-b" 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 => const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
({ ({
_creationTime: 1, _creationTime: 1,
@@ -91,10 +103,11 @@ describe("extractArtifactPaths", () => {
}); });
describe("buildWorkUnitCard", () => { 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({ const card = buildWorkUnitCard({
issue: makeIssue(), issue: makeIssue(),
issueEvents: [], issueEvents: [],
linkedSignals: [],
}); });
expect(card.title).toBe("Fix Safari OAuth callback"); expect(card.title).toBe("Fix Safari OAuth callback");
expect(card.status).toBe("open"); expect(card.status).toBe("open");
@@ -123,6 +136,7 @@ describe("buildWorkUnitCard", () => {
const card = buildWorkUnitCard({ const card = buildWorkUnitCard({
issue: makeIssue({ status: "working" }), issue: makeIssue({ status: "working" }),
issueEvents: events, issueEvents: events,
linkedSignals: [],
}); });
expect(card.artifactCount).toBe(2); expect(card.artifactCount).toBe(2);
expect(card.stepCount).toBe(2); expect(card.stepCount).toBe(2);
@@ -137,6 +151,7 @@ describe("buildWorkUnitCard", () => {
const card = buildWorkUnitCard({ const card = buildWorkUnitCard({
issue: makeIssue({ status: "needs-input" }), issue: makeIssue({ status: "needs-input" }),
issueEvents: events, issueEvents: events,
linkedSignals: [],
}); });
expect(card.needsInput).toBe(true); expect(card.needsInput).toBe(true);
expect(card.stepCount).toBe(3); expect(card.stepCount).toBe(3);
@@ -157,11 +172,68 @@ describe("buildWorkUnitCard", () => {
const card = buildWorkUnitCard({ const card = buildWorkUnitCard({
issue: makeIssue({ status: "completed" }), issue: makeIssue({ status: "completed" }),
issueEvents: events, issueEvents: events,
linkedSignals: [],
}); });
expect(card.hasPullRequest).toBe(true); 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", () => { describe("extractLatestSummary", () => {
test("returns the most recent agent summary", () => { test("returns the most recent agent summary", () => {
const events = [ const events = [
@@ -228,6 +300,19 @@ describe("buildActivityTimeline", () => {
expect(timeline[1].kind).toBe("issue.created"); expect(timeline[1].kind).toBe("issue.created");
expect(timeline[1].label).toBe("Work 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", () => { describe("buildWorkUnitDetail", () => {
@@ -253,11 +338,12 @@ describe("buildWorkUnitDetail", () => {
const detail = buildWorkUnitDetail({ const detail = buildWorkUnitDetail({
issue: makeIssue({ status: "completed" }), issue: makeIssue({ status: "completed" }),
issueEvents: events, issueEvents: events,
linkedSignals: [makeLinkedSignal()],
}); });
expect(detail.stepCount).toBe(4); expect(detail.stepCount).toBe(4);
expect(detail.artifactCount).toBe(2); expect(detail.artifactCount).toBe(2);
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]); 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.hasPullRequest).toBe(true);
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3"); expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
expect(detail.pullRequestNumber).toBe(3); expect(detail.pullRequestNumber).toBe(3);
@@ -272,6 +358,12 @@ describe("buildWorkUnitDetail", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("cross-issue isolation", () => { 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[] = [ const sharedEvents: Event[] = [
makeEvent( makeEvent(
"issue.created", "issue.created",
@@ -316,6 +408,7 @@ describe("cross-issue isolation", () => {
const cardA = buildWorkUnitCard({ const cardA = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_A, number: 1 }), issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA, issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
}); });
expect(cardA.artifactCount).toBe(1); expect(cardA.artifactCount).toBe(1);
expect(cardA.stepCount).toBe(4); expect(cardA.stepCount).toBe(4);
@@ -324,6 +417,7 @@ describe("cross-issue isolation", () => {
const cardB = buildWorkUnitCard({ const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB, issueEvents: eventsB,
linkedSignals: [SIGNAL_B],
}); });
expect(cardB.artifactCount).toBe(1); expect(cardB.artifactCount).toBe(1);
expect(cardB.stepCount).toBe(3); expect(cardB.stepCount).toBe(3);
@@ -334,18 +428,17 @@ describe("cross-issue isolation", () => {
const cardB = buildWorkUnitCard({ const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB, issueEvents: eventsB,
linkedSignals: [],
}); });
expect(cardB.hasPullRequest).toBe(false); expect(cardB.hasPullRequest).toBe(false);
}); });
test("issue A does not inherit issue B's summary", () => { test("issue A does not inherit issue B's summary", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A); const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const summaryA = extractLatestSummary(eventsA); expect(extractLatestSummary(eventsA)).toBe("Done for A.");
expect(summaryA).toBe("Done for A.");
const eventsB = eventsForIssue(sharedEvents, ISSUE_B); const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const summaryB = extractLatestSummary(eventsB); expect(extractLatestSummary(eventsB)).toBe("Failed for B.");
expect(summaryB).toBe("Failed for B.");
}); });
test("detail for issue A lists only issue A's artifact paths", () => { test("detail for issue A lists only issue A's artifact paths", () => {
@@ -353,6 +446,7 @@ describe("cross-issue isolation", () => {
const detailA = buildWorkUnitDetail({ const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }), issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA, issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
}); });
expect(detailA.artifactPaths).toEqual(["work.md"]); expect(detailA.artifactPaths).toEqual(["work.md"]);
expect(detailA.pullRequestNumber).toBe(5); expect(detailA.pullRequestNumber).toBe(5);
@@ -363,60 +457,28 @@ describe("cross-issue isolation", () => {
const detailB = buildWorkUnitDetail({ const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB, issueEvents: eventsB,
linkedSignals: [],
}); });
expect(detailB.artifactPaths).toEqual(["artifacts.md"]); expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
expect(detailB.hasPullRequest).toBe(false); expect(detailB.hasPullRequest).toBe(false);
expect(detailB.pullRequestUrl).toBeNull(); expect(detailB.pullRequestUrl).toBeNull();
}); });
});
// --------------------------------------------------------------------------- test("issue A does not inherit issue B's linked signals", () => {
// Signal isolation: a project may have signals, but no Work Unit should const detailA = buildWorkUnitDetail({
// claim them until a signal-to-issue relation is integrated. issue: makeIssue({ _id: ISSUE_A, number: 1 }),
// --------------------------------------------------------------------------- issueEvents: eventsForIssue(sharedEvents, ISSUE_A),
linkedSignals: [SIGNAL_A],
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: [],
}); });
// The card never sees projectSignals — signalCount is always 0. expect(detailA.signalCount).toBe(1);
expect(card.signalCount).toBe(0); expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari");
expect(projectHasSignals.length).toBeGreaterThan(0);
});
test("detail signalCount is 0 even when the project has signals", () => { const detailB = buildWorkUnitDetail({
const projectHasSignals = [ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
{ issueEvents: eventsForIssue(sharedEvents, ISSUE_B),
sourceCount: 3, linkedSignals: [SIGNAL_B],
summary: "Three tickets",
title: "Users report OAuth crash",
},
];
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
],
}); });
expect(detail.signalCount).toBe(0); expect(detailB.signalCount).toBe(1);
expect(projectHasSignals.length).toBeGreaterThan(0); 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 // Work Unit card projection — pure functions that turn Convex documents into
// the collapsed/expanded Work Unit views used by the Work OS surface. // the collapsed/expanded Work Unit views used by the Work OS surface.
// //
// Every count is derived from issue-scoped events only. We never use // Counts are derived from issue-scoped evidence: events for artifacts/steps/
// project-wide artifact lists or signal lists, because those would // PRs, and authenticated signalIssueAttachments for linked Signals. We never
// incorrectly attribute evidence to every issue in the project. Signal // use project-wide artifact lists or signal lists.
// linkage is intentionally 0 until a Lane A signal-to-issue relation is
// integrated.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ProjectEvent = Doc<"projectEvents">; export type ProjectEvent = Doc<"projectEvents">;
export type ProjectIssue = Doc<"projectIssues">; 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 { export interface WorkUnitCard {
readonly issueId: Id<"projectIssues">; readonly issueId: Id<"projectIssues">;
readonly number: number; readonly number: number;
@@ -44,6 +51,7 @@ export interface WorkUnitDetail {
readonly artifactCount: number; readonly artifactCount: number;
readonly artifactPaths: readonly string[]; readonly artifactPaths: readonly string[];
readonly signalCount: number; readonly signalCount: number;
readonly linkedSignals: readonly LinkedSignal[];
readonly hasPullRequest: boolean; readonly hasPullRequest: boolean;
readonly pullRequestUrl: string | null; readonly pullRequestUrl: string | null;
readonly pullRequestNumber: number | null; readonly pullRequestNumber: number | null;
@@ -148,6 +156,10 @@ const ACTIVITY_HANDLERS: Record<
detail: `Queued #${data.number ?? "?"} for the project manager`, detail: `Queued #${data.number ?? "?"} for the project manager`,
label: "Work queued", 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 => { 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 = ({ export const buildWorkUnitCard = ({
issue, issue,
issueEvents, issueEvents,
linkedSignals,
}: { }: {
readonly issue: ProjectIssue; readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[]; readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitCard => { }): WorkUnitCard => {
const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt); const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt);
const [latestEvent] = sorted; const [latestEvent] = sorted;
@@ -272,9 +286,7 @@ export const buildWorkUnitCard = ({
issueId: issue._id, issueId: issue._id,
needsInput: issue.status === "needs-input", needsInput: issue.status === "needs-input",
number: issue.number, number: issue.number,
// No signal-to-issue relation exists in the event model yet; Lane A will signalCount: linkedSignals.length,
// add one. Show 0 rather than falsely claiming project-wide signals.
signalCount: 0,
status: issue.status, status: issue.status,
stepCount: issueEvents.length, stepCount: issueEvents.length,
summary, 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 = ({ export const buildWorkUnitDetail = ({
issue, issue,
issueEvents, issueEvents,
linkedSignals,
}: { }: {
readonly issue: ProjectIssue; readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[]; readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitDetail => { }): WorkUnitDetail => {
const activity = buildActivityTimeline(issueEvents); const activity = buildActivityTimeline(issueEvents);
const prInfo = extractPullRequestFromEvents(issueEvents); const prInfo = extractPullRequestFromEvents(issueEvents);
@@ -305,10 +319,11 @@ export const buildWorkUnitDetail = ({
hasPullRequest: prInfo.hasPR, hasPullRequest: prInfo.hasPR,
issue, issue,
latestSummary: extractLatestSummary(issueEvents), latestSummary: extractLatestSummary(issueEvents),
linkedSignals,
needsInput: issue.status === "needs-input", needsInput: issue.status === "needs-input",
pullRequestNumber: prInfo.number, pullRequestNumber: prInfo.number,
pullRequestUrl: prInfo.url, pullRequestUrl: prInfo.url,
signalCount: 0, signalCount: linkedSignals.length,
stepCount: issueEvents.length, stepCount: issueEvents.length,
}; };
}; };

View File

@@ -9,7 +9,7 @@ import {
type QueryCtx, type QueryCtx,
query, query,
} from "./_generated/server"; } from "./_generated/server";
import { requireOrganizationMember } from "./authz"; import { requireOrganizationMember, requireProjectMember } from "./authz";
const PROBLEM_STATEMENT = v.object({ const PROBLEM_STATEMENT = v.object({
title: v.string(), 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<Record<string, LinkedSignalView[]>> => {
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<string, LinkedSignalView[]> = {};
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;
},
});