Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4c41ef6
test(wall): task 1 Hypothesis comment thread — failing tests for acce…
jukka-matti May 30, 2026
1dc6f35
feat(wall): task 1 Hypothesis comment thread
jukka-matti May 30, 2026
366142e
test(wall): task 2 @mentions + AIContext.recentComments — failing tes…
jukka-matti May 30, 2026
2eec5f2
feat(wall): task 2 @mentions + AIContext.recentComments
jukka-matti May 30, 2026
06e0966
test(wall): task 3 ActionItem tasks on hypotheses — failing tests for…
jukka-matti May 30, 2026
b2c5f5c
feat(wall): task 3 ActionItem tasks on hypotheses
jukka-matti May 30, 2026
6d7fa49
test(wall): task 4 Plan-owner data-collection task surface — failing …
jukka-matti May 30, 2026
9de99d0
feat(wall): task 4 Plan-owner data-collection task surface
jukka-matti May 30, 2026
03f4444
test(wall): task 5 Multi-scope rail — failing tests for acceptance
jukka-matti May 30, 2026
14f5c05
feat(wall): task 5 Multi-scope rail
jukka-matti May 30, 2026
cdd3e89
test(wall): task 6 Re-mount the 3 detached IM-1 flows — failing tests…
jukka-matti May 30, 2026
8dd1c91
feat(wall): task 6 Re-mount the 3 detached IM-1 flows
jukka-matti May 30, 2026
ce09ef0
test(wall): complete IM-4b fixtures with required deletedAt + drop st…
jukka-matti May 30, 2026
ccff74f
fix(wall): handle HYPOTHESIS_ACTION_* in both apps' applyAction (exha…
jukka-matti May 30, 2026
103889e
feat(wall): wire comment thread + ActionItems + idea flows into WallC…
jukka-matti May 30, 2026
51bdca9
feat(wall): wire Wall collaboration callbacks + scope rail into Azure…
jukka-matti May 30, 2026
0b0eb5e
chore(wall): descope createHubFromFinding to IM-4c + localize ScopeRa…
jukka-matti May 30, 2026
3fd223f
test(wall): seam-level integration tests for the Wall collaboration a…
jukka-matti May 30, 2026
c7b5b05
test(wall): update plan-filter assertions for the {primaryFactor} hea…
jukka-matti May 30, 2026
eedb317
fix(im-4b): ACL-gate the Wall idea write surface (read-for-all, write…
jukka-matti May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion apps/azure/src/components/editor/AnalyzeWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AnalyzeConclusion,
FindingsLog,
WallCanvas,
ScopeRail,
CommandPalette,
Minimap,
CANVAS_W,
Expand All @@ -29,6 +30,7 @@ import type {
CurrentUnderstanding,
FindingStatus,
Hypothesis,
IdeaImpact,
ProblemCondition,
ProcessContext,
} from '@variscout/core';
Expand All @@ -40,6 +42,7 @@ import {
categoricalFiltersToActiveFilters,
buildConditionFromCategoricalFilters,
predicateSetKey,
parseMentions,
} from '@variscout/core';
import { computeBestSubsets } from '@variscout/core/stats';
import { detectEvidenceClusters } from '@variscout/core/findings';
Expand Down Expand Up @@ -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<string, IdeaImpact | undefined>;
/** IM-4b — open What-If for an improvement idea (handleProjectIdea). */
onProjectIdea?: (hypothesisId: string, ideaId: string) => void;
}

/**
Expand Down Expand Up @@ -149,6 +161,8 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
viewMode: externalViewMode,
onViewModeChange,
planningProps,
ideaImpacts,
onProjectIdea,
}) => {
const voiceInput = isSpeechToTextAvailable() ? { isAvailable: true, transcribeAudio } : undefined;
const outcome = useProjectStore(s => s.outcome);
Expand Down Expand Up @@ -244,6 +258,81 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
() => (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<string, (string | number)[]>();
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<WallCanvasPlanningProps | undefined>(() => {
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).
Expand Down Expand Up @@ -876,6 +965,20 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
{analyzeViewMode === 'map' ? (
wallViewMode === 'wall' ? (
<div className="relative flex-1 flex flex-col min-h-0">
{/* 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 && (
<div className="border-edge bg-surface-secondary/40 border-b px-3 py-2">
<ScopeRail
scopes={railScopes}
activeScopeId={activeScope?.id}
onScopeSelect={handleScopeSelect}
onScopeArchive={handleScopeArchive}
/>
</div>
)}
<WallCanvas
hubId={wallHubId}
hubs={scopedHubs}
Expand All @@ -892,7 +995,7 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ∩ '),
};
});

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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(<AnalyzeWorkspace {...makeMinimalProps()} />);
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(<AnalyzeWorkspace {...makeMinimalProps()} />);
expect(screen.queryByTestId(/scope-chip-/)).toBeNull();
});

it('selecting a chip re-anchors by rewriting analysisScopeStore.categoricalFilters', () => {
render(<AnalyzeWorkspace {...makeMinimalProps()} />);
// 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(<AnalyzeWorkspace {...makeMinimalProps()} />);
fireEvent.click(screen.getByTestId('scope-archive-scope-a'));

const archived = useAnalyzeStore.getState().scopes.find(s => s.id === 'scope-a');
expect(archived?.deletedAt).not.toBeNull();
});
});
13 changes: 13 additions & 0 deletions apps/azure/src/features/ai/useAIOrchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -292,6 +303,8 @@ export function useAIOrchestration({
evidenceMapTopology: effectiveTopology,
hypotheses,
bestSubsetsResult,
hubComments,
findingComments,
});

// AI narration (disabled when per-component toggle is off)
Expand Down
16 changes: 2 additions & 14 deletions apps/azure/src/features/analyze/useAnalyzeOrchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,8 +19,6 @@ import type {
IdeaImpact,
ProcessContext,
StatsResult,
Hypothesis,
DisconfirmationAttempt,
} from '@variscout/core';

// ── Interfaces ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<string, IdeaImpact | undefined>;
}
Expand Down
19 changes: 12 additions & 7 deletions apps/azure/src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1178,25 +1178,28 @@ export const Editor: React.FC<EditorProps> = ({
);

// 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,
stats,
});
// 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);
Expand Down Expand Up @@ -1926,6 +1929,8 @@ export const Editor: React.FC<EditorProps> = ({
}
hypothesesState={hypothesesState}
planningProps={wallPlanningProps}
ideaImpacts={ideaImpacts}
onProjectIdea={handleProjectIdea}
/>
) : activeView === 'projects' ? (
<ProjectsTabView
Expand Down
8 changes: 8 additions & 0 deletions apps/azure/src/persistence/applyAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,14 @@ export async function applyAction(action: HubAction): Promise<void> {
// 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;
Expand Down
Loading