Skip to content

Commit 8a1c46f

Browse files
github-actions[bot]Marfuenclaude
authored
fix(treatment-plan): cap linked-work lists and treatment plan body height
* fix(treatment-plan): cap linked-work lists and treatment plan body height Long Tasks/Controls lists or AI-generated treatment plans were stretching the right (Linked Work) and middle (Treatment plan) columns past the Strategy column, leaving the row visually unbalanced and forcing the whole page to scroll just to see the bottom controls. - LinkedWork: each list (Tasks, Controls) now caps at max-h-80 with internal overflow-y-auto. The progress bar / counts stay pinned. - DescriptionEditor: markdown preview and the auto-growing textarea share a TEXTAREA_MAX_PX (480) cap. Past the cap, internal scroll takes over instead of pushing the row down. Net effect: the three columns stay roughly aligned regardless of how many tasks/controls are linked or how long the AI's plan runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(treatment-plan): paginate linked tasks and controls (4 per page) + seed fix LinkedWork - Each list (Tasks, Controls) now paginates at 4 items per page with prev/next + "X–Y of N" controls. Replaces the prior height cap + internal scroll. Long linked sets stay visually compact and the Linked Work column no longer pushes past the Strategy / Treatment plan columns regardless of list size. - Pagination state resets defensively when the underlying list shrinks past the current page (e.g. after an unlink). Seed fix (FrameworkEditorControlTemplate.json) - `documentTypes: null` → `documentTypes: []` for all 204 rows. The schema requires a non-nullable `EvidenceFormType[]` (default `[]`) but the export was writing nulls, which Prisma rejects on upsert. - Mapped enum values from kebab-case (`"infrastructure-inventory"`) to the TS-side snake_case (`"infrastructure_inventory"`). Prisma's client expects the enum identifier, not the `@map`'d DB string. Together these unblock `bun run db:seed` from a clean DB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(seed): refresh framework editor templates + finding templates Updates the seed JSON exports to the latest snapshot. Touches: - FindingTemplate: regenerated with current cuid-style ids and the current title/content set the audit team is using. - FrameworkEditorFramework / PolicyTemplate / Requirement / TaskTemplate: refreshed primitives to match the live framework editor state. - Three Control↔(Task|Policy|Requirement) join files: refreshed to match the new template ids. bun.lock: dedup of @jridgewell/trace-mapping pulled in by `bun install`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(treatment-plan): hide linked work for non-mitigate + drop Avoid as a new selection Reverts two changes I introduced for Cubic findings #36 / #37 — they were technically correct readings of "ENG-221 keeps controls/tasks visible" but the user-intent was the opposite: Linked Work and the Avoid strategy are Mitigate-shaped concerns and shouldn't surface for treatments that aren't operational reductions. - TreatmentPlanTab: `showLinkedWorkColumn` is gated on `isMitigate && hasLinkedWork` again. Accept ("live with the risk"), Transfer (insurance / contractual instruments), and Avoid (discontinue the activity) are not control-driven mitigations, so the column adds noise and a misread when shown. - StrategyPicker: Avoid is back to legacy-only — never offered as a new selection, but rendered for risks that are already set to Avoid so existing state isn't dropped silently. - ScoreExplainer: dropped the Avoid bullet and the Avoid mention in the coverage-gate paragraph, matching what the picker now offers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(treatment-plan): url tabs, regen refresh, scroll caps, 4-per-page UX fixes layered into a single commit so they ship together. - RiskPageClient + VendorDetailTabs: active tab is now URL-backed via useQueryState('tab'), and only the active panel is mounted. This bookmarks the current tab in `?tab=...` (refresh keeps the view) and eliminates the visible "both panels stacked" flash on switch (base-ui keeps the outgoing panel at full opacity for the duration of the incoming `fade-in-0 duration-200` animation). - TreatmentPlanTab: drop the AutoMitigationPlaceholder ("AI is preparing your treatment plan…"). Its heuristic stayed true forever whenever the AI mitigation never wrote a description, so users sat on an indefinite spinner. Falling through to DescriptionEditor gives them the explicit "Generate treatment plan" button. - DescriptionEditor: regen-completion now bypasses the in-edit draft-resync guard. When the user clicks "Regenerate with AI" while in edit mode, they're explicitly opting into an overwrite, but the existing guard kept the textarea showing the old draft until refresh. Tracking regenRun's prev state and forcing preview + setDraft(value) on transition fixes it. - ScoreExplainer: cap the popover body at max-h-[70vh] with overflow-y-auto. The narrative + formulas + references could exceed the viewport on shorter screens with no way to reach the bottom. - AutoLinkSuggestions.sections: PAGE_SIZE 10 → 4 to match the new LinkedWork pagination. Selection UI now feels consistent with the post-apply view. - LinkedWork: explicit list-none + pl-0 on the Tasks/Controls `<ul>` to defeat an inherited list-style coming from somewhere (random bullet + ~40px left padding on each row). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(treatment-plan): drop orphan dot + checkbox-spacer indent on control rows Two visual bugs in the Controls list of AutoLinkSuggestions: - When `c.code` is empty (some frameworks ship controls without a code, e.g. "PCI"), the row rendered "· PCI" because the prefix format was unconditional `${c.code} · ${c.name}`. Now we only prepend the "code · " segment when a code exists. - Each row had a 16x16 invisible spacer <span> meant to align the control text with the task text below the task-row checkbox. In practice it just pushed controls ~24px to the right (spacer + gap), making them look indented relative to the section header and the Tasks rows above. Dropped the spacer so controls now align to the left edge of the column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(onboarding): calibrate vendor inherent-risk prompt so well-known SaaS isn't 10/10 The previous system prompt for the AI vendor extraction had zero calibration guidance — just "return inherent_probability and inherent_impact". Without a rubric, gpt-4.1-mini defaulted every vendor to (very_likely × severe) = 25 → 10/10 CRITICAL. GitHub landed at 10/10 even though the same vendor card shows it carries SOC 2 + ISO 27001 + ISO 42001 + four other certifications. Adds explicit calibration: - Per-bucket definitions for inherent_probability (very_unlikely through very_likely), each anchored to concrete vendor archetypes (hyperscaler / SaaS-with-SOC-2 / no-attestation / etc). - Per-bucket definitions for inherent_impact, anchored to data classes (none / metadata / PII / auth-or-source-or-payments / production-infrastructure). - Explicit DEFAULT for residual: "leave residual = inherent unless the user's answers describe their OWN compensating controls." The vendor's certifications already feed into inherent — they shouldn't be double-counted as residual reductions. - Sanity-check sentence naming the well-known vendors that should land at (unlikely, moderate) ≈ 3/10, not 10/10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(onboarding): score vendor inherent risk from user signals, not name lookups Drop the hardcoded list of "well-known SaaS" vendor names from the inherent-risk calibration prompt. Maintaining a list inside an LLM prompt is brittle (every new well-known vendor is a prompt edit) and asks the LLM to reason from its prior knowledge rather than from the data we actually have. The new prompt makes two things explicit: 1. The model is scoring from the USER'S answers only — it does NOT have access to the vendor's public posture (SOC 2, ISO 27001, incident history). A separate research step (research-vendor → GlobalVendors) fills that in later, and a follow-up will use the researched data to refine the per-org Vendor row's score. 2. Default to MIDDLE (possible × moderate ≈ 5/10) when no signal exists. Only deviate when the user's answers contain explicit signals — listed by category (lowers probability / raises probability / lowers impact / raises impact). When the user simply NAMES the vendor with no further context, return (possible, moderate) and let the research step refine. This fixes the "GitHub got 10/10" pathology without coupling the prompt to a vendor name registry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(trigger): throttle slow tasks so they don't hog dev's 25-slot budget Trigger.dev's dev environment is hardcoded at 25 concurrent runs (no extra purchasable). With our onboarding fan-out (10 research-vendor + 21 mitigations + 30+ update-policy + a few coordinators) we routinely queue 70+ jobs. The slow tasks were holding slots long enough that the fast, user-visible mitigation tasks sat queued for minutes. Two cap changes, both at the queue level so they're effective in dev but don't hurt prod (which has 240 slots and won't notice): - update-policy: 50 → 5. Each policy LLM update takes ~20-40s. With no cap, 30+ policies firing simultaneously would fill every slot for the entire policy window, starving generate-risk-mitigation and generate-vendor-mitigation that fire right after. - research-vendor: NEW queue, capped at 5. Each scrape can hold a slot for minutes (firecrawl + LLM extraction). 10 of them firing in parallel was eating 40% of the dev budget on its own. Net effect: fast tasks (mitigations, ~10s each) get to run promptly while slow tasks (policies ~25s, scrapes ~60s+) drain in the background. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(treatment-plan): default strategy to mitigate + force mitigate when AI plan lands Three layered bugs all rooted in the schema default + the AI plan ending up under the wrong strategy slot: 1. Vendors (and risks) defaulted to `accept`. Most users want to actively mitigate via controls/tasks, not accept the inherent risk — accept is for documented exception cases. 2. The AI mitigation generator wrote its plan into the entity's *current* strategy slot. With default = accept, the plan landed under map[accept] AND treatmentStrategyDescription with strategy = accept. The user saw their AI-generated mitigation plan labeled as the "Accept rationale" — the symptom they reported. 3. Switching strategies looked broken because the plan was in the wrong slot. Switching mitigate → accept showed the plan correctly (it was stored under accept), and switching accept → mitigate looked empty (nothing was stored under mitigate). Fixes: - Schema migration 20260506130119_default_treatment_strategy_to_ mitigate flips Risk.treatmentStrategy and Vendor.treatmentStrategy defaults from `accept` to `mitigate`. - New helper `applyMitigationPlanFields` in lib/strategy-descriptions unconditionally writes the AI plan to map[mitigate] AND forces treatmentStrategy=mitigate. Any prior non-mitigate description on the entity is preserved under its own slot, so users on existing rows with an Accept rationale don't lose their text. - generate-risk-mitigation and generate-vendor-mitigation both use the new helper instead of mirroring into whatever the active strategy happens to be. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(onboarding): drop LLM-picked treatment strategy when creating risks Even with the schema default flipped to mitigate, risks were still landing on whatever strategy the extraction LLM picked (`risk_treatment_strategy` in the structured output). The field is ignored at the create-call sites now — risks are always created with the schema default (mitigate) so the AI mitigation plan that runs immediately after lands under the correct slot. The field stays in the LLM schema/RiskData type so the prompt doesn't change shape (small blast radius); cleanup of the dead field is a separate refactor. Both create paths covered: - createRisksFromData (LLM-only) - createRisksFromDataWithBaseline (LLM + baseline) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vendors): residual column reflects current treatment progress, not static residual Mirror the risks-table pattern. The Vendors table previously showed the static `residualProbability × residualImpact` fields, which never move once set — so a vendor with a treatment plan in progress always looked the same as one with no progress. Now the column shows the score interpolated by linked-task completion, identical to the treatment-plan hero. - API (apps/api/src/vendors/vendors.service.ts): findAllByOrganization now `include`s `tasks: { select: { id, status } }`. Same shape the risks service has. - Frontend (VendorsTable.tsx): adds `currentVendorSeverityScore` helper using `previewResidual` + `suggestedResidual` + `interpolatedResidualScore`, mirroring the risks-side helper exactly. The residual cell renders the badge from this score instead of the static fields, and the residual sort uses the same number so order matches the displayed values. Result: a vendor on Mitigate with 50% of linked tasks complete renders mid-way between inherent and target, like the hero numeral on the vendor detail page already did. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): add Inherent column to risks table Three score columns now paint the before-vs-now picture explicitly: - INHERENT — raw score before any treatment (fixed once at risk creation). Renders as a colored chip showing the score. - SEVERITY — current treatment-aware level (Low / Medium / High / Critical / Negligible) as plain text. - RISK SCORE — current treatment-aware score, interpolated by linked-task completion. Colored chip carries the band so we don't double-paint with SEVERITY. Lets users see at a glance how far their treatment is moving each risk (e.g. inherent 8 → current 5 means the treatment is doing work). Mirrors the inherent + residual pair already visible on the Vendors table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): reorder columns — severity before inherent Order is now: RISK | SEVERITY | INHERENT | RISK SCORE | STATUS | OWNER | UPDATED Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): rename Risk Score column → Residual Now matches the Vendors table naming and the standard inherent / residual GRC vocabulary. Order: RISK | SEVERITY | INHERENT | RESIDUAL | STATUS | OWNER | UPDATED Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tables): rename residual column to current The score we render in this column is the interpolated current score (updates with treatment progress), not the canonical residual (which is the target at 100% completion — what the hero's right-side arrow number shows). Calling it 'Residual' caused a real mismatch: hero showed 4 → 2, table showed 4 → 3 for the same risk. Renamed both tables for consistency. Sort key 'residualRisk' kept as-is (URL-stable, internal); only the visible label changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): suffix score columns with 'risk' for clarity Both score columns now read 'INHERENT RISK' and 'CURRENT RISK', matching the Vendors table convention where the same suffix is already in use. * fix: handle async regen value + label prose with mitigate (cubic 2764) Two findings from cubic on PR #2764: P1 — DescriptionEditor regen async-arrival: When a regenerate-with-AI run completed, the parent flipped `regenRun` to null and called `mutateRisk()`. The new AI prose could arrive in either: (a) the same render (sync) — `value` already updated, OR (b) a later render after SWR refetched — `value` lagged. My previous fix only handled (a): on the regenRun set→null transition, it called setDraft(value) once. If the prose was arriving via (b), value was still stale at that moment, the immediate setDraft applied the OLD value, and the resync effect that would normally pick up the new value skipped because mode === 'edit'. Net effect: user sat on the old text until refresh. Fix captures value-at-regen-clear-time in a ref. A second effect watches for value to differ from the captured snapshot and applies the new prose then. Both sync and async paths now land the AI text. P2 — Mitigation prose labeled with old strategy: `combineSentencesWithCitations` builds the prose with header `Treatment plan (${treatmentStrategy})`, but `applyMitigationPlanFields` forces the saved `treatmentStrategy` to 'mitigate'. So a row that was on 'accept' before the AI plan ran ended up with prose labeled "Treatment plan (accept)" stored under strategy=mitigate. Pin the prose label + the strategy in the LLM prompt to 'mitigate' (literal) at both call sites — risk and vendor generators. Same source of truth as the persistence side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mariano <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06e218b commit 8a1c46f

11 files changed

Lines changed: 243 additions & 54 deletions

File tree

apps/api/src/vendors/vendors.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ export class VendorsService {
107107
},
108108
},
109109
},
110+
// Linked task statuses are needed by the vendors table to compute
111+
// the current (interpolated) severity score so the residual badge
112+
// reflects treatment progress, not just the static residual
113+
// probability/impact. Mirrors the risks service.
114+
tasks: { select: { id: true, status: true } },
110115
},
111116
});
112117

apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,8 @@ export const RisksTable = ({
625625
</button>
626626
</TableHead>
627627
<TableHead>SEVERITY</TableHead>
628-
<TableHead>RISK SCORE</TableHead>
628+
<TableHead>INHERENT RISK</TableHead>
629+
<TableHead>CURRENT RISK</TableHead>
629630
<TableHead>STATUS</TableHead>
630631
<TableHead>OWNER</TableHead>
631632
<TableHead>
@@ -658,18 +659,29 @@ export const RisksTable = ({
658659
</HStack>
659660
</TableCell>
660661
{(() => {
661-
// Both columns describe the *current* treatment-aware
662-
// state. SEVERITY shows the qualitative level as
663-
// plain text (no chip — the colored chip is on RISK
664-
// SCORE, so a single visual signal carries the band
665-
// and the second column adds the precise number).
662+
// Three score columns paint the before-vs-now picture:
663+
// SEVERITY = current treatment-aware level (text).
664+
// INHERENT = raw score before treatment, fixed.
665+
// CURRENT = treatment-aware score interpolated by
666+
// linked-task completion. Named "Current"
667+
// (not "Residual") because the canonical
668+
// residual is the *target* score at 100%
669+
// completion — what's shown here moves
670+
// with progress and matches the hero's
671+
// "Currently X/10" subline.
672+
// SEVERITY is plain text and CURRENT carries the
673+
// colored chip so we don't double-paint the band.
674+
const inherentScore = getRiskScore(risk.likelihood, risk.impact).score;
666675
const score = currentSeverityScore(risk);
667676
const level = getRiskLevelFromScore(score);
668677
return (
669678
<>
670679
<TableCell>
671680
<Text>{LEVEL_LABEL[level]}</Text>
672681
</TableCell>
682+
<TableCell>
683+
<RiskScoreBadge score={inherentScore} />
684+
</TableCell>
673685
<TableCell>
674686
<RiskScoreBadge score={score} />
675687
</TableCell>

apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { VendorStatus } from '@/components/vendor-status';
66
import { usePermissions } from '@/hooks/use-permissions';
77
import { useVendors, useVendorActions, type Vendor } from '@/hooks/use-vendors';
88
import { getRiskScore } from '@/lib/risk-score';
9+
import {
10+
interpolatedResidualScore,
11+
previewResidual,
12+
suggestedResidual,
13+
} from '@/lib/suggested-residual';
14+
import type { TaskStatus } from '@db';
915
import {
1016
AlertDialog,
1117
AlertDialogAction,
@@ -54,6 +60,40 @@ export type VendorRow = Vendor & {
5460
isAssessing?: boolean;
5561
};
5662

63+
/**
64+
* Mirrors `currentSeverityScore` in the risks table — projects the vendor's
65+
* inherent + treatment-strategy + linked-task completion into the same
66+
* interpolated 1–10 score the Treatment Plan hero shows. Falls back to
67+
* inherent when there's no linked work or strategy doesn't reduce.
68+
*/
69+
function currentVendorSeverityScore(vendor: {
70+
inherentProbability: VendorRow['inherentProbability'];
71+
inherentImpact: VendorRow['inherentImpact'];
72+
treatmentStrategy: VendorRow['treatmentStrategy'];
73+
tasks?: Array<{ status: TaskStatus }>;
74+
}): number {
75+
const inherent = getRiskScore(vendor.inherentProbability, vendor.inherentImpact);
76+
const tasks = vendor.tasks ?? [];
77+
const target = previewResidual({
78+
inherentLikelihood: vendor.inherentProbability,
79+
inherentImpact: vendor.inherentImpact,
80+
strategy: vendor.treatmentStrategy,
81+
hasLinkedWork: tasks.length > 0,
82+
});
83+
const targetScore = getRiskScore(target.likelihood, target.impact).score;
84+
const completion = suggestedResidual({
85+
likelihood: vendor.inherentProbability,
86+
impact: vendor.inherentImpact,
87+
strategy: vendor.treatmentStrategy,
88+
tasks,
89+
}).completion;
90+
return interpolatedResidualScore({
91+
inherentScore: inherent.score,
92+
targetScore,
93+
completion,
94+
});
95+
}
96+
5797
type AssigneeMember = {
5898
id: string;
5999
role: string;
@@ -349,8 +389,11 @@ export function VendorsTable({
349389
const aAssessed = a.status === 'assessed';
350390
const bAssessed = b.status === 'assessed';
351391
if (aAssessed !== bAssessed) return aAssessed ? -1 : 1;
352-
const aScore = getRiskScore(a.residualProbability, a.residualImpact).raw;
353-
const bScore = getRiskScore(b.residualProbability, b.residualImpact).raw;
392+
// Sort by the SAME interpolated score the badge renders so the
393+
// sort order matches what the user sees (treatment-progress
394+
// aware), not the static residual fields.
395+
const aScore = currentVendorSeverityScore(a);
396+
const bScore = currentVendorSeverityScore(b);
354397
const comparison = aScore - bScore;
355398
return sort.desc ? -comparison : comparison;
356399
}
@@ -571,7 +614,7 @@ export function VendorsTable({
571614
onClick={() => handleSort('residualRisk')}
572615
className="flex items-center hover:text-foreground"
573616
>
574-
RESIDUAL RISK
617+
CURRENT RISK
575618
{getSortIcon('residualRisk')}
576619
</button>
577620
</TableHead>
@@ -610,10 +653,12 @@ export function VendorsTable({
610653
{vendor.status === 'not_assessed' ? (
611654
<Text variant="muted" size="sm"></Text>
612655
) : (
613-
<RiskScoreBadge
614-
likelihood={vendor.residualProbability}
615-
impact={vendor.residualImpact}
616-
/>
656+
// Show the current (interpolated) score that
657+
// reflects how far the linked tasks have driven
658+
// the residual down — same logic the risks table
659+
// uses. Static residualProbability / Impact alone
660+
// can't reflect mid-treatment progress.
661+
<RiskScoreBadge score={currentVendorSeverityScore(vendor)} />
617662
)}
618663
</TableCell>
619664
<TableCell>

apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,50 @@ export function DescriptionEditor({
113113
// Regenerate-with-AI bypasses the in-edit guard above. When a regen run
114114
// terminates (`regenRun` flips from set → null), the user explicitly
115115
// asked to overwrite whatever they had — keeping the stale draft and
116-
// requiring a refresh to see the new prose was confusing. Force a
117-
// preview reset so the new value lands immediately.
116+
// requiring a refresh to see the new prose was confusing.
117+
//
118+
// The new prose may already be in `value` at the moment regenRun
119+
// clears (sync write before the parent flips the run handle), or it
120+
// may arrive in a later render after SWR refetches. Both paths are
121+
// handled:
122+
//
123+
// 1. Sync arrival: when regenRun flips set→null, immediately apply
124+
// the current value and force preview.
125+
// 2. Async arrival: capture the value-at-clear-time. The next render
126+
// where `value` differs from the captured snapshot is the AI prose
127+
// landing — apply it, force preview, and clear the latch.
128+
//
129+
// Without (2), a regen that completes BEFORE the SWR refetch would
130+
// sync-apply the OLD value, and the new prose arriving moments later
131+
// would be ignored because the in-edit guard skips resync while
132+
// mode === 'edit'.
118133
const prevRegenRunRef = useRef(regenRun);
134+
const valueAtRegenClearRef = useRef<string | null>(null);
119135
useEffect(() => {
120-
const wasRunning = prevRegenRunRef.current !== null && prevRegenRunRef.current !== undefined;
121-
const isRunning = regenRun !== null && regenRun !== undefined;
136+
const wasRunning = prevRegenRunRef.current != null;
137+
const isRunning = regenRun != null;
122138
prevRegenRunRef.current = regenRun;
123139
if (wasRunning && !isRunning) {
140+
// Path 1: sync arrival — value has already updated.
141+
valueAtRegenClearRef.current = value;
124142
setDraft(value);
125143
if (value.trim().length > 0) setMode('preview');
126144
}
127145
}, [regenRun, value]);
128146

147+
useEffect(() => {
148+
const captured = valueAtRegenClearRef.current;
149+
if (captured === null) return;
150+
if (value === captured) return;
151+
// Path 2: async arrival — value just changed since regen cleared,
152+
// so this is the AI prose landing. Overwrite even if user is in
153+
// edit mode (they explicitly opted into the overwrite by clicking
154+
// Regenerate).
155+
valueAtRegenClearRef.current = null;
156+
setDraft(value);
157+
if (value.trim().length > 0) setMode('preview');
158+
}, [value]);
159+
129160
// Auto-grow the textarea to fit content, but cap at TEXTAREA_MAX_PX so a
130161
// long draft doesn't stretch the Treatment plan column past the Strategy
131162
// / Linked Work columns. Internal scroll kicks in past the cap.

apps/app/src/lib/strategy-descriptions.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,52 @@ export function mirrorActiveDescriptionIntoMap({
2727
}
2828
return map;
2929
}
30+
31+
/**
32+
* Build the data fields to write when the AI mitigation generator emits a
33+
* fresh treatment plan. Always lands the plan under the `mitigate` slot
34+
* (the plan IS a mitigation plan), forces the active strategy to
35+
* mitigate so the user sees the new plan in the correct column, and
36+
* preserves any existing non-mitigate description under its own slot so
37+
* the user's prior Accept / Transfer / Avoid rationale isn't lost.
38+
*
39+
* Used by both generate-risk-mitigation and generate-vendor-mitigation.
40+
* Without this guarantee, entities created with a non-mitigate default
41+
* have the AI plan stored under the wrong strategy and switching back
42+
* to mitigate looks empty (the bug fixed in this commit).
43+
*/
44+
export function applyMitigationPlanFields({
45+
plan,
46+
currentStrategy,
47+
currentDescription,
48+
currentMap,
49+
}: {
50+
plan: string;
51+
currentStrategy: string;
52+
currentDescription: string | null;
53+
currentMap: unknown;
54+
}): {
55+
treatmentStrategy: 'mitigate';
56+
treatmentStrategyDescription: string;
57+
strategyDescriptions: Record<string, string>;
58+
} {
59+
const map: Record<string, string> = {};
60+
if (currentMap && typeof currentMap === 'object' && !Array.isArray(currentMap)) {
61+
for (const [k, v] of Object.entries(currentMap as Record<string, unknown>)) {
62+
if (typeof v === 'string' && v.length > 0) map[k] = v;
63+
}
64+
}
65+
if (
66+
currentStrategy !== 'mitigate' &&
67+
currentDescription &&
68+
currentDescription.length > 0
69+
) {
70+
map[currentStrategy] = currentDescription;
71+
}
72+
map.mitigate = plan;
73+
return {
74+
treatmentStrategy: 'mitigate',
75+
treatmentStrategyDescription: plan,
76+
strategyDescriptions: map,
77+
};
78+
}

0 commit comments

Comments
 (0)