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
+40 -16
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,