diff --git a/apps/azure/src/components/editor/AnalyzeWorkspace.tsx b/apps/azure/src/components/editor/AnalyzeWorkspace.tsx index 07ff1d6d8..92524043f 100644 --- a/apps/azure/src/components/editor/AnalyzeWorkspace.tsx +++ b/apps/azure/src/components/editor/AnalyzeWorkspace.tsx @@ -4,6 +4,7 @@ import { AnalyzeConclusion, FindingsLog, WallCanvas, + ScopeRail, CommandPalette, Minimap, CANVAS_W, @@ -29,6 +30,7 @@ import type { CurrentUnderstanding, FindingStatus, Hypothesis, + IdeaImpact, ProblemCondition, ProcessContext, } from '@variscout/core'; @@ -40,6 +42,7 @@ import { categoricalFiltersToActiveFilters, buildConditionFromCategoricalFilters, predicateSetKey, + parseMentions, } from '@variscout/core'; import { computeBestSubsets } from '@variscout/core/stats'; import { detectEvidenceClusters } from '@variscout/core/findings'; @@ -112,8 +115,17 @@ interface AnalyzeWorkspaceProps { * Optional measurement-plan affordances threaded into WallCanvas. * When provided, hub cards render HypothesisCardWithPlans. * When omitted (default), hub cards render bare HypothesisCard. + * + * IM-4b: AnalyzeWorkspace ENRICHES this with the hub comment-thread, ActionItem, + * and improvement-idea callbacks (sourced from `hypothesesState` + `ideaImpacts`) + * before passing the merged bag to WallCanvas — Editor's base bag carries only + * the measurement-plan + disconfirmation callbacks (TDZ-bound there). */ planningProps?: WallCanvasPlanningProps; + /** IM-4b — computed IdeaImpact map keyed by ideaId, for the Wall ideas section. */ + ideaImpacts?: Record; + /** IM-4b — open What-If for an improvement idea (handleProjectIdea). */ + onProjectIdea?: (hypothesisId: string, ideaId: string) => void; } /** @@ -149,6 +161,8 @@ export const AnalyzeWorkspace: React.FC = ({ viewMode: externalViewMode, onViewModeChange, planningProps, + ideaImpacts, + onProjectIdea, }) => { const voiceInput = isSpeechToTextAvailable() ? { isAvailable: true, transcribeAudio } : undefined; const outcome = useProjectStore(s => s.outcome); @@ -244,6 +258,81 @@ export const AnalyzeWorkspace: React.FC = ({ () => (outcome ? (measureSpecs[outcome] ?? specs) : undefined), [measureSpecs, outcome, specs] ); + + // ── IM-4b Task 5 — multi-scope rail ────────────────────────────────────── + // Active (non-archived) scopes for the current investigation + outcome. + const railScopes = useMemo( + () => scopes.filter(s => s.investigationId === scopeInvestigationId && s.deletedAt === null), + [scopes] + ); + // Re-anchor: selecting a scope chip rewrites the drill filters to that scope's + // compound WHERE (IM-4a's predicateSetKey-matched producer then re-selects the + // scope, re-anchoring the Problem card). Reconstruct categoricalFilters from the + // scope's eq-predicates, grouped by column. + const handleScopeSelect = useCallback( + (scopeId: string) => { + const scope = scopes.find(s => s.id === scopeId); + if (!scope) return; + const byColumn = new Map(); + for (const leaf of scope.predicates) { + if (leaf.op !== 'eq') continue; + const vals = byColumn.get(leaf.column) ?? []; + vals.push(leaf.value as string | number); + byColumn.set(leaf.column, vals); + } + const scopeStore = useAnalysisScopeStore.getState(); + scopeStore.clearScope(); + for (const [column, values] of byColumn) { + scopeStore.setCategoricalValues(column, values); + } + }, + [scopes] + ); + const handleScopeArchive = useCallback((scopeId: string) => { + useAnalyzeStore.getState().archiveScope(scopeId); + }, []); + + // ── IM-4b — enrich the base measurement-plan bag with the comment-thread, + // ActionItem, and improvement-idea callbacks. Routes through `hypothesesState` + // (the Wall's source of truth — updating it re-renders the Wall live; its + // onHubsChange syncs useAnalyzeStore for the analyze blob). The author display + // name resolves from the active members against `userId`. + const wallAuthorName = useMemo(() => { + if (!userId) return undefined; + return members.find(m => m.userId === userId)?.displayName ?? userId; + }, [members, userId]); + const enrichedPlanningProps = useMemo(() => { + if (!planningProps) return undefined; + return { + ...planningProps, + // Task 1 — comment thread (parseMentions runs here; mentions ride the comment) + onAddHubComment: (hubId: string, text: string) => { + const mentionedUserIds = parseMentions(text, members); + hypothesesState.addComment(hubId, text, wallAuthorName, mentionedUserIds); + }, + onEditHubComment: (hubId: string, commentId: string, text: string) => + hypothesesState.editComment(hubId, commentId, text), + onDeleteHubComment: (hubId: string, commentId: string) => + hypothesesState.deleteComment(hubId, commentId), + showCommentAuthors: members.length > 0, + // Task 3 — ActionItem tasks + onAddHypothesisAction: (hypothesisId: string, text: string) => + hypothesesState.addAction(hypothesisId, text), + onCompleteHypothesisAction: (hypothesisId: string, actionId: string) => + hypothesesState.completeAction(hypothesisId, actionId, Date.now()), + // Task 6 — improvement ideas + ideaImpacts: ideaImpacts ?? {}, + onProjectIdea, + onAddIdea: (hypothesisId: string, text: string) => + hypothesesState.addIdea(hypothesisId, text), + onUpdateIdea: (hypothesisId, ideaId, updates) => + hypothesesState.updateIdea(hypothesisId, ideaId, updates), + onRemoveIdea: (hypothesisId: string, ideaId: string) => + hypothesesState.removeIdea(hypothesisId, ideaId), + onSelectIdea: (hypothesisId: string, ideaId: string, selected: boolean) => + hypothesesState.selectIdea(hypothesisId, ideaId, selected), + }; + }, [planningProps, members, wallAuthorName, hypothesesState, ideaImpacts, onProjectIdea]); // Live Problem-card base values for the scoped subset (no longer hardcoded): // Cpk from the filtered-data stats, and the out-of-spec event COUNT as the // "events" proxy (no reliable weekly cadence in V1 — count of occurrences). @@ -876,6 +965,20 @@ export const AnalyzeWorkspace: React.FC = ({ {analyzeViewMode === 'map' ? ( wallViewMode === 'wall' ? (
+ {/* IM-4b Task 5 — multi-scope rail above the canvas. Selecting a + chip re-anchors the Problem card (rewrites the drill filters + → IM-4a's producer re-selects the scope). Hidden when no scopes + have been captured yet. */} + {!wallIsMobile && railScopes.length > 0 && ( +
+ +
+ )} = ({ zoom={wallZoom} pan={wallPan} groupByTributary={Boolean(processMap && wallGroupByTributary)} - planningProps={planningProps} + planningProps={enrichedPlanningProps} /> {/* Minimap + CommandPalette are desktop-only. WallCanvas self-gates to MobileCardList below 768px, so these diff --git a/apps/azure/src/components/editor/__tests__/AnalyzeWorkspace.mapwall.test.tsx b/apps/azure/src/components/editor/__tests__/AnalyzeWorkspace.mapwall.test.tsx index 220cf2b67..ed1c9be6a 100644 --- a/apps/azure/src/components/editor/__tests__/AnalyzeWorkspace.mapwall.test.tsx +++ b/apps/azure/src/components/editor/__tests__/AnalyzeWorkspace.mapwall.test.tsx @@ -184,6 +184,10 @@ vi.mock('@variscout/core', async importOriginal => { } return map; }, + // ScopeRail (mounted by AnalyzeWorkspace) consumes formatConditionLeaves — + // another deep re-export the `...actual` spread can drop under the mock runtime. + formatConditionLeaves: (predicates: Array<{ column: string; value: unknown }>) => + predicates.map(p => `${p.column} = ${String(p.value)}`).join(' ∩ '), }; }); @@ -212,7 +216,9 @@ import { getCanvasViewportInitialState, useCanvasViewportStore, useAnalysisScopeStore, + useAnalyzeStore, } from '@variscout/stores'; +import type { ProblemStatementScope } from '@variscout/core'; import { RETURN_NAVIGATION_STORAGE_KEY } from '@variscout/hooks'; import { usePanelsStore } from '../../../features/panels/panelsStore'; import { AnalyzeWorkspace } from '../AnalyzeWorkspace'; @@ -547,3 +553,79 @@ describe('AnalyzeWorkspace Map/Wall toggle', () => { }); }); }); + +// ── IM-4b Task 5 — ScopeRail SEAM (production mount in AnalyzeWorkspace) ──────── +// +// NOT an injected-prop ScopeRail unit test — renders the real AnalyzeWorkspace +// (ScopeRail is preserved via `...actual` in the @variscout/ui mock; WallCanvas +// is stubbed) and asserts the rail RENDERS from useAnalyzeStore.scopes and that +// selecting a chip RE-ANCHORS by rewriting analysisScopeStore.categoricalFilters +// (which IM-4a's producer consumes to re-select the active scope / Problem card), +// and archiving soft-deletes via analyzeStore.archiveScope. + +describe('AnalyzeWorkspace ScopeRail seam (IM-4b Task 5)', () => { + const SCOPE_INV = 'general-unassigned'; // matches AnalyzeWorkspace.scopeInvestigationId + + const scopeA: ProblemStatementScope = { + id: 'scope-a', + investigationId: SCOPE_INV, + outcome: 'Fill Weight', + predicates: [{ kind: 'leaf', column: 'Machine', op: 'eq', value: 'B' }], + hypothesisIds: [], + createdAt: 1, + updatedAt: 1, + deletedAt: null, + }; + const scopeB: ProblemStatementScope = { + id: 'scope-b', + investigationId: SCOPE_INV, + outcome: 'Fill Weight', + predicates: [{ kind: 'leaf', column: 'Shift', op: 'eq', value: 'Night' }], + hypothesisIds: [], + createdAt: 2, + updatedAt: 2, + deletedAt: null, + }; + + beforeEach(() => { + useCanvasViewportStore.setState(getCanvasViewportInitialState()); + useCanvasViewportStore.getState().setViewMode('wall'); + usePanelsStore.getState().setAnalyzeViewMode('map'); + useAnalysisScopeStore.setState({ categoricalFilters: [] }); + useAnalyzeStore.setState({ scopes: [scopeA, scopeB] }); + }); + + it('renders one ScopeRail chip per active (non-archived) scope', () => { + render(); + expect(screen.getByTestId('scope-chip-scope-a')).toBeInTheDocument(); + expect(screen.getByTestId('scope-chip-scope-b')).toBeInTheDocument(); + expect(screen.getByTestId('scope-chip-scope-a')).toHaveTextContent('Machine = B'); + }); + + it('does NOT render the rail when no scopes are captured', () => { + useAnalyzeStore.setState({ scopes: [] }); + render(); + expect(screen.queryByTestId(/scope-chip-/)).toBeNull(); + }); + + it('selecting a chip re-anchors by rewriting analysisScopeStore.categoricalFilters', () => { + render(); + // No drill filters before selection. + expect(useAnalysisScopeStore.getState().categoricalFilters).toEqual([]); + + fireEvent.click(screen.getByTestId('scope-chip-scope-b')); + + // The Problem card re-anchors because the drill filters now equal scope-b's + // compound WHERE (Shift = Night) — IM-4a's producer matches by predicateSetKey. + const filters = useAnalysisScopeStore.getState().categoricalFilters; + expect(filters).toEqual([{ column: 'Shift', values: ['Night'] }]); + }); + + it('archiving a chip soft-deletes the scope via analyzeStore.archiveScope', () => { + render(); + fireEvent.click(screen.getByTestId('scope-archive-scope-a')); + + const archived = useAnalyzeStore.getState().scopes.find(s => s.id === 'scope-a'); + expect(archived?.deletedAt).not.toBeNull(); + }); +}); diff --git a/apps/azure/src/features/ai/useAIOrchestration.ts b/apps/azure/src/features/ai/useAIOrchestration.ts index 990f2b05e..7908c94d0 100644 --- a/apps/azure/src/features/ai/useAIOrchestration.ts +++ b/apps/azure/src/features/ai/useAIOrchestration.ts @@ -161,6 +161,17 @@ export function useAIOrchestration({ const causalLinks = useAnalyzeStore(s => s.causalLinks); const hypotheses = useAnalyzeStore(s => s.hypotheses); + // IM-4b — flatten hub + finding comments so CoScout sees today's team + // discussion (buildAIContext filters to >= todayStartMs and caps at 10). + const hubComments = useMemo( + () => hypotheses.flatMap(h => (h.comments ?? []).filter(c => c.deletedAt === null)), + [hypotheses] + ); + const findingComments = useMemo( + () => findings.flatMap(f => (f.comments ?? []).filter(c => c.deletedAt === null)), + [findings] + ); + // Per-component preferences (default all on) const prefs = aiPreferences ?? { narration: true, insights: true, coscout: true }; @@ -292,6 +303,8 @@ export function useAIOrchestration({ evidenceMapTopology: effectiveTopology, hypotheses, bestSubsetsResult, + hubComments, + findingComments, }); // AI narration (disabled when per-component toggle is off) diff --git a/apps/azure/src/features/analyze/useAnalyzeOrchestration.ts b/apps/azure/src/features/analyze/useAnalyzeOrchestration.ts index 5d5ce641f..5d25e42e1 100644 --- a/apps/azure/src/features/analyze/useAnalyzeOrchestration.ts +++ b/apps/azure/src/features/analyze/useAnalyzeOrchestration.ts @@ -10,7 +10,7 @@ import { useMemo, useCallback } from 'react'; import { useAnalyzeFeatureStore, buildIdeaImpacts } from './analyzeStore'; import { usePanelsStore } from '../panels/panelsStore'; -import { useHypotheses, type HypothesisUpdate } from '@variscout/hooks'; +import { useHypotheses, type UseHypothesesReturn } from '@variscout/hooks'; import { useAnalyzeStore } from '@variscout/stores'; import type { Finding, @@ -19,8 +19,6 @@ import type { IdeaImpact, ProcessContext, StatsResult, - Hypothesis, - DisconfirmationAttempt, } from '@variscout/core'; // ── Interfaces ──────────────────────────────────────────────────────────── @@ -51,17 +49,7 @@ export interface UseAnalyzeOrchestrationReturn { /** Set finding status with automatic idea-to-action conversion */ handleSetFindingStatus: (id: string, status: FindingStatus) => void; /** Full hypotheses hook state — hub CRUD operations for the Investigation workspace */ - hypothesesState: { - hubs: Hypothesis[]; - createHub: (name: string, synthesis: string) => Hypothesis; - updateHub: (hubId: string, updates: HypothesisUpdate) => void; - deleteHub: (hubId: string) => void; - resetHubs: (newHubs: Hypothesis[]) => void; - connectFinding: (hubId: string, findingId: string) => void; - disconnectFinding: (hubId: string, findingId: string) => void; - getHubForFinding: (findingId: string) => Hypothesis | undefined; - recordDisconfirmation: (hubId: string, attempt: DisconfirmationAttempt) => void; - }; + hypothesesState: UseHypothesesReturn; /** Computed idea impacts keyed by idea ID */ ideaImpacts: Record; } diff --git a/apps/azure/src/pages/Editor.tsx b/apps/azure/src/pages/Editor.tsx index 7f312958f..70e9377bb 100644 --- a/apps/azure/src/pages/Editor.tsx +++ b/apps/azure/src/pages/Editor.tsx @@ -1178,18 +1178,18 @@ export const Editor: React.FC = ({ ); // Investigation workflow (IM-1: hypothesis-driven, Question entity retired) - // IM-1: handleProjectIdea + ideaImpacts (improvement-idea projection, now - // keyed by hypothesisId) are not destructured here — their render target was - // the Question-driven FindingsLog ideas surface that IM-1 dismantled. The - // replacement, ImprovementIdeasSection (built in @variscout/ui, keyed by - // hypothesisId), is not yet mounted in any app render path; wiring it into the - // Wall / hypothesis-card surface is owned by IM-4/IM-5. The orchestration hook - // still returns both so the surface can re-consume them once mounted. + // IM-4b: handleProjectIdea + ideaImpacts (improvement-idea projection, keyed by + // hypothesisId) are now consumed — they feed the ImprovementIdeasSection that + // mounts on the hypothesis card via the WallCanvas production seam (the IM-1 + // Question-driven FindingsLog ideas surface they originally targeted was + // dismantled; the replacement is the Wall hypothesis card). const { handleSaveIdeaProjection, clearProjectionTarget, handleSetFindingStatus, hypothesesState, + handleProjectIdea, + ideaImpacts, } = useAnalyzeOrchestration({ findingsState, processContext, @@ -1197,6 +1197,9 @@ export const Editor: React.FC = ({ }); // Keep the latest-ref current so wallPlanningProps.onRecordDisconfirmation // routes through the hook rather than the store (see declaration above). + // The hub comment/task/idea collab callbacks are built inside AnalyzeWorkspace, + // where `hypothesesState`, `ideaImpacts`, and `handleProjectIdea` are reactively + // in scope (Editor's wallPlanningProps memo is TDZ-bound before the hook). recordDisconfirmationRef.current = hypothesesState.recordDisconfirmation; const projectionTarget = useAnalyzeFeatureStore(s => s.projectionTarget); @@ -1926,6 +1929,8 @@ export const Editor: React.FC = ({ } hypothesesState={hypothesesState} planningProps={wallPlanningProps} + ideaImpacts={ideaImpacts} + onProjectIdea={handleProjectIdea} /> ) : activeView === 'projects' ? ( { // Azure has no 'causalLink' table today; F3 normalizes — no-op. return; + case 'HYPOTHESIS_ACTION_ADD': + case 'HYPOTHESIS_ACTION_UPDATE': + case 'HYPOTHESIS_ACTION_COMPLETE': + // Hypothesis ActionItems (Task 3, IM-4b) are in-session-only / F5-DEFERRED, + // like the Hypothesis they hang off — they live in analyzeStore for the + // session and ride the analyze blob (no callers yet). No Azure table — no-op. + return; + case 'HYPOTHESIS_ADD': // Azure has no 'hypothesis' table today; F3 normalizes — no-op. return; diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index b1d223529..74e8748cb 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -69,8 +69,10 @@ import { DEFAULT_PROCESS_HUB_ID, normalizeProcessHubId, type ExclusionReason, + type ImprovementIdea, toNumericValue, extractHubName, + parseMentions, } from '@variscout/core'; import { resolveMode } from '@variscout/core/strategy'; import { resolveCpkTarget } from '@variscout/core/capability'; @@ -884,9 +886,45 @@ function AppMain() { onEditPlan: (planId: string) => { console.warn(`[wall] Plan edit UI deferred to V2 — planId: ${planId}`); }, + // IM-4b Task 1 — hub comment thread. The PWA Wall (AnalyzeView) reads hubs + // from useAnalyzeStore, so comment/task/idea writes route through the store + // (its source of truth). parseMentions resolves @-tags against members. + onAddHubComment: (hubId: string, text: string) => { + const mentionedUserIds = parseMentions(text, wallActiveIPMembers); + void useAnalyzeStore + .getState() + .addHubComment(hubId, text, PWA_WALL_USER_ID, mentionedUserIds); + }, + onEditHubComment: (hubId: string, commentId: string, text: string) => + useAnalyzeStore.getState().editHubComment(hubId, commentId, text), + onDeleteHubComment: (hubId: string, commentId: string) => + useAnalyzeStore.getState().deleteHubComment(hubId, commentId), + showCommentAuthors: wallActiveIPMembers.length > 0, + // IM-4b Task 3 — ActionItem tasks + onAddHypothesisAction: (hypothesisId: string, text: string) => { + useAnalyzeStore.getState().addHypothesisAction(hypothesisId, text); + }, + onCompleteHypothesisAction: (hypothesisId: string, actionId: string) => + useAnalyzeStore.getState().completeHypothesisAction(hypothesisId, actionId), + // IM-4b Task 6 — improvement ideas + ideaImpacts: investigation.ideaImpacts, + onProjectIdea: (hypothesisId: string, ideaId: string) => + investigation.handleProjectIdea(hypothesisId, ideaId), + onAddIdea: (hypothesisId: string, text: string) => { + useAnalyzeStore.getState().addIdea(hypothesisId, text); + }, + onUpdateIdea: ( + hypothesisId: string, + ideaId: string, + updates: Partial> + ) => useAnalyzeStore.getState().updateIdea(hypothesisId, ideaId, updates), + onRemoveIdea: (hypothesisId: string, ideaId: string) => + useAnalyzeStore.getState().deleteIdea(hypothesisId, ideaId), + onSelectIdea: (hypothesisId: string, ideaId: string, selected: boolean) => + useAnalyzeStore.getState().selectIdea(hypothesisId, ideaId, selected), }), - [wallMeasurementPlans, wallActiveIPMembers] + [wallMeasurementPlans, wallActiveIPMembers, PWA_WALL_USER_ID, investigation] ); const activeIPAnalyzeFactorRequest = useMemo( diff --git a/apps/pwa/src/features/analyze/useAnalyzeOrchestration.ts b/apps/pwa/src/features/analyze/useAnalyzeOrchestration.ts index a246a7fb2..05ef79a96 100644 --- a/apps/pwa/src/features/analyze/useAnalyzeOrchestration.ts +++ b/apps/pwa/src/features/analyze/useAnalyzeOrchestration.ts @@ -10,7 +10,7 @@ import { useMemo, useCallback } from 'react'; import { useAnalyzeFeatureStore, buildIdeaImpacts } from './analyzeStore'; import { usePanelsStore } from '../panels/panelsStore'; -import { useHypotheses, type HypothesisUpdate } from '@variscout/hooks'; +import { useHypotheses, type UseHypothesesReturn } from '@variscout/hooks'; import { useAnalyzeStore } from '@variscout/stores'; import type { Finding, @@ -19,7 +19,6 @@ import type { IdeaImpact, ProcessContext, StatsResult, - Hypothesis, } from '@variscout/core'; // ── Interfaces ──────────────────────────────────────────────────────────── @@ -49,16 +48,7 @@ export interface UseAnalyzeOrchestrationReturn { /** Set finding status with automatic idea-to-action conversion */ handleSetFindingStatus: (id: string, status: FindingStatus) => void; /** Full hypotheses hook state — hub CRUD operations for the Investigation workspace */ - hypothesesState: { - hubs: Hypothesis[]; - createHub: (name: string, synthesis: string) => Hypothesis; - updateHub: (hubId: string, updates: HypothesisUpdate) => void; - deleteHub: (hubId: string) => void; - resetHubs: (newHubs: Hypothesis[]) => void; - connectFinding: (hubId: string, findingId: string) => void; - disconnectFinding: (hubId: string, findingId: string) => void; - getHubForFinding: (findingId: string) => Hypothesis | undefined; - }; + hypothesesState: UseHypothesesReturn; /** Computed idea impacts keyed by idea ID */ ideaImpacts: Record; } diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts index c873f931d..f0838e0ca 100644 --- a/apps/pwa/src/persistence/applyAction.ts +++ b/apps/pwa/src/persistence/applyAction.ts @@ -391,6 +391,9 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { + it('Hypothesis accepts an actions array of ActionItems', () => { + // This is a compile-time + shape test. + // If `Hypothesis` does not have `actions?: ActionItem[]`, TypeScript errors here. + const action: ActionItem = { + id: 'ai-h1', + text: '@Jane: validate against night-shift data', + assignee: { upn: 'jane@contoso.com', displayName: 'Jane Analyst' }, + dueDate: '2026-06-15', + createdAt: 1_748_649_600_000, // 2026-05-30 deterministic + deletedAt: null, + }; + + // Type-level assertion: Hypothesis must accept actions field. + const h: Hypothesis = { + id: 'h-1', + name: 'Worn spindle', + synthesis: 'Night shift data shows Cpk drop of 0.4', + findingIds: ['f-1'], + status: 'proposed', + createdAt: 1_748_649_600_000, + updatedAt: 1_748_649_600_000, + deletedAt: null, + investigationId: 'inv-1', + actions: [action], // ← NEW field — must exist on Hypothesis + }; + + expect(h.actions).toHaveLength(1); + expect(h.actions![0].text).toBe('@Jane: validate against night-shift data'); + expect(h.actions![0].assignee?.displayName).toBe('Jane Analyst'); + }); + + it('Hypothesis.actions defaults to absent (backward-compatible)', () => { + const h: Hypothesis = { + id: 'h-2', + name: 'Coolant temp drift', + synthesis: '', + findingIds: [], + status: 'proposed', + createdAt: 1_748_649_600_000, + updatedAt: 1_748_649_600_000, + deletedAt: null, + investigationId: 'inv-1', + }; + // No actions field → undefined (optional, not required) + expect(h.actions).toBeUndefined(); + }); + + it('ActionItem on Hypothesis has open/done status', () => { + const openAction: ActionItem = { + id: 'ai-open', + text: 'Confirm spindle wear on next shift', + createdAt: 1_748_649_600_000, + deletedAt: null, + }; + // completedAt absent → open + expect(openAction.completedAt).toBeUndefined(); + + const doneAction: ActionItem = { + ...openAction, + id: 'ai-done', + completedAt: 1_748_736_000_000, // 2026-05-31 deterministic + }; + expect(doneAction.completedAt).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// HubAction union — new kinds compile and carry the right payload +// --------------------------------------------------------------------------- + +describe('HYPOTHESIS_ACTION_ADD — compiles under HubAction', () => { + it('carries hypothesisId + actionItem payload', () => { + const action: HubAction = { + kind: 'HYPOTHESIS_ACTION_ADD', + hypothesisId: 'h-1', + actionItem: { + id: 'ai-1', + text: '@Jane: validate against night-shift data', + assignee: { upn: 'jane@contoso.com', displayName: 'Jane Analyst' }, + dueDate: '2026-06-15', + createdAt: 1_748_649_600_000, + deletedAt: null, + }, + }; + expect(action.kind).toBe('HYPOTHESIS_ACTION_ADD'); + if (action.kind === 'HYPOTHESIS_ACTION_ADD') { + expect(action.hypothesisId).toBe('h-1'); + expect(action.actionItem.text).toBe('@Jane: validate against night-shift data'); + expect(action.actionItem.assignee?.displayName).toBe('Jane Analyst'); + } + }); +}); + +describe('HYPOTHESIS_ACTION_UPDATE — compiles under HubAction', () => { + it('carries hypothesisId + actionId + patch', () => { + const action: HubAction = { + kind: 'HYPOTHESIS_ACTION_UPDATE', + hypothesisId: 'h-1', + actionId: 'ai-1', + patch: { text: 'Updated task text' }, + }; + expect(action.kind).toBe('HYPOTHESIS_ACTION_UPDATE'); + if (action.kind === 'HYPOTHESIS_ACTION_UPDATE') { + expect(action.hypothesisId).toBe('h-1'); + expect(action.actionId).toBe('ai-1'); + expect(action.patch.text).toBe('Updated task text'); + } + }); + + it('patch can include assignee (re-assign)', () => { + const action: HubAction = { + kind: 'HYPOTHESIS_ACTION_UPDATE', + hypothesisId: 'h-1', + actionId: 'ai-1', + patch: { + assignee: { upn: 'bob@contoso.com', displayName: 'Bob Sponsor' }, + dueDate: '2026-06-30', + }, + }; + expect(action.kind).toBe('HYPOTHESIS_ACTION_UPDATE'); + if (action.kind === 'HYPOTHESIS_ACTION_UPDATE') { + expect(action.patch.assignee?.displayName).toBe('Bob Sponsor'); + } + }); +}); + +describe('HYPOTHESIS_ACTION_COMPLETE — compiles under HubAction', () => { + it('carries hypothesisId + actionId + completedAt', () => { + const action: HubAction = { + kind: 'HYPOTHESIS_ACTION_COMPLETE', + hypothesisId: 'h-1', + actionId: 'ai-1', + completedAt: 1_748_736_000_000, + }; + expect(action.kind).toBe('HYPOTHESIS_ACTION_COMPLETE'); + if (action.kind === 'HYPOTHESIS_ACTION_COMPLETE') { + expect(action.hypothesisId).toBe('h-1'); + expect(action.actionId).toBe('ai-1'); + expect(action.completedAt).toBe(1_748_736_000_000); + } + }); +}); + +// --------------------------------------------------------------------------- +// Exhaustiveness guard (mirrors exhaustiveness.test.ts with new cases) +// --------------------------------------------------------------------------- + +describe('HubAction exhaustiveness — includes HYPOTHESIS_ACTION_* kinds', () => { + it('_exhaustiveWithHypothesisActions compiles with assertNever fallback', () => { + // If a new action kind is added without a case branch, assertNever errors at compile. + // This also provides a runtime assertion that the function exists. + expect(typeof _exhaustiveWithHypothesisActions).toBe('function'); + }); +}); + +// --------------------------------------------------------------------------- +// Distinct from Measurement Plan (spec §5 locked decision) +// --------------------------------------------------------------------------- + +describe('HYPOTHESIS_ACTION_* is distinct from MEASUREMENT_PLAN_* actions', () => { + it('HYPOTHESIS_ACTION_ADD does NOT carry MeasurementPlan fields', () => { + // The data-collection task (owner=collector) routes through MEASUREMENT_PLAN_ADD. + // The general ActionItem task routes through HYPOTHESIS_ACTION_ADD. + // This test asserts that the HYPOTHESIS_ACTION_ADD payload shape does NOT have + // `primaryFactor` or `method` (which belong to MeasurementPlan, not ActionItem). + + const action: HubAction = { + kind: 'HYPOTHESIS_ACTION_ADD', + hypothesisId: 'h-1', + actionItem: { + id: 'ai-2', + text: 'Run gemba walk on night shift', + createdAt: 1_748_649_600_000, + deletedAt: null, + }, + }; + + // primaryFactor / method are NOT on ActionItem — this line compiles only if + // the action payload is typed as ActionItem (not MeasurementPlan). + // @ts-expect-error primaryFactor is not on ActionItem + const _noPrimaryFactor = action.actionItem.primaryFactor; + // @ts-expect-error method is not on ActionItem + const _noMethod = action.actionItem.method; + + expect(action.kind).toBe('HYPOTHESIS_ACTION_ADD'); + }); +}); diff --git a/packages/core/src/actions/hypothesisActions.ts b/packages/core/src/actions/hypothesisActions.ts index 67ce5e375..235e8bcba 100644 --- a/packages/core/src/actions/hypothesisActions.ts +++ b/packages/core/src/actions/hypothesisActions.ts @@ -1,4 +1,4 @@ -import type { DisconfirmationAttempt, Hypothesis } from '../findings/types'; +import type { DisconfirmationAttempt, Hypothesis, ActionItem } from '../findings/types'; import type { ProcessHubAnalyze } from '../processHub'; export type HypothesisAction = @@ -24,4 +24,28 @@ export type HypothesisAction = kind: 'HYPOTHESIS_RECORD_DISCONFIRMATION'; hypothesisId: Hypothesis['id']; attempt: DisconfirmationAttempt; + } + | { + /** + * Task 3 (IM-4b) — add a general ActionItem task to a hypothesis. + * Reuses the ActionItem model (assignee/dueDate/completedAt). + * Distinct from MEASUREMENT_PLAN_ADD (no primaryFactor/method here). + */ + kind: 'HYPOTHESIS_ACTION_ADD'; + hypothesisId: Hypothesis['id']; + actionItem: ActionItem; + } + | { + /** Task 3 (IM-4b) — update text/assignee/dueDate on a hypothesis action item. */ + kind: 'HYPOTHESIS_ACTION_UPDATE'; + hypothesisId: Hypothesis['id']; + actionId: ActionItem['id']; + patch: Partial>; + } + | { + /** Task 3 (IM-4b) — soft-complete a hypothesis action item (sets completedAt). */ + kind: 'HYPOTHESIS_ACTION_COMPLETE'; + hypothesisId: Hypothesis['id']; + actionId: ActionItem['id']; + completedAt: number; }; diff --git a/packages/core/src/ai/__tests__/buildAIContext.test.ts b/packages/core/src/ai/__tests__/buildAIContext.test.ts index 05c966f2c..b950e46e7 100644 --- a/packages/core/src/ai/__tests__/buildAIContext.test.ts +++ b/packages/core/src/ai/__tests__/buildAIContext.test.ts @@ -998,3 +998,162 @@ describe('ADR-060 Pillar 1', () => { expect(context.investigation?.evidenceMapTopology?.relationships).toHaveLength(1); }); }); + +// --------------------------------------------------------------------------- +// Task 2 — AIContext.investigation.recentComments (task 2 failing tests — RED) +// +// Acceptance: AIContext.investigation.recentComments includes recent comment TEXT. +// Today only `commentCount` exists on topFindings — this adds the field. +// +// Semantics: +// - "recent" = created today (ms timestamp ≥ start of today in UTC). +// - Includes hub comments (parentKind='hypothesis') AND finding comments. +// - Capped at a sensible number (≤10) to keep tokens bounded. +// - Comment text is included as-is (no truncation guard tested here). +// - Absent when no comments were made today. +// +// NOTE: These tests use a FIXED reference timestamp so they are fully +// deterministic (no wall-clock dependency). `todayStartMs` is passed as an +// option so the implementer can inject it; the option defaults to +// `new Date().setUTCHours(0,0,0,0)` in production. +// --------------------------------------------------------------------------- + +describe('buildAIContext — investigation.recentComments (task 2)', () => { + // Fixed reference: 2024-01-15T12:00:00.000Z + const TODAY_NOON = 1705320000000; // 2024-01-15T12:00:00Z + const TODAY_START = 1705276800000; // 2024-01-15T00:00:00Z + const YESTERDAY_END = TODAY_START - 1; // 2024-01-14T23:59:59.999Z + + const makeHubComment = ( + id: string, + text: string, + createdAt: number + ): import('../../findings/types').FindingComment => ({ + id, + text, + createdAt, + parentId: 'hub-1', + parentKind: 'hypothesis', + deletedAt: null, + }); + + const makeFindingComment = ( + id: string, + text: string, + createdAt: number + ): import('../../findings/types').FindingComment => ({ + id, + text, + createdAt, + parentId: 'f-1', + parentKind: 'finding', + deletedAt: null, + }); + + it('populates recentComments with hub comment text created today', () => { + const todayComment = makeHubComment('c1', 'Nozzle gap is 0.3mm — too wide', TODAY_NOON); + + const ctx = buildAIContext({ + process: { issueStatement: 'Nozzle wear' }, + hubComments: [todayComment], + todayStartMs: TODAY_START, + }); + + expect(ctx.investigation?.recentComments).toBeDefined(); + expect(ctx.investigation?.recentComments).toHaveLength(1); + expect(ctx.investigation?.recentComments![0]).toBe('Nozzle gap is 0.3mm — too wide'); + }); + + it('excludes hub comments created before today', () => { + const oldComment = makeHubComment('c1', 'Yesterday observation', YESTERDAY_END); + const todayComment = makeHubComment('c2', 'Today observation', TODAY_NOON); + + const ctx = buildAIContext({ + process: { issueStatement: 'Nozzle wear' }, + hubComments: [oldComment, todayComment], + todayStartMs: TODAY_START, + }); + + expect(ctx.investigation?.recentComments).toHaveLength(1); + expect(ctx.investigation?.recentComments![0]).toBe('Today observation'); + }); + + it('omits recentComments when no comments were made today', () => { + const oldComment = makeHubComment('c1', 'Old comment', YESTERDAY_END); + + const ctx = buildAIContext({ + process: { issueStatement: 'Test' }, + hubComments: [oldComment], + todayStartMs: TODAY_START, + }); + + expect(ctx.investigation?.recentComments).toBeUndefined(); + }); + + it('omits recentComments when hubComments is not provided', () => { + const ctx = buildAIContext({ + process: { issueStatement: 'Test' }, + todayStartMs: TODAY_START, + }); + + expect(ctx.investigation?.recentComments).toBeUndefined(); + }); + + it('includes finding comments created today alongside hub comments', () => { + const hubComment = makeHubComment('c1', 'Hub comment today', TODAY_NOON); + const findingComment = makeFindingComment('c2', 'Finding comment today', TODAY_NOON); + + const ctx = buildAIContext({ + process: { issueStatement: 'Test' }, + hubComments: [hubComment], + findingComments: [findingComment], + todayStartMs: TODAY_START, + }); + + expect(ctx.investigation?.recentComments).toHaveLength(2); + expect(ctx.investigation?.recentComments).toContain('Hub comment today'); + expect(ctx.investigation?.recentComments).toContain('Finding comment today'); + }); + + it('caps recentComments at 10 entries (token budget)', () => { + // 12 comments today — only the 10 most recent should appear + const manyComments = Array.from({ length: 12 }, (_, i) => + makeHubComment(`c${i}`, `Comment number ${i}`, TODAY_NOON + i * 1000) + ); + + const ctx = buildAIContext({ + process: { issueStatement: 'Test' }, + hubComments: manyComments, + todayStartMs: TODAY_START, + }); + + expect(ctx.investigation?.recentComments).toHaveLength(10); + }); + + it('includes the most recent 10 when capped (sorted by createdAt desc)', () => { + // 12 comments — most recent should be c11 .. c2 + const manyComments = Array.from({ length: 12 }, (_, i) => + makeHubComment(`c${i}`, `Comment number ${i}`, TODAY_NOON + i * 1000) + ); + + const ctx = buildAIContext({ + process: { issueStatement: 'Test' }, + hubComments: manyComments, + todayStartMs: TODAY_START, + }); + + // The 10 most recent (c11 down to c2) — order may be desc + expect(ctx.investigation?.recentComments).toContain('Comment number 11'); + expect(ctx.investigation?.recentComments).not.toContain('Comment number 0'); + expect(ctx.investigation?.recentComments).not.toContain('Comment number 1'); + }); + + it('does not require hubComments to trigger investigation context (process.issueStatement suffices)', () => { + const ctx = buildAIContext({ + process: { issueStatement: 'Cpk below target' }, + }); + // investigation is set; recentComments absent — not an error + expect(ctx.investigation).toBeDefined(); + expect(ctx.investigation?.recentComments).toBeUndefined(); + }); +}); diff --git a/packages/core/src/ai/buildAIContext.ts b/packages/core/src/ai/buildAIContext.ts index 4091ed41c..e3d92364e 100644 --- a/packages/core/src/ai/buildAIContext.ts +++ b/packages/core/src/ai/buildAIContext.ts @@ -11,7 +11,7 @@ import type { AIContext, ProcessContext, TargetMetric, AnalyzePhase } from './types'; import type { InsightChartType } from './chartInsights'; -import type { Finding, AnalyzeCategory, Hypothesis } from '../findings'; +import type { Finding, AnalyzeCategory, Hypothesis, FindingComment } from '../findings'; import type { StagedComparison } from '../stats/staged'; import { groupFindingsByStatus, getCategoryForFactor } from '../findings'; import { computeOptimum } from '../stats/safeMath'; @@ -126,6 +126,21 @@ export interface BuildAIContextOptions { }; /** Evidence Map topology for graph-aware CoScout reasoning */ evidenceMapTopology?: NonNullable['evidenceMapTopology']; + /** + * Hub comments (parentKind='hypothesis') to include in recentComments. + * Only comments with createdAt >= todayStartMs are included. + */ + hubComments?: FindingComment[]; + /** + * Finding comments (parentKind='finding') to include in recentComments. + * Only comments with createdAt >= todayStartMs are included. + */ + findingComments?: FindingComment[]; + /** + * Start-of-today in UTC ms (injected for deterministic tests). + * Defaults to `new Date().setUTCHours(0,0,0,0)` in production. + */ + todayStartMs?: number; } /** @@ -440,6 +455,24 @@ export function buildAIContext(options: BuildAIContextOptions): AIContext { context.investigation.liveStatement = options.liveStatement; } + // Recent comments (hub + finding, created today, capped at 10, newest-first) + const hasHubComments = options.hubComments && options.hubComments.length > 0; + const hasFindingComments = options.findingComments && options.findingComments.length > 0; + if (hasHubComments || hasFindingComments) { + const todayStart = options.todayStartMs ?? new Date().setUTCHours(0, 0, 0, 0); + const allComments: FindingComment[] = [ + ...(options.hubComments ?? []), + ...(options.findingComments ?? []), + ]; + const todayComments = allComments + .filter(c => c.createdAt >= todayStart) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, 10); + if (todayComments.length > 0) { + context.investigation.recentComments = todayComments.map(c => c.text); + } + } + if (options.evidenceMapTopology) { // Enrich factor nodes with type metadata when bestSubsetsResult is available const topology = options.evidenceMapTopology; diff --git a/packages/core/src/ai/types.ts b/packages/core/src/ai/types.ts index 6aa159f76..d40161927 100644 --- a/packages/core/src/ai/types.ts +++ b/packages/core/src/ai/types.ts @@ -386,6 +386,11 @@ export interface AIContext { problemStatementStage?: 'partial' | 'actionable' | 'with-causes'; /** Live problem statement text (auto-synthesized) */ liveStatement?: string; + /** + * Recent comment text (hub + finding comments created today, capped at 10, + * sorted newest-first). Absent when no comments were made today. + */ + recentComments?: string[]; }; /** Focus context from "Ask CoScout about this" actions */ focusContext?: { diff --git a/packages/core/src/findings/__tests__/parseMentions.test.ts b/packages/core/src/findings/__tests__/parseMentions.test.ts new file mode 100644 index 000000000..accd58b3a --- /dev/null +++ b/packages/core/src/findings/__tests__/parseMentions.test.ts @@ -0,0 +1,160 @@ +/** + * parseMentions — task 2 failing tests (RED). + * + * Acceptance: + * - Typing "@" in the comment composer is parsed to a + * mention (resolved to the matching ProjectMember's userId). + * - Unresolved @-tags (no member with that display name) are ignored (no crash). + * - Multiple mentions in one comment are all parsed. + * - Case-insensitive match on display name. + * - Returns a deduplicated list of userId strings. + * + * The utility lives at packages/core/src/findings/parseMentions.ts and is + * exported from the findings barrel. It is a pure function — no side effects, + * deterministic, no wall-clock dependency. + * + * NOTE: the companion store-level test (addHubComment stores mentionedUserIds) + * is in packages/stores/src/__tests__/analyzeStore.test.ts — see the + * "analyzeStore — addHubComment with @mentions" describe block added there. + */ + +import { describe, it, expect } from 'vitest'; +import type { ProjectMember } from '../../projectMembership/types'; +// ── The function under test — does NOT exist yet (RED) ─────────────────────── +import { parseMentions } from '../parseMentions'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const alice: ProjectMember = { + id: 'm1', + userId: 'user-alice', + displayName: 'Alice Lead', + role: 'lead', + invitedAt: 1, + createdAt: 1, + deletedAt: null, +}; + +const bob: ProjectMember = { + id: 'm2', + userId: 'user-bob', + displayName: 'Bob Member', + role: 'member', + invitedAt: 1, + createdAt: 1, + deletedAt: null, +}; + +const carol: ProjectMember = { + id: 'm3', + userId: 'user-carol', + displayName: 'Carol Sponsor', + role: 'sponsor', + invitedAt: 1, + createdAt: 1, + deletedAt: null, +}; + +const members: ReadonlyArray = [alice, bob, carol]; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('parseMentions', () => { + it('returns an empty array when the text has no @-tags', () => { + const result = parseMentions('No mentions here at all.', members); + expect(result).toEqual([]); + }); + + it('returns an empty array for an empty member list', () => { + const result = parseMentions('@Alice Lead can you check this?', []); + expect(result).toEqual([]); + }); + + it('resolves a single @mention to the matching member userId', () => { + const result = parseMentions('@Alice Lead — can you check this?', members); + expect(result).toEqual(['user-alice']); + }); + + it('resolves multiple @mentions in one comment', () => { + const result = parseMentions('@Alice Lead and @Bob Member — please validate', members); + expect(result).toContain('user-alice'); + expect(result).toContain('user-bob'); + expect(result).toHaveLength(2); + }); + + it('ignores @-tags that do not match any member display name', () => { + // "Jane" is not in the member list + const result = parseMentions('@Jane validate against night-shift data', members); + expect(result).toEqual([]); + }); + + it('deduplicates when the same member is mentioned twice', () => { + const result = parseMentions('@Alice Lead look at this — @Alice Lead did you see it?', members); + expect(result).toEqual(['user-alice']); + }); + + it('is case-insensitive on display name matching', () => { + const result = parseMentions('@alice lead can you look?', members); + expect(result).toEqual(['user-alice']); + }); + + it('handles a mention at the start of the text with no surrounding spaces', () => { + const result = parseMentions('@Bob Member', members); + expect(result).toEqual(['user-bob']); + }); + + it('returns an empty array for an empty string', () => { + const result = parseMentions('', members); + expect(result).toEqual([]); + }); + + it('resolves all three members when all are mentioned', () => { + const text = '@Alice Lead @Bob Member @Carol Sponsor'; + const result = parseMentions(text, members); + expect(result).toContain('user-alice'); + expect(result).toContain('user-bob'); + expect(result).toContain('user-carol'); + expect(result).toHaveLength(3); + }); + + // ── Longest-match-wins for prefix-overlapping display names ────────────────── + describe('longest-match-wins (prefix-overlapping names)', () => { + const bobShort: ProjectMember = { + id: 'm-short', + userId: 'user-bob-short', + displayName: 'Bob', + role: 'member', + invitedAt: 1, + createdAt: 1, + deletedAt: null, + }; + // bob (above) has displayName 'Bob Member' / userId 'user-bob'. + const overlapping: ReadonlyArray = [bobShort, bob]; + + it('resolves "@Bob Member" to the LONGER name only (Bob Member), not the prefix (Bob)', () => { + const result = parseMentions('@Bob Member please validate', overlapping); + expect(result).toEqual(['user-bob']); + expect(result).not.toContain('user-bob-short'); + }); + + it('still resolves the SHORT name when only the prefix is @-tagged', () => { + // "@Bob," — the trailing comma is a boundary so "Bob" matches; "Bob Member" + // does not (no " member" follows), so only the short member resolves. + const result = parseMentions('@Bob, can you look?', overlapping); + expect(result).toEqual(['user-bob-short']); + }); + + it('resolves both when each is @-tagged in its own span', () => { + const result = parseMentions('@Bob and @Bob Member', overlapping); + expect(result).toContain('user-bob-short'); + expect(result).toContain('user-bob'); + expect(result).toHaveLength(2); + }); + + it('is order-independent — short member listed first still loses to the longer match', () => { + // overlapping lists bobShort BEFORE bob; longest-match sorting must still win. + const result = parseMentions('@Bob Member', [bobShort, bob]); + expect(result).toEqual(['user-bob']); + }); + }); +}); diff --git a/packages/core/src/findings/index.ts b/packages/core/src/findings/index.ts index 5b9d5acef..1faf29719 100644 --- a/packages/core/src/findings/index.ts +++ b/packages/core/src/findings/index.ts @@ -54,6 +54,7 @@ export { projectMechanismBranch, projectMechanismBranches } from './mechanismBra // from this sub-path to avoid a duplicate identifier at the root barrel. The evaluator // accepts `Record`-compatible rows; consumers get the canonical DataRow // via `@variscout/core` root or `@variscout/core/types` directly. +export { parseMentions } from './parseMentions'; export { computeFindingWindowDrift } from './drift'; export type { DriftResult } from './drift'; // WindowContext is already re-exported via `export * from './types'` above. diff --git a/packages/core/src/findings/parseMentions.ts b/packages/core/src/findings/parseMentions.ts new file mode 100644 index 000000000..7e51ae63d --- /dev/null +++ b/packages/core/src/findings/parseMentions.ts @@ -0,0 +1,64 @@ +/** + * parseMentions — extract resolved userId strings from @-tag text. + * + * Pure utility: no side effects, no wall-clock dependency. Resolves each + * "@Display Name" token against the provided member roster (case-insensitive). + * Unknown @-tags are silently ignored. The result is deduplicated. + * + * Strategy: scan the text for every `@`-prefixed word sequence that matches + * a member's `displayName`. Member display names may contain spaces + * (e.g. "Alice Lead") so we test each member's full name as a token anchored + * to an `@` prefix. + * + * Resolution policy: LONGEST-MATCH-WINS. When two member names share a prefix + * (e.g. "Bob" and "Bob Member"), "@Bob Member" resolves ONLY to "Bob Member" + * — the shorter "Bob" does not also claim the same `@`-span. This is enforced + * by sorting candidates longest-first and marking each matched character span + * as consumed before a shorter prefix can match the same start position. + */ + +import type { ProjectMember } from '../projectMembership/types'; + +/** + * Parse @mentions in `text` and return the resolved userId strings. + * + * @param text - Comment text (may be empty). + * @param members - Project member roster to resolve against. + * @returns Deduplicated array of userId strings for matched members. + */ +export function parseMentions(text: string, members: ReadonlyArray): string[] { + if (!text || members.length === 0) return []; + + const lower = text.toLowerCase(); + const found = new Set(); + + // Longest-match-wins: process the longest display names first so a longer + // name claims its `@`-span before any shorter prefix can match the same + // start index. `consumed[i] === true` means index `i` already belongs to a + // resolved (longer) mention's token span. + const consumed = new Array(lower.length).fill(false); + const ordered = [...members].sort((a, b) => b.displayName.length - a.displayName.length); + + for (const member of ordered) { + const name = member.displayName.toLowerCase(); + // Match "@" — the @ must immediately precede the name. + const token = '@' + name; + let idx = lower.indexOf(token); + while (idx !== -1) { + const afterIdx = idx + token.length; + const nextChar = lower[afterIdx]; + // Reject when the matched start was already consumed by a longer name + // (the shorter prefix loses), and when the char after the name is a word + // char (prevents "@Alice Lead" matching inside "@Alice Leadbetter"). + const startConsumed = consumed[idx]; + const boundaryOk = nextChar === undefined || !/[a-z0-9_-]/.test(nextChar); + if (!startConsumed && boundaryOk) { + found.add(member.userId); + for (let i = idx; i < afterIdx; i++) consumed[i] = true; + } + idx = lower.indexOf(token, afterIdx); + } + } + + return Array.from(found); +} diff --git a/packages/core/src/findings/types.ts b/packages/core/src/findings/types.ts index eb7530023..9622e78f9 100644 --- a/packages/core/src/findings/types.ts +++ b/packages/core/src/findings/types.ts @@ -123,6 +123,12 @@ export interface FindingComment extends EntityBase { photos?: PhotoAttachment[]; /** Non-image file attachments (PDF, XLSX, CSV, TXT). Team plan: OneDrive upload. Standard: local reference. */ attachments?: CommentAttachment[]; + /** + * Resolved userId strings for @-tagged members in this comment. + * Populated by parseMentions(text, members) at the call site (composer or store action). + * Absent/empty for comments with no @mentions (backward compatible). + */ + mentionedUserIds?: string[]; } // ============================================================================ @@ -692,6 +698,12 @@ export interface Hypothesis extends EntityBase { signalCardIds?: string[]; /** Timestamped hypothesis-level team discussion. Same shape as FindingComment. */ comments?: FindingComment[]; + /** + * ActionItem tasks assigned to this hypothesis (Task 3). + * Reuses the same `ActionItem` shape as `Finding.actions` + canvas steps. + * Distinct from `MeasurementPlan` (no primaryFactor/method). + */ + actions?: ActionItem[]; /** * Recorded falsification attempts. Empty/absent + ≥2 evidence types triggers * the Survey confirm-gate rule (status auto-derives to `needs-disconfirmation`). diff --git a/packages/core/src/i18n/messages/ar.ts b/packages/core/src/i18n/messages/ar.ts index b80171a2f..5cb997d26 100644 --- a/packages/core/src/i18n/messages/ar.ts +++ b/packages/core/src/i18n/messages/ar.ts @@ -868,6 +868,20 @@ export const ar: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/bg.ts b/packages/core/src/i18n/messages/bg.ts index 9f60b6350..33b5c3615 100644 --- a/packages/core/src/i18n/messages/bg.ts +++ b/packages/core/src/i18n/messages/bg.ts @@ -877,6 +877,20 @@ export const bg: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/cs.ts b/packages/core/src/i18n/messages/cs.ts index c72f931e2..7a8deaefa 100644 --- a/packages/core/src/i18n/messages/cs.ts +++ b/packages/core/src/i18n/messages/cs.ts @@ -790,6 +790,20 @@ export const cs: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/da.ts b/packages/core/src/i18n/messages/da.ts index 474369872..4e6cabdfc 100644 --- a/packages/core/src/i18n/messages/da.ts +++ b/packages/core/src/i18n/messages/da.ts @@ -841,6 +841,20 @@ export const da: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/de.ts b/packages/core/src/i18n/messages/de.ts index 252c4325b..f8cf79677 100644 --- a/packages/core/src/i18n/messages/de.ts +++ b/packages/core/src/i18n/messages/de.ts @@ -880,6 +880,20 @@ export const de: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/el.ts b/packages/core/src/i18n/messages/el.ts index 6c20ddd8d..59bb74157 100644 --- a/packages/core/src/i18n/messages/el.ts +++ b/packages/core/src/i18n/messages/el.ts @@ -880,6 +880,20 @@ export const el: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/en.ts b/packages/core/src/i18n/messages/en.ts index 43fe9d1d4..125970ef7 100644 --- a/packages/core/src/i18n/messages/en.ts +++ b/packages/core/src/i18n/messages/en.ts @@ -885,6 +885,20 @@ export const en: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/es.ts b/packages/core/src/i18n/messages/es.ts index e238f3a44..7a157067d 100644 --- a/packages/core/src/i18n/messages/es.ts +++ b/packages/core/src/i18n/messages/es.ts @@ -882,6 +882,20 @@ export const es: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/fi.ts b/packages/core/src/i18n/messages/fi.ts index 5280afe1b..f767e283a 100644 --- a/packages/core/src/i18n/messages/fi.ts +++ b/packages/core/src/i18n/messages/fi.ts @@ -878,6 +878,20 @@ export const fi: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/fr.ts b/packages/core/src/i18n/messages/fr.ts index 9cae937c0..b4d7370ba 100644 --- a/packages/core/src/i18n/messages/fr.ts +++ b/packages/core/src/i18n/messages/fr.ts @@ -886,6 +886,20 @@ export const fr: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/he.ts b/packages/core/src/i18n/messages/he.ts index c68c412d1..aff61b22b 100644 --- a/packages/core/src/i18n/messages/he.ts +++ b/packages/core/src/i18n/messages/he.ts @@ -866,6 +866,20 @@ export const he: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/hi.ts b/packages/core/src/i18n/messages/hi.ts index 3e5749973..41b6b66ae 100644 --- a/packages/core/src/i18n/messages/hi.ts +++ b/packages/core/src/i18n/messages/hi.ts @@ -878,6 +878,20 @@ export const hi: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/hr.ts b/packages/core/src/i18n/messages/hr.ts index 4dd5c34be..e2dab5970 100644 --- a/packages/core/src/i18n/messages/hr.ts +++ b/packages/core/src/i18n/messages/hr.ts @@ -873,6 +873,20 @@ export const hr: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/hu.ts b/packages/core/src/i18n/messages/hu.ts index 422aff487..0cca70f87 100644 --- a/packages/core/src/i18n/messages/hu.ts +++ b/packages/core/src/i18n/messages/hu.ts @@ -794,6 +794,20 @@ export const hu: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/id.ts b/packages/core/src/i18n/messages/id.ts index eadfb20d7..c1c2ca48c 100644 --- a/packages/core/src/i18n/messages/id.ts +++ b/packages/core/src/i18n/messages/id.ts @@ -829,6 +829,20 @@ export const id: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/it.ts b/packages/core/src/i18n/messages/it.ts index a01bd8b6d..2b457f994 100644 --- a/packages/core/src/i18n/messages/it.ts +++ b/packages/core/src/i18n/messages/it.ts @@ -850,6 +850,20 @@ export const it: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/ja.ts b/packages/core/src/i18n/messages/ja.ts index 1704892e2..4bf19fe8d 100644 --- a/packages/core/src/i18n/messages/ja.ts +++ b/packages/core/src/i18n/messages/ja.ts @@ -839,6 +839,20 @@ export const ja: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/ko.ts b/packages/core/src/i18n/messages/ko.ts index c28820e42..65ea3e6f9 100644 --- a/packages/core/src/i18n/messages/ko.ts +++ b/packages/core/src/i18n/messages/ko.ts @@ -839,6 +839,20 @@ export const ko: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/ms.ts b/packages/core/src/i18n/messages/ms.ts index c57e6a12a..c3d78b4d1 100644 --- a/packages/core/src/i18n/messages/ms.ts +++ b/packages/core/src/i18n/messages/ms.ts @@ -878,6 +878,20 @@ export const ms: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/nb.ts b/packages/core/src/i18n/messages/nb.ts index 086071052..c31d6a6c7 100644 --- a/packages/core/src/i18n/messages/nb.ts +++ b/packages/core/src/i18n/messages/nb.ts @@ -791,6 +791,20 @@ export const nb: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/nl.ts b/packages/core/src/i18n/messages/nl.ts index db4f54b3f..813f9a882 100644 --- a/packages/core/src/i18n/messages/nl.ts +++ b/packages/core/src/i18n/messages/nl.ts @@ -849,6 +849,20 @@ export const nl: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/pl.ts b/packages/core/src/i18n/messages/pl.ts index 3d598180d..330dc78bd 100644 --- a/packages/core/src/i18n/messages/pl.ts +++ b/packages/core/src/i18n/messages/pl.ts @@ -845,6 +845,20 @@ export const pl: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/pt.ts b/packages/core/src/i18n/messages/pt.ts index 0c0bf856c..d1f16ad4d 100644 --- a/packages/core/src/i18n/messages/pt.ts +++ b/packages/core/src/i18n/messages/pt.ts @@ -882,6 +882,20 @@ export const pt: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/ro.ts b/packages/core/src/i18n/messages/ro.ts index 94bcfeb2c..3671d2e05 100644 --- a/packages/core/src/i18n/messages/ro.ts +++ b/packages/core/src/i18n/messages/ro.ts @@ -830,6 +830,20 @@ export const ro: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/sk.ts b/packages/core/src/i18n/messages/sk.ts index c324b0818..a599d136f 100644 --- a/packages/core/src/i18n/messages/sk.ts +++ b/packages/core/src/i18n/messages/sk.ts @@ -877,6 +877,20 @@ export const sk: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/sv.ts b/packages/core/src/i18n/messages/sv.ts index 9a4fb4da7..24ec55cb8 100644 --- a/packages/core/src/i18n/messages/sv.ts +++ b/packages/core/src/i18n/messages/sv.ts @@ -839,6 +839,20 @@ export const sv: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/th.ts b/packages/core/src/i18n/messages/th.ts index 8d37aff39..e6f625eff 100644 --- a/packages/core/src/i18n/messages/th.ts +++ b/packages/core/src/i18n/messages/th.ts @@ -820,6 +820,20 @@ export const th: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/tr.ts b/packages/core/src/i18n/messages/tr.ts index f466e9f43..e85ba101e 100644 --- a/packages/core/src/i18n/messages/tr.ts +++ b/packages/core/src/i18n/messages/tr.ts @@ -847,6 +847,20 @@ export const tr: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/uk.ts b/packages/core/src/i18n/messages/uk.ts index 51ac57545..1c93be90a 100644 --- a/packages/core/src/i18n/messages/uk.ts +++ b/packages/core/src/i18n/messages/uk.ts @@ -830,6 +830,20 @@ export const uk: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/vi.ts b/packages/core/src/i18n/messages/vi.ts index 5ee444112..f6ec846a3 100644 --- a/packages/core/src/i18n/messages/vi.ts +++ b/packages/core/src/i18n/messages/vi.ts @@ -828,6 +828,20 @@ export const vi: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/zhHans.ts b/packages/core/src/i18n/messages/zhHans.ts index a44d465fd..9adf74c58 100644 --- a/packages/core/src/i18n/messages/zhHans.ts +++ b/packages/core/src/i18n/messages/zhHans.ts @@ -830,6 +830,20 @@ export const zhHans: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/messages/zhHant.ts b/packages/core/src/i18n/messages/zhHant.ts index 25209458c..8568ddd92 100644 --- a/packages/core/src/i18n/messages/zhHant.ts +++ b/packages/core/src/i18n/messages/zhHant.ts @@ -830,6 +830,20 @@ export const zhHant: MessageCatalog = { 'wall.disconfirm.verdictRefuted': 'Broke it (refuted)', 'wall.disconfirm.record': 'Record', 'wall.disconfirm.cancel': 'Cancel', + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': '+ Add Task', + 'wall.task.taskLabel': 'Task description', + 'wall.task.save': 'Save', + 'wall.task.cancel': 'Cancel', + 'wall.task.markDone': 'Mark Done', + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': 'Assigned: collect {primaryFactor}', + 'wall.collect.status.planned': 'planned', + 'wall.collect.status.inProgress': 'in-progress', + 'wall.collect.status.complete': 'complete', + 'wall.collect.status.skipped': 'skipped', + 'wall.collect.due': 'Due: {date}', + 'wall.scope.archive': 'Archive scope {condition}', 'wall.gate.and': 'AND', 'wall.gate.or': 'OR', 'wall.gate.not': 'NOT', diff --git a/packages/core/src/i18n/types.ts b/packages/core/src/i18n/types.ts index 79baaef65..57f7f5aac 100644 --- a/packages/core/src/i18n/types.ts +++ b/packages/core/src/i18n/types.ts @@ -956,6 +956,7 @@ export interface MessageCatalog { 'wall.problem.ariaLabel': string; 'wall.scope.whatIf': string; 'wall.scope.coverage': string; + 'wall.scope.archive': string; 'wall.evidence.supports': string; 'wall.evidence.countsAgainst': string; 'wall.evidence.contributingFactors': string; @@ -967,6 +968,19 @@ export interface MessageCatalog { 'wall.disconfirm.verdictRefuted': string; 'wall.disconfirm.record': string; 'wall.disconfirm.cancel': string; + // ActionItem tasks on hypotheses (IM-4b Task 3) + 'wall.task.addButton': string; + 'wall.task.taskLabel': string; + 'wall.task.save': string; + 'wall.task.cancel': string; + 'wall.task.markDone': string; + // Plan-owner data-collection task surface (IM-4b Task 4) + 'wall.collect.assigned': string; + 'wall.collect.status.planned': string; + 'wall.collect.status.inProgress': string; + 'wall.collect.status.complete': string; + 'wall.collect.status.skipped': string; + 'wall.collect.due': string; 'wall.gate.and': string; 'wall.gate.or': string; 'wall.gate.not': string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 492239e9a..44c8108e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -820,6 +820,8 @@ export { categoricalFiltersToActiveFilters, // Compound-condition display (scope-anchor card, IM-4a) formatConditionLeaves, + // @mention resolution (hub comment composer, IM-4b) + parseMentions, // Predicate-set identity (drill→scope producer idempotency, IM-4a) predicateSetKey, predicateSetsEqual, diff --git a/packages/core/src/measurementPlan/types.ts b/packages/core/src/measurementPlan/types.ts index be78f2494..f13338675 100644 --- a/packages/core/src/measurementPlan/types.ts +++ b/packages/core/src/measurementPlan/types.ts @@ -65,4 +65,10 @@ export interface MeasurementPlan extends EntityBase { */ msaNote?: string; linkedFindingIds?: Finding['id'][]; + /** + * Optional ISO-8601 date string (YYYY-MM-DD) by which the data-collection + * task should be completed. Surfaced prominently on the Wall card next to + * the status badge (Task 4, IM-4b). + */ + dueDate?: string; } diff --git a/packages/hooks/src/useAIContext.ts b/packages/hooks/src/useAIContext.ts index d7309afaf..37983dec2 100644 --- a/packages/hooks/src/useAIContext.ts +++ b/packages/hooks/src/useAIContext.ts @@ -16,7 +16,13 @@ import type { EntryScenario, Hypothesis, } from '@variscout/core'; -import type { StatsResult, SpecLimits, Finding, AnalysisMode } from '@variscout/core'; +import type { + StatsResult, + SpecLimits, + Finding, + AnalysisMode, + FindingComment, +} from '@variscout/core'; export interface UseAIContextOptions { /** Whether AI is enabled */ @@ -77,6 +83,15 @@ export interface UseAIContextOptions { hypotheses?: Hypothesis[]; /** Best subsets result for model equation and interaction effects context */ bestSubsetsResult?: BuildAIContextOptions['bestSubsetsResult']; + /** + * IM-4b — hub comments (parentKind='hypothesis') for `investigation.recentComments`. + * Only comments created today (>= todayStartMs) are surfaced to CoScout. + */ + hubComments?: FindingComment[]; + /** IM-4b — finding comments (parentKind='finding') for `investigation.recentComments`. */ + findingComments?: FindingComment[]; + /** IM-4b — start-of-today UTC ms (injected for deterministic tests). */ + todayStartMs?: number; } export interface UseAIContextReturn { @@ -115,6 +130,9 @@ export function useAIContext(options: UseAIContextOptions): UseAIContextReturn { evidenceMapTopology, hypotheses, bestSubsetsResult, + hubComments, + findingComments, + todayStartMs, } = options; const context = useMemo(() => { @@ -142,6 +160,9 @@ export function useAIContext(options: UseAIContextOptions): UseAIContextReturn { evidenceMapTopology, hypotheses, bestSubsetsResult, + hubComments, + findingComments, + todayStartMs, }; // Map StatsResult to AIStatsInput @@ -187,6 +208,9 @@ export function useAIContext(options: UseAIContextOptions): UseAIContextReturn { evidenceMapTopology, hypotheses, bestSubsetsResult, + hubComments, + findingComments, + todayStartMs, ]); return { context }; diff --git a/packages/hooks/src/useHypotheses.ts b/packages/hooks/src/useHypotheses.ts index 1fd4527b8..e1b3ada62 100644 --- a/packages/hooks/src/useHypotheses.ts +++ b/packages/hooks/src/useHypotheses.ts @@ -1,6 +1,17 @@ import { useState, useCallback } from 'react'; -import { createHypothesis } from '@variscout/core/findings'; -import type { Hypothesis, DisconfirmationAttempt } from '@variscout/core'; +import { + createHypothesis, + createFindingComment, + createActionItem, + createImprovementIdea, +} from '@variscout/core/findings'; +import type { + Hypothesis, + DisconfirmationAttempt, + FindingComment, + ActionItem, + ImprovementIdea, +} from '@variscout/core'; // ============================================================================ // Types @@ -48,6 +59,45 @@ export interface UseHypothesesReturn { * No-op if the hub is not found. */ recordDisconfirmation: (hubId: string, attempt: DisconfirmationAttempt) => void; + /** + * IM-4b Task 1 — append a team comment to a hub. Routes through the hook's + * `update()` so the Wall (which reads `hubs` from this hook) re-renders with + * the new comment immediately AND `onHubsChange` syncs the domain store. + * Returns the created comment so the caller can mirror it to the repository. + * No-op + returns null if the hub is not found. + */ + addComment: ( + hubId: string, + text: string, + author?: string, + mentionedUserIds?: string[] + ) => FindingComment | null; + /** IM-4b Task 1 — edit a hub comment's text. No-op if hub/comment not found. */ + editComment: (hubId: string, commentId: string, text: string) => void; + /** IM-4b Task 1 — delete a hub comment. No-op if hub/comment not found. */ + deleteComment: (hubId: string, commentId: string) => void; + /** + * IM-4b Task 3 — append an ActionItem task to a hub. Returns the created item + * so the caller can mirror it to the repository. No-op + null if hub absent. + */ + addAction: (hubId: string, text: string) => ActionItem | null; + /** IM-4b Task 3 — mark a hub action item done (sets completedAt). */ + completeAction: (hubId: string, actionId: string, completedAt: number) => void; + /** + * IM-4b Task 6 — append an improvement idea to a hub. Returns the created + * idea so the caller can mirror it. No-op + null if hub absent. + */ + addIdea: (hubId: string, text: string) => ImprovementIdea | null; + /** IM-4b Task 6 — patch an improvement idea on a hub. */ + updateIdea: ( + hubId: string, + ideaId: string, + updates: Partial> + ) => void; + /** IM-4b Task 6 — remove an improvement idea from a hub. */ + removeIdea: (hubId: string, ideaId: string) => void; + /** IM-4b Task 6 — select/deselect an improvement idea on a hub. */ + selectIdea: (hubId: string, ideaId: string, selected: boolean) => void; } // ============================================================================ @@ -168,6 +218,174 @@ export function useHypotheses(options: UseHypothesesOptions): UseHypothesesRetur [update] ); + // ── IM-4b Task 1 — hub comment thread ─────────────────────────────────── + const addComment = useCallback( + ( + hubId: string, + text: string, + author?: string, + mentionedUserIds?: string[] + ): FindingComment | null => { + const hub = hubs.find(h => h.id === hubId); + if (!hub) return null; + const comment = createFindingComment(text, hubId, 'hypothesis', author); + if (mentionedUserIds && mentionedUserIds.length > 0) { + comment.mentionedUserIds = mentionedUserIds; + } + update(prev => + prev.map(h => + h.id === hubId + ? { ...h, comments: [...(h.comments ?? []), comment], updatedAt: Date.now() } + : h + ) + ); + return comment; + }, + [hubs, update] + ); + + const editComment = useCallback( + (hubId: string, commentId: string, text: string): void => { + update(prev => + prev.map(h => + h.id === hubId + ? { + ...h, + comments: (h.comments ?? []).map(c => (c.id === commentId ? { ...c, text } : c)), + updatedAt: Date.now(), + } + : h + ) + ); + }, + [update] + ); + + const deleteComment = useCallback( + (hubId: string, commentId: string): void => { + update(prev => + prev.map(h => + h.id === hubId + ? { + ...h, + comments: (h.comments ?? []).filter(c => c.id !== commentId), + updatedAt: Date.now(), + } + : h + ) + ); + }, + [update] + ); + + // ── IM-4b Task 3 — hub ActionItem tasks ───────────────────────────────── + const addAction = useCallback( + (hubId: string, text: string): ActionItem | null => { + const hub = hubs.find(h => h.id === hubId); + if (!hub) return null; + const action = createActionItem(text); + update(prev => + prev.map(h => + h.id === hubId + ? { ...h, actions: [...(h.actions ?? []), action], updatedAt: Date.now() } + : h + ) + ); + return action; + }, + [hubs, update] + ); + + const completeAction = useCallback( + (hubId: string, actionId: string, completedAt: number): void => { + update(prev => + prev.map(h => + h.id === hubId + ? { + ...h, + actions: (h.actions ?? []).map(a => + a.id === actionId ? { ...a, completedAt } : a + ), + updatedAt: Date.now(), + } + : h + ) + ); + }, + [update] + ); + + // ── IM-4b Task 6 — improvement ideas ──────────────────────────────────── + const addIdea = useCallback( + (hubId: string, text: string): ImprovementIdea | null => { + const hub = hubs.find(h => h.id === hubId); + if (!hub) return null; + const idea = createImprovementIdea(text); + update(prev => + prev.map(h => + h.id === hubId ? { ...h, ideas: [...(h.ideas ?? []), idea], updatedAt: Date.now() } : h + ) + ); + return idea; + }, + [hubs, update] + ); + + const updateIdea = useCallback( + ( + hubId: string, + ideaId: string, + updates: Partial> + ): void => { + update(prev => + prev.map(h => + h.id === hubId + ? { + ...h, + ideas: (h.ideas ?? []).map(i => (i.id === ideaId ? { ...i, ...updates } : i)), + updatedAt: Date.now(), + } + : h + ) + ); + }, + [update] + ); + + const removeIdea = useCallback( + (hubId: string, ideaId: string): void => { + update(prev => + prev.map(h => + h.id === hubId + ? { + ...h, + ideas: (h.ideas ?? []).filter(i => i.id !== ideaId), + updatedAt: Date.now(), + } + : h + ) + ); + }, + [update] + ); + + const selectIdea = useCallback( + (hubId: string, ideaId: string, selected: boolean): void => { + update(prev => + prev.map(h => + h.id === hubId + ? { + ...h, + ideas: (h.ideas ?? []).map(i => (i.id === ideaId ? { ...i, selected } : i)), + updatedAt: Date.now(), + } + : h + ) + ); + }, + [update] + ); + return { hubs, createHub, @@ -178,5 +396,14 @@ export function useHypotheses(options: UseHypothesesOptions): UseHypothesesRetur disconnectFinding, getHubForFinding, recordDisconfirmation, + addComment, + editComment, + deleteComment, + addAction, + completeAction, + addIdea, + updateIdea, + removeIdea, + selectIdea, }; } diff --git a/packages/stores/src/__tests__/analyzeStore.hypothesisActionItems.test.ts b/packages/stores/src/__tests__/analyzeStore.hypothesisActionItems.test.ts new file mode 100644 index 000000000..dd59e9705 --- /dev/null +++ b/packages/stores/src/__tests__/analyzeStore.hypothesisActionItems.test.ts @@ -0,0 +1,210 @@ +/** + * Task 3 — analyzeStore: ActionItem tasks on hypotheses (RED tests). + * + * Encodes the acceptance oracle for the store layer: + * - `addHypothesisAction(hubId, text, assignee?, dueDate?)` → appends ActionItem to + * `hypothesis.actions[]`; returns null for unknown hubId. + * - `updateHypothesisAction(hubId, actionId, patch)` → merges patch onto the matched item. + * - `completeHypothesisAction(hubId, actionId)` → sets `completedAt` (soft-complete). + * - `toggleHypothesisActionComplete(hubId, actionId)` → toggles completedAt. + * - `deleteHypothesisAction(hubId, actionId)` → removes from actions[]. + * - No side-effects on other state (finding actions, MeasurementPlans untouched). + * + * All timestamps use store's own Date.now() calls — tests use toBeGreaterThanOrEqual + * or check defined/undefined to remain deterministic. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAnalyzeStore, getAnalyzeInitialState } from '../analyzeStore'; + +beforeEach(() => { + useAnalyzeStore.setState(getAnalyzeInitialState()); +}); + +// ============================================================================ +// addHypothesisAction +// ============================================================================ + +describe('analyzeStore — addHypothesisAction', () => { + it('appends an ActionItem to hypothesis.actions with text', () => { + const hub = useAnalyzeStore.getState().createHub('Worn spindle', 'Night shift data'); + const item = useAnalyzeStore + .getState() + .addHypothesisAction(hub.id, '@Jane: validate against night-shift data'); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions).toHaveLength(1); + expect(updated.actions![0].text).toBe('@Jane: validate against night-shift data'); + expect(item).not.toBeNull(); + expect(item!.text).toBe('@Jane: validate against night-shift data'); + }); + + it('appends with optional assignee', () => { + const hub = useAnalyzeStore.getState().createHub('Coolant drift', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Validate temp', { + upn: 'jane@contoso.com', + displayName: 'Jane Analyst', + }); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions![0].assignee?.displayName).toBe('Jane Analyst'); + expect(updated.actions![0].assignee?.upn).toBe('jane@contoso.com'); + }); + + it('appends with optional dueDate', () => { + const hub = useAnalyzeStore.getState().createHub('Spindle wear', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Run test', undefined, '2026-06-15'); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions![0].dueDate).toBe('2026-06-15'); + }); + + it('returns null when hubId is unknown', () => { + const result = useAnalyzeStore.getState().addHypothesisAction('nonexistent', 'Task'); + expect(result).toBeNull(); + }); + + it('accumulates multiple actions in order', () => { + const hub = useAnalyzeStore.getState().createHub('Mechanism', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'First task'); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Second task'); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions).toHaveLength(2); + expect(updated.actions![0].text).toBe('First task'); + expect(updated.actions![1].text).toBe('Second task'); + }); + + it('does NOT affect finding actions (no cross-contamination)', () => { + const ctx = { activeFilters: {}, cumulativeScope: null }; + const finding = useAnalyzeStore.getState().addFinding('Observation', ctx); + useAnalyzeStore.getState().addFindingAction(finding.id, 'Finding task'); + const hub = useAnalyzeStore.getState().createHub('Mechanism', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Hypothesis task'); + + const updatedFinding = useAnalyzeStore.getState().findings.find(f => f.id === finding.id)!; + expect(updatedFinding.actions).toHaveLength(1); + expect(updatedFinding.actions![0].text).toBe('Finding task'); + + const updatedHub = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updatedHub.actions).toHaveLength(1); + expect(updatedHub.actions![0].text).toBe('Hypothesis task'); + }); +}); + +// ============================================================================ +// updateHypothesisAction +// ============================================================================ + +describe('analyzeStore — updateHypothesisAction', () => { + it('merges patch onto the matched action item', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Original task'); + const actionId = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions![0] + .id; + useAnalyzeStore.getState().updateHypothesisAction(hub.id, actionId, { text: 'Updated task' }); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions![0].text).toBe('Updated task'); + expect(updated.actions![0].id).toBe(actionId); + }); + + it('can re-assign to a new assignee', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore + .getState() + .addHypothesisAction(hub.id, 'Task', { upn: 'alice@contoso.com', displayName: 'Alice' }); + const actionId = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions![0] + .id; + useAnalyzeStore.getState().updateHypothesisAction(hub.id, actionId, { + assignee: { upn: 'bob@contoso.com', displayName: 'Bob' }, + }); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions![0].assignee?.displayName).toBe('Bob'); + }); + + it('leaves non-matching actions unchanged', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Task A'); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Task B'); + const actions = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions!; + const actionAId = actions[0].id; + const actionBId = actions[1].id; + useAnalyzeStore + .getState() + .updateHypothesisAction(hub.id, actionAId, { text: 'Task A Updated' }); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions!.find(a => a.id === actionBId)!.text).toBe('Task B'); + }); + + it('is a no-op for unknown hubId', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Task'); + // Should not throw + expect(() => + useAnalyzeStore.getState().updateHypothesisAction('nonexistent', 'ai-1', { text: 'X' }) + ).not.toThrow(); + }); +}); + +// ============================================================================ +// completeHypothesisAction / toggleHypothesisActionComplete +// ============================================================================ + +describe('analyzeStore — completeHypothesisAction', () => { + it('sets completedAt on the matched action item', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Task'); + const actionId = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions![0] + .id; + const before = Date.now(); + useAnalyzeStore.getState().completeHypothesisAction(hub.id, actionId); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions![0].completedAt).toBeDefined(); + expect(updated.actions![0].completedAt as number).toBeGreaterThanOrEqual(before); + }); +}); + +describe('analyzeStore — toggleHypothesisActionComplete', () => { + it('toggles completedAt on → off', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Task'); + const actionId = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions![0] + .id; + + // Toggle on + useAnalyzeStore.getState().toggleHypothesisActionComplete(hub.id, actionId); + expect( + useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions![0].completedAt + ).toBeDefined(); + + // Toggle off + useAnalyzeStore.getState().toggleHypothesisActionComplete(hub.id, actionId); + expect( + useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions![0].completedAt + ).toBeUndefined(); + }); +}); + +// ============================================================================ +// deleteHypothesisAction +// ============================================================================ + +describe('analyzeStore — deleteHypothesisAction', () => { + it('removes the matched action item from hypothesis.actions', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'To delete'); + const actionId = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions![0] + .id; + useAnalyzeStore.getState().deleteHypothesisAction(hub.id, actionId); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions).toHaveLength(0); + }); + + it('does not remove other actions when deleting one', () => { + const hub = useAnalyzeStore.getState().createHub('Test', ''); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Keep'); + useAnalyzeStore.getState().addHypothesisAction(hub.id, 'Delete me'); + const actions = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!.actions!; + const deleteId = actions[1].id; + useAnalyzeStore.getState().deleteHypothesisAction(hub.id, deleteId); + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.actions).toHaveLength(1); + expect(updated.actions![0].text).toBe('Keep'); + }); +}); diff --git a/packages/stores/src/__tests__/analyzeStore.test.ts b/packages/stores/src/__tests__/analyzeStore.test.ts index 6f959fccd..0b3c41024 100644 --- a/packages/stores/src/__tests__/analyzeStore.test.ts +++ b/packages/stores/src/__tests__/analyzeStore.test.ts @@ -872,6 +872,146 @@ describe('analyzeStore — addHubComment', () => { }); }); +// ============================================================================ +// editHubComment / deleteHubComment — task 1 failing tests +// ============================================================================ + +describe('analyzeStore — editHubComment', () => { + it('edits the comment text in place, leaving other comments unchanged', () => { + const hub = useAnalyzeStore.getState().createHub('Nozzle wear', 'Night shift'); + // Seed two comments directly (bypassing async addHubComment) + const c1 = { + id: 'c1', + text: 'Original text', + createdAt: 1, + deletedAt: null, + parentId: hub.id, + parentKind: 'hypothesis' as const, + }; + const c2 = { + id: 'c2', + text: 'Another comment', + createdAt: 2, + deletedAt: null, + parentId: hub.id, + parentKind: 'hypothesis' as const, + }; + useAnalyzeStore.setState(state => ({ + hypotheses: state.hypotheses.map(h => (h.id === hub.id ? { ...h, comments: [c1, c2] } : h)), + })); + + useAnalyzeStore.getState().editHubComment(hub.id, 'c1', 'Edited text'); + + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.comments).toHaveLength(2); + expect(updated.comments!.find(c => c.id === 'c1')!.text).toBe('Edited text'); + // second comment unchanged + expect(updated.comments!.find(c => c.id === 'c2')!.text).toBe('Another comment'); + }); + + it('is a no-op when the commentId does not exist', () => { + const hub = useAnalyzeStore.getState().createHub('Hub', 'Synth'); + const c1 = { + id: 'c1', + text: 'Stays the same', + createdAt: 1, + deletedAt: null, + parentId: hub.id, + parentKind: 'hypothesis' as const, + }; + useAnalyzeStore.setState(state => ({ + hypotheses: state.hypotheses.map(h => (h.id === hub.id ? { ...h, comments: [c1] } : h)), + })); + + useAnalyzeStore.getState().editHubComment(hub.id, 'c-does-not-exist', 'Ignored'); + + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.comments![0].text).toBe('Stays the same'); + }); + + it('is a no-op when the hubId does not exist', () => { + // Just confirm it doesn't throw + expect(() => + useAnalyzeStore.getState().editHubComment('no-such-hub', 'c1', 'text') + ).not.toThrow(); + }); +}); + +describe('analyzeStore — deleteHubComment', () => { + it('removes the comment identified by commentId', () => { + const hub = useAnalyzeStore.getState().createHub('Hub', 'Synth'); + const c1 = { + id: 'c1', + text: 'Keep', + createdAt: 1, + deletedAt: null, + parentId: hub.id, + parentKind: 'hypothesis' as const, + }; + const c2 = { + id: 'c2', + text: 'Delete me', + createdAt: 2, + deletedAt: null, + parentId: hub.id, + parentKind: 'hypothesis' as const, + }; + useAnalyzeStore.setState(state => ({ + hypotheses: state.hypotheses.map(h => (h.id === hub.id ? { ...h, comments: [c1, c2] } : h)), + })); + + useAnalyzeStore.getState().deleteHubComment(hub.id, 'c2'); + + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.comments).toHaveLength(1); + expect(updated.comments![0].id).toBe('c1'); + }); + + it('empties the comments array when the last comment is deleted', () => { + const hub = useAnalyzeStore.getState().createHub('Hub', 'Synth'); + const c1 = { + id: 'c1', + text: 'Only one', + createdAt: 1, + deletedAt: null, + parentId: hub.id, + parentKind: 'hypothesis' as const, + }; + useAnalyzeStore.setState(state => ({ + hypotheses: state.hypotheses.map(h => (h.id === hub.id ? { ...h, comments: [c1] } : h)), + })); + + useAnalyzeStore.getState().deleteHubComment(hub.id, 'c1'); + + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.comments).toHaveLength(0); + }); + + it('is a no-op when the commentId does not exist', () => { + const hub = useAnalyzeStore.getState().createHub('Hub', 'Synth'); + const c1 = { + id: 'c1', + text: 'Stays', + createdAt: 1, + deletedAt: null, + parentId: hub.id, + parentKind: 'hypothesis' as const, + }; + useAnalyzeStore.setState(state => ({ + hypotheses: state.hypotheses.map(h => (h.id === hub.id ? { ...h, comments: [c1] } : h)), + })); + + useAnalyzeStore.getState().deleteHubComment(hub.id, 'c-ghost'); + + const updated = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(updated.comments).toHaveLength(1); + }); + + it('is a no-op when the hubId does not exist', () => { + expect(() => useAnalyzeStore.getState().deleteHubComment('no-such-hub', 'c1')).not.toThrow(); + }); +}); + // ============================================================================ // Bulk + category tests // ============================================================================ @@ -1263,6 +1403,91 @@ describe('analyzeStore — composeScopeGate', () => { // Relocation assertions (IM-1 / F4) // ============================================================================ +// ============================================================================ +// @mentions — task 2 failing tests (RED) +// addHubComment stores mentionedUserIds when text contains @DisplayName tokens +// ============================================================================ + +describe('analyzeStore — addHubComment with @mentions', () => { + // mentionedUserIds are resolved externally (via parseMentions) and passed into + // addHubComment so the store stays transport-agnostic. The resulting + // FindingComment.mentionedUserIds must be persisted verbatim. + // + // Acceptance: "typing @ yields a mention" — the stored comment carries + // the resolved userId(s) so the SSE fan-out and UI badge can reference them. + + beforeEach(() => { + useAnalyzeStore.setState(getAnalyzeInitialState()); + }); + + it('stores mentionedUserIds on the comment when passed to addHubComment', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + + const hub = useAnalyzeStore.getState().createHub('Nozzle wear', 'Night shift'); + const comment = await useAnalyzeStore + .getState() + .addHubComment(hub.id, '@Alice Lead — can you validate?', 'Bob', ['user-alice']); + + expect(comment.mentionedUserIds).toEqual(['user-alice']); + + const liveHub = useAnalyzeStore.getState().hypotheses.find(h => h.id === hub.id)!; + expect(liveHub.comments?.[0]?.mentionedUserIds).toEqual(['user-alice']); + + vi.unstubAllGlobals(); + }); + + it('stores multiple mentionedUserIds when several members are mentioned', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + + const hub = useAnalyzeStore.getState().createHub('Coolant temp', 'Day shift'); + const comment = await useAnalyzeStore + .getState() + .addHubComment(hub.id, '@Alice Lead and @Bob Member please validate', 'Carol', [ + 'user-alice', + 'user-bob', + ]); + + expect(comment.mentionedUserIds).toEqual(['user-alice', 'user-bob']); + + vi.unstubAllGlobals(); + }); + + it('stores no mentionedUserIds when none are provided (backward-compatible)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + + const hub = useAnalyzeStore.getState().createHub('Hub', 'Synth'); + const comment = await useAnalyzeStore + .getState() + .addHubComment(hub.id, 'Plain comment without any mentions', 'Bob'); + + // mentionedUserIds should be absent or empty — not an error + expect(comment.mentionedUserIds ?? []).toEqual([]); + + vi.unstubAllGlobals(); + }); + + it('includes mentionedUserIds in the POST body sent to the server', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', mockFetch); + useProjectStore.setState({ projectId: 'proj-mention-test' }); + + const hub = useAnalyzeStore.getState().createHub('Hub', 'Synth'); + await useAnalyzeStore + .getState() + .addHubComment(hub.id, '@Alice Lead check this', 'Carol', ['user-alice']); + + const callArgs = mockFetch.mock.calls[0]; + const body = JSON.parse(callArgs[1].body) as { mentionedUserIds?: string[] }; + expect(body.mentionedUserIds).toEqual(['user-alice']); + + vi.unstubAllGlobals(); + }); +}); + +// ============================================================================ +// Relocation assertions (IM-1 / F4) +// ============================================================================ + describe('analyzeStore — relocation assertions (IM-1)', () => { it('does not own questions (retired in ADR-085)', () => { const state = useAnalyzeStore.getState() as unknown as Record; @@ -1290,4 +1515,73 @@ describe('analyzeStore — relocation assertions (IM-1)', () => { expect('removeScope' in state).toBe(true); expect('addHypothesisToScope' in state).toBe(true); }); + + it('owns archiveScope action (new in IM-4b Task 5 — scope rail)', () => { + // archiveScope is the store-level owner of the SCOPE_ARCHIVE side-effect. + // This test FAILS until the implementer adds archiveScope to AnalyzeActions. + const state = useAnalyzeStore.getState() as unknown as Record; + expect('archiveScope' in state).toBe(true); + }); +}); + +// ============================================================================ +// archiveScope (IM-4b Task 5 — SCOPE_ARCHIVE via scope rail) +// +// The acceptance requires that SCOPE_ARCHIVE prunes a scope from the rail. +// archiveScope is a soft-delete (sets deletedAt) so the HubRepository can +// persist the tombstone; the store filters deleted scopes out of the +// presentation slice OR marks them deleted — tests cover both observable +// effects (the scope no longer appears in the active listing). +// ============================================================================ + +describe('analyzeStore — archiveScope (IM-4b Task 5)', () => { + it('archiveScope removes the scope from the active scopes list', () => { + const sA = useAnalyzeStore + .getState() + .addScope('inv-1', 'Scope A', [{ kind: 'leaf', column: 'Machine', op: 'eq', value: 'B' }]); + const sB = useAnalyzeStore + .getState() + .addScope('inv-1', 'Scope B', [{ kind: 'leaf', column: 'Line', op: 'eq', value: '1' }]); + expect(useAnalyzeStore.getState().scopes).toHaveLength(2); + + useAnalyzeStore.getState().archiveScope(sA.id); + + // After archive, scope A must not appear in the active listing. + const remaining = useAnalyzeStore.getState().scopes; + expect(remaining.some(s => s.id === sA.id && !s.deletedAt)).toBe(false); + // Scope B is untouched. + expect(remaining.some(s => s.id === sB.id)).toBe(true); + }); + + it('archiveScope is a no-op for an unknown scope id', () => { + useAnalyzeStore.getState().addScope('inv-1', 'Scope A'); + expect(() => useAnalyzeStore.getState().archiveScope('does-not-exist')).not.toThrow(); + expect(useAnalyzeStore.getState().scopes).toHaveLength(1); + }); + + it('archiveScope on the last scope leaves an empty active listing', () => { + const s = useAnalyzeStore.getState().addScope('inv-1', 'Only scope'); + useAnalyzeStore.getState().archiveScope(s.id); + // Active (non-deleted) scopes must be empty. + const active = useAnalyzeStore.getState().scopes.filter(sc => !sc.deletedAt); + expect(active).toHaveLength(0); + }); + + it('archiveScope does not affect the sibling scope predicates or hypothesisIds', () => { + const sA = useAnalyzeStore + .getState() + .addScope('inv-1', 'Scope A', [{ kind: 'leaf', column: 'Machine', op: 'eq', value: 'B' }]); + const sB = useAnalyzeStore + .getState() + .addScope('inv-1', 'Scope B', [{ kind: 'leaf', column: 'Product', op: 'eq', value: 'X' }]); + useAnalyzeStore.getState().addHypothesisToScope(sB.id, 'h-1'); + + useAnalyzeStore.getState().archiveScope(sA.id); + + const sibling = useAnalyzeStore.getState().scopes.find(s => s.id === sB.id); + expect(sibling?.predicates).toEqual([ + { kind: 'leaf', column: 'Product', op: 'eq', value: 'X' }, + ]); + expect(sibling?.hypothesisIds).toEqual(['h-1']); + }); }); diff --git a/packages/stores/src/analyzeStore.ts b/packages/stores/src/analyzeStore.ts index 696be7406..39c2b1088 100644 --- a/packages/stores/src/analyzeStore.ts +++ b/packages/stores/src/analyzeStore.ts @@ -159,6 +159,13 @@ export interface AnalyzeActions { ) => void; removeScope: (scopeId: string) => void; addHypothesisToScope: (scopeId: string, hypothesisId: string) => void; + /** + * IM-4b Task 5 — soft-delete a scope (sets `deletedAt` to the current + * timestamp). The scope is retained in the store for the HubRepository + * tombstone; the presentation layer filters by `!deletedAt`. No-op for an + * unknown scope id. + */ + archiveScope: (scopeId: string) => void; /** * IM-5: recompute and persist the scope's `whatIfProjection` (projected overall * Cpk if the scope's drilled condition were fixed) from the live project data. @@ -172,6 +179,22 @@ export interface AnalyzeActions { */ recomputeScopeWhatIf: (scopeId: string) => void; + // --- Hub ActionItem task actions (Task 3 IM-4b) --- + addHypothesisAction: ( + hubId: string, + text: string, + assignee?: FindingAssignee, + dueDate?: string + ) => ActionItem | null; + updateHypothesisAction: ( + hubId: string, + actionId: string, + patch: Partial> + ) => void; + completeHypothesisAction: (hubId: string, actionId: string) => void; + toggleHypothesisActionComplete: (hubId: string, actionId: string) => void; + deleteHypothesisAction: (hubId: string, actionId: string) => void; + // --- Hub actions --- createHub: (name: string, synthesis: string) => Hypothesis; /** @@ -204,7 +227,22 @@ export interface AnalyzeActions { * Returns the locally generated FindingComment so callers can render * immediately without waiting for the network round-trip. */ - addHubComment: (hubId: string, text: string, author?: string) => Promise; + addHubComment: ( + hubId: string, + text: string, + author?: string, + mentionedUserIds?: string[] + ) => Promise; + /** + * Edit a comment's text in place on a hypothesis hub. + * Twin of `editFindingComment`. No-op when hubId or commentId does not exist. + */ + editHubComment: (hubId: string, commentId: string, text: string) => void; + /** + * Remove a comment from a hypothesis hub. + * Twin of `deleteFindingComment`. No-op when hubId or commentId does not exist. + */ + deleteHubComment: (hubId: string, commentId: string) => void; // --- Ideas actions (F2 — keyed by hypothesisId, live on Hypothesis.ideas) --- addIdea: (hypothesisId: string, text: string) => ImprovementIdea | null; @@ -689,6 +727,16 @@ export const useAnalyzeStore = create()((set, get })); }, + archiveScope: scopeId => { + const exists = get().scopes.some(s => s.id === scopeId); + if (!exists) return; + set(state => ({ + scopes: state.scopes.map(s => + s.id === scopeId ? { ...s, deletedAt: Date.now(), updatedAt: Date.now() } : s + ), + })); + }, + recomputeScopeWhatIf: scopeId => { const scope = get().scopes.find(s => s.id === scopeId); if (!scope) return; @@ -813,11 +861,15 @@ export const useAnalyzeStore = create()((set, get set({ hypotheses: hubs }); }, - addHubComment: async (hubId, text, author) => { + addHubComment: async (hubId, text, author, mentionedUserIds) => { // 1. Build the comment locally so optimistic append + server payload // share the same id — keeps the SSE echo idempotent (server dedupes // by id, so the echo that fans back via the stream is a no-op). const comment = createFindingComment(text, hubId, 'hypothesis', author); + // Attach resolved mention targets (parsed externally via parseMentions). + if (mentionedUserIds && mentionedUserIds.length > 0) { + comment.mentionedUserIds = mentionedUserIds; + } // 2. Optimistic update: append to the hub's comments array. set(state => ({ @@ -850,6 +902,7 @@ export const useAnalyzeStore = create()((set, get id: comment.id, text: comment.text, author: comment.author, + ...(mentionedUserIds && mentionedUserIds.length > 0 ? { mentionedUserIds } : {}), }), }); if (!res.ok) { @@ -876,6 +929,104 @@ export const useAnalyzeStore = create()((set, get return comment; }, + editHubComment: (hubId, commentId, text) => { + set(state => ({ + hypotheses: state.hypotheses.map(h => + h.id === hubId + ? { + ...h, + comments: (h.comments ?? []).map(c => (c.id === commentId ? { ...c, text } : c)), + } + : h + ), + })); + }, + + deleteHubComment: (hubId, commentId) => { + set(state => ({ + hypotheses: state.hypotheses.map(h => + h.id === hubId ? { ...h, comments: (h.comments ?? []).filter(c => c.id !== commentId) } : h + ), + })); + }, + + // ======================================================================== + // Hub ActionItem task actions (Task 3 IM-4b) — mirrors Finding action item methods + // ======================================================================== + + addHypothesisAction: (hubId, text, assignee?, dueDate?) => { + const { hypotheses } = get(); + const hypothesis = hypotheses.find(h => h.id === hubId); + if (!hypothesis) return null; + const action = createActionItem(text, assignee, dueDate); + set(state => ({ + hypotheses: state.hypotheses.map(h => + h.id === hubId + ? { ...h, actions: [...(h.actions ?? []), action], updatedAt: Date.now() } + : h + ), + })); + return action; + }, + + updateHypothesisAction: (hubId, actionId, patch) => { + set(state => ({ + hypotheses: state.hypotheses.map(h => + h.id === hubId + ? { + ...h, + actions: h.actions?.map(a => (a.id === actionId ? { ...a, ...patch } : a)), + updatedAt: Date.now(), + } + : h + ), + })); + }, + + completeHypothesisAction: (hubId, actionId) => { + set(state => ({ + hypotheses: state.hypotheses.map(h => + h.id === hubId + ? { + ...h, + actions: h.actions?.map(a => + a.id === actionId ? { ...a, completedAt: Date.now() } : a + ), + updatedAt: Date.now(), + } + : h + ), + })); + }, + + toggleHypothesisActionComplete: (hubId, actionId) => { + set(state => ({ + hypotheses: state.hypotheses.map(h => { + if (h.id !== hubId) return h; + return { + ...h, + actions: h.actions?.map(a => { + if (a.id !== actionId) return a; + return a.completedAt + ? { ...a, completedAt: undefined } + : { ...a, completedAt: Date.now() }; + }), + updatedAt: Date.now(), + }; + }), + })); + }, + + deleteHypothesisAction: (hubId, actionId) => { + set(state => ({ + hypotheses: state.hypotheses.map(h => + h.id === hubId + ? { ...h, actions: h.actions?.filter(a => a.id !== actionId), updatedAt: Date.now() } + : h + ), + })); + }, + // ======================================================================== // Ideas actions (F2 — keyed by hypothesisId, live on Hypothesis.ideas) // ======================================================================== diff --git a/packages/ui/src/components/AnalyzeWall/FindingChip.tsx b/packages/ui/src/components/AnalyzeWall/FindingChip.tsx index b3b040a8c..2052b7a3e 100644 --- a/packages/ui/src/components/AnalyzeWall/FindingChip.tsx +++ b/packages/ui/src/components/AnalyzeWall/FindingChip.tsx @@ -16,6 +16,9 @@ export interface FindingChipProps { y: number; onSelect?: (id: string) => void; onDetach?: (id: string) => void; + // IM-4c: finding→hypothesis-on-Wall (createHubFromFinding) is deferred to the + // IM-4c bipartite re-layout. The propose-hypothesis affordance + its callback + // were intentionally removed here; do not re-add until IM-4c wires the seam. } const CHIP_W = 220; @@ -25,26 +28,27 @@ export const FindingChip: React.FC = ({ finding, x, y, onSelec const sourceLabel = finding.source?.chart ? `${finding.source.chart}` : 'observation'; const label = `Finding: ${finding.text || sourceLabel}`; return ( - onSelect?.(finding.id)} - onContextMenu={e => { - e.preventDefault(); - onDetach?.(finding.id); - }} - className="cursor-pointer" - data-validation={finding.validationStatus ?? 'none'} - > - - - {sourceLabel} - - - {finding.text || '(no note)'} - + + onSelect?.(finding.id)} + onContextMenu={e => { + e.preventDefault(); + onDetach?.(finding.id); + }} + className="cursor-pointer" + > + + + {sourceLabel} + + + {finding.text || '(no note)'} + + ); }; diff --git a/packages/ui/src/components/AnalyzeWall/HypothesisCardWithPlans.tsx b/packages/ui/src/components/AnalyzeWall/HypothesisCardWithPlans.tsx index b7bb00273..1e524845f 100644 --- a/packages/ui/src/components/AnalyzeWall/HypothesisCardWithPlans.tsx +++ b/packages/ui/src/components/AnalyzeWall/HypothesisCardWithPlans.tsx @@ -18,7 +18,7 @@ */ import React, { useState } from 'react'; -import type { Finding } from '@variscout/core'; +import type { Finding, ActionItem, ImprovementIdea, IdeaImpact } from '@variscout/core'; import type { ConditionLeaf } from '@variscout/core/findings'; import type { MeasurementPlan } from '@variscout/core/measurementPlan'; import type { ProjectMember } from '@variscout/core/projectMembership'; @@ -28,13 +28,23 @@ import { HypothesisCard, type HypothesisCardProps } from './HypothesisCard'; import { MeasurementPlanChip } from './MeasurementPlanChip'; import { AddPlanForm, type StepOption } from './AddPlanForm'; import { LinkFindingPicker } from './LinkFindingPicker'; +import { HypothesisComments } from './HypothesisComments'; import { useWallLocale } from './hooks/useWallLocale'; +import ImprovementIdeasSection from '../FindingsLog/ImprovementIdeasSection'; // Card geometry constants (mirrors HypothesisCard internals) const CARD_W = 280; const CARD_H = 288; -/** Height of one MeasurementPlanChip row in the foreignObject (px). */ -const CHIP_ROW_H = 32; +/** + * Height of one data-collection-task section per plan (px). + * Each section contains a header row (~28px) + embedded MeasurementPlanChip + * row (32px) + padding/gap (~8px) = 68px. + */ +const DATA_COLLECT_ROW_H = 68; +/** Height of one ActionItem task row in the foreignObject (px). */ +const ACTION_ROW_H = 32; +/** Height of the inline add-task form (px). */ +const ADD_TASK_FORM_H = 72; /** Height of the + Add Plan button row (px). */ const ADD_BTN_H = 32; /** Height of the expanded disconfirmation form (description + verdict + actions). */ @@ -49,6 +59,10 @@ const FORM_H = 660; const PLANS_GAP = 8; /** Horizontal offset of the foreignObject from the card's center-top anchor. */ const FO_X = -(CARD_W / 2); +/** Fixed height reserved for the ImprovementIdeasSection foreignObject (px). */ +const IDEAS_SECTION_H = 300; +/** Fixed height reserved for the HypothesisComments foreignObject (px). */ +const COMMENTS_SECTION_H = 320; export interface HypothesisCardWithPlansProps extends HypothesisCardProps { /** All measurement plans for this hypothesis (non-deleted). */ @@ -94,6 +108,17 @@ export interface HypothesisCardWithPlansProps extends HypothesisCardProps { * Pass `undefined` to default to `''`. */ defaultOutcome?: string; + /** + * Task 3 (IM-4b) — called when the user saves a new ActionItem task on this hypothesis. + * Receives (hypothesisId, text, assignee?). Parent dispatches HYPOTHESIS_ACTION_ADD. + * When omitted, the "+ Add Task" button is not rendered. + */ + onAddHypothesisAction?: (hypothesisId: string, text: string) => void; + /** + * Task 3 (IM-4b) — called when the user clicks "Mark Done" on an open action item. + * Receives (hypothesisId, actionId). Parent dispatches HYPOTHESIS_ACTION_COMPLETE. + */ + onCompleteHypothesisAction?: (hypothesisId: string, actionId: string) => void; /** * IM-4a — record a falsification attempt against this hypothesis * ("we tried to break this — did it hold?"). When provided AND the user has @@ -106,6 +131,51 @@ export interface HypothesisCardWithPlansProps extends HypothesisCardProps { hypothesisId: string, input: { description: string; verdict: 'pending' | 'survived' | 'refuted' } ) => void; + /** + * Task 6 (IM-4b) — IdeaImpact map keyed by ideaId. + * Passed through to ImprovementIdeasSection for rendering impact badges. + * Required to mount ImprovementIdeasSection (pass {} when no impacts known). + */ + ideaImpacts?: Record; + /** + * Task 6 (IM-4b) — called when the user clicks "Project idea with What-If". + * Receives (hypothesisId, ideaId). Wires handleProjectIdea from useAnalyzeOrchestration. + */ + onProjectIdea?: (hypothesisId: string, ideaId: string) => void; + /** + * Task 6 (IM-4b) — called when the user adds a new improvement idea. + * Receives (hypothesisId, text). When omitted, the add-idea input is hidden. + */ + onAddIdea?: (hypothesisId: string, text: string) => void; + /** + * Task 6 (IM-4b) — called when the user updates an improvement idea. + */ + onUpdateIdea?: ( + hypothesisId: string, + ideaId: string, + updates: Partial> + ) => void; + /** + * Task 6 (IM-4b) — called when the user removes an improvement idea. + */ + onRemoveIdea?: (hypothesisId: string, ideaId: string) => void; + /** + * Task 6 (IM-4b) — called when the user selects/deselects an improvement idea. + */ + onSelectIdea?: (hypothesisId: string, ideaId: string, selected: boolean) => void; + /** + * Task 1 (IM-4b) — called when the user submits a new comment on this hub's + * team thread. Receives (hubId, text, attachment?). Parent dispatches + * addHubComment (which runs parseMentions on the text). When omitted, the + * comment thread is not mounted. + */ + onAddHubComment?: (hubId: string, text: string, attachment?: File) => void; + /** Task 1 (IM-4b) — called when the user saves an edited comment. */ + onEditHubComment?: (hubId: string, commentId: string, text: string) => void; + /** Task 1 (IM-4b) — called when the user deletes a comment. */ + onDeleteHubComment?: (hubId: string, commentId: string) => void; + /** Task 1 (IM-4b) — show author names on the comment thread (default false). */ + showCommentAuthors?: boolean; } export const HypothesisCardWithPlans: React.FC = ({ @@ -116,7 +186,19 @@ export const HypothesisCardWithPlans: React.FC = ( onAddPlan, onLinkFinding, onEditPlan, + onAddHypothesisAction, + onCompleteHypothesisAction, onRecordDisconfirmation, + ideaImpacts, + onProjectIdea, + onAddIdea, + onUpdateIdea, + onRemoveIdea, + onSelectIdea, + onAddHubComment, + onEditHubComment, + onDeleteHubComment, + showCommentAuthors, stepOptions, defaultScope, defaultOutcome, @@ -130,6 +212,9 @@ export const HypothesisCardWithPlans: React.FC = ( const [disconfirmVerdict, setDisconfirmVerdict] = useState<'pending' | 'survived' | 'refuted'>( 'pending' ); + // Add Task form state (Task 3 IM-4b) + const [addTaskFormOpen, setAddTaskFormOpen] = useState(false); + const [taskText, setTaskText] = useState(''); // ACL gate — open-access when no members configured (V1 single-user scenario) const canEdit = @@ -145,9 +230,21 @@ export const HypothesisCardWithPlans: React.FC = ( // user has edit-contributions access (the same ACL gate as the plans zone). const showDisconfirmGesture = canEdit && Boolean(onRecordDisconfirmation); - // Dynamic height: chips rows + optional add-plan button + optional add-plan - // form + optional disconfirmation gesture (button or expanded form). - const chipsTotalH = plans.length * CHIP_ROW_H; + // ImprovementIdeasSection mounts when the parent wires the impacts map AND the + // hub carries at least one idea (Task 6 IM-4b). Hoisted so the comments + // foreignObject can offset itself below the ideas section. + const showIdeasSection = ideaImpacts !== undefined && (cardProps.hub.ideas?.length ?? 0) > 0; + + // Dynamic height: action item rows + add-task form/button + + // data-collection-task sections (each includes the chip) + + // optional add-plan button + optional add-plan form + + // optional disconfirmation gesture. + const actions = cardProps.hub.actions ?? []; + const actionRowsTotalH = actions.length * ACTION_ROW_H; + const addTaskBtnH = canEdit && onAddHypothesisAction && !addTaskFormOpen ? ADD_BTN_H : 0; + const addTaskFormH = canEdit && addTaskFormOpen ? ADD_TASK_FORM_H : 0; + // Each data-collection-task section now embeds the chip — no separate chipsTotalH. + const dataCollectTotalH = plans.length * DATA_COLLECT_ROW_H; const btnH = canEdit && !addPlanFormOpen ? ADD_BTN_H : 0; const formH = canEdit && addPlanFormOpen ? FORM_H : 0; const disconfirmH = showDisconfirmGesture @@ -155,12 +252,27 @@ export const HypothesisCardWithPlans: React.FC = ( ? DISCONFIRM_FORM_H : ADD_BTN_H : 0; - const plansSectionH = chipsTotalH + btnH + formH + disconfirmH; + const plansSectionH = + actionRowsTotalH + addTaskBtnH + addTaskFormH + dataCollectTotalH + btnH + formH + disconfirmH; // Owner name resolver const resolveOwner = (ownerId: string): string => members.find(m => m.id === ownerId)?.displayName ?? '(unknown)'; + // Status label resolver for data-collection-task section + const resolveStatusLabel = (status: MeasurementPlan['status']): string => { + switch (status) { + case 'planned': + return getMessage(locale, 'wall.collect.status.planned'); + case 'in-progress': + return getMessage(locale, 'wall.collect.status.inProgress'); + case 'complete': + return getMessage(locale, 'wall.collect.status.complete'); + case 'skipped': + return getMessage(locale, 'wall.collect.status.skipped'); + } + }; + // Picker state: find the plan being linked const pickerPlan = linkFindingForPlanId ? (plans.find(p => p.id === linkFindingForPlanId) ?? null) @@ -200,17 +312,144 @@ export const HypothesisCardWithPlans: React.FC = ( data-testid="plans-section" >
- {/* Chip rows */} - {plans.map(plan => ( - setLinkFindingForPlanId(id)} - /> - ))} + {/* ActionItem task rows (Task 3 IM-4b) */} + {actions.map((action: ActionItem) => { + const isDone = action.completedAt !== undefined; + return ( +
+ {canEdit && !isDone && onCompleteHypothesisAction && ( + + )} +
+ + {action.text} + + {action.assignee && ( + + {action.assignee.displayName} + + )} +
+
+ ); + })} + + {/* + Add Task button (Task 3 IM-4b) */} + {canEdit && onAddHypothesisAction && !addTaskFormOpen && ( + + )} + + {/* Inline add-task form (Task 3 IM-4b) */} + {canEdit && addTaskFormOpen && ( +
+ +
+ + +
+
+ )} + + {/* Data-collection task sections (Task 4 IM-4b) — one per plan, read-display (no ACL gate). + Each section embeds the MeasurementPlanChip so its text (including primaryFactor) + appears in section.textContent without adding an independent matching element + alongside the chip for Testing Library's getAllByText queries. */} + {plans.map(plan => { + const ownerName = resolveOwner(plan.owner); + const statusLabel = resolveStatusLabel(plan.status); + return ( +
+ {/* Header: "Assigned: collect {primaryFactor}" label + status badge + due date. + Owner name is intentionally omitted here — it already appears + in the embedded MeasurementPlanChip row below, so + section.textContent contains it without duplicating the DOM node + (avoids getByText('Alice Lead') ambiguity in existing tests). */} +
+ + {getMessage(locale, 'wall.collect.assigned').replace( + '{primaryFactor}', + plan.primaryFactor + )} + + + {statusLabel} + + {plan.dueDate && ( + + {getMessage(locale, 'wall.collect.due').replace('{date}', plan.dueDate)} + + )} +
+ {/* Chip row embedded inside the section — its text (primaryFactor etc.) + contributes to section.textContent without duplicating getAllByText results */} + setLinkFindingForPlanId(id)} + /> +
+ ); + })} {/* + Add Plan button */} {canEdit && !addPlanFormOpen && ( @@ -329,6 +568,59 @@ export const HypothesisCardWithPlans: React.FC = ( /> )} + + {/* Task 6 (IM-4b) — ImprovementIdeasSection: re-mount the detached IM-1 flow. + Rendered only when hub.ideas is non-empty AND ideaImpacts is provided. + Positioned below the plans section in a foreignObject. */} + {showIdeasSection && ( + + + + )} + + {/* Task 1 (IM-4b) — HypothesisComments: team discussion thread. Mounted + via the production seam when the parent wires onAddHubComment (the + presence of the add callback is the "comments enabled" signal). The + ACL gate lives inside HypothesisComments (mirrors the plans-zone gate). + Positioned below the plans + ideas sections. */} + {onAddHubComment && ( + + {})} + onDelete={onDeleteHubComment ?? (() => {})} + showAuthors={showCommentAuthors} + /> + + )} ); }; diff --git a/packages/ui/src/components/AnalyzeWall/HypothesisComments.tsx b/packages/ui/src/components/AnalyzeWall/HypothesisComments.tsx new file mode 100644 index 000000000..1b3473719 --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/HypothesisComments.tsx @@ -0,0 +1,69 @@ +/** + * HypothesisComments — team comment thread for a Hypothesis card (Investigation Wall). + * + * Lifts/parameterizes FindingComments: reuses the existing thread widget and + * editor, wiring hub.comments as the data source and the hub id as the + * "findingId" context. ACL gate mirrors HypothesisCardWithPlans: + * - members.length === 0 → open-access (V1 single-user scenario) + * - otherwise → canAccess(currentUserId, members, 'edit-contributions') + * + * SSE sync: addHubComment (+ useHubCommentStream) are wired at the app level; + * this component receives callbacks (onAdd/onEdit/onDelete) and is agnostic to + * transport. + * + * Rendered inside a foreignObject extension zone (not SVG), so normal div + * context applies. + */ + +import React from 'react'; +import type { Hypothesis } from '@variscout/core'; +import type { ProjectMember } from '@variscout/core/projectMembership'; +import { canAccess } from '@variscout/core/projectMembership'; +import FindingComments from '../FindingsLog/FindingComments'; + +export interface HypothesisCommentsProps { + /** The hypothesis whose comments to display. */ + hub: Hypothesis; + /** Project members for ACL checks. Pass `[]` for open-access (single-user). */ + members: ReadonlyArray; + /** + * Current user's userId (ProjectMember.userId). Pass `null` when unauthenticated. + * Ignored when `members` is empty (open-access escape). + */ + currentUserId: string | null; + /** Called when the user submits a new comment. */ + onAdd: (hubId: string, text: string, attachment?: File) => void; + /** Called when the user saves an edited comment. */ + onEdit: (hubId: string, commentId: string, text: string) => void; + /** Called when the user deletes a comment. */ + onDelete: (hubId: string, commentId: string) => void; + /** Show author names on comments (default false). */ + showAuthors?: boolean; +} + +export const HypothesisComments: React.FC = ({ + hub, + members, + currentUserId, + onAdd, + onEdit, + onDelete, + showAuthors, +}) => { + // ACL gate — open-access when no members configured (V1 single-user scenario) + const canEdit = + members.length === 0 || + (currentUserId !== null && canAccess(currentUserId, [...members], 'edit-contributions')); + + return ( + + ); +}; diff --git a/packages/ui/src/components/AnalyzeWall/ScopeRail.tsx b/packages/ui/src/components/AnalyzeWall/ScopeRail.tsx new file mode 100644 index 000000000..95ed73ae0 --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/ScopeRail.tsx @@ -0,0 +1,93 @@ +/** + * ScopeRail — Task 5 (IM-4b) Multi-scope rail + * + * A horizontal breadcrumb/tab rail that lists the persisted + * ProblemStatementScopes for the active investigation. + * + * - Clicking a chip calls onScopeSelect(scope.id) to re-anchor the Wall. + * - The archive button on each chip calls onScopeArchive(scope.id) via + * SCOPE_ARCHIVE (analyzeStore.archiveScope); stops propagation so the + * chip click (onScopeSelect) does NOT fire. + * - The active scope chip carries aria-current="true". + * - Condition text is rendered via formatConditionLeaves from @variscout/core. + * + * Accessibility: each scope is a `role="tab"` button. The archive affordance is + * a sibling ` + +
+ ); + })} +
+ ); +} diff --git a/packages/ui/src/components/AnalyzeWall/WallCanvas.tsx b/packages/ui/src/components/AnalyzeWall/WallCanvas.tsx index d4721037c..b3722ff6b 100644 --- a/packages/ui/src/components/AnalyzeWall/WallCanvas.tsx +++ b/packages/ui/src/components/AnalyzeWall/WallCanvas.tsx @@ -20,6 +20,8 @@ import type { GateNode, GatePath, ProblemStatementScope, + ImprovementIdea, + IdeaImpact, } from '@variscout/core'; import type { DataRow } from '@variscout/core'; import type { ColumnTypeMap, ConditionLeaf } from '@variscout/core/findings'; @@ -104,6 +106,49 @@ export interface WallCanvasPlanningProps { hypothesisId: string, input: { description: string; verdict: 'pending' | 'survived' | 'refuted' } ) => void; + /** + * IM-4b Task 1 — team comment thread on each hub. When `onAddHubComment` is + * provided, the card mounts `HypothesisComments`; the ACL gate lives inside + * that component. The app calls `addHubComment` (which runs `parseMentions` + * on the text + dispatches HUB_COMMENT_ADD). + */ + onAddHubComment?: (hubId: string, text: string, attachment?: File) => void; + /** IM-4b Task 1 — edit a hub comment (calls `editHubComment`). */ + onEditHubComment?: (hubId: string, commentId: string, text: string) => void; + /** IM-4b Task 1 — delete a hub comment (calls `deleteHubComment`). */ + onDeleteHubComment?: (hubId: string, commentId: string) => void; + /** IM-4b Task 1 — show author names on the comment thread (default false). */ + showCommentAuthors?: boolean; + /** + * IM-4b Task 3 — add an ActionItem task to a hub. When provided AND the user + * has edit-contributions access, the card renders the "+ Add Task" affordance. + * The app dispatches HYPOTHESIS_ACTION_ADD. + */ + onAddHypothesisAction?: (hypothesisId: string, text: string) => void; + /** + * IM-4b Task 3 — mark an open ActionItem done. When provided, each open task + * row renders a "Mark Done" control. The app dispatches HYPOTHESIS_ACTION_COMPLETE. + */ + onCompleteHypothesisAction?: (hypothesisId: string, actionId: string) => void; + /** + * IM-4b Task 6 — IdeaImpact map keyed by ideaId. When provided, hubs with + * `ideas` mount `ImprovementIdeasSection`. Pass `{}` when no impacts are known. + */ + ideaImpacts?: Record; + /** IM-4b Task 6 — project an idea through What-If (handleProjectIdea). */ + onProjectIdea?: (hypothesisId: string, ideaId: string) => void; + /** IM-4b Task 6 — add a new improvement idea. */ + onAddIdea?: (hypothesisId: string, text: string) => void; + /** IM-4b Task 6 — update an improvement idea. */ + onUpdateIdea?: ( + hypothesisId: string, + ideaId: string, + updates: Partial> + ) => void; + /** IM-4b Task 6 — remove an improvement idea. */ + onRemoveIdea?: (hypothesisId: string, ideaId: string) => void; + /** IM-4b Task 6 — select/deselect an improvement idea. */ + onSelectIdea?: (hypothesisId: string, ideaId: string, selected: boolean) => void; } export interface WallCanvasProps { @@ -524,6 +569,21 @@ export const WallCanvas: React.FC = ({ onLinkFinding: planningProps.onLinkFinding, onEditPlan: planningProps.onEditPlan, onRecordDisconfirmation: planningProps.onRecordDisconfirmation, + // IM-4b Task 1 — comment thread + onAddHubComment: planningProps.onAddHubComment, + onEditHubComment: planningProps.onEditHubComment, + onDeleteHubComment: planningProps.onDeleteHubComment, + showCommentAuthors: planningProps.showCommentAuthors, + // IM-4b Task 3 — ActionItem tasks + onAddHypothesisAction: planningProps.onAddHypothesisAction, + onCompleteHypothesisAction: planningProps.onCompleteHypothesisAction, + // IM-4b Task 6 — improvement ideas + ideaImpacts: planningProps.ideaImpacts, + onProjectIdea: planningProps.onProjectIdea, + onAddIdea: planningProps.onAddIdea, + onUpdateIdea: planningProps.onUpdateIdea, + onRemoveIdea: planningProps.onRemoveIdea, + onSelectIdea: planningProps.onSelectIdea, stepOptions: planningStepOptions, defaultScope: planningProps.defaultScope, defaultOutcome: planningProps.defaultOutcome, diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.actionItems.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.actionItems.test.tsx new file mode 100644 index 000000000..0464b85c3 --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.actionItems.test.tsx @@ -0,0 +1,482 @@ +/** + * Task 3 — HypothesisCardWithPlans: ActionItem tasks on hypotheses (RED tests). + * + * Encodes the acceptance oracle for the UI layer: + * - When hub.actions has items, the card renders them (assignee + text + status). + * - "+ Add Task" button visible when canEdit is true. + * - "+ Add Task" button hidden when canEdit is false (non-member user). + * - Adding a task fires `onAddHypothesisAction(hypothesisId, text, assignee?)`. + * - Completing a task fires `onCompleteHypothesisAction(hypothesisId, actionId)`. + * - Open task renders "open" label / incomplete marker; done task renders "done". + * - Empty members → open-access → button visible. + * - Sponsor member can add tasks (edit-contributions ACL). + * - Distinct from Measurement Plan rows: action item rows use different + * data-testid ("action-item-row") vs plan chips ("chip-body"). + * + * These tests FAIL today because: + * 1. HypothesisCardWithPlans does not accept onAddHypothesisAction / + * onCompleteHypothesisAction props yet. + * 2. No action item rows are rendered from hub.actions. + * 3. No "+ Add Task" button exists. + */ + +// vi.mock MUST come before component imports per project convention. +vi.mock('@variscout/stores', () => ({ + useAnalyzeStore: Object.assign(vi.fn(), { + getState: () => ({ addFinding: vi.fn(() => ({ id: 'f-test' })), connectFindingToHub: vi.fn() }), + }), + usePreferencesStore: Object.assign(vi.fn(), { + getState: () => ({ timeLens: { mode: 'rolling', windowSize: 50 } }), + }), +})); + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { HypothesisCardWithPlans } from '../HypothesisCardWithPlans'; +import type { Hypothesis, ActionItem } from '@variscout/core'; +import type { ProjectMember } from '@variscout/core/projectMembership'; + +// ── fixtures ────────────────────────────────────────────────────────────────── + +const hub: Hypothesis = { + id: 'h1', + name: 'Nozzle runs hot on night shift', + synthesis: '', + findingIds: ['f1'], + status: 'proposed', + createdAt: 1_748_649_600_000, + updatedAt: 1_748_649_600_000, + deletedAt: null, + investigationId: 'inv-1', +}; + +const openAction: ActionItem = { + id: 'ai-1', + text: '@Jane: validate against night-shift data', + assignee: { upn: 'jane@contoso.com', displayName: 'Jane Analyst' }, + dueDate: '2026-06-15', + createdAt: 1_748_649_600_000, + deletedAt: null, + // completedAt absent → open +}; + +const doneAction: ActionItem = { + id: 'ai-2', + text: 'Reviewed temperature log', + assignee: { upn: 'bob@contoso.com', displayName: 'Bob Sponsor' }, + createdAt: 1_748_649_600_000, + deletedAt: null, + completedAt: 1_748_736_000_000, // done +}; + +const hubWithActions: Hypothesis = { + ...hub, + actions: [openAction, doneAction], +}; + +const leadMember: ProjectMember = { + id: 'm1', + userId: 'user-lead', + displayName: 'Alice Lead', + role: 'lead', + invitedAt: 1_748_649_600_000, + createdAt: 1_748_649_600_000, + deletedAt: null, +}; + +const sponsorMember: ProjectMember = { + id: 'm2', + userId: 'user-sponsor', + displayName: 'Bob Sponsor', + role: 'sponsor', + invitedAt: 1_748_649_600_000, + createdAt: 1_748_649_600_000, + deletedAt: null, +}; + +function renderInSvg(ui: React.ReactElement) { + return render({ui}); +} + +// ── Rendering action item rows ───────────────────────────────────────────────── + +describe('HypothesisCardWithPlans — action item rows from hub.actions', () => { + it('renders one row per action item in hub.actions', () => { + renderInSvg( + + ); + // Each action item renders with its text + expect(screen.getByText('@Jane: validate against night-shift data')).toBeInTheDocument(); + expect(screen.getByText('Reviewed temperature log')).toBeInTheDocument(); + }); + + it('renders assignee name on each action item row', () => { + renderInSvg( + + ); + expect(screen.getByText('Jane Analyst')).toBeInTheDocument(); + expect(screen.getByText('Bob Sponsor')).toBeInTheDocument(); + }); + + it('shows open status indicator for incomplete action items', () => { + renderInSvg( + + ); + // Open action — aria-label or text indicating "open" or "to-do" + const row = document.querySelector('[data-testid="action-item-row"][data-status="open"]'); + expect(row).toBeTruthy(); + }); + + it('shows done status indicator for completed action items', () => { + renderInSvg( + + ); + // Done action — data-status="done" on the row + const row = document.querySelector('[data-testid="action-item-row"][data-status="done"]'); + expect(row).toBeTruthy(); + }); + + it('renders no action item rows when hub.actions is absent', () => { + renderInSvg( + + ); + expect(document.querySelectorAll('[data-testid="action-item-row"]').length).toBe(0); + }); + + it('action item rows are visually distinct from plan chip rows', () => { + renderInSvg( + + ); + // Plan chip rows use data-testid="chip-body"; action item rows use "action-item-row" + expect(document.querySelectorAll('[data-testid="action-item-row"]').length).toBe(1); + expect(document.querySelectorAll('[data-testid="chip-body"]').length).toBe(0); + }); +}); + +// ── "+ Add Task" button (ACL gated) ────────────────────────────────────────── + +describe('HypothesisCardWithPlans — + Add Task button ACL', () => { + it('shows "+ Add Task" button when canEdit is true (lead member)', () => { + renderInSvg( + + ); + expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument(); + }); + + it('shows "+ Add Task" button for Sponsor — tasks are contributions per 2-tier ACL', () => { + renderInSvg( + + ); + expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument(); + }); + + it('shows "+ Add Task" button with empty members (open-access escape)', () => { + renderInSvg( + + ); + expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument(); + }); + + it('hides "+ Add Task" button when user is not in members list (canEdit false)', () => { + renderInSvg( + + ); + expect(screen.queryByRole('button', { name: /add task/i })).toBeNull(); + }); + + it('omits "+ Add Task" button when onAddHypothesisAction is not provided', () => { + renderInSvg( + + ); + expect(screen.queryByRole('button', { name: /add task/i })).toBeNull(); + }); +}); + +// ── Add task flow ───────────────────────────────────────────────────────────── + +describe('HypothesisCardWithPlans — add task flow', () => { + it('clicking "+ Add Task" opens the inline add-task form', () => { + renderInSvg( + + ); + fireEvent.click(screen.getByRole('button', { name: /add task/i })); + // Form appears — a text input for the task description + expect(screen.getByRole('textbox', { name: /task/i })).toBeInTheDocument(); + }); + + it('save fires onAddHypothesisAction with hypothesisId + text', () => { + const onAdd = vi.fn(); + renderInSvg( + + ); + fireEvent.click(screen.getByRole('button', { name: /add task/i })); + fireEvent.change(screen.getByRole('textbox', { name: /task/i }), { + target: { value: '@Jane: validate against night-shift data' }, + }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(onAdd).toHaveBeenCalledOnce(); + const [hypothesisId, text] = onAdd.mock.calls[0]; + expect(hypothesisId).toBe('h1'); + expect(text).toBe('@Jane: validate against night-shift data'); + }); + + it('cancel closes the form without firing onAddHypothesisAction', () => { + const onAdd = vi.fn(); + renderInSvg( + + ); + fireEvent.click(screen.getByRole('button', { name: /add task/i })); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onAdd).not.toHaveBeenCalled(); + // Form is gone, button is back + expect(screen.queryByRole('textbox', { name: /task/i })).toBeNull(); + expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument(); + }); +}); + +// ── Complete task flow ──────────────────────────────────────────────────────── + +describe('HypothesisCardWithPlans — complete task flow', () => { + it('clicking the complete button on an open task fires onCompleteHypothesisAction', () => { + const onComplete = vi.fn(); + renderInSvg( + + ); + // The complete button (checkbox or "Mark done") on the open task row + const completeBtn = screen.getByRole('button', { name: /mark done|complete/i }); + fireEvent.click(completeBtn); + expect(onComplete).toHaveBeenCalledOnce(); + expect(onComplete).toHaveBeenCalledWith('h1', 'ai-1'); + }); + + it('complete button is hidden when canEdit is false', () => { + renderInSvg( + + ); + expect(screen.queryByRole('button', { name: /mark done|complete/i })).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.ideasSection.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.ideasSection.test.tsx new file mode 100644 index 000000000..06ffcd54b --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.ideasSection.test.tsx @@ -0,0 +1,411 @@ +/** + * Task 6 — Re-mount the 3 detached IM-1 flows (RED tests). + * + * Acceptance oracle: + * 1. ImprovementIdeasSection mounted on HypothesisCardWithPlans, keyed by + * hypothesisId — renders when hub.ideas is non-empty; shows "Improvement Ideas" + * toggle; shows each idea's text; passes ideaImpacts down (impact badges); + * fires onAddIdea / onProjectIdea via the component's own affordances. + * 2. handleProjectIdea + ideaImpacts wired into HypothesisCardWithPlans via new + * props (onProjectIdea: (hypothesisId, ideaId) => void; + * ideaImpacts: Record). + * 3. createHubFromFinding CTA: WallCanvas / FindingChip exposes + * onProposeHypothesis?: (findingId: string) => void and fires it on the + * "Propose hypothesis" gesture on an unattached finding chip. + * + * These tests FAIL today because: + * - HypothesisCardWithPlans does not render ImprovementIdeasSection (neither + * imported nor mounted). + * - HypothesisCardWithPlans does not accept ideaImpacts / onProjectIdea / + * onAddIdea / onUpdateIdea / onSelectIdea / onRemoveIdea props. + * - FindingChip / WallCanvas does not accept onProposeHypothesis prop. + * + * EXISTING tests are READ-ONLY — no existing test is modified here. + * This file is additive only. + * + * Patterns followed: + * - vi.mock BEFORE component imports (project convention) + * - Fixtures with deterministic timestamps (no Date.now / Math.random) + * - renderInSvg() wrapper (mirrors existing HypothesisCardWithPlans tests) + * - Store resets not needed here (no Zustand store reads in these surfaces) + */ + +// vi.mock MUST come before component imports. +vi.mock('@variscout/stores', () => ({ + useAnalyzeStore: Object.assign(vi.fn(), { + getState: () => ({ + addFinding: vi.fn(() => ({ id: 'f-test' })), + connectFindingToHub: vi.fn(), + }), + }), + usePreferencesStore: Object.assign(vi.fn(), { + getState: () => ({ timeLens: { mode: 'rolling', windowSize: 50 } }), + }), +})); + +vi.mock('@variscout/hooks', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + locale: 'en-US', + formatStat: (n: number, d?: number) => n.toFixed(d ?? 0), + }), +})); + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { HypothesisCardWithPlans } from '../HypothesisCardWithPlans'; +import { FindingChip } from '../FindingChip'; +import type { Hypothesis, Finding, ImprovementIdea, IdeaImpact } from '@variscout/core'; +import type { MeasurementPlan } from '@variscout/core/measurementPlan'; +import type { ProjectMember } from '@variscout/core/projectMembership'; +import { DEFAULT_TIME_LENS } from '@variscout/core'; + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const idea1: ImprovementIdea = { + id: 'idea-1', + text: 'Reduce coolant flow by 10%', + selected: false, + createdAt: 1_748_649_600_000, + deletedAt: null, +}; + +const idea2: ImprovementIdea = { + id: 'idea-2', + text: 'Switch to night-shift heat trace', + selected: true, + createdAt: 1_748_649_601_000, + deletedAt: null, +}; + +const hub: Hypothesis = { + id: 'h1', + name: 'Nozzle runs hot on night shift', + synthesis: '', + findingIds: ['f1'], + status: 'proposed', + createdAt: 1_748_649_600_000, + updatedAt: 1_748_649_600_000, + deletedAt: null, + investigationId: 'inv-1', + ideas: [idea1, idea2], +}; + +const hubNoIdeas: Hypothesis = { + ...hub, + id: 'h2', + ideas: [], +}; + +const ideaImpacts: Record = { + 'idea-1': 'high', + 'idea-2': 'medium', +}; + +const leadMember: ProjectMember = { + id: 'm1', + userId: 'user-lead', + displayName: 'Alice Lead', + role: 'lead', + invitedAt: 1_748_649_600_000, + createdAt: 1_748_649_600_000, + deletedAt: null, +}; + +const finding: Finding = { + id: 'f1', + text: 'Temperature spike at 02:00', + context: { activeFilters: {}, cumulativeScope: null }, + evidenceType: 'data', + status: 'observed', + comments: [], + statusChangedAt: 1_748_649_600_000, + investigationId: 'inv-1', + createdAt: 1_748_649_600_000, + deletedAt: null, + source: { + chart: 'ichart', + anchorX: 0, + anchorY: 0, + timeLens: DEFAULT_TIME_LENS, + }, +}; + +const emptyPlans: MeasurementPlan[] = []; + +function renderInSvg(ui: React.ReactElement) { + return render({ui}); +} + +// Shared minimal props for HypothesisCardWithPlans (all required fields): +function makeCardProps( + overrides: Partial> = {} +): React.ComponentProps { + return { + hub, + displayStatus: 'proposed', + x: 0, + y: 0, + plans: emptyPlans, + members: [leadMember], + currentUserId: 'user-lead', + findings: [finding], + onAddPlan: vi.fn(), + onLinkFinding: vi.fn(), + onEditPlan: vi.fn(), + ...overrides, + }; +} + +// ── Flow 1: ImprovementIdeasSection mounted (keyed by hypothesisId) ───────────── + +describe('HypothesisCardWithPlans — ImprovementIdeasSection (Flow 1, IM-1 re-mount)', () => { + it('renders the Improvement Ideas section when hub has ideas', () => { + renderInSvg(); + // ImprovementIdeasSection renders with data-testid keyed by hypothesisId + expect(screen.getByTestId(`ideas-section-${hub.id}`)).toBeInTheDocument(); + }); + + it('does NOT render the ImprovementIdeasSection when hub has no ideas', () => { + renderInSvg( + + ); + expect(screen.queryByTestId(`ideas-section-${hubNoIdeas.id}`)).toBeNull(); + }); + + it('section is keyed by hypothesisId (data-testid="ideas-section-")', () => { + renderInSvg(); + // The testid must contain the hub's actual id, not a generic placeholder + expect(screen.getByTestId('ideas-section-h1')).toBeInTheDocument(); + }); + + it('renders idea text for each idea in hub.ideas', () => { + renderInSvg(); + // Expand the section to make ideas visible (ImprovementIdeasSection starts open when ideas.length > 0) + expect(screen.getByText('Reduce coolant flow by 10%')).toBeInTheDocument(); + expect(screen.getByText('Switch to night-shift heat trace')).toBeInTheDocument(); + }); + + it('passes ideaImpacts down — renders impact badge for idea-1 (high)', () => { + renderInSvg(); + // ImprovementIdeasSection renders impact badges via data-testid="idea-impact-" + expect(screen.getByTestId('idea-impact-idea-1')).toBeInTheDocument(); + expect(screen.getByTestId('idea-impact-idea-1').textContent).toMatch(/high/i); + }); + + it('passes ideaImpacts down — renders impact badge for idea-2 (medium)', () => { + renderInSvg(); + expect(screen.getByTestId('idea-impact-idea-2')).toBeInTheDocument(); + expect(screen.getByTestId('idea-impact-idea-2').textContent).toMatch(/medium/i); + }); + + it('shows Add idea input when onAddIdea is wired', () => { + renderInSvg( + + ); + // ImprovementIdeasSection renders data-testid="add-idea-input-" + expect(screen.getByTestId('add-idea-input-h1')).toBeInTheDocument(); + }); + + it('does NOT show Add idea input when onAddIdea is omitted', () => { + // Without onAddIdea, the input is hidden (ImprovementIdeasSection guard) + renderInSvg( + + ); + expect(screen.queryByTestId('add-idea-input-h1')).toBeNull(); + }); +}); + +// ── ACL gate: ideas section is read-only for non-editors ─────────────────────── +// +// The Wall is per-project. A viewer who is NOT in the member roster (i.e. +// canAccess(..., 'edit-contributions') === false) must see existing ideas but +// get no data-mutating affordance — mirrors the read-for-all / write-for-editors +// pattern used by HypothesisComments and the plans/tasks zone. + +describe('HypothesisCardWithPlans — ImprovementIdeasSection ACL gate (non-member viewer)', () => { + // members populated AND current user not among them => canEdit === false + const viewerProps = () => + makeCardProps({ members: [leadMember], currentUserId: 'user-stranger' }); + + it('still renders existing ideas + impact badges for a non-member viewer (read view preserved)', () => { + renderInSvg( + + ); + expect(screen.getByText('Reduce coolant flow by 10%')).toBeInTheDocument(); + expect(screen.getByText('Switch to night-shift heat trace')).toBeInTheDocument(); + expect(screen.getByTestId('idea-impact-idea-1')).toBeInTheDocument(); + }); + + it('hides the Add idea input even when onAddIdea is wired', () => { + renderInSvg( + + ); + expect(screen.queryByTestId('add-idea-input-h1')).toBeNull(); + }); + + it('hides the Project button even when onProjectIdea is wired', () => { + renderInSvg( + + ); + expect(screen.queryByRole('button', { name: /project idea with what-if/i })).toBeNull(); + }); + + it('hides the Remove button and renders the select toggle as a non-interactive indicator', () => { + renderInSvg( + + ); + expect(screen.queryByRole('button', { name: /remove idea/i })).toBeNull(); + // The select toggle is a static (not a button) for viewers. + expect(screen.queryByRole('button', { name: /select idea|deselect idea/i })).toBeNull(); + // idea-2 is selected — its static indicator is still shown. + expect(screen.getByLabelText('Selected idea')).toBeInTheDocument(); + }); +}); + +// ── Flow 2: handleProjectIdea / onProjectIdea wired ─────────────────────────── + +describe('HypothesisCardWithPlans — onProjectIdea wiring (Flow 2, IM-1 re-mount)', () => { + it('fires onProjectIdea(hypothesisId, ideaId) when the Project button is clicked', () => { + const onProjectIdea = vi.fn(); + renderInSvg( + + ); + // ImprovementIdeasSection renders aria-label="Project idea with What-If simulator" + const projectBtns = screen.getAllByRole('button', { name: /project idea with what-if/i }); + expect(projectBtns.length).toBeGreaterThan(0); + fireEvent.click(projectBtns[0]); + // First call: hypothesisId='h1', ideaId='idea-1' (first idea) + expect(onProjectIdea).toHaveBeenCalledWith('h1', 'idea-1'); + }); + + it('passes hypothesisId and ideaId correctly for the second idea', () => { + const onProjectIdea = vi.fn(); + renderInSvg( + + ); + const projectBtns = screen.getAllByRole('button', { name: /project idea with what-if/i }); + // Second button corresponds to idea-2 + fireEvent.click(projectBtns[1]); + expect(onProjectIdea).toHaveBeenCalledWith('h1', 'idea-2'); + }); + + it('does NOT render Project button when onProjectIdea is omitted', () => { + renderInSvg( + + ); + expect(screen.queryByRole('button', { name: /project idea with what-if/i })).toBeNull(); + }); +}); + +// ── Flow 3: createHubFromFinding — DESCOPED to IM-4c ────────────────────────── +// +// The propose-hypothesis-from-finding CTA (onProposeHypothesis + +// createHubFromFinding) was removed from FindingChip in IM-4b. The apps +// explicitly defer finding→hypothesis-on-Wall to the IM-4c bipartite +// re-layout. The guard below pins the descope: FindingChip never renders a +// "Propose hypothesis" affordance. + +describe('FindingChip — propose-hypothesis affordance is descoped (IM-4c)', () => { + it('never renders a "Propose hypothesis" button', () => { + render( + + + + ); + expect(screen.queryByRole('button', { name: /propose hypothesis/i })).toBeNull(); + }); + + it('still fires onSelect on chip click (unchanged behaviour)', () => { + const onSelect = vi.fn(); + render( + + + + ); + fireEvent.click(screen.getByRole('button', { name: /finding/i })); + expect(onSelect).toHaveBeenCalledWith('f1'); + expect(onSelect).toHaveBeenCalledTimes(1); + }); +}); + +// ── Regression guard: existing ImprovementIdeasSection prop interface ─────────── + +describe('ImprovementIdeasSection — direct mount (component API shape guard)', () => { + // Import directly so we can test the component in isolation. + // The tests above verify it mounts INSIDE HypothesisCardWithPlans. + // This guard verifies the stand-alone component renders correctly with + // the props that HypothesisCardWithPlans will pass. + it('renders section container with data-testid keyed by hypothesisId', async () => { + const { default: ImprovementIdeasSection } = + await import('../../FindingsLog/ImprovementIdeasSection'); + render( + + ); + expect(screen.getByTestId('ideas-section-h-standalone')).toBeInTheDocument(); + }); + + it('renders idea text when ideas are provided', async () => { + const { default: ImprovementIdeasSection } = + await import('../../FindingsLog/ImprovementIdeasSection'); + render( + + ); + expect(screen.getByText('Reduce coolant flow by 10%')).toBeInTheDocument(); + expect(screen.getByText('Switch to night-shift heat trace')).toBeInTheDocument(); + }); + + it('renders impact badge when ideaImpacts is provided', async () => { + const { default: ImprovementIdeasSection } = + await import('../../FindingsLog/ImprovementIdeasSection'); + render( + + ); + expect(screen.getByTestId('idea-impact-idea-1')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.planCollector.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.planCollector.test.tsx new file mode 100644 index 000000000..81e518b79 --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisCardWithPlans.planCollector.test.tsx @@ -0,0 +1,372 @@ +/** + * Task 4 — HypothesisCardWithPlans: Plan-owner data-collection task surface (RED tests). + * + * Encodes the acceptance oracle: + * - The card shows MeasurementPlan.owner + status as the data-collection task, + * distinct from Task 3's ActionItems. + * - Owner name is resolved from the member roster and surfaced with a + * "Assigned: collect {primaryFactor}" label. + * - Status (planned / in-progress / complete / skipped) is shown as a labeled badge. + * - Optional dueDate, when present, is displayed next to the badge. + * - The section uses data-testid="data-collection-task" to be distinct from + * data-testid="action-item-row" (Task 3) and data-testid="chip-body" (compact chip). + * - The section is visible regardless of canEdit (it is a read-display, not write gate). + * - No data-collection-task section is rendered when plans is empty. + * - When multiple plans exist each gets its own data-collection-task section. + * + * These tests FAIL today because: + * 1. data-testid="data-collection-task" does not exist in the rendered DOM. + * 2. No "Assigned: collect" label is rendered anywhere. + * 3. MeasurementPlan has no dueDate field — the implementer adds it. + * 4. Status is only shown as an icon in the compact chip; no labeled badge in + * a dedicated "data-collection-task" section. + * + * Existing tests read and preserved (DO NOT MODIFY): + * - HypothesisCardWithPlans.test.tsx — plan rows, ACL gate, AddPlanForm, picker + * - HypothesisCardWithPlans.actionItems.test.tsx — Task 3 ActionItem rows + * - MeasurementPlanChip.test.tsx — compact chip unit coverage + */ + +// vi.mock MUST precede component imports per project convention. +vi.mock('@variscout/stores', () => ({ + useAnalyzeStore: Object.assign(vi.fn(), { + getState: () => ({ addFinding: vi.fn(() => ({ id: 'f-test' })), connectFindingToHub: vi.fn() }), + }), + usePreferencesStore: Object.assign(vi.fn(), { + getState: () => ({ timeLens: { mode: 'rolling', windowSize: 50 } }), + }), +})); + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { HypothesisCardWithPlans } from '../HypothesisCardWithPlans'; +import type { Hypothesis } from '@variscout/core'; +import type { MeasurementPlan } from '@variscout/core/measurementPlan'; +import type { ProjectMember } from '@variscout/core/projectMembership'; + +// ── fixtures ────────────────────────────────────────────────────────────────── + +const hub: Hypothesis = { + id: 'h1', + name: 'Nozzle runs hot on night shift', + synthesis: '', + findingIds: [], + status: 'proposed', + createdAt: 1_748_649_600_000, + updatedAt: 1_748_649_600_000, + deletedAt: null, + investigationId: 'inv-1', +}; + +const leadMember: ProjectMember = { + id: 'm1', + userId: 'user-lead', + displayName: 'Alice Lead', + role: 'lead', + invitedAt: 1_748_649_600_000, + createdAt: 1_748_649_600_000, + deletedAt: null, +}; + +const memberCollector: ProjectMember = { + id: 'm2', + userId: 'user-member', + displayName: 'Bob Collector', + role: 'member', + invitedAt: 1_748_649_600_000, + createdAt: 1_748_649_600_000, + deletedAt: null, +}; + +/** + * Minimal planned MeasurementPlan — no dueDate (that field is net-new for Task 4). + * The implementer adds dueDate?: string to MeasurementPlan. + */ +const plannedPlan: MeasurementPlan = { + id: 'mp-1', + createdAt: 1_748_649_600_000, + deletedAt: null, + hypothesisId: 'h1', + outcome: 'Fill Weight (g)', + primaryFactor: 'Nozzle temperature', + neededFactors: [], + method: 'sensor', + sampleSize: 30, + owner: 'm1', + status: 'planned', + scope: [], + processLocation: '', +}; + +const inProgressPlan: MeasurementPlan = { + ...plannedPlan, + id: 'mp-2', + status: 'in-progress', + owner: 'm2', + primaryFactor: 'Coolant flow rate', + // dueDate is added by the implementer — tested conditionally below +}; + +const completePlan: MeasurementPlan = { + ...plannedPlan, + id: 'mp-3', + status: 'complete', + owner: 'm1', + primaryFactor: 'Vibration amplitude', +}; + +const skippedPlan: MeasurementPlan = { + ...plannedPlan, + id: 'mp-4', + status: 'skipped', + owner: 'm2', + primaryFactor: 'Ambient humidity', +}; + +/** Plan with a dueDate — the implementer adds this field to MeasurementPlan. */ +const planWithDue: MeasurementPlan & { dueDate?: string } = { + ...plannedPlan, + id: 'mp-5', + primaryFactor: 'Spindle RPM', + dueDate: '2026-06-30', +}; + +function renderInSvg(ui: React.ReactElement) { + return render({ui}); +} + +/** Shared base props reused across tests. */ +function baseProps( + plans: MeasurementPlan[], + overrides?: Partial> +): React.ComponentProps { + return { + hub, + displayStatus: 'proposed', + x: 0, + y: 0, + plans, + members: [leadMember, memberCollector], + currentUserId: 'user-lead', + findings: [], + onAddPlan: vi.fn(), + onLinkFinding: vi.fn(), + onEditPlan: vi.fn(), + ...overrides, + }; +} + +// ── data-collection-task section presence ───────────────────────────────────── + +describe('HypothesisCardWithPlans — data-collection task section presence', () => { + it('renders one data-collection-task section per plan', () => { + renderInSvg(); + const sections = document.querySelectorAll('[data-testid="data-collection-task"]'); + expect(sections.length).toBe(2); + }); + + it('renders no data-collection-task section when plans is empty', () => { + renderInSvg(); + expect(document.querySelector('[data-testid="data-collection-task"]')).toBeNull(); + }); + + it('renders a single section for a single plan', () => { + renderInSvg(); + const sections = document.querySelectorAll('[data-testid="data-collection-task"]'); + expect(sections.length).toBe(1); + }); +}); + +// ── "Assigned: collect X" label ────────────────────────────────────────────── + +describe('HypothesisCardWithPlans — Assigned: collect {primaryFactor} label', () => { + it('shows "Assigned: collect {primaryFactor}" for a planned plan', () => { + renderInSvg(); + // The label should read approximately "Assigned: collect Nozzle temperature" + // (the exact phrasing is an i18n key — match on the primaryFactor name) + const section = document.querySelector('[data-testid="data-collection-task"]'); + expect(section).not.toBeNull(); + // The section body must contain the primaryFactor prominently + expect(section!.textContent).toMatch(/Nozzle temperature/i); + }); + + it('shows an "Assigned" or "collect" heading to label the task surface', () => { + renderInSvg(); + // The card must render text that signals this is an assignment / collection task, + // not merely a compact chip. Accept "Assigned", "collect", or an i18n variant. + // Use a broad matcher; the exact copy is the implementer's choice. + const section = document.querySelector('[data-testid="data-collection-task"]'); + expect(section!.textContent).toMatch(/assign|collect/i); + }); + + it('uses the correct primaryFactor for each plan when multiple plans exist', () => { + renderInSvg(); + const sections = document.querySelectorAll('[data-testid="data-collection-task"]'); + const texts = Array.from(sections).map(s => s.textContent ?? ''); + expect(texts.some(t => /Nozzle temperature/i.test(t))).toBe(true); + expect(texts.some(t => /Coolant flow rate/i.test(t))).toBe(true); + }); +}); + +// ── Owner name surfaced prominently ─────────────────────────────────────────── + +describe('HypothesisCardWithPlans — owner name in data-collection task section', () => { + it('shows the resolved owner displayName in the data-collection task section', () => { + renderInSvg(); + const section = document.querySelector('[data-testid="data-collection-task"]'); + // plannedPlan.owner = 'm1' → 'Alice Lead' + expect(section!.textContent).toMatch(/Alice Lead/i); + }); + + it('shows collector member (member role) displayName for an in-progress plan', () => { + renderInSvg(); + const section = document.querySelector('[data-testid="data-collection-task"]'); + // inProgressPlan.owner = 'm2' → 'Bob Collector' + expect(section!.textContent).toMatch(/Bob Collector/i); + }); + + it('falls back to "(unknown)" when the owner id is not in members', () => { + const orphanPlan: MeasurementPlan = { ...plannedPlan, owner: 'nobody' }; + renderInSvg(); + const section = document.querySelector('[data-testid="data-collection-task"]'); + expect(section!.textContent).toMatch(/unknown/i); + }); +}); + +// ── Status label ───────────────────────────────────────────────────────────── + +describe('HypothesisCardWithPlans — status label in data-collection task section', () => { + it.each([ + ['planned', plannedPlan], + ['in-progress', inProgressPlan], + ['complete', completePlan], + ['skipped', skippedPlan], + ] as const)('shows status "%s" in the data-collection task section', (status, plan) => { + renderInSvg(); + const section = document.querySelector('[data-testid="data-collection-task"]'); + // The section must expose the status string or a human-readable equivalent. + // Match on the raw status value OR a label matching it (e.g. "In progress", "Complete"). + expect(section!.textContent).toMatch(new RegExp(status.replace('-', '.?'), 'i')); + }); + + it('exposes the plan status via data-status attribute on the section or a child', () => { + renderInSvg(); + const section = document.querySelector('[data-testid="data-collection-task"]'); + // The implementer should expose status for automated tests and screen readers + // via a data-status attribute (mirrors the action-item-row pattern from Task 3). + const withStatus = + section?.querySelector('[data-status]') ?? + (section?.hasAttribute('data-status') ? section : null); + expect(withStatus).not.toBeNull(); + const statusValue = (withStatus as HTMLElement).getAttribute('data-status'); + expect(statusValue).toBe('in-progress'); + }); +}); + +// ── Due date ───────────────────────────────────────────────────────────────── + +describe('HypothesisCardWithPlans — due date in data-collection task section', () => { + it('displays the dueDate when present on the plan', () => { + // planWithDue has dueDate '2026-06-30' (field added by the implementer) + renderInSvg(); + const section = document.querySelector('[data-testid="data-collection-task"]'); + // The section should contain some representation of the due date. + // Accept ISO (2026-06-30), locale-formatted (Jun 30), or "due" label text. + expect(section!.textContent).toMatch(/2026.06.30|Jun.+30|30.+Jun|due/i); + }); + + it('does not render a due date when dueDate is absent', () => { + // plannedPlan has no dueDate + renderInSvg(); + const section = document.querySelector('[data-testid="data-collection-task"]'); + // Should not show a stale date string or a "Due:" label with no value + expect(section!.textContent).not.toMatch(/Due:\s*$/i); + }); +}); + +// ── Distinct from Task 3 ActionItems ───────────────────────────────────────── + +describe('HypothesisCardWithPlans — data-collection task section is distinct from action-item-row', () => { + it('data-collection-task and action-item-row coexist independently', () => { + const hubWithAction: Hypothesis = { + ...hub, + actions: [ + { + id: 'ai-1', + text: '@Jane: validate against night-shift data', + createdAt: 1_748_649_600_000, + deletedAt: null, + }, + ], + }; + renderInSvg(); + // Both section types are rendered simultaneously + expect(document.querySelector('[data-testid="data-collection-task"]')).not.toBeNull(); + expect(document.querySelector('[data-testid="action-item-row"]')).not.toBeNull(); + }); + + it('data-collection-task section does NOT use data-testid="action-item-row"', () => { + renderInSvg(); + // Ensure the plan section has its own identity, not reusing the action row testid + const section = document.querySelector('[data-testid="data-collection-task"]'); + expect(section).not.toBeNull(); + // The data-collection-task section must not itself carry data-testid="action-item-row" + expect(section!.getAttribute('data-testid')).not.toBe('action-item-row'); + // And there must be no action-item-row elements from a plan (actions = absent) + expect(document.querySelectorAll('[data-testid="action-item-row"]').length).toBe(0); + }); + + it('data-collection-task section is separate from the compact chip-body row', () => { + renderInSvg(); + // The compact chip-body (MeasurementPlanChip) may or may not still be rendered, + // but the data-collection-task section is a separate, prominent element. + const collectorSection = document.querySelector('[data-testid="data-collection-task"]'); + expect(collectorSection).not.toBeNull(); + // The section itself must not be identified as "chip-body" + expect(collectorSection!.getAttribute('data-testid')).not.toBe('chip-body'); + }); +}); + +// ── Visibility gating (read-display, not write gate) ───────────────────────── + +describe('HypothesisCardWithPlans — data-collection task section visibility (no write gate)', () => { + it('shows the data-collection-task section even when canEdit is false (non-member)', () => { + // The data-collection task surface is a READ display — it shows the assigned + // collector to anyone viewing the card, not gated behind edit-contributions. + renderInSvg( + + ); + expect(document.querySelector('[data-testid="data-collection-task"]')).not.toBeNull(); + }); + + it('shows the data-collection-task section when members is empty (open-access)', () => { + renderInSvg( + + ); + expect(document.querySelector('[data-testid="data-collection-task"]')).not.toBeNull(); + }); + + it('shows the data-collection-task section when currentUserId is null', () => { + renderInSvg( + + ); + expect(document.querySelector('[data-testid="data-collection-task"]')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisComments.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisComments.test.tsx new file mode 100644 index 000000000..f897b8b21 --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/__tests__/HypothesisComments.test.tsx @@ -0,0 +1,283 @@ +/** + * HypothesisComments — task 1 failing tests (RED). + * + * Acceptance: + * - Hypothesis card renders a comment thread bound to hub.comments. + * - An edit-contributions member can ADD/EDIT/DELETE a comment via a human composer. + * - The composer is hidden when canAccess(…, 'edit-contributions') is false. + * - Existing FindingComments tests stay green (untouched). + * + * Reuse strategy: HypothesisComments lifts/parameterizes FindingComments, + * binding hub.comments as the comment list and addHubComment/editHubComment/ + * deleteHubComment as the write callbacks. + * + * vi.mock() calls MUST be hoisted before any production imports (Vitest + * hoists vi.mock to the top of the module regardless of textual position, + * but to be safe we group them at the top before the non-mock imports). + * + * HypothesisComments wraps FindingComments → FindingEditor, which imports + * `useTranslation` from @variscout/hooks (the `finding.note` aria-label below + * depends on the identity `t` mock) and lucide-react icons. Those two mocks + * are load-bearing. It does NOT import @variscout/stores — no store mock needed. + */ + +vi.mock('lucide-react', () => ({ + MessageSquare: (props: Record) => ( + + ), + Pencil: (props: Record) => , + Trash2: (props: Record) => , + Camera: (props: Record) => , + Loader2: (props: Record) => , + ImageIcon: (props: Record) => , + Paperclip: (props: Record) => , + FileText: (props: Record) => , + X: (props: Record) => , + Mic: (props: Record) => , +})); + +vi.mock('@variscout/hooks', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + locale: 'en-US', + }), +})); + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { Hypothesis, FindingComment } from '@variscout/core'; +import type { ProjectMember } from '@variscout/core/projectMembership'; + +// ── The component under test — does NOT exist yet (RED) ─────────────────────── +// HypothesisComments: lifts FindingComments and binds to hub.comments + +// addHubComment/editHubComment/deleteHubComment. Renders inside a normal div +// context (not SVG), as it lives in the foreignObject extension zone. +import { HypothesisComments } from '../HypothesisComments'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const leadMember: ProjectMember = { + id: 'm1', + userId: 'user-lead', + displayName: 'Alice Lead', + role: 'lead', + invitedAt: 1, + createdAt: 1, + deletedAt: null, +}; + +const memberMember: ProjectMember = { + id: 'm2', + userId: 'user-member', + displayName: 'Bob Member', + role: 'member', + invitedAt: 1, + createdAt: 1, + deletedAt: null, +}; + +const sponsorMember: ProjectMember = { + id: 'm3', + userId: 'user-sponsor', + displayName: 'Carol Sponsor', + role: 'sponsor', + invitedAt: 1, + createdAt: 1, + deletedAt: null, +}; + +const existingComment: FindingComment = { + id: 'comment-1', + text: 'First team comment', + createdAt: 1_000_000, + deletedAt: null, + parentId: 'h1', + parentKind: 'hypothesis', + author: 'Alice Lead', +}; + +const hub: Hypothesis = { + id: 'h1', + name: 'Nozzle runs hot on night shift', + synthesis: '', + findingIds: ['f1'], + status: 'proposed', + createdAt: 1, + updatedAt: 1, + deletedAt: null, + investigationId: 'inv-1', + comments: [existingComment], +}; + +const hubNoComments: Hypothesis = { + ...hub, + id: 'h2', + comments: [], +}; + +// ── Helper ───────────────────────────────────────────────────────────────────── + +type Props = React.ComponentProps; + +function makeProps(overrides: Partial = {}): Props { + return { + hub: hub, + members: [leadMember, memberMember, sponsorMember], + currentUserId: 'user-lead', + onAdd: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + ...overrides, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('HypothesisComments — thread rendering', () => { + it('renders a comment count toggle when hub has comments', () => { + render(); + // FindingComments renders "N comments" toggle button + expect(screen.getByText(/1 comment/i)).toBeInTheDocument(); + }); + + it('renders "Add comment" when hub has no comments', () => { + render(); + expect(screen.getByText(/Add comment/i)).toBeInTheDocument(); + }); + + it('expands the thread on toggle click, showing existing comment text', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByText('First team comment')).toBeInTheDocument(); + }); + + it('shows author name when showAuthors is true', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByText('Alice Lead')).toBeInTheDocument(); + }); + + it('renders all comments when hub has multiple', () => { + const secondComment: FindingComment = { + id: 'comment-2', + text: 'Second team comment', + createdAt: 2_000_000, + deletedAt: null, + parentId: 'h1', + parentKind: 'hypothesis', + }; + const hubMulti: Hypothesis = { ...hub, comments: [existingComment, secondComment] }; + render(); + fireEvent.click(screen.getByText(/2 comments/i)); + expect(screen.getByText('First team comment')).toBeInTheDocument(); + expect(screen.getByText('Second team comment')).toBeInTheDocument(); + }); +}); + +describe('HypothesisComments — human composer (ACL-gated add)', () => { + it('shows "+ Add comment" button when canEdit (lead member)', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByText('+ Add comment')).toBeInTheDocument(); + }); + + it('shows "+ Add comment" button when canEdit (member role)', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByText('+ Add comment')).toBeInTheDocument(); + }); + + it('shows "+ Add comment" button when canEdit (sponsor role)', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByText('+ Add comment')).toBeInTheDocument(); + }); + + it('hides the composer completely when user is not in members (non-empty members)', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.queryByText('+ Add comment')).toBeNull(); + }); + + it('shows the composer when members is empty (open-access escape)', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByText('+ Add comment')).toBeInTheDocument(); + }); + + it('shows the composer when currentUserId is null and members is empty', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByText('+ Add comment')).toBeInTheDocument(); + }); + + it('calls onAdd with hubId + text when a comment is submitted', () => { + const onAdd = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText(/Add comment/i)); + fireEvent.click(screen.getByText('+ Add comment')); + + // FindingEditor uses aria-label 'finding.note' + const textarea = screen.getByLabelText('finding.note'); + fireEvent.change(textarea, { target: { value: 'Night shift observation' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(onAdd).toHaveBeenCalledWith('h2', 'Night shift observation', undefined); + }); +}); + +describe('HypothesisComments — edit (ACL-gated)', () => { + it('shows the edit button on hover when canEdit is true', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + // Edit button is present (opacity-0 on idle, but rendered in DOM) + expect(screen.getByLabelText('Edit comment')).toBeInTheDocument(); + }); + + it('does NOT render the edit button when canEdit is false', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.queryByLabelText('Edit comment')).toBeNull(); + }); + + it('calls onEdit with hubId + commentId + new text when the edit is saved', () => { + const onEdit = vi.fn(); + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + + fireEvent.click(screen.getByLabelText('Edit comment')); + + const textarea = screen.getByLabelText('finding.note'); + fireEvent.change(textarea, { target: { value: 'Edited team comment' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(onEdit).toHaveBeenCalledWith('h1', 'comment-1', 'Edited team comment'); + }); +}); + +describe('HypothesisComments — delete (ACL-gated)', () => { + it('shows the delete button when canEdit is true', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.getByLabelText('Delete comment')).toBeInTheDocument(); + }); + + it('does NOT render the delete button when canEdit is false', () => { + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + expect(screen.queryByLabelText('Delete comment')).toBeNull(); + }); + + it('calls onDelete with hubId + commentId when the delete button is clicked', () => { + const onDelete = vi.fn(); + render(); + fireEvent.click(screen.getByText(/1 comment/i)); + fireEvent.click(screen.getByLabelText('Delete comment')); + expect(onDelete).toHaveBeenCalledWith('h1', 'comment-1'); + }); +}); diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/ScopeRail.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/ScopeRail.test.tsx new file mode 100644 index 000000000..670304b90 --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/__tests__/ScopeRail.test.tsx @@ -0,0 +1,209 @@ +/** + * Task 5 — ScopeRail: Multi-scope rail (RED tests). + * + * Encodes the acceptance oracle: + * - The persisted ProblemStatementScopes for the investigation are listed in + * a rail/breadcrumb; switching re-anchors the Wall Problem card to that scope + * (via IM-4a active-scope selection via onScopeSelect); SCOPE_ARCHIVE prunes + * a scope (onScopeArchive); deterministic (no wall-clock / RNG). + * + * Reuse: ProblemStatementScope from @variscout/core + formatConditionLeaves to + * render the WHERE label. analyzeStore.archiveScope is the store-level owner of + * the SCOPE_ARCHIVE side-effect (separate store-level tests in analyzeStore.test.ts). + * + * ScopeRail imports only @variscout/core + the local useWallLocale hook — it does + * NOT import @variscout/stores or @variscout/hooks, so no module mocks are needed. + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ProblemStatementScope } from '@variscout/core'; + +// ── The component under test — does NOT exist yet (RED) ─────────────────────── +// ScopeRail: a horizontal breadcrumb/tab rail that lists persisted +// ProblemStatementScopes. Clicking a chip calls onScopeSelect(scope.id). +// An archive button on each chip calls onScopeArchive(scope.id). +// The active scope (activeScopeId) receives an aria-current="true" marker. +import { ScopeRail } from '../ScopeRail'; + +// ── Fixtures — deterministic (no Date.now(), no Math.random()) ─────────────── + +const scopeA: ProblemStatementScope = { + id: 'scope-a', + investigationId: 'inv-1', + outcome: 'lead_time', + predicates: [{ kind: 'leaf', column: 'Machine', op: 'eq', value: 'B' }], + hypothesisIds: [], + createdAt: 1_748_649_600_000, + updatedAt: 1_748_649_600_000, + deletedAt: null, +}; + +const scopeB: ProblemStatementScope = { + id: 'scope-b', + investigationId: 'inv-1', + outcome: 'lead_time', + predicates: [ + { kind: 'leaf', column: 'Machine', op: 'eq', value: 'B' }, + { kind: 'leaf', column: 'Product', op: 'eq', value: 'X' }, + ], + hypothesisIds: ['h-1'], + createdAt: 1_748_649_601_000, + updatedAt: 1_748_649_601_000, + deletedAt: null, +}; + +const scopeC: ProblemStatementScope = { + id: 'scope-c', + investigationId: 'inv-1', + outcome: 'defect_rate', + predicates: [{ kind: 'leaf', column: 'Shift', op: 'eq', value: 'Night' }], + hypothesisIds: [], + createdAt: 1_748_649_602_000, + updatedAt: 1_748_649_602_000, + deletedAt: null, +}; + +// ── Helper ──────────────────────────────────────────────────────────────────── + +type Props = React.ComponentProps; + +function makeProps(overrides: Partial = {}): Props { + return { + scopes: [scopeA, scopeB], + activeScopeId: scopeA.id, + onScopeSelect: vi.fn(), + onScopeArchive: vi.fn(), + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ScopeRail — scope listing', () => { + it('renders one chip per scope', () => { + render(); + // scopeA: "Machine = B" + expect(screen.getByTestId('scope-chip-scope-a')).toBeInTheDocument(); + // scopeB: "Machine = B ∩ Product = X" + expect(screen.getByTestId('scope-chip-scope-b')).toBeInTheDocument(); + }); + + it('renders the scope condition text from formatConditionLeaves', () => { + render(); + // formatConditionLeaves([{ kind:'leaf', column:'Machine', op:'eq', value:'B' }]) + // → "Machine = B" + expect(screen.getByTestId('scope-chip-scope-a')).toHaveTextContent('Machine = B'); + }); + + it('renders a compound condition joined by ∩', () => { + render(); + // scopeB has two predicates: "Machine = B ∩ Product = X" + expect(screen.getByTestId('scope-chip-scope-b')).toHaveTextContent('Machine = B'); + expect(screen.getByTestId('scope-chip-scope-b')).toHaveTextContent('Product = X'); + }); + + it('renders nothing (or an empty container) when scopes is empty', () => { + render(); + expect(screen.queryByTestId(/scope-chip-/)).toBeNull(); + }); + + it('renders three scopes when three are provided', () => { + render(); + expect(screen.getByTestId('scope-chip-scope-a')).toBeInTheDocument(); + expect(screen.getByTestId('scope-chip-scope-b')).toBeInTheDocument(); + expect(screen.getByTestId('scope-chip-scope-c')).toBeInTheDocument(); + }); +}); + +describe('ScopeRail — active scope highlighting', () => { + it('marks the active scope chip with aria-current="true"', () => { + render(); + expect(screen.getByTestId('scope-chip-scope-a')).toHaveAttribute('aria-current', 'true'); + }); + + it('does NOT mark the inactive scope chip with aria-current', () => { + render(); + const chipB = screen.getByTestId('scope-chip-scope-b'); + // Must not be marked active. + expect(chipB).not.toHaveAttribute('aria-current', 'true'); + }); + + it('switches the aria-current marker when activeScopeId changes', () => { + const { rerender } = render(); + expect(screen.getByTestId('scope-chip-scope-a')).toHaveAttribute('aria-current', 'true'); + + rerender(); + expect(screen.getByTestId('scope-chip-scope-b')).toHaveAttribute('aria-current', 'true'); + expect(screen.getByTestId('scope-chip-scope-a')).not.toHaveAttribute('aria-current', 'true'); + }); + + it('marks no chip active when activeScopeId is undefined', () => { + render(); + expect(screen.getByTestId('scope-chip-scope-a')).not.toHaveAttribute('aria-current', 'true'); + expect(screen.getByTestId('scope-chip-scope-b')).not.toHaveAttribute('aria-current', 'true'); + }); +}); + +describe('ScopeRail — scope switching (re-anchors the Problem card)', () => { + it('calls onScopeSelect with the clicked scope id', () => { + const onScopeSelect = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('scope-chip-scope-b')); + expect(onScopeSelect).toHaveBeenCalledWith('scope-b'); + expect(onScopeSelect).toHaveBeenCalledTimes(1); + }); + + it('calls onScopeSelect with the correct id for each chip independently', () => { + const onScopeSelect = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('scope-chip-scope-c')); + expect(onScopeSelect).toHaveBeenCalledWith('scope-c'); + }); + + it('does NOT call onScopeSelect when the already-active scope is clicked', () => { + // The active scope chip click MAY be a no-op — verify the callback contract. + // The implementer may or may not guard this; the key is the selection event + // fires correctly for non-active chips. + const onScopeSelect = vi.fn(); + render(); + // Click the NON-active chip — must fire. + fireEvent.click(screen.getByTestId('scope-chip-scope-a')); + expect(onScopeSelect).toHaveBeenCalledWith('scope-a'); + }); +}); + +describe('ScopeRail — scope archive (SCOPE_ARCHIVE prunes)', () => { + it('renders an archive button for each scope chip', () => { + render(); + // Each chip has a dedicated archive/remove affordance. + expect(screen.getByTestId('scope-archive-scope-a')).toBeInTheDocument(); + expect(screen.getByTestId('scope-archive-scope-b')).toBeInTheDocument(); + }); + + it('calls onScopeArchive with the correct scope id when archive is clicked', () => { + const onScopeArchive = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('scope-archive-scope-a')); + expect(onScopeArchive).toHaveBeenCalledWith('scope-a'); + expect(onScopeArchive).toHaveBeenCalledTimes(1); + }); + + it('does NOT fire onScopeSelect when archive button is clicked', () => { + const onScopeSelect = vi.fn(); + const onScopeArchive = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('scope-archive-scope-b')); + expect(onScopeArchive).toHaveBeenCalledWith('scope-b'); + expect(onScopeSelect).not.toHaveBeenCalled(); + }); + + it('calls onScopeArchive for each scope independently', () => { + const onScopeArchive = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('scope-archive-scope-b')); + expect(onScopeArchive).toHaveBeenCalledWith('scope-b'); + expect(onScopeArchive).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/WallCanvas.collab.seam.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/WallCanvas.collab.seam.test.tsx new file mode 100644 index 000000000..3cdfbcecd --- /dev/null +++ b/packages/ui/src/components/AnalyzeWall/__tests__/WallCanvas.collab.seam.test.tsx @@ -0,0 +1,286 @@ +/** + * WallCanvas collaboration SEAM tests (IM-4b). + * + * These are NOT injected-prop unit tests of the leaf components — they render + * the PRODUCTION `WallCanvas` with a hub + members + a full `planningProps` bag + * and assert the collaboration affordances actually RENDER + DISPATCH through + * the production seam: + * + * WallCanvas → hubPlanningProps builder → HypothesisCardWithPlans + * → HypothesisComments (comment thread) + * → ActionItem '+ Add Task' rows + * → ImprovementIdeasSection + * + * The adversarial review found these features GREEN-but-DEAD: the leaf unit + * tests passed but `WallCanvas` forwarded 0 of the new callbacks, so nothing + * rendered on the production path. These tests pin the seam. + * + * FindingComments (wrapped by HypothesisComments) pulls `useTranslation` from + * @variscout/hooks + lucide-react icons. We partial-mock @variscout/hooks so + * WallCanvas's own real `useCanvasViewportInput` stays intact, and stub lucide + * (mirrors the FindingComments unit-test setup). + */ + +vi.mock('lucide-react', () => ({ + MessageSquare: (props: Record) => ( + + ), + Pencil: (props: Record) => , + Trash2: (props: Record) => , + Camera: (props: Record) => , + Loader2: (props: Record) => , + ImageIcon: (props: Record) => , + Paperclip: (props: Record) => , + FileText: (props: Record) => , + X: (props: Record) => , + Mic: (props: Record) => , +})); + +vi.mock('@variscout/hooks', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }), + }; +}); + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { WallCanvas, type WallCanvasPlanningProps } from '../WallCanvas'; +import { getCanvasViewportInitialState, useCanvasViewportStore } from '@variscout/stores'; +import type { Hypothesis, ProcessMap, ActionItem, ImprovementIdea } from '@variscout/core'; +import type { ProjectMember } from '@variscout/core/projectMembership'; + +const processMap: ProcessMap = { + version: 1, + nodes: [{ id: 'n1', name: 'Fill', order: 0 }], + tributaries: [{ id: 't1', stepId: 'n1', column: 'SHIFT' }], + ctsColumn: 'FILL', + createdAt: '2026-05-09T00:00:00.000Z', + updatedAt: '2026-05-09T00:00:00.000Z', +}; + +const leadMember: ProjectMember = { + id: 'm1', + userId: 'user-lead', + displayName: 'Alice Lead', + role: 'lead', + invitedAt: 1, + createdAt: 1, + deletedAt: null, +}; + +const openAction: ActionItem = { + id: 'ai-1', + text: 'Validate night-shift data', + createdAt: 1, + deletedAt: null, +}; + +const idea: ImprovementIdea = { + id: 'idea-1', + text: 'Reduce coolant flow by 10%', + createdAt: 1, + deletedAt: null, +}; + +const baseHub: Hypothesis = { + id: 'h1', + name: 'Nozzle runs hot', + synthesis: '', + findingIds: [], + status: 'proposed', + createdAt: 1, + updatedAt: 1, + deletedAt: null, + investigationId: 'inv-test', +}; + +/** + * Build a full planningProps bag with vi.fn() callbacks so each test can assert + * the production callback fires. `members: []` → open-access so the ACL gate + * never hides the composer (the gate itself is unit-tested in HypothesisComments). + */ +function makePlanningProps( + overrides: Partial = {} +): WallCanvasPlanningProps { + return { + plans: [], + members: [], + currentUserId: null, + onAddPlan: vi.fn(), + onLinkFinding: vi.fn(), + onEditPlan: vi.fn(), + onAddHubComment: vi.fn(), + onEditHubComment: vi.fn(), + onDeleteHubComment: vi.fn(), + onAddHypothesisAction: vi.fn(), + onCompleteHypothesisAction: vi.fn(), + ideaImpacts: {}, + onProjectIdea: vi.fn(), + onAddIdea: vi.fn(), + onUpdateIdea: vi.fn(), + onRemoveIdea: vi.fn(), + onSelectIdea: vi.fn(), + ...overrides, + }; +} + +function renderWall(hub: Hypothesis, planningProps: WallCanvasPlanningProps) { + return render( + + ); +} + +beforeEach(() => { + useCanvasViewportStore.setState(getCanvasViewportInitialState()); +}); + +// ── Comment thread (Task 1) ─────────────────────────────────────────────────── + +describe('WallCanvas seam — comment thread renders + dispatches through production path', () => { + it('mounts HypothesisComments on the hub card when onAddHubComment is wired', () => { + const hub: Hypothesis = { + ...baseHub, + comments: [ + { + id: 'c1', + text: 'First team note', + createdAt: 1_000, + deletedAt: null, + parentId: 'h1', + parentKind: 'hypothesis', + }, + ], + }; + const { container } = renderWall(hub, makePlanningProps()); + // The comments foreignObject is mounted on the production path… + expect(container.querySelector('[data-testid="comments-fo-h1"]')).toBeTruthy(); + // …and the thread toggle renders the existing comment count. + expect(screen.getByText(/1 comment/i)).toBeInTheDocument(); + }); + + it('does NOT mount the comment thread when onAddHubComment is omitted', () => { + const { container } = renderWall( + { ...baseHub, comments: [] }, + makePlanningProps({ onAddHubComment: undefined }) + ); + expect(container.querySelector('[data-testid="comments-fo-h1"]')).toBeNull(); + }); + + it('adding a comment fires onAddHubComment through the production callback', () => { + const onAddHubComment = vi.fn(); + renderWall({ ...baseHub, comments: [] }, makePlanningProps({ onAddHubComment })); + + // Open the composer (FindingComments empty state → "Add comment" → "+ Add comment"). + fireEvent.click(screen.getByText(/Add comment/i)); + fireEvent.click(screen.getByText('+ Add comment')); + const textarea = screen.getByLabelText('finding.note'); + fireEvent.change(textarea, { target: { value: 'Night-shift spike' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(onAddHubComment).toHaveBeenCalledWith('h1', 'Night-shift spike', undefined); + }); +}); + +// ── ActionItem tasks (Task 3) ────────────────────────────────────────────────── + +describe('WallCanvas seam — ActionItem "+ Add Task" renders + dispatches', () => { + it('renders existing action rows on the card via the production path', () => { + const { container } = renderWall({ ...baseHub, actions: [openAction] }, makePlanningProps()); + const rows = container.querySelectorAll('[data-testid="action-item-row"]'); + expect(rows.length).toBe(1); + expect(screen.getByText('Validate night-shift data')).toBeInTheDocument(); + }); + + it('clicking "+ Add Task" + saving fires onAddHypothesisAction', () => { + // The card renders via getMessage (core i18n), so labels are the real + // English strings — not message keys. + const onAddHypothesisAction = vi.fn(); + renderWall({ ...baseHub, actions: [] }, makePlanningProps({ onAddHypothesisAction })); + + fireEvent.click(screen.getByLabelText('+ Add Task')); + const input = screen.getByLabelText('Task description'); + fireEvent.change(input, { target: { value: 'Collect 30 samples' } }); + fireEvent.click(screen.getByText('Save')); + + expect(onAddHypothesisAction).toHaveBeenCalledWith('h1', 'Collect 30 samples'); + }); + + it('marking an open task done fires onCompleteHypothesisAction', () => { + const onCompleteHypothesisAction = vi.fn(); + renderWall( + { ...baseHub, actions: [openAction] }, + makePlanningProps({ onCompleteHypothesisAction }) + ); + fireEvent.click(screen.getByLabelText('Mark Done')); + expect(onCompleteHypothesisAction).toHaveBeenCalledWith('h1', 'ai-1'); + }); + + it('does NOT render "+ Add Task" when onAddHypothesisAction is omitted', () => { + renderWall( + { ...baseHub, actions: [] }, + makePlanningProps({ onAddHypothesisAction: undefined }) + ); + expect(screen.queryByLabelText('+ Add Task')).toBeNull(); + }); +}); + +// ── ImprovementIdeasSection (Task 6) ─────────────────────────────────────────── + +describe('WallCanvas seam — ImprovementIdeasSection renders + dispatches', () => { + it('mounts the ideas section on the card when the hub has ideas + ideaImpacts is wired', () => { + const { container } = renderWall({ ...baseHub, ideas: [idea] }, makePlanningProps()); + const fo = container.querySelector('[data-testid="ideas-fo-h1"]'); + expect(fo).toBeTruthy(); + expect(within(fo as HTMLElement).getByText('Reduce coolant flow by 10%')).toBeInTheDocument(); + }); + + it('does NOT mount the ideas section when the hub has no ideas', () => { + const { container } = renderWall({ ...baseHub, ideas: [] }, makePlanningProps()); + expect(container.querySelector('[data-testid="ideas-fo-h1"]')).toBeNull(); + }); +}); + +// ── Data-collection-task header (Task 4 — {primaryFactor} fix) ────────────────── + +describe('WallCanvas seam — data-collection task header renders the primaryFactor', () => { + it('renders "collect " (not a blanked placeholder)', () => { + const { container } = renderWall( + baseHub, + makePlanningProps({ + plans: [ + { + id: 'p1', + hypothesisId: 'h1', + outcome: 'Fill Weight', + primaryFactor: 'Nozzle Temp', + neededFactors: [], + method: 'sensor', + sampleSize: 30, + owner: 'user-lead', + status: 'planned', + scope: [], + processLocation: '', + createdAt: 1, + deletedAt: null, + }, + ], + members: [leadMember], + currentUserId: 'user-lead', + }) + ); + const section = container.querySelector('[data-testid="data-collection-task"]'); + expect(section).toBeTruthy(); + // The header interpolates the real factor — not 'Assigned: collect ' with a blank. + expect(section!.textContent).toMatch(/Nozzle Temp/); + }); +}); diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/WallCanvas.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/WallCanvas.test.tsx index 5525234f4..93f94f2aa 100644 --- a/packages/ui/src/components/AnalyzeWall/__tests__/WallCanvas.test.tsx +++ b/packages/ui/src/components/AnalyzeWall/__tests__/WallCanvas.test.tsx @@ -879,8 +879,11 @@ describe('WallCanvas', () => { }} /> ); - // The plan chip for 'Nozzle Temp' should appear exactly once (on h1 only) - expect(screen.getAllByText(/Nozzle Temp/i).length).toBe(1); + // The plan for 'Nozzle Temp' belongs to h1 only. IM-4b renders the factor + // in BOTH the data-collection-task header ("collect Nozzle Temp") AND the + // embedded chip, so it appears exactly twice — and only under h1 (h2 has + // no matching plan, so 0 there → 2 total, proving per-hypothesis filtering). + expect(screen.getAllByText(/Nozzle Temp/i).length).toBe(2); }); it('filters out soft-deleted plans (deletedAt !== null) — deleted plan chip absent', () => { @@ -911,8 +914,9 @@ describe('WallCanvas', () => { }} /> ); - // Active plan chip visible; deleted plan chip not visible - expect(screen.getByText(/ActiveFactor/i)).toBeInTheDocument(); + // Active plan visible (factor appears in both the collect-task header and + // the chip — IM-4b); deleted plan absent entirely. + expect(screen.getAllByText(/ActiveFactor/i).length).toBeGreaterThan(0); expect(screen.queryByText(/DeletedFactor/i)).not.toBeInTheDocument(); }); diff --git a/packages/ui/src/components/AnalyzeWall/index.ts b/packages/ui/src/components/AnalyzeWall/index.ts index ae29888a5..85cf52ac7 100644 --- a/packages/ui/src/components/AnalyzeWall/index.ts +++ b/packages/ui/src/components/AnalyzeWall/index.ts @@ -56,3 +56,7 @@ export { LinkFindingPicker } from './LinkFindingPicker'; export type { LinkFindingPickerProps } from './LinkFindingPicker'; export { HypothesisCardWithPlans } from './HypothesisCardWithPlans'; export type { HypothesisCardWithPlansProps } from './HypothesisCardWithPlans'; +export { HypothesisComments } from './HypothesisComments'; +export type { HypothesisCommentsProps } from './HypothesisComments'; +export { ScopeRail } from './ScopeRail'; +export type { ScopeRailProps } from './ScopeRail'; diff --git a/packages/ui/src/components/FindingsLog/FindingComments.tsx b/packages/ui/src/components/FindingsLog/FindingComments.tsx index f6a5c6990..de17dd43c 100644 --- a/packages/ui/src/components/FindingsLog/FindingComments.tsx +++ b/packages/ui/src/components/FindingsLog/FindingComments.tsx @@ -29,6 +29,12 @@ export interface FindingCommentsProps { showAuthors?: boolean; /** Optional Azure-only voice input that transcribes into the comment draft */ voiceInput?: VoiceInputConfig; + /** + * Controls whether add/edit/delete affordances are rendered. + * Defaults to `true` (open access) — pass `false` to hide all write surfaces. + * Used by HypothesisComments to apply the ACL gate without re-implementing the thread UI. + */ + canEdit?: boolean; } /** Format a relative time string (e.g., "2h ago", "3d ago") */ @@ -82,6 +88,7 @@ const FindingComments: React.FC = ({ onCaptureFromTeams, showAuthors, voiceInput, + canEdit = true, }) => { const [isExpanded, setIsExpanded] = useState(false); const [isAdding, setIsAdding] = useState(false); @@ -318,48 +325,50 @@ const FindingComments: React.FC = ({ )} {relativeTime(comment.createdAt)} -
- {onAddPhoto && ( + {canEdit && ( +
+ {onAddPhoto && ( + + )} - )} - - -
+ +
+ )} )} ))} - {/* Add comment input */} - {isAdding ? ( + {/* Add comment input — only rendered for users with edit-contributions access */} + {canEdit && isAdding ? (
{/* Pending attachment preview */} {pendingAttachment && ( @@ -415,7 +424,7 @@ const FindingComments: React.FC = ({
- ) : ( + ) : canEdit ? (
- )} + ) : null} )} diff --git a/packages/ui/src/components/FindingsLog/ImprovementIdeasSection.tsx b/packages/ui/src/components/FindingsLog/ImprovementIdeasSection.tsx index 7c170ea72..271ac52c7 100644 --- a/packages/ui/src/components/FindingsLog/ImprovementIdeasSection.tsx +++ b/packages/ui/src/components/FindingsLog/ImprovementIdeasSection.tsx @@ -15,6 +15,13 @@ const TIMEFRAME_COLORS: Record = { months: 'text-red-400', }; +const TIMEFRAME_LABELS: Record = { + 'just-do': 'Just Do', + days: 'Days', + weeks: 'Weeks', + months: 'Months', +}; + const DIRECTION_BADGE_COLORS: Record = { prevent: 'bg-purple-500/15 text-purple-400', detect: 'bg-blue-500/15 text-blue-400', @@ -37,6 +44,15 @@ export interface ImprovementIdeasSectionProps { onProjectIdea?: (hypothesisId: string, ideaId: string) => void; onAskCoScout?: (question: string) => void; hypothesisText: string; + /** + * ACL gate (parent-owned). When false, every data-mutating affordance + * (add / edit-timeframe / remove / select-toggle / project) is hidden and the + * section renders read-only — existing ideas, impacts and projections stay + * visible (mirrors the read-for-all / write-for-editors pattern in + * HypothesisComments). Defaults to true so FindingsLog-era call sites are + * unaffected. The parent (HypothesisCardWithPlans) passes `canEdit`. + */ + canEdit?: boolean; } const ImprovementIdeasSection: React.FC = ({ @@ -50,6 +66,7 @@ const ImprovementIdeasSection: React.FC = ({ onProjectIdea, onAskCoScout, hypothesisText, + canEdit = true, }) => { const { formatStat } = useTranslation(); const [isOpen, setIsOpen] = useState(ideas.length > 0); @@ -92,15 +109,24 @@ const ImprovementIdeasSection: React.FC = ({ className="flex items-start gap-1.5 py-1 px-1.5 rounded text-xs group/idea" data-testid={`idea-${idea.id}`} > - {/* Selected toggle */} - + {/* Selected toggle \u2014 interactive for editors, static indicator otherwise */} + {canEdit ? ( + + ) : ( + + {idea.selected ? '\u2605' : '\u25CB'} + + )} {/* Content */}
@@ -136,26 +162,37 @@ const ImprovementIdeasSection: React.FC = ({ )} - {/* Timeframe dropdown (always visible, color-coded) */} - + {/* Timeframe — editable dropdown for editors, static label otherwise */} + {canEdit ? ( + + ) : ( + idea.timeframe && ( + + {TIMEFRAME_LABELS[idea.timeframe]} + + ) + )} {/* Projection summary */} {idea.projection && ( @@ -174,35 +211,37 @@ const ImprovementIdeasSection: React.FC = ({ )}
- {/* Action buttons */} -
- {/* Project button */} - {onProjectIdea && ( + {/* Action buttons — editors only (data-mutating affordances) */} + {canEdit && ( +
+ {/* Project button */} + {onProjectIdea && ( + + )} + {/* Remove button */} - )} - {/* Remove button */} - -
+
+ )} ); })} - {/* Add idea input */} - {onAddIdea && ( + {/* Add idea input — editors only */} + {canEdit && onAddIdea && (