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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user