From 0e3a80e01168c732b253bc2b3c320be7e6170c45 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sat, 16 May 2026 23:35:50 +0300 Subject: [PATCH 01/11] feat(wedge): drop 'control-handoff' from ProcessStateResponsePath + simplify open-sustainment action --- .../core/src/__tests__/responsePathAction.test.ts | 15 +-------------- packages/core/src/processState.ts | 3 +-- packages/core/src/responsePathAction.ts | 10 ++-------- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/packages/core/src/__tests__/responsePathAction.test.ts b/packages/core/src/__tests__/responsePathAction.test.ts index da46a6a28..112c5c3cf 100644 --- a/packages/core/src/__tests__/responsePathAction.test.ts +++ b/packages/core/src/__tests__/responsePathAction.test.ts @@ -61,7 +61,7 @@ describe('deriveResponsePathAction', () => { }); }); - it('maps sustainment-review to open-sustainment/review', () => { + it('maps sustainment-review to open-sustainment', () => { const action = deriveResponsePathAction( baseItem({ responsePath: 'sustainment-review' }), DEFAULT_ID @@ -69,19 +69,6 @@ describe('deriveResponsePathAction', () => { expect(action).toEqual({ kind: 'open-sustainment', investigationId: DEFAULT_ID, - surface: 'review', - }); - }); - - it('maps control-handoff to open-sustainment/handoff', () => { - const action = deriveResponsePathAction( - baseItem({ responsePath: 'control-handoff' }), - DEFAULT_ID - ); - expect(action).toEqual({ - kind: 'open-sustainment', - investigationId: DEFAULT_ID, - surface: 'handoff', }); }); diff --git a/packages/core/src/processState.ts b/packages/core/src/processState.ts index a6593d545..7876b780c 100644 --- a/packages/core/src/processState.ts +++ b/packages/core/src/processState.ts @@ -17,8 +17,7 @@ export type ProcessStateResponsePath = | 'focused-investigation' | 'chartered-project' | 'measurement-system-work' - | 'sustainment-review' - | 'control-handoff'; + | 'sustainment-review'; export type ProcessStateSource = | 'review-signal' diff --git a/packages/core/src/responsePathAction.ts b/packages/core/src/responsePathAction.ts index a2100fac2..8468bb10b 100644 --- a/packages/core/src/responsePathAction.ts +++ b/packages/core/src/responsePathAction.ts @@ -7,11 +7,7 @@ export type ResponsePathAction = investigationId: string; intent: 'focused' | 'chartered' | 'quick'; } - | { - kind: 'open-sustainment'; - investigationId: string; - surface: 'review' | 'handoff'; - } + | { kind: 'open-sustainment'; investigationId: string } | { kind: 'unsupported'; reason: 'planned' | 'informational' }; /** @@ -44,9 +40,7 @@ export function deriveResponsePathAction( case 'chartered-project': return { kind: 'open-investigation', investigationId, intent: 'chartered' }; case 'sustainment-review': - return { kind: 'open-sustainment', investigationId, surface: 'review' }; - case 'control-handoff': - return { kind: 'open-sustainment', investigationId, surface: 'handoff' }; + return { kind: 'open-sustainment', investigationId }; default: return assertNever(path); } From 793ce8f280647013f5a9b97a0683760f174fada3 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sat, 16 May 2026 23:39:50 +0300 Subject: [PATCH 02/11] feat(wedge): drop isHandoffReady + remove control-handoff state-item production Co-Authored-By: Claude Sonnet 4.6 --- .../core/src/__tests__/processState.test.ts | 6 ------ .../__tests__/responsePathReadiness.test.ts | 16 -------------- packages/core/src/index.ts | 2 +- packages/core/src/processHub.ts | 2 +- packages/core/src/processState.ts | 21 +------------------ packages/core/src/responsePathReadiness.ts | 4 ---- 6 files changed, 3 insertions(+), 48 deletions(-) diff --git a/packages/core/src/__tests__/processState.test.ts b/packages/core/src/__tests__/processState.test.ts index 635612da7..474c8b9aa 100644 --- a/packages/core/src/__tests__/processState.test.ts +++ b/packages/core/src/__tests__/processState.test.ts @@ -213,7 +213,6 @@ describe('buildCurrentProcessState', () => { expect(state.responsePathCounts['focused-investigation']).toBeGreaterThan(0); expect(state.responsePathCounts['measurement-system-work']).toBeGreaterThan(0); expect(state.responsePathCounts['sustainment-review']).toBe(1); - expect(state.responsePathCounts['control-handoff']).toBe(1); expect(state.responsePathCounts['quick-action']).toBeGreaterThan(0); expect(state.responsePathCounts['chartered-project']).toBe(1); expect(state.items).toEqual( @@ -248,11 +247,6 @@ describe('buildCurrentProcessState', () => { lens: 'sustainment', responsePath: 'sustainment-review', }), - expect.objectContaining({ - id: 'control-handoff', - lens: 'sustainment', - responsePath: 'control-handoff', - }), expect.objectContaining({ id: 'active:quick', responsePath: 'quick-action', diff --git a/packages/core/src/__tests__/responsePathReadiness.test.ts b/packages/core/src/__tests__/responsePathReadiness.test.ts index 168028929..de308a492 100644 --- a/packages/core/src/__tests__/responsePathReadiness.test.ts +++ b/packages/core/src/__tests__/responsePathReadiness.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { isCharterReady, isSustainmentReady, - isHandoffReady, type WorkflowReadinessSignals, } from '../responsePathReadiness'; @@ -37,18 +36,3 @@ describe('isSustainmentReady', () => { expect(isSustainmentReady({ ...empty, hasIntervention: true, isDemo: false })).toBe(true); }); }); - -describe('isHandoffReady', () => { - it('returns false when sustainment not confirmed and not demo', () => { - expect(isHandoffReady(empty)).toBe(false); - }); - it('returns false even with intervention if sustainment not confirmed', () => { - expect(isHandoffReady({ ...empty, hasIntervention: true })).toBe(false); - }); - it('returns true when sustainment is confirmed', () => { - expect(isHandoffReady({ ...empty, sustainmentConfirmed: true })).toBe(true); - }); - it('returns true in demo mode regardless of sustainment state', () => { - expect(isHandoffReady({ ...empty, isDemo: true })).toBe(true); - }); -}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 23b517c4c..3d26a91f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -203,7 +203,7 @@ export { } from './tier'; // Response-path readiness helpers (prerequisite checks for canvas CTAs) -export { isCharterReady, isSustainmentReady, isHandoffReady } from './responsePathReadiness'; +export { isCharterReady, isSustainmentReady } from './responsePathReadiness'; export type { WorkflowReadinessSignals } from './responsePathReadiness'; // Process Hub review signals diff --git a/packages/core/src/processHub.ts b/packages/core/src/processHub.ts index f371caae0..63338ee67 100644 --- a/packages/core/src/processHub.ts +++ b/packages/core/src/processHub.ts @@ -29,7 +29,7 @@ import { } from './sustainment'; export { buildReviewItem } from './processHubReview'; -export { isCharterReady, isSustainmentReady, isHandoffReady } from './responsePathReadiness'; +export { isCharterReady, isSustainmentReady } from './responsePathReadiness'; export type { WorkflowReadinessSignals } from './responsePathReadiness'; /** diff --git a/packages/core/src/processState.ts b/packages/core/src/processState.ts index 7876b780c..49e6309ee 100644 --- a/packages/core/src/processState.ts +++ b/packages/core/src/processState.ts @@ -266,26 +266,7 @@ export function buildCurrentProcessState item.reasons.includes('control-handoff-missing'))) { - items.push({ - id: 'control-handoff', - lens: 'sustainment', - severity: 'amber', - responsePath: 'control-handoff', - source: 'sustainment', - label: 'Control handoff needed', - count: cadence.sustainment.items.filter(item => - item.reasons.includes('control-handoff-missing') - ).length, - investigationIds: cadence.sustainment.items - .filter(item => item.reasons.includes('control-handoff-missing')) - .map(item => item.investigation.id), - }); - } - - const sustainmentReviewItems = cadence.sustainment.items.filter( - item => !item.reasons.includes('control-handoff-missing') - ); + const sustainmentReviewItems = cadence.sustainment.items; if (sustainmentReviewItems.length > 0) { items.push({ id: 'sustainment', diff --git a/packages/core/src/responsePathReadiness.ts b/packages/core/src/responsePathReadiness.ts index e8803347d..1ab3d507e 100644 --- a/packages/core/src/responsePathReadiness.ts +++ b/packages/core/src/responsePathReadiness.ts @@ -27,7 +27,3 @@ export function isCharterReady(_signals: WorkflowReadinessSignals): boolean { export function isSustainmentReady(signals: WorkflowReadinessSignals): boolean { return signals.isDemo === true || signals.hasIntervention; } - -export function isHandoffReady(signals: WorkflowReadinessSignals): boolean { - return signals.isDemo === true || signals.sustainmentConfirmed; -} From 163929d2ad4622ac1fd9f74ac80f2b678c1d4ef3 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sat, 16 May 2026 23:51:25 +0300 Subject: [PATCH 03/11] feat(wedge): reduce ResponsePathKind to 3 always-available paths --- .../__tests__/responsePathCta.test.ts | 130 +----------------- .../Canvas/internal/responsePathCta.ts | 51 ++----- 2 files changed, 16 insertions(+), 165 deletions(-) diff --git a/packages/ui/src/components/Canvas/internal/__tests__/responsePathCta.test.ts b/packages/ui/src/components/Canvas/internal/__tests__/responsePathCta.test.ts index 955451c65..ad321f6be 100644 --- a/packages/ui/src/components/Canvas/internal/__tests__/responsePathCta.test.ts +++ b/packages/ui/src/components/Canvas/internal/__tests__/responsePathCta.test.ts @@ -1,134 +1,16 @@ import { describe, it, expect } from 'vitest'; -import type { WorkflowReadinessSignals } from '@variscout/core'; import { computeCtaState, type ResponsePathKind } from '../responsePathCta'; -const empty: WorkflowReadinessSignals = { - hasIntervention: false, - sustainmentConfirmed: false, -}; +const ALL_PATHS: ResponsePathKind[] = ['quick-action', 'focused-investigation', 'charter']; -const ALWAYS_AVAILABLE: ResponsePathKind[] = ['quick-action', 'focused-investigation', 'charter']; - -describe('computeCtaState — paths with no prerequisite', () => { - it('keeps the charter route key available for the Improvement Project response path', () => { - expect(computeCtaState({ path: 'charter', signals: empty, hasHandler: true })).toEqual({ - kind: 'active', - }); - }); - - for (const path of ALWAYS_AVAILABLE) { - it(`${path} is active when handler wired (regardless of signals)`, () => { - expect(computeCtaState({ path, signals: empty, hasHandler: true })).toEqual({ - kind: 'active', - }); - expect( - computeCtaState({ - path, - signals: { hasIntervention: true, sustainmentConfirmed: true }, - hasHandler: true, - }) - ).toEqual({ kind: 'active' }); +describe('computeCtaState — all canvas response paths are always-available', () => { + for (const path of ALL_PATHS) { + it(`${path} is active when handler wired`, () => { + expect(computeCtaState({ path, hasHandler: true })).toEqual({ kind: 'active' }); }); it(`${path} is hidden when no handler is wired`, () => { - expect(computeCtaState({ path, signals: empty, hasHandler: false })).toEqual({ - kind: 'hidden', - }); + expect(computeCtaState({ path, hasHandler: false })).toEqual({ kind: 'hidden' }); }); } }); - -describe('computeCtaState — sustainment', () => { - it('is prerequisite-locked (no-intervention) when no intervention recorded', () => { - expect(computeCtaState({ path: 'sustainment', signals: empty, hasHandler: true })).toEqual({ - kind: 'prerequisite-locked', - reason: 'no-intervention', - }); - }); - - it('is prerequisite-locked even when handler missing if intervention also missing', () => { - expect(computeCtaState({ path: 'sustainment', signals: empty, hasHandler: false })).toEqual({ - kind: 'prerequisite-locked', - reason: 'no-intervention', - }); - }); - - it('is active when intervention exists and handler wired', () => { - expect( - computeCtaState({ - path: 'sustainment', - signals: { ...empty, hasIntervention: true }, - hasHandler: true, - }) - ).toEqual({ kind: 'active' }); - }); - - it('is hidden when intervention exists but handler missing', () => { - expect( - computeCtaState({ - path: 'sustainment', - signals: { ...empty, hasIntervention: true }, - hasHandler: false, - }) - ).toEqual({ kind: 'hidden' }); - }); - - it('isDemo: true bypasses the intervention prerequisite', () => { - expect( - computeCtaState({ - path: 'sustainment', - signals: { ...empty, isDemo: true }, - hasHandler: true, - }) - ).toEqual({ kind: 'active' }); - }); -}); - -describe('computeCtaState — handoff', () => { - it('is prerequisite-locked (no-sustainment-confirmed) when sustainment not confirmed', () => { - expect(computeCtaState({ path: 'handoff', signals: empty, hasHandler: true })).toEqual({ - kind: 'prerequisite-locked', - reason: 'no-sustainment-confirmed', - }); - }); - - it('is prerequisite-locked even with intervention if sustainment not confirmed', () => { - expect( - computeCtaState({ - path: 'handoff', - signals: { ...empty, hasIntervention: true }, - hasHandler: true, - }) - ).toEqual({ kind: 'prerequisite-locked', reason: 'no-sustainment-confirmed' }); - }); - - it('is active when sustainment confirmed and handler wired', () => { - expect( - computeCtaState({ - path: 'handoff', - signals: { ...empty, sustainmentConfirmed: true }, - hasHandler: true, - }) - ).toEqual({ kind: 'active' }); - }); - - it('is hidden when sustainment confirmed but handler missing', () => { - expect( - computeCtaState({ - path: 'handoff', - signals: { ...empty, sustainmentConfirmed: true }, - hasHandler: false, - }) - ).toEqual({ kind: 'hidden' }); - }); - - it('isDemo: true bypasses the sustainment-confirmed prerequisite', () => { - expect( - computeCtaState({ - path: 'handoff', - signals: { ...empty, isDemo: true }, - hasHandler: true, - }) - ).toEqual({ kind: 'active' }); - }); -}); diff --git a/packages/ui/src/components/Canvas/internal/responsePathCta.ts b/packages/ui/src/components/Canvas/internal/responsePathCta.ts index ae88e6004..4b283bf3d 100644 --- a/packages/ui/src/components/Canvas/internal/responsePathCta.ts +++ b/packages/ui/src/components/Canvas/internal/responsePathCta.ts @@ -1,61 +1,30 @@ /** - * Maps `(path, signals, hasHandler)` to a `ResponsePathCtaState` for each of - * the five response-path CTAs in the canvas drill-down. Sustainment and Handoff - * are prerequisite-gated (intervention exists / sustainment confirmed); - * Quick action, Focused investigation, and Improvement Project are always available. - * `'hidden'` is reserved for the case where a path's handler is not wired — - * we hide rather than tease unfinished features. + * Maps `(path, hasHandler)` to a `ResponsePathCtaState` for each of the three + * canvas drill-down response-path CTAs. All three (Quick action, Focused + * investigation, Improvement Project) are always-available — `'hidden'` is + * reserved for the case where a path's handler is not wired (we hide rather + * than tease unfinished features). * - * Vision spec: docs/superpowers/specs/2026-05-03-variscout-vision-design.md §5.3 + * Wedge spec: docs/superpowers/specs/2026-05-16-wedge-architecture-design.md §3 */ -import type { WorkflowReadinessSignals } from '@variscout/core'; -import { isSustainmentReady, isHandoffReady } from '@variscout/core'; +import { assertNever } from '@variscout/core/types'; -export type ResponsePathKind = - | 'quick-action' - | 'focused-investigation' - | 'charter' - | 'sustainment' - | 'handoff'; +export type ResponsePathKind = 'quick-action' | 'focused-investigation' | 'charter'; -export type PrerequisiteLockedReason = 'no-intervention' | 'no-sustainment-confirmed'; - -export type ResponsePathCtaState = - | { kind: 'active' } - | { kind: 'prerequisite-locked'; reason: PrerequisiteLockedReason } - | { kind: 'hidden' }; +export type ResponsePathCtaState = { kind: 'active' } | { kind: 'hidden' }; export interface ComputeCtaStateInput { path: ResponsePathKind; - signals: WorkflowReadinessSignals; hasHandler: boolean; } -function assertNever(value: never): never { - throw new Error(`Unhandled response-path kind: ${String(value)}`); -} - -export function computeCtaState({ - path, - signals, - hasHandler, -}: ComputeCtaStateInput): ResponsePathCtaState { +export function computeCtaState({ path, hasHandler }: ComputeCtaStateInput): ResponsePathCtaState { switch (path) { case 'quick-action': case 'focused-investigation': case 'charter': return hasHandler ? { kind: 'active' } : { kind: 'hidden' }; - case 'sustainment': - if (!isSustainmentReady(signals)) { - return { kind: 'prerequisite-locked', reason: 'no-intervention' }; - } - return hasHandler ? { kind: 'active' } : { kind: 'hidden' }; - case 'handoff': - if (!isHandoffReady(signals)) { - return { kind: 'prerequisite-locked', reason: 'no-sustainment-confirmed' }; - } - return hasHandler ? { kind: 'active' } : { kind: 'hidden' }; default: return assertNever(path); } From e38d3349f322c0d92201f4a1efdd2c8cb8eff1b1 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sun, 17 May 2026 00:08:31 +0300 Subject: [PATCH 04/11] feat(wedge): canvas overlay renders 3 always-available CTAs only Drop sustainment/handoff handlers, signals prop, prerequisite-locked branch, and PREREQUISITE_TOOLTIP_KEY from CanvasStepOverlay. Component now passes (path, hasHandler) to computeCtaState with no signals dependency. useTranslation removed as no longer referenced. --- .../Canvas/internal/CanvasStepOverlay.tsx | 56 ++----------- .../__tests__/CanvasStepOverlay.test.tsx | 82 ++++++------------- 2 files changed, 30 insertions(+), 108 deletions(-) diff --git a/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx b/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx index 521daf910..cb902e8e6 100644 --- a/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx +++ b/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx @@ -1,19 +1,13 @@ import React from 'react'; import FocusTrap from 'focus-trap-react'; import { formatStatistic } from '@variscout/core/i18n'; -import type { WorkflowReadinessSignals } from '@variscout/core'; import type { ActionItem } from '@variscout/core/findings'; import type { CanvasInvestigationFocus, CanvasStepCardModel, CanvasStepInvestigationOverlay, } from '@variscout/hooks'; -import { useTranslation } from '@variscout/hooks'; -import { - computeCtaState, - type ResponsePathKind, - type PrerequisiteLockedReason, -} from './responsePathCta'; +import { computeCtaState, type ResponsePathKind } from './responsePathCta'; import { ContextBadgesRow, type ContextLinkGroup, type ContextLinkItem } from '../../CrossSurface'; import { LogActionModal, RecentActivityPanel, type LogActionPayload } from '../../QuickAction'; @@ -30,13 +24,10 @@ interface CanvasStepOverlayProps { card: CanvasStepCardModel; anchorRect?: CanvasOverlayAnchorRect | null; onClose: () => void; - signals: WorkflowReadinessSignals; onQuickAction?: (stepId: string) => void; onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; - onSustainment?: (stepId: string) => void; - onHandoff?: (stepId: string) => void; investigationOverlay?: CanvasStepInvestigationOverlay; onOpenInvestigationFocus?: (focus: CanvasInvestigationFocus) => void; onRemoveCausalLink?: (linkId: string) => void; @@ -104,29 +95,16 @@ const CTA_LABELS: Record = { 'quick-action': 'Quick action', 'focused-investigation': 'Focused investigation', charter: 'Improvement Project', - sustainment: 'Sustainment', - handoff: 'Handoff', -}; - -const PREREQUISITE_TOOLTIP_KEY: Record< - PrerequisiteLockedReason, - keyof import('@variscout/core').MessageCatalog -> = { - 'no-intervention': 'frame.canvasOverlay.cta.sustainment.notReady', - 'no-sustainment-confirmed': 'frame.canvasOverlay.cta.handoff.notReady', }; export const CanvasStepOverlay: React.FC = ({ card, anchorRect, onClose, - signals, onQuickAction, onLogQuickAction, onFocusedInvestigation, onCharter, - onSustainment, - onHandoff, investigationOverlay, onOpenInvestigationFocus, onRemoveCausalLink, @@ -134,7 +112,6 @@ export const CanvasStepOverlay: React.FC = ({ onNavigateContextLink, actionItems = [], }) => { - const { t } = useTranslation(); const [showLogAction, setShowLogAction] = React.useState(false); const touchStartY = React.useRef(null); const mobile = isMobileViewport(); @@ -143,44 +120,25 @@ export const CanvasStepOverlay: React.FC = ({ 'quick-action': onLogQuickAction ? () => setShowLogAction(true) : onQuickAction, 'focused-investigation': onFocusedInvestigation, charter: onCharter, - sustainment: onSustainment, - handoff: onHandoff, }; const renderCta = (path: ResponsePathKind, extraClass?: string): React.ReactNode => { const handler = handlerMap[path]; - const state = computeCtaState({ path, signals, hasHandler: handler !== undefined }); + const state = computeCtaState({ path, hasHandler: handler !== undefined }); const baseClass = 'rounded-md border border-edge bg-surface-secondary px-3 py-2 text-sm font-medium'; const cls = extraClass ? `${baseClass} ${extraClass}` : baseClass; if (state.kind === 'hidden') return null; - if (state.kind === 'active') { - return ( - - ); - } - return ( @@ -380,9 +338,7 @@ export const CanvasStepOverlay: React.FC = ({
{renderCta('quick-action')} {renderCta('focused-investigation')} - {renderCta('charter')} - {renderCta('sustainment')} - {renderCta('handoff', 'sm:col-span-2')} + {renderCta('charter', 'sm:col-span-2')}
diff --git a/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx index ac99b67eb..47f9a9b94 100644 --- a/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx +++ b/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import type { CanvasStepCardModel } from '@variscout/hooks'; -import type { ActionItem, WorkflowReadinessSignals } from '@variscout/core'; +import type { ActionItem } from '@variscout/core'; import { CanvasStepOverlay } from '../CanvasStepOverlay'; const baseCard: CanvasStepCardModel = { @@ -27,11 +27,6 @@ const baseCard: CanvasStepCardModel = { }, }; -const emptySignals: WorkflowReadinessSignals = { - hasIntervention: false, - sustainmentConfirmed: false, -}; - function makeAction(overrides: Partial = {}): ActionItem { return { id: overrides.id ?? 'action-1', @@ -56,93 +51,64 @@ function renderOverlay(overrides: Partial undefined} - signals={emptySignals} actionItems={[]} onQuickAction={() => undefined} onFocusedInvestigation={() => undefined} onCharter={() => undefined} - onSustainment={() => undefined} - onHandoff={() => undefined} {...overrides} /> ); } describe('CanvasStepOverlay — response-path CTA rendering', () => { - it('renders all five CTAs when handlers wired and prerequisites met', () => { - renderOverlay({ - signals: { hasIntervention: true, sustainmentConfirmed: true }, - }); - for (const path of [ - 'quick-action', - 'focused-investigation', - 'charter', - 'sustainment', - 'handoff', - ]) { + it('renders exactly 3 CTAs when all handlers wired', () => { + renderOverlay(); + for (const path of ['quick-action', 'focused-investigation', 'charter']) { const cta = screen.getByTestId(`canvas-cta-${path}`); expect(cta).toHaveAttribute('data-cta-state', 'active'); expect(cta).not.toBeDisabled(); } + expect(screen.queryByTestId('canvas-cta-sustainment')).toBeNull(); + expect(screen.queryByTestId('canvas-cta-handoff')).toBeNull(); }); - it('renders quick-action and focused-investigation as active even when other paths have unmet prerequisites', () => { - renderOverlay(); // emptySignals - for (const path of ['quick-action', 'focused-investigation']) { - const cta = screen.getByTestId(`canvas-cta-${path}`); - expect(cta).toHaveAttribute('data-cta-state', 'active'); - expect(cta).not.toBeDisabled(); - } + it('renders quick-action as active', () => { + renderOverlay(); + const cta = screen.getByTestId('canvas-cta-quick-action'); + expect(cta).toHaveAttribute('data-cta-state', 'active'); + expect(cta).not.toBeDisabled(); }); - it('renders Improvement Project as active regardless of signals (DMAIC Define-phase, no prerequisite)', () => { - renderOverlay(); // emptySignals - const cta = screen.getByTestId('canvas-cta-charter'); - expect(cta).toHaveTextContent('Improvement Project'); + it('renders focused-investigation as active', () => { + renderOverlay(); + const cta = screen.getByTestId('canvas-cta-focused-investigation'); expect(cta).toHaveAttribute('data-cta-state', 'active'); expect(cta).not.toBeDisabled(); }); - it('renders Sustainment as prerequisite-locked when no intervention exists', () => { + it('renders Improvement Project as active (DMAIC Define-phase, no prerequisite)', () => { renderOverlay(); - const cta = screen.getByTestId('canvas-cta-sustainment'); - expect(cta).toHaveAttribute('data-cta-state', 'prerequisite-locked'); - expect(cta).toHaveAttribute('data-cta-reason', 'no-intervention'); - expect(cta).toBeDisabled(); - expect(cta.getAttribute('title')).toMatch(/process change to monitor/i); + const cta = screen.getByTestId('canvas-cta-charter'); + expect(cta).toHaveTextContent('Improvement Project'); + expect(cta).toHaveAttribute('data-cta-state', 'active'); + expect(cta).not.toBeDisabled(); }); - it('renders Handoff as prerequisite-locked when sustainment not confirmed', () => { - renderOverlay({ signals: { hasIntervention: true, sustainmentConfirmed: false } }); - const cta = screen.getByTestId('canvas-cta-handoff'); - expect(cta).toHaveAttribute('data-cta-state', 'prerequisite-locked'); - expect(cta).toHaveAttribute('data-cta-reason', 'no-sustainment-confirmed'); - expect(cta).toBeDisabled(); - expect(cta.getAttribute('title')).toMatch(/sustainment monitoring confirms gains/i); + it('sustainment and handoff CTAs are never rendered', () => { + renderOverlay(); + expect(screen.queryByTestId('canvas-cta-sustainment')).toBeNull(); + expect(screen.queryByTestId('canvas-cta-handoff')).toBeNull(); }); - it('hides any CTA whose handler is not wired', () => { + it('hides a CTA whose handler is not wired', () => { renderOverlay({ - signals: { hasIntervention: true, sustainmentConfirmed: true }, onCharter: undefined, - onSustainment: undefined, - onHandoff: undefined, }); expect(screen.queryByTestId('canvas-cta-charter')).toBeNull(); - expect(screen.queryByTestId('canvas-cta-sustainment')).toBeNull(); - expect(screen.queryByTestId('canvas-cta-handoff')).toBeNull(); expect(screen.queryByTestId('canvas-cta-quick-action')).not.toBeNull(); expect(screen.queryByTestId('canvas-cta-focused-investigation')).not.toBeNull(); }); - it('isDemo bypasses sustainment + handoff prerequisites', () => { - renderOverlay({ signals: { ...emptySignals, isDemo: true } }); - for (const path of ['sustainment', 'handoff']) { - const cta = screen.getByTestId(`canvas-cta-${path}`); - expect(cta).toHaveAttribute('data-cta-state', 'active'); - } - }); - it('clicking an active CTA invokes its handler with the step id', () => { const onCharter = vi.fn(); renderOverlay({ onCharter }); From 921e06553c35f1676bb91eb08c8a16f434dc02dc Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sun, 17 May 2026 00:35:07 +0300 Subject: [PATCH 05/11] feat(wedge): drop onSustainment + onHandoff from Canvas + FrameView wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the `onSustainment` and `onHandoff` prop chain from the Canvas component tree (CanvasProps → CanvasWorkspace → CanvasLevelRouter → LocalMechanismView) and from both FrameView consumer apps. The sustainment and handoff panels remain reachable via other entry points (response-path prompts, action-item navigation, URL-driven handoff in Editor). Co-Authored-By: Claude Sonnet 4.6 --- .../azure/src/components/editor/FrameView.tsx | 10 ------ .../editor/__tests__/FrameView.test.tsx | 18 +--------- apps/pwa/src/components/views/FrameView.tsx | 10 ------ .../views/__tests__/FrameView.test.tsx | 18 +--------- .../src/components/Canvas/CanvasWorkspace.tsx | 6 ---- packages/ui/src/components/Canvas/index.tsx | 8 ----- .../Canvas/internal/CanvasLevelRouter.tsx | 6 ---- .../Canvas/internal/LocalMechanismView.tsx | 34 +------------------ .../__tests__/LocalMechanismView.test.tsx | 12 ++----- 9 files changed, 5 insertions(+), 117 deletions(-) diff --git a/apps/azure/src/components/editor/FrameView.tsx b/apps/azure/src/components/editor/FrameView.tsx index fad4c0280..accb3e722 100644 --- a/apps/azure/src/components/editor/FrameView.tsx +++ b/apps/azure/src/components/editor/FrameView.tsx @@ -315,14 +315,6 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showCharter(); }, []); - const handleSustainment = React.useCallback(() => { - usePanelsStore.getState().showSustainment(); - }, []); - - const handleHandoff = React.useCallback(() => { - usePanelsStore.getState().showHandoff(); - }, []); - const handleInboxNavigate = React.useCallback((prompt: InboxDigestPrompt) => { const surface = prompt.action?.opensSurface; if (surface === 'sustainment') { @@ -393,8 +385,6 @@ const FrameView: React.FC = () => { onRemoveCausalLink={handleRemoveCausalLink} signals={signals} onCharter={handleCharter} - onSustainment={handleSustainment} - onHandoff={handleHandoff} contextLinkGroups={contextLinkGroups} onNavigateContextLink={handleNavigateContextLink} priorStepStats={priorStepStats} diff --git a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx index 971a30534..82d7fadec 100644 --- a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx +++ b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx @@ -141,8 +141,6 @@ vi.mock('@variscout/ui', async () => { ) => void; onRemoveCausalLink?: (linkId: string) => void; onCharter?: () => void; - onSustainment?: () => void; - onHandoff?: () => void; priorStepStats?: ReadonlyMap; actionItems?: unknown[]; contextLinkGroups?: { surfaceType: string; items: { id: string }[] }[]; @@ -200,16 +198,6 @@ vi.mock('@variscout/ui', async () => { 'button', { type: 'button', 'data-testid': 'cta-charter', onClick: props.onCharter }, 'Charter' - ), - React.createElement( - 'button', - { type: 'button', 'data-testid': 'cta-sustainment', onClick: props.onSustainment }, - 'Sustainment' - ), - React.createElement( - 'button', - { type: 'button', 'data-testid': 'cta-handoff', onClick: props.onHandoff }, - 'Handoff' ) ); }, @@ -608,16 +596,12 @@ describe('FrameView (Azure shell)', () => { expect(removeCausalLinkMock).toHaveBeenCalledWith('link-created'); }); - it('wires Canvas charter/sustainment/handoff CTAs to the panels-store show actions', () => { + it('wires Canvas charter CTA to the panels-store show action', () => { render(); fireEvent.click(screen.getByTestId('cta-charter')); - fireEvent.click(screen.getByTestId('cta-sustainment')); - fireEvent.click(screen.getByTestId('cta-handoff')); expect(showCharterMock).toHaveBeenCalledTimes(1); - expect(showSustainmentMock).toHaveBeenCalledTimes(1); - expect(showHandoffMock).toHaveBeenCalledTimes(1); }); it('marks Sustainment ready only when a closed project has completed intervention evidence and keeps Handoff gated until sustainment is confirmed', async () => { diff --git a/apps/pwa/src/components/views/FrameView.tsx b/apps/pwa/src/components/views/FrameView.tsx index 5435e1911..8aa26be3b 100644 --- a/apps/pwa/src/components/views/FrameView.tsx +++ b/apps/pwa/src/components/views/FrameView.tsx @@ -336,14 +336,6 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showCharter(); }, []); - const handleSustainment = React.useCallback(() => { - usePanelsStore.getState().showSustainment(); - }, []); - - const handleHandoff = React.useCallback(() => { - usePanelsStore.getState().showHandoff(); - }, []); - const handleInboxNavigate = React.useCallback((prompt: InboxDigestPrompt) => { const surface = prompt.action?.opensSurface; if (surface === 'sustainment') { @@ -414,8 +406,6 @@ const FrameView: React.FC = () => { onRemoveCausalLink={handleRemoveCausalLink} signals={signals} onCharter={handleCharter} - onSustainment={handleSustainment} - onHandoff={handleHandoff} contextLinkGroups={contextLinkGroups} onNavigateContextLink={handleNavigateContextLink} priorStepStats={priorStepStats} diff --git a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx index 537a6e397..8938e70ae 100644 --- a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx +++ b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx @@ -142,8 +142,6 @@ vi.mock('@variscout/ui', async () => { ) => void; onRemoveCausalLink?: (linkId: string) => void; onCharter?: () => void; - onSustainment?: () => void; - onHandoff?: () => void; priorStepStats?: ReadonlyMap; actionItems?: unknown[]; contextLinkGroups?: { surfaceType: string; items: { id: string }[] }[]; @@ -201,16 +199,6 @@ vi.mock('@variscout/ui', async () => { 'button', { type: 'button', 'data-testid': 'cta-charter', onClick: props.onCharter }, 'Charter' - ), - React.createElement( - 'button', - { type: 'button', 'data-testid': 'cta-sustainment', onClick: props.onSustainment }, - 'Sustainment' - ), - React.createElement( - 'button', - { type: 'button', 'data-testid': 'cta-handoff', onClick: props.onHandoff }, - 'Handoff' ) ); }, @@ -615,16 +603,12 @@ describe('FrameView (PWA shell)', () => { expect(removeCausalLinkMock).toHaveBeenCalledWith('link-created'); }); - it('wires Canvas charter/sustainment/handoff CTAs to the panels-store show actions', () => { + it('wires Canvas charter CTA to the panels-store show action', () => { render(); fireEvent.click(screen.getByTestId('cta-charter')); - fireEvent.click(screen.getByTestId('cta-sustainment')); - fireEvent.click(screen.getByTestId('cta-handoff')); expect(showCharterMock).toHaveBeenCalledTimes(1); - expect(showSustainmentMock).toHaveBeenCalledTimes(1); - expect(showHandoffMock).toHaveBeenCalledTimes(1); }); it('marks Sustainment ready only when a closed project has completed intervention evidence and keeps Handoff gated until sustainment is confirmed', async () => { diff --git a/packages/ui/src/components/Canvas/CanvasWorkspace.tsx b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx index feaff566c..b26f6aefa 100644 --- a/packages/ui/src/components/Canvas/CanvasWorkspace.tsx +++ b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx @@ -67,8 +67,6 @@ export interface CanvasWorkspaceProps { onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; - onSustainment?: (stepId: string) => void; - onHandoff?: (stepId: string) => void; questions?: readonly Question[]; findings?: readonly Finding[]; hypotheses?: readonly Hypothesis[]; @@ -206,8 +204,6 @@ export const CanvasWorkspace: React.FC = ({ onLogQuickAction, onFocusedInvestigation, onCharter, - onSustainment, - onHandoff, questions = [], findings = [], hypotheses = [], @@ -609,8 +605,6 @@ export const CanvasWorkspace: React.FC = ({ onLogQuickAction={onLogQuickAction} onFocusedInvestigation={onFocusedInvestigation} onCharter={onCharter} - onSustainment={onSustainment} - onHandoff={onHandoff} onOpenInvestigationFocus={onOpenInvestigationFocus} onOpenColumnDetail={onOpenColumnDetail} contextLinkGroups={contextLinkGroups} diff --git a/packages/ui/src/components/Canvas/index.tsx b/packages/ui/src/components/Canvas/index.tsx index 30aa3eec2..e24a918b0 100644 --- a/packages/ui/src/components/Canvas/index.tsx +++ b/packages/ui/src/components/Canvas/index.tsx @@ -230,8 +230,6 @@ export interface CanvasProps { onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; - onSustainment?: (stepId: string) => void; - onHandoff?: (stepId: string) => void; onOpenInvestigationFocus?: (focus: CanvasInvestigationFocus) => void; onRemoveCausalLink?: (linkId: string) => void; contextLinkGroups?: readonly ContextLinkGroup[]; @@ -297,8 +295,6 @@ export const Canvas: React.FC = ({ onLogQuickAction, onFocusedInvestigation, onCharter, - onSustainment, - onHandoff, onOpenInvestigationFocus, onRemoveCausalLink, contextLinkGroups, @@ -768,8 +764,6 @@ export const Canvas: React.FC = ({ onLogQuickAction={onLogQuickAction} onFocusedInvestigation={onFocusedInvestigation} onCharter={onCharter} - onSustainment={onSustainment} - onHandoff={onHandoff} resolvedL3Archetype={resolvedL3Archetype} authoringMode={authoringMode} disabled={disabled} @@ -827,8 +821,6 @@ export const Canvas: React.FC = ({ onLogQuickAction={onLogQuickAction} onFocusedInvestigation={onFocusedInvestigation} onCharter={onCharter} - onSustainment={onSustainment} - onHandoff={onHandoff} investigationOverlay={activeStepInvestigationOverlay} onOpenInvestigationFocus={onOpenInvestigationFocus} onRemoveCausalLink={onRemoveCausalLink} diff --git a/packages/ui/src/components/Canvas/internal/CanvasLevelRouter.tsx b/packages/ui/src/components/Canvas/internal/CanvasLevelRouter.tsx index e9fffd9a9..0806187de 100644 --- a/packages/ui/src/components/Canvas/internal/CanvasLevelRouter.tsx +++ b/packages/ui/src/components/Canvas/internal/CanvasLevelRouter.tsx @@ -81,8 +81,6 @@ export interface CanvasLevelRouterProps { onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; - onSustainment?: (stepId: string) => void; - onHandoff?: (stepId: string) => void; // L3 mode toggle resolvedL3Archetype: CanvasL3Archetype; authoringMode: CanvasAuthoringMode; @@ -131,8 +129,6 @@ export function CanvasLevelRouter({ onLogQuickAction, onFocusedInvestigation, onCharter, - onSustainment, - onHandoff, resolvedL3Archetype, authoringMode, disabled, @@ -209,8 +205,6 @@ export function CanvasLevelRouter({ onLogQuickAction={onLogQuickAction} onFocusedInvestigation={onFocusedInvestigation} onCharter={onCharter} - onSustainment={onSustainment} - onHandoff={onHandoff} /> ) : ( diff --git a/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx b/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx index 66f8d8b4c..b08e5a918 100644 --- a/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx +++ b/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx @@ -38,8 +38,6 @@ export interface LocalMechanismViewProps { onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; - onSustainment?: (stepId: string) => void; - onHandoff?: (stepId: string) => void; } const EMPTY_ROWS: ReadonlyArray = []; @@ -184,8 +182,6 @@ function ColumnMiniChart({ onOpenQuickAction, onFocusedInvestigation, onCharter, - onSustainment, - onHandoff, }: { column: string; kind: string | undefined; @@ -196,8 +192,6 @@ function ColumnMiniChart({ onOpenQuickAction: (column: string) => void; onFocusedInvestigation?: (column: string) => void; onCharter?: (column: string) => void; - onSustainment?: (column: string) => void; - onHandoff?: (column: string) => void; }) { const values = numericValues(rows, column); const categories = distribution(rows, column); @@ -225,7 +219,7 @@ function ColumnMiniChart({ {getMessage(locale, 'canvas.localMechanism.actionButton')} - {onFocusedInvestigation || onCharter || onSustainment || onHandoff ? ( + {onFocusedInvestigation || onCharter ? (
{onFocusedInvestigation ? ( - ) : null} - {onHandoff ? ( - - ) : null}
) : null} - - - {!activeHub ? ( -

- Create or select a Process Hub before opening handoff. -

- ) : error ? ( -

- {error} -

- ) : isLoading ? ( -

- Loading handoff... -

- ) : selectedHandoff ? ( - - ) : selectedRecord ? ( -

- Creating handoff... -

- ) : ( -

- Confirm sustainment before recording handoff. -

- )} - - ); -}; - -export default HandoffPanel; diff --git a/apps/azure/src/features/panels/__tests__/panelsStore.test.ts b/apps/azure/src/features/panels/__tests__/panelsStore.test.ts index b01d2f187..893462f77 100644 --- a/apps/azure/src/features/panels/__tests__/panelsStore.test.ts +++ b/apps/azure/src/features/panels/__tests__/panelsStore.test.ts @@ -451,10 +451,10 @@ describe('panelsStore', () => { expect(usePanelsStore.getState().activeView).toBe('sustainment'); }); - it('showHandoff sets activeView to handoff', () => { + it('showHandoff redirects to sustainment (handoff folded into sustainment in wedge V1)', () => { usePanelsStore.getState().showHandoff('sr-1'); - expect(usePanelsStore.getState().activeView).toBe('handoff'); - expect(usePanelsStore.getState().handoffTargetId).toBe('sr-1'); + expect(usePanelsStore.getState().activeView).toBe('sustainment'); + expect(usePanelsStore.getState().sustainmentTargetId).toBe('sr-1'); }); }); diff --git a/apps/azure/src/features/panels/panelsStore.ts b/apps/azure/src/features/panels/panelsStore.ts index 74685f977..509b2af0c 100644 --- a/apps/azure/src/features/panels/panelsStore.ts +++ b/apps/azure/src/features/panels/panelsStore.ts @@ -151,11 +151,12 @@ export const usePanelsStore = create(set => ({ isFindingsOpen: false, sustainmentTargetId: targetId ?? null, })), + // Alias for showSustainment — wedge V1 folds Handoff into Sustainment-closure (ADR-082). Inbox prompts + context links still emit surface === 'handoff'; routing through this alias keeps them reachable. showHandoff: targetId => set(() => ({ - activeView: 'handoff', + activeView: 'sustainment', isFindingsOpen: false, - handoffTargetId: targetId ?? null, + sustainmentTargetId: targetId ?? null, })), // Data table diff --git a/apps/azure/src/features/panels/usePanelsPersistence.ts b/apps/azure/src/features/panels/usePanelsPersistence.ts index b895812bf..aae9b35cc 100644 --- a/apps/azure/src/features/panels/usePanelsPersistence.ts +++ b/apps/azure/src/features/panels/usePanelsPersistence.ts @@ -3,7 +3,7 @@ import { usePanelsStore } from './panelsStore'; import type { ViewState } from '@variscout/hooks'; /** Stub views are transient surfaces; do not persist them to ViewState. */ -const STUB_VIEWS = new Set(['charter', 'sustainment', 'handoff'] as const); +const STUB_VIEWS = new Set(['charter', 'sustainment'] as const); type PersistedActiveView = NonNullable; @@ -41,7 +41,7 @@ export function usePanelsPersistence( } prevRef.current = { isFindingsOpen, isWhatIfOpen, activeView }; // Stub views are not persisted — omit activeView from the payload when on a stub. - const persistedView = STUB_VIEWS.has(activeView as 'charter' | 'sustainment' | 'handoff') + const persistedView = STUB_VIEWS.has(activeView as 'charter' | 'sustainment') ? undefined : (activeView as PersistedActiveView | undefined); onViewStateChange?.({ isFindingsOpen, isWhatIfOpen, activeView: persistedView }); diff --git a/apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap b/apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap index e36f1a5f4..1a98b72e8 100644 --- a/apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap +++ b/apps/azure/src/lib/__tests__/__snapshots__/processHubRoutes.test.ts.snap @@ -5,8 +5,7 @@ exports[`actionToHref > snapshot — URL shapes are stable 1`] = ` "chartered": "/editor/X?intent=chartered", "focused": "/editor/X?intent=focused", "quick": "/editor/X?intent=quick", - "sustainmentHandoff": "/editor/X/sustainment?surface=handoff", - "sustainmentReview": "/editor/X/sustainment", + "sustainment": "/editor/X/sustainment", "unsupportedInfo": null, "unsupportedPlanned": null, } diff --git a/apps/azure/src/lib/__tests__/processHubRoutes.test.ts b/apps/azure/src/lib/__tests__/processHubRoutes.test.ts index 79e948f43..9e2e6ea93 100644 --- a/apps/azure/src/lib/__tests__/processHubRoutes.test.ts +++ b/apps/azure/src/lib/__tests__/processHubRoutes.test.ts @@ -40,24 +40,14 @@ describe('actionToHref', () => { expect(actionToHref(action)).toBe('/editor/inv-q?intent=quick'); }); - it('builds /editor/:id/sustainment for open-sustainment/review', () => { + it('builds /editor/:id/sustainment for open-sustainment', () => { const action: ResponsePathAction = { kind: 'open-sustainment', investigationId: 'inv-s', - surface: 'review', }; expect(actionToHref(action)).toBe('/editor/inv-s/sustainment'); }); - it('builds /editor/:id/sustainment?surface=handoff for open-sustainment/handoff', () => { - const action: ResponsePathAction = { - kind: 'open-sustainment', - investigationId: 'inv-h', - surface: 'handoff', - }; - expect(actionToHref(action)).toBe('/editor/inv-h/sustainment?surface=handoff'); - }); - it('snapshot — URL shapes are stable', () => { expect({ focused: actionToHref({ @@ -71,15 +61,9 @@ describe('actionToHref', () => { intent: 'chartered', }), quick: actionToHref({ kind: 'open-investigation', investigationId: 'X', intent: 'quick' }), - sustainmentReview: actionToHref({ - kind: 'open-sustainment', - investigationId: 'X', - surface: 'review', - }), - sustainmentHandoff: actionToHref({ + sustainment: actionToHref({ kind: 'open-sustainment', investigationId: 'X', - surface: 'handoff', }), unsupportedPlanned: actionToHref({ kind: 'unsupported', reason: 'planned' }), unsupportedInfo: actionToHref({ kind: 'unsupported', reason: 'informational' }), diff --git a/apps/azure/src/lib/processHubRoutes.ts b/apps/azure/src/lib/processHubRoutes.ts index 96349d1f8..d4df90ece 100644 --- a/apps/azure/src/lib/processHubRoutes.ts +++ b/apps/azure/src/lib/processHubRoutes.ts @@ -11,8 +11,7 @@ export function actionToHref(action: ResponsePathAction): string | null { case 'open-investigation': return `/editor/${action.investigationId}?intent=${action.intent}`; case 'open-sustainment': { - const base = `/editor/${action.investigationId}/sustainment`; - return action.surface === 'handoff' ? `${base}?surface=handoff` : base; + return `/editor/${action.investigationId}/sustainment`; } default: return assertNever(action); diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx index 10a2004f3..e99242cdb 100644 --- a/apps/azure/src/pages/Dashboard.tsx +++ b/apps/azure/src/pages/Dashboard.tsx @@ -46,15 +46,8 @@ import SampleDataPicker from '../components/SampleDataPicker'; import StateItemNotesDrawer from '../components/StateItemNotesDrawer'; import { usePanelsStore } from '../features/panels/panelsStore'; -const PENDING_HANDOFF_TARGET_KEY = 'variscout.pendingHandoffTargetId'; - interface DashboardProps { - onOpenProject: ( - id?: string, - processHubId?: string, - startPaste?: boolean, - handoffTargetId?: string - ) => void; + onOpenProject: (id?: string, processHubId?: string, startPaste?: boolean) => void; /** Load a .vrs project file (from SharePoint download) */ onLoadProjectFile?: (file: File) => void; /** Load a sample dataset directly into a new analysis */ @@ -265,10 +258,9 @@ export const Dashboard: React.FC = ({ const record = sustainmentRecords.find( r => r.id === targetId || r.controlHandoffId === targetId || r.investigationId === targetId ); - const handoffTargetId = record?.id ?? targetId; - window.sessionStorage.setItem(PENDING_HANDOFF_TARGET_KEY, handoffTargetId); - usePanelsStore.getState().showHandoff(handoffTargetId); - onOpenProject(undefined, record?.hubId, false, handoffTargetId); + const sustainmentTargetId = record?.id ?? targetId; + usePanelsStore.getState().showHandoff(sustainmentTargetId); + onOpenProject(undefined, record?.hubId, false); }, [sustainmentRecords, onOpenProject] ); diff --git a/apps/azure/src/pages/Editor.tsx b/apps/azure/src/pages/Editor.tsx index 524e3f253..00ee3af7d 100644 --- a/apps/azure/src/pages/Editor.tsx +++ b/apps/azure/src/pages/Editor.tsx @@ -114,7 +114,6 @@ import { InvestigationWorkspace } from '../components/editor/InvestigationWorksp import FrameView from '../components/editor/FrameView'; import ImprovementProjectPanel from '../components/charter/ImprovementProjectPanel'; import SustainmentPanel from '../components/sustainment/SustainmentPanel'; -import HandoffPanel from '../components/handoff/HandoffPanel'; import { EditorModals } from '../components/editor/EditorModals'; import { EditorMobileSheet } from '../components/editor/EditorMobileSheet'; import ProjectDashboard from '../components/ProjectDashboard'; @@ -125,7 +124,6 @@ const WhatIfPage = lazyWithRetry(() => import('../components/WhatIfPage')); const ReportView = lazyWithRetry(() => import('../components/views/ReportView')); const INVESTIGATION_DEPTHS: InvestigationDepth[] = ['quick', 'focused', 'chartered']; -const PENDING_HANDOFF_TARGET_KEY = 'variscout.pendingHandoffTargetId'; const INVESTIGATION_STATUSES: InvestigationStatus[] = [ 'issue-captured', 'framing', @@ -296,8 +294,6 @@ interface EditorProps { initialSample?: SampleDataset | null; /** Process Hub to assign when starting a new investigation from the hub home */ initialProcessHubId?: string; - /** Open Handoff after cross-view navigation from Survey Inbox. */ - initialHandoffTargetId?: string; /** * When true, open PasteScreen immediately on mount (used by "Add framing" CTA * to route directly to the paste flow rather than stopping at EditorEmptyState). @@ -315,7 +311,6 @@ export const Editor: React.FC = ({ initialMode, initialSample, initialProcessHubId, - initialHandoffTargetId, startPasteOnMount, }) => { const { @@ -515,12 +510,8 @@ export const Editor: React.FC = ({ // Panel visibility and chart/table sync (Zustand store) const activeView = usePanelsStore(s => s.activeView); - const handoffTargetId = usePanelsStore(s => s.handoffTargetId); const sustainmentTargetId = usePanelsStore(s => s.sustainmentTargetId); const selectedProjectId = usePanelsStore(s => s.selectedProjectId); - const [navigationHandoffTargetId, setNavigationHandoffTargetId] = useState( - initialHandoffTargetId ?? null - ); const isCoScoutOpen = usePanelsStore(s => s.isCoScoutOpen); const isWhatIfOpen = usePanelsStore(s => s.isWhatIfOpen); const isPISidebarOpen = usePanelsStore(s => s.isPISidebarOpen); @@ -533,22 +524,6 @@ export const Editor: React.FC = ({ usePanelsStore.getState().initFromViewState(viewState); }, [viewState]); - useEffect(() => { - const pendingHandoffTargetId = - initialHandoffTargetId ?? window.sessionStorage.getItem(PENDING_HANDOFF_TARGET_KEY); - if (pendingHandoffTargetId) { - window.sessionStorage.removeItem(PENDING_HANDOFF_TARGET_KEY); - setNavigationHandoffTargetId(pendingHandoffTargetId); - usePanelsStore.getState().showHandoff(pendingHandoffTargetId); - } - }, [initialHandoffTargetId]); - - useEffect(() => { - if (handoffTargetId && activeView !== 'handoff') { - usePanelsStore.getState().showHandoff(handoffTargetId); - } - }, [activeView, handoffTargetId]); - // Bridge hook: persists Zustand panel state to DataContext (IndexedDB/OneDrive) usePanelsPersistence(handleViewStateChange); @@ -1714,9 +1689,7 @@ export const Editor: React.FC = ({ saveStatus={saveStatus} hasData={rawData.length > 0} activeView={ - activeView === 'charter' || activeView === 'sustainment' || activeView === 'handoff' - ? undefined - : activeView + activeView === 'charter' || activeView === 'sustainment' ? undefined : activeView } openQuestionCount={ questionsState.questions.filter(h => h.questionSource && h.status === 'open').length @@ -1797,16 +1770,7 @@ export const Editor: React.FC = ({ }} className="flex-1 flex flex-col min-h-0 bg-surface rounded-xl border border-edge overflow-hidden" > - {activeView === 'handoff' || navigationHandoffTargetId ? ( - { - setNavigationHandoffTargetId(null); - usePanelsStore.getState().showFrame(); - }} - /> - ) : activeView === 'sustainment' ? ( + {activeView === 'sustainment' ? ( import('./components/views/FrameView')); const ImprovementProjectPanel = lazyWithRetry(() => import('./components/ImprovementProjectPanel')); const SustainmentPanel = lazyWithRetry(() => import('./components/SustainmentPanel')); -const HandoffPanel = lazyWithRetry(() => import('./components/HandoffPanel')); const InvestigationView = lazyWithRetry(() => import('./components/views/InvestigationView')); const ImprovementView = lazyWithRetry(() => import('./components/views/ImprovementView')); const ProjectsTabView = lazyWithRetry(() => import('./components/ProjectsTabView')); @@ -1070,8 +1069,7 @@ function AppMain() { !importFlow.isManualEntry && !importFlow.isMapping && panels.activeView !== 'charter' && - panels.activeView !== 'sustainment' && - panels.activeView !== 'handoff' + panels.activeView !== 'sustainment' ? panels.activeView : undefined } @@ -1320,12 +1318,6 @@ function AppMain() { targetId={panels.sustainmentTargetId ?? undefined} onBack={panels.showFrame} /> - ) : panels.activeView === 'handoff' ? ( - ) : panels.activeView === 'investigation' ? ( void; -} - -function makeId(): string { - if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); - return `handoff-${Date.now()}`; -} - -function liveConfirmedRecords(records: SustainmentRecord[] | undefined): SustainmentRecord[] { - return (records ?? []).filter( - record => record.deletedAt === null && record.status === 'confirmed-sustained' - ); -} - -function selectRecord(records: SustainmentRecord[], targetId: string | undefined) { - if (targetId) { - const byId = records.find(record => record.id === targetId); - if (byId) return byId; - const byHandoff = records.find(record => record.controlHandoffId === targetId); - if (byHandoff) return byHandoff; - } - return records[0] ?? null; -} - -function selectHandoff( - handoffs: ControlHandoff[], - record: SustainmentRecord | null, - targetId: string | undefined -) { - if (targetId) { - const byId = handoffs.find(handoff => handoff.id === targetId); - if (byId) return byId; - } - if (!record) return null; - return ( - handoffs.find(handoff => handoff.id === record.controlHandoffId) ?? - handoffs.find(handoff => handoff.investigationId === record.investigationId) ?? - null - ); -} - -function buildDraftHandoff(hub: ProcessHub, record: SustainmentRecord): ControlHandoff { - const now = Date.now(); - return { - id: makeId(), - investigationId: record.investigationId, - hubId: hub.id, - status: 'pending', - surface: 'qms-procedure', - systemName: '', - operationalOwner: record.owner ?? hub.processOwner ?? { displayName: '' }, - handoffDate: now, - description: record.targetSummary ?? '', - retainSustainmentReview: true, - recordedBy: { displayName: 'Local browser' }, - escalationPath: record.openConcerns, - reactionPlan: '', - createdAt: now, - deletedAt: null, - }; -} - -const HandoffPanel: React.FC = ({ activeHub, targetId, onBack }) => { - const { isPaid } = useTier(); - const [records, setRecords] = useState([]); - const [handoffs, setHandoffs] = useState([]); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(Boolean(activeHub)); - const creatingForRecordRef = useRef(null); - - useEffect(() => { - setRecords(liveConfirmedRecords(activeHub?.sustainmentRecords)); - setHandoffs((activeHub?.controlHandoffs ?? []).filter(handoff => handoff.deletedAt === null)); - setError(null); - setIsLoading(Boolean(activeHub)); - - if (!activeHub) { - setIsLoading(false); - return; - } - - let cancelled = false; - void Promise.all([ - pwaHubRepository.sustainmentRecords.listByHub(activeHub.id), - pwaHubRepository.controlHandoffs.listByHub(activeHub.id), - ]) - .then(([loadedRecords, loadedHandoffs]) => { - if (cancelled) return; - setRecords(liveConfirmedRecords(loadedRecords)); - setHandoffs(loadedHandoffs.filter(handoff => handoff.deletedAt === null)); - }) - .catch(() => { - if (cancelled) return; - setRecords(liveConfirmedRecords(activeHub.sustainmentRecords)); - setHandoffs( - (activeHub.controlHandoffs ?? []).filter(handoff => handoff.deletedAt === null) - ); - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [activeHub]); - - const selectedRecord = useMemo(() => selectRecord(records, targetId), [records, targetId]); - const selectedHandoff = useMemo( - () => selectHandoff(handoffs, selectedRecord, targetId), - [handoffs, selectedRecord, targetId] - ); - - useEffect(() => { - if (!activeHub || isLoading || !selectedRecord || selectedHandoff) return; - if (creatingForRecordRef.current === selectedRecord.id) return; - - const draft = buildDraftHandoff(activeHub, selectedRecord); - creatingForRecordRef.current = selectedRecord.id; - setError(null); - let cancelled = false; - - void (async () => { - await pwaHubRepository.dispatch({ - kind: 'CONTROL_HANDOFF_CREATE', - hubId: activeHub.id, - handoff: draft, - }); - if (!selectedRecord.controlHandoffId) { - await pwaHubRepository.dispatch({ - kind: 'SUSTAINMENT_RECORD_UPDATE', - recordId: selectedRecord.id, - patch: { controlHandoffId: draft.id }, - }); - } - })() - .then(() => { - if (cancelled) return; - setHandoffs(current => [...current, draft]); - setRecords(current => - current.map(record => - record.id === selectedRecord.id ? { ...record, controlHandoffId: draft.id } : record - ) - ); - }) - .catch(() => { - if (!cancelled) setError('Could not create a handoff.'); - }) - .finally(() => { - if (creatingForRecordRef.current === selectedRecord.id) creatingForRecordRef.current = null; - }); - - return () => { - cancelled = true; - }; - }, [activeHub, isLoading, selectedHandoff, selectedRecord]); - - const patchHandoff = useCallback( - (patch: HandoffChangePatch) => { - if (!selectedHandoff) return; - const next = { ...selectedHandoff, ...patch }; - setHandoffs(current => - current.map(handoff => (handoff.id === selectedHandoff.id ? next : handoff)) - ); - void pwaHubRepository - .dispatch({ kind: 'CONTROL_HANDOFF_UPDATE', handoffId: selectedHandoff.id, patch }) - .catch(() => setError('Could not save handoff changes.')); - }, - [selectedHandoff] - ); - - const acknowledge = useCallback(() => { - if (!selectedHandoff) return; - const acknowledgedAt = Date.now(); - const acknowledgedBy = selectedHandoff.operationalOwner; - setHandoffs(current => - current.map(handoff => - handoff.id === selectedHandoff.id - ? { - ...handoff, - status: 'acknowledged', - acknowledgedAt, - ownerAcknowledgement: { acknowledgedBy }, - } - : handoff - ) - ); - void pwaHubRepository.dispatch({ - kind: 'CONTROL_HANDOFF_ACKNOWLEDGE', - handoffId: selectedHandoff.id, - acknowledgedAt, - acknowledgedBy, - }); - }, [selectedHandoff]); - - const markOperational = useCallback(() => { - if (!selectedHandoff) return; - const operationalAt = Date.now(); - setHandoffs(current => - current.map(handoff => - handoff.id === selectedHandoff.id - ? { ...handoff, status: 'operational', operationalAt } - : handoff - ) - ); - void pwaHubRepository.dispatch({ - kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', - handoffId: selectedHandoff.id, - operationalAt, - }); - }, [selectedHandoff]); - - const sponsorSignoff = useCallback(() => { - if (!selectedHandoff) return; - const signoff = { approvedAt: Date.now(), approvedBy: { displayName: 'Sponsor' } }; - setHandoffs(current => - current.map(handoff => - handoff.id === selectedHandoff.id - ? { - ...handoff, - status: 'operational', - operationalAt: handoff.operationalAt ?? signoff.approvedAt, - signoff: { ...(handoff.signoff ?? {}), ...signoff }, - } - : handoff - ) - ); - void pwaHubRepository.dispatch({ - kind: 'CONTROL_HANDOFF_SIGNOFF', - handoffId: selectedHandoff.id, - signoff, - }); - }, [selectedHandoff]); - - const heading = activeHub?.name ?? 'No active hub'; - - return ( -
-
-
-

Handoff

-

{heading}

-
- -
- - {!activeHub ? ( -

- Create or select a Process Hub before opening handoff. -

- ) : error ? ( -

- {error} -

- ) : isLoading ? ( -

- Loading handoff... -

- ) : selectedHandoff ? ( - - ) : selectedRecord ? ( -

- Creating handoff... -

- ) : ( -

- Confirm sustainment before recording handoff. -

- )} -
- ); -}; - -export default HandoffPanel; diff --git a/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts b/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts index ab20cc6c2..13253104e 100644 --- a/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts +++ b/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts @@ -162,10 +162,10 @@ describe('panelsStore', () => { expect(usePanelsStore.getState().activeView).toBe('sustainment'); }); - it('showHandoff sets activeView to handoff', () => { + it('showHandoff redirects to sustainment (handoff folded into sustainment in wedge V1)', () => { usePanelsStore.getState().showHandoff('sr-1'); - expect(usePanelsStore.getState().activeView).toBe('handoff'); - expect(usePanelsStore.getState().handoffTargetId).toBe('sr-1'); + expect(usePanelsStore.getState().activeView).toBe('sustainment'); + expect(usePanelsStore.getState().sustainmentTargetId).toBe('sr-1'); }); }); diff --git a/apps/pwa/src/features/panels/panelsStore.ts b/apps/pwa/src/features/panels/panelsStore.ts index b3d3e19d4..668affecb 100644 --- a/apps/pwa/src/features/panels/panelsStore.ts +++ b/apps/pwa/src/features/panels/panelsStore.ts @@ -120,8 +120,13 @@ export const usePanelsStore = create(set => ({ isFindingsOpen: false, sustainmentTargetId: targetId ?? null, }), + // Alias for showSustainment — wedge V1 folds Handoff into Sustainment-closure (ADR-082). Inbox prompts + context links still emit surface === 'handoff'; routing through this alias keeps them reachable. showHandoff: targetId => - set({ activeView: 'handoff', isFindingsOpen: false, handoffTargetId: targetId ?? null }), + set({ + activeView: 'sustainment', + isFindingsOpen: false, + sustainmentTargetId: targetId ?? null, + }), // Simple toggles setSettingsOpen: open => set({ isSettingsOpen: open }), diff --git a/packages/ui/src/components/Handoff/HandoffForm.tsx b/packages/ui/src/components/Handoff/HandoffForm.tsx deleted file mode 100644 index 283e28528..000000000 --- a/packages/ui/src/components/Handoff/HandoffForm.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import React from 'react'; -import { CheckCircle2, Lock } from 'lucide-react'; -import type { ControlHandoff, ControlHandoffSurface, SustainmentRecord } from '@variscout/core'; -import { CollapsibleSection } from '../ImprovementProject/CollapsibleSection'; - -export interface HandoffFormProps { - handoff: ControlHandoff; - sustainmentRecord?: SustainmentRecord; - isPaidTier?: boolean; - onHandoffChange?: (patch: HandoffChangePatch) => void; - onAcknowledge?: () => void; - onMarkOperational?: () => void; - onSponsorSignoff?: () => void; -} - -export type HandoffChangePatch = Partial< - Pick< - ControlHandoff, - | 'surface' - | 'systemName' - | 'description' - | 'referenceUri' - | 'retainSustainmentReview' - | 'escalationPath' - | 'reactionPlan' - > -> & { - operationalOwner?: ControlHandoff['operationalOwner']; - handoffDate?: ControlHandoff['handoffDate']; -}; - -const surfaceOptions: ControlHandoffSurface[] = [ - 'mes-recipe', - 'scada-alarm', - 'qms-procedure', - 'work-instruction', - 'training-record', - 'audit-program', - 'dashboard-only', - 'ticket-queue', - 'other', -]; - -const labelClassName = 'block space-y-2'; -const labelTextClassName = 'text-sm font-medium text-content'; -const inputClassName = - 'w-full rounded-md border border-edge bg-surface px-3 py-2 text-sm text-content shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'; -const disabledInputClassName = `${inputClassName} disabled:cursor-not-allowed disabled:bg-surface-secondary disabled:text-content/60`; -const metadataClassName = - 'rounded border border-edge bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content/70'; -const actionButtonClassName = - 'inline-flex items-center justify-center gap-2 rounded-md border border-edge bg-surface px-3 py-2 text-sm font-medium text-content hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60'; - -function formatLabel(value: string | undefined): string { - return value?.replaceAll('-', ' ') ?? 'not set'; -} - -function dateInputValue(value: number | undefined): string { - if (value === undefined) return ''; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ''; - return date.toISOString().slice(0, 10); -} - -function timestampFromDateInput(value: string): number | undefined { - if (!value) return undefined; - const time = new Date(`${value}T00:00:00.000Z`).getTime(); - return Number.isNaN(time) ? undefined : time; -} - -export const HandoffForm: React.FC = ({ - handoff, - sustainmentRecord, - isPaidTier = false, - onHandoffChange, - onAcknowledge, - onMarkOperational, - onSponsorSignoff, -}) => { - const isReadOnly = !onHandoffChange; - const isAcknowledged = handoff.status === 'acknowledged' || handoff.status === 'operational'; - const isOperational = handoff.status === 'operational'; - - return ( -
- -
- - - - - - - - -