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
6ab9cbd
fix(treatment-plan): cap linked-work lists and treatment plan body he…
Marfuen May 6, 2026
c4f3699
fix(treatment-plan): paginate linked tasks and controls (4 per page) …
Marfuen May 6, 2026
8c72e20
chore(seed): refresh framework editor templates + finding templates
Marfuen May 6, 2026
11d1c11
fix(treatment-plan): hide linked work for non-mitigate + drop Avoid a…
Marfuen May 6, 2026
894abe4
fix(treatment-plan): url tabs, regen refresh, scroll caps, 4-per-page
Marfuen May 6, 2026
f9bd0bb
fix(treatment-plan): drop orphan dot + checkbox-spacer indent on cont…
Marfuen May 6, 2026
c41deae
fix(onboarding): calibrate vendor inherent-risk prompt so well-known …
Marfuen May 6, 2026
7f20408
refactor(onboarding): score vendor inherent risk from user signals, n…
Marfuen May 6, 2026
e3988a4
perf(trigger): throttle slow tasks so they don't hog dev's 25-slot bu…
Marfuen May 6, 2026
5fef797
fix(treatment-plan): default strategy to mitigate + force mitigate wh…
Marfuen May 6, 2026
77402f8
fix(onboarding): drop LLM-picked treatment strategy when creating risks
Marfuen May 6, 2026
3dd7f58
fix(vendors): residual column reflects current treatment progress, no…
Marfuen May 6, 2026
0eaf5c8
feat(risks): add Inherent column to risks table
Marfuen May 6, 2026
1e4cc8e
fix(risks): reorder columns — severity before inherent
Marfuen May 6, 2026
69b344d
fix(risks): rename Risk Score column → Residual
Marfuen May 6, 2026
7b7022b
fix(tables): rename residual column to current
Marfuen May 6, 2026
2ec17ac
fix(risks): suffix score columns with 'risk' for clarity
Marfuen May 6, 2026
3ab0b77
Merge branch 'main' into mariano/treatment-plan-scroll
Marfuen May 6, 2026
a93b573
fix: handle async regen value + label prose with mitigate (cubic 2764)
Marfuen May 6, 2026
e047ddf
Merge remote-tracking branch 'origin/mariano/treatment-plan-scroll' i…
Marfuen May 6, 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
5 changes: 5 additions & 0 deletions apps/api/src/vendors/vendors.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ export class VendorsService {
},
},
},
// Linked task statuses are needed by the vendors table to compute
// the current (interpolated) severity score so the residual badge
// reflects treatment progress, not just the static residual
// probability/impact. Mirrors the risks service.
tasks: { select: { id: true, status: true } },
},
});

Expand Down
24 changes: 18 additions & 6 deletions apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,8 @@ export const RisksTable = ({
</button>
</TableHead>
<TableHead>SEVERITY</TableHead>
<TableHead>RISK SCORE</TableHead>
<TableHead>INHERENT RISK</TableHead>
<TableHead>CURRENT RISK</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>OWNER</TableHead>
<TableHead>
Expand Down Expand Up @@ -658,18 +659,29 @@ export const RisksTable = ({
</HStack>
</TableCell>
{(() => {
// Both columns describe the *current* treatment-aware
// state. SEVERITY shows the qualitative level as
// plain text (no chip — the colored chip is on RISK
// SCORE, so a single visual signal carries the band
// and the second column adds the precise number).
// Three score columns paint the before-vs-now picture:
// SEVERITY = current treatment-aware level (text).
// INHERENT = raw score before treatment, fixed.
// CURRENT = treatment-aware score interpolated by
// linked-task completion. Named "Current"
// (not "Residual") because the canonical
// residual is the *target* score at 100%
// completion — what's shown here moves
// with progress and matches the hero's
// "Currently X/10" subline.
// SEVERITY is plain text and CURRENT carries the
// colored chip so we don't double-paint the band.
const inherentScore = getRiskScore(risk.likelihood, risk.impact).score;
const score = currentSeverityScore(risk);
const level = getRiskLevelFromScore(score);
return (
<>
<TableCell>
<Text>{LEVEL_LABEL[level]}</Text>
</TableCell>
<TableCell>
<RiskScoreBadge score={inherentScore} />
</TableCell>
<TableCell>
<RiskScoreBadge score={score} />
</TableCell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { VendorStatus } from '@/components/vendor-status';
import { usePermissions } from '@/hooks/use-permissions';
import { useVendors, useVendorActions, type Vendor } from '@/hooks/use-vendors';
import { getRiskScore } from '@/lib/risk-score';
import {
interpolatedResidualScore,
previewResidual,
suggestedResidual,
} from '@/lib/suggested-residual';
import type { TaskStatus } from '@db';
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -54,6 +60,40 @@ export type VendorRow = Vendor & {
isAssessing?: boolean;
};

/**
* Mirrors `currentSeverityScore` in the risks table — projects the vendor's
* inherent + treatment-strategy + linked-task completion into the same
* interpolated 1–10 score the Treatment Plan hero shows. Falls back to
* inherent when there's no linked work or strategy doesn't reduce.
*/
function currentVendorSeverityScore(vendor: {
inherentProbability: VendorRow['inherentProbability'];
inherentImpact: VendorRow['inherentImpact'];
treatmentStrategy: VendorRow['treatmentStrategy'];
tasks?: Array<{ status: TaskStatus }>;
}): number {
const inherent = getRiskScore(vendor.inherentProbability, vendor.inherentImpact);
const tasks = vendor.tasks ?? [];
const target = previewResidual({
inherentLikelihood: vendor.inherentProbability,
inherentImpact: vendor.inherentImpact,
strategy: vendor.treatmentStrategy,
hasLinkedWork: tasks.length > 0,
});
const targetScore = getRiskScore(target.likelihood, target.impact).score;
const completion = suggestedResidual({
likelihood: vendor.inherentProbability,
impact: vendor.inherentImpact,
strategy: vendor.treatmentStrategy,
tasks,
}).completion;
return interpolatedResidualScore({
inherentScore: inherent.score,
targetScore,
completion,
});
}

type AssigneeMember = {
id: string;
role: string;
Expand Down Expand Up @@ -349,8 +389,11 @@ export function VendorsTable({
const aAssessed = a.status === 'assessed';
const bAssessed = b.status === 'assessed';
if (aAssessed !== bAssessed) return aAssessed ? -1 : 1;
const aScore = getRiskScore(a.residualProbability, a.residualImpact).raw;
const bScore = getRiskScore(b.residualProbability, b.residualImpact).raw;
// Sort by the SAME interpolated score the badge renders so the
// sort order matches what the user sees (treatment-progress
// aware), not the static residual fields.
const aScore = currentVendorSeverityScore(a);
const bScore = currentVendorSeverityScore(b);
const comparison = aScore - bScore;
return sort.desc ? -comparison : comparison;
}
Expand Down Expand Up @@ -571,7 +614,7 @@ export function VendorsTable({
onClick={() => handleSort('residualRisk')}
className="flex items-center hover:text-foreground"
>
RESIDUAL RISK
CURRENT RISK
{getSortIcon('residualRisk')}
</button>
</TableHead>
Expand Down Expand Up @@ -610,10 +653,12 @@ export function VendorsTable({
{vendor.status === 'not_assessed' ? (
<Text variant="muted" size="sm">—</Text>
) : (
<RiskScoreBadge
likelihood={vendor.residualProbability}
impact={vendor.residualImpact}
/>
// Show the current (interpolated) score that
// reflects how far the linked tasks have driven
// the residual down — same logic the risks table
// uses. Static residualProbability / Impact alone
// can't reflect mid-treatment progress.
<RiskScoreBadge score={currentVendorSeverityScore(vendor)} />
)}
</TableCell>
<TableCell>
Expand Down
39 changes: 35 additions & 4 deletions apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,50 @@ export function DescriptionEditor({
// Regenerate-with-AI bypasses the in-edit guard above. When a regen run
// terminates (`regenRun` flips from set → null), the user explicitly
// asked to overwrite whatever they had — keeping the stale draft and
// requiring a refresh to see the new prose was confusing. Force a
// preview reset so the new value lands immediately.
// requiring a refresh to see the new prose was confusing.
//
// The new prose may already be in `value` at the moment regenRun
// clears (sync write before the parent flips the run handle), or it
// may arrive in a later render after SWR refetches. Both paths are
// handled:
//
// 1. Sync arrival: when regenRun flips set→null, immediately apply
// the current value and force preview.
// 2. Async arrival: capture the value-at-clear-time. The next render
// where `value` differs from the captured snapshot is the AI prose
// landing — apply it, force preview, and clear the latch.
//
// Without (2), a regen that completes BEFORE the SWR refetch would
// sync-apply the OLD value, and the new prose arriving moments later
// would be ignored because the in-edit guard skips resync while
// mode === 'edit'.
const prevRegenRunRef = useRef(regenRun);
const valueAtRegenClearRef = useRef<string | null>(null);
useEffect(() => {
const wasRunning = prevRegenRunRef.current !== null && prevRegenRunRef.current !== undefined;
const isRunning = regenRun !== null && regenRun !== undefined;
const wasRunning = prevRegenRunRef.current != null;
const isRunning = regenRun != null;
prevRegenRunRef.current = regenRun;
if (wasRunning && !isRunning) {
// Path 1: sync arrival — value has already updated.
valueAtRegenClearRef.current = value;
setDraft(value);
if (value.trim().length > 0) setMode('preview');
}
}, [regenRun, value]);

useEffect(() => {
const captured = valueAtRegenClearRef.current;
if (captured === null) return;
if (value === captured) return;
// Path 2: async arrival — value just changed since regen cleared,
// so this is the AI prose landing. Overwrite even if user is in
// edit mode (they explicitly opted into the overwrite by clicking
// Regenerate).
valueAtRegenClearRef.current = null;
setDraft(value);
if (value.trim().length > 0) setMode('preview');
}, [value]);

// Auto-grow the textarea to fit content, but cap at TEXTAREA_MAX_PX so a
// long draft doesn't stretch the Treatment plan column past the Strategy
// / Linked Work columns. Internal scroll kicks in past the cap.
Expand Down
49 changes: 49 additions & 0 deletions apps/app/src/lib/strategy-descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,52 @@ export function mirrorActiveDescriptionIntoMap({
}
return map;
}

/**
* Build the data fields to write when the AI mitigation generator emits a
* fresh treatment plan. Always lands the plan under the `mitigate` slot
* (the plan IS a mitigation plan), forces the active strategy to
* mitigate so the user sees the new plan in the correct column, and
* preserves any existing non-mitigate description under its own slot so
* the user's prior Accept / Transfer / Avoid rationale isn't lost.
*
* Used by both generate-risk-mitigation and generate-vendor-mitigation.
* Without this guarantee, entities created with a non-mitigate default
* have the AI plan stored under the wrong strategy and switching back
* to mitigate looks empty (the bug fixed in this commit).
*/
export function applyMitigationPlanFields({
plan,
currentStrategy,
currentDescription,
currentMap,
}: {
plan: string;
currentStrategy: string;
currentDescription: string | null;
currentMap: unknown;
}): {
treatmentStrategy: 'mitigate';
treatmentStrategyDescription: string;
strategyDescriptions: Record<string, string>;
} {
const map: Record<string, string> = {};
if (currentMap && typeof currentMap === 'object' && !Array.isArray(currentMap)) {
for (const [k, v] of Object.entries(currentMap as Record<string, unknown>)) {
if (typeof v === 'string' && v.length > 0) map[k] = v;
}
}
if (
currentStrategy !== 'mitigate' &&
currentDescription &&
currentDescription.length > 0
) {
map[currentStrategy] = currentDescription;
}
map.mitigate = plan;
return {
treatmentStrategy: 'mitigate',
treatmentStrategyDescription: plan,
strategyDescriptions: map,
};
}
Loading
Loading