Skip to content

Commit 9b052f5

Browse files
Marfuenclaude
andauthored
fix(risks): treatment plan disappears when switching strategies (#2860)
Root cause: DescriptionEditor stored `draft` and `mode` as independent state synced to the `value` prop via a chain of 4 useEffects. When switching from a strategy with content to one without, mode flipped to 'edit' and the resync effect's edit-mode guard blocked reloading the original plan when switching back. Rewrote the component to use a single `draft: string | null` state — null means preview mode (render value directly from props), string means edit mode. This eliminates the 4 sync effects entirely and makes the state model trivial: enter edit → copy value into draft, save → push draft up and clear, cancel → clear. Strategy changes remount the editor via key={strategy} so internal state initializes fresh from the new value. Also hides the "Generate treatment plan" button for non-Mitigate strategies since regeneration always produces a mitigation plan. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a4adb20 commit 9b052f5

3 files changed

Lines changed: 79 additions & 139 deletions

File tree

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

Lines changed: 44 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,10 @@ import { cn } from '@/lib/utils';
1111
interface DescriptionEditorProps {
1212
value: string;
1313
onSave: (next: string) => Promise<void>;
14-
onRegenerate: () => Promise<void>;
15-
regenerating: boolean;
14+
onRegenerate?: () => Promise<void>;
15+
regenerating?: boolean;
1616
disabled?: boolean;
17-
/**
18-
* The trigger.dev run handle for an in-flight regeneration. When set, the
19-
* editor subscribes via `useRealtimeRun`, renders status-specific progress
20-
* copy, and notifies the parent via `onRegenSettled` when the run reaches
21-
* a terminal state. Null/undefined while no regen is active.
22-
*/
2317
regenRun?: { runId: string; publicAccessToken: string } | null;
24-
/** Called once the regeneration run terminates (success or failure). */
2518
onRegenSettled?: (result: { success: boolean; reason?: string }) => void;
2619
}
2720

@@ -34,32 +27,17 @@ const TERMINAL_FAILURE_STATUSES = new Set([
3427
'TIMED_OUT',
3528
]);
3629

37-
/**
38-
* Cap (in px) for both the markdown preview and the auto-growing textarea.
39-
* Past this height, the body scrolls internally so the Treatment plan column
40-
* stays roughly aligned with the Strategy and Linked Work columns instead
41-
* of pushing the whole row downward when AI emits a long plan.
42-
*/
4330
const TEXTAREA_MAX_PX = 480;
4431

4532
function regenStatusCopy(status: string | undefined): { headline: string; sub: string } {
4633
if (!status || status === 'WAITING_FOR_DEPLOY') {
47-
return {
48-
headline: 'Starting AI scan…',
49-
sub: 'Allocating compute capacity.',
50-
};
34+
return { headline: 'Starting AI scan…', sub: 'Allocating compute capacity.' };
5135
}
5236
if (status === 'QUEUED' || status === 'DELAYED') {
53-
return {
54-
headline: 'Queued — waiting to start…',
55-
sub: 'Your regeneration will begin in a moment.',
56-
};
37+
return { headline: 'Queued — waiting to start…', sub: 'Your regeneration will begin in a moment.' };
5738
}
5839
if (status === 'INTERRUPTED' || status === 'WAITING_TO_RESUME') {
59-
return {
60-
headline: 'Resuming…',
61-
sub: 'Picking up where the run left off.',
62-
};
40+
return { headline: 'Resuming…', sub: 'Picking up where the run left off.' };
6341
}
6442
return {
6543
headline: 'AI is drafting your treatment plan…',
@@ -82,121 +60,55 @@ export function DescriptionEditor({
8260
regenRun,
8361
onRegenSettled,
8462
}: DescriptionEditorProps) {
85-
const [draft, setDraft] = useState(value);
86-
const [saving, setSaving] = useState(false);
87-
// Mode: 'preview' renders markdown, 'edit' shows the auto-growing textarea.
88-
// We default to 'edit' when the value is empty (nothing to preview yet) and
89-
// stay in 'edit' when an AI regeneration completes with new content so the
90-
// user immediately sees what was drafted.
91-
const [mode, setMode] = useState<'preview' | 'edit'>(
92-
value.trim().length > 0 ? 'preview' : 'edit',
63+
// null = preview mode (render value directly), string = edit mode (user's draft).
64+
// Initialized to edit mode when value is empty (nothing to preview).
65+
// Parent uses key={strategy} to remount on strategy change, so this
66+
// always initializes from the correct value — no sync effects needed.
67+
const [draft, setDraft] = useState<string | null>(
68+
value.trim().length > 0 ? null : value,
9369
);
70+
const [saving, setSaving] = useState(false);
9471
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
9572

96-
// Resync the draft from upstream `value` ONLY when the user isn't
97-
// actively editing. Without the `mode === 'edit'` guard, a background
98-
// SWR revalidation, AI regeneration, or any other prop change would
99-
// wipe whatever the user was typing. (Cubic finding on PR #2671.)
100-
useEffect(() => {
101-
if (saving) return;
102-
if (mode === 'edit') return;
103-
setDraft(value);
104-
}, [value, saving, mode]);
105-
106-
// When a fresh value arrives from upstream (regenerate, server update) and
107-
// we're not actively editing, drop back to preview.
108-
useEffect(() => {
109-
if (mode === 'edit' || saving) return;
110-
if (value.trim().length === 0) setMode('edit');
111-
}, [value, mode, saving]);
112-
113-
// Regenerate-with-AI bypasses the in-edit guard above. When a regen run
114-
// terminates (`regenRun` flips from set → null), the user explicitly
115-
// asked to overwrite whatever they had — keeping the stale draft and
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'.
133-
const prevRegenRunRef = useRef(regenRun);
134-
const valueAtRegenClearRef = useRef<string | null>(null);
135-
useEffect(() => {
136-
const wasRunning = prevRegenRunRef.current != null;
137-
const isRunning = regenRun != null;
138-
prevRegenRunRef.current = regenRun;
139-
if (wasRunning && !isRunning) {
140-
// Path 1: sync arrival — value has already updated.
141-
valueAtRegenClearRef.current = value;
142-
setDraft(value);
143-
if (value.trim().length > 0) setMode('preview');
144-
}
145-
}, [regenRun, value]);
146-
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]);
73+
const isEditing = draft !== null;
74+
const displayText = draft ?? value;
75+
const hasValue = value.trim().length > 0;
76+
const isDirty = isEditing && draft.trim() !== value.trim();
77+
const wordCount = countWords(displayText);
78+
const charCount = displayText.length;
15979

160-
// Auto-grow the textarea to fit content, but cap at TEXTAREA_MAX_PX so a
161-
// long draft doesn't stretch the Treatment plan column past the Strategy
162-
// / Linked Work columns. Internal scroll kicks in past the cap.
16380
useLayoutEffect(() => {
164-
if (mode !== 'edit') return;
81+
if (!isEditing) return;
16582
const el = textareaRef.current;
16683
if (!el) return;
16784
el.style.height = 'auto';
16885
const next = Math.max(Math.min(el.scrollHeight, TEXTAREA_MAX_PX), 200);
16986
el.style.height = `${next}px`;
17087
el.style.overflowY = el.scrollHeight > TEXTAREA_MAX_PX ? 'auto' : 'hidden';
171-
}, [draft, mode]);
172-
173-
const isDirty = draft.trim() !== (value ?? '').trim();
174-
const wordCount = countWords(draft);
175-
const charCount = draft.length;
176-
const hasValue = value.trim().length > 0;
88+
}, [displayText, isEditing]);
17789

17890
const handleSave = async () => {
17991
if (!isDirty) {
180-
setMode('preview');
92+
setDraft(null);
18193
return;
18294
}
18395
setSaving(true);
18496
try {
18597
await onSave(draft.trim());
186-
setMode('preview');
98+
setDraft(null);
18799
} finally {
188100
setSaving(false);
189101
}
190102
};
191103

192-
const handleCancelEdit = () => {
193-
setDraft(value);
194-
setMode('preview');
104+
const handleRegenerate = () => {
105+
setDraft(null);
106+
onRegenerate?.();
195107
};
196108

197109
return (
198110
<div className="flex flex-col">
199-
{mode === 'preview' && hasValue ? (
111+
{!isEditing && hasValue ? (
200112
<div
201113
className="mt-4 overflow-y-auto border-t border-border pt-4"
202114
style={{ maxHeight: TEXTAREA_MAX_PX }}
@@ -206,35 +118,37 @@ export function DescriptionEditor({
206118
) : (
207119
<textarea
208120
ref={textareaRef}
209-
value={draft}
121+
value={displayText}
210122
onChange={(e) => setDraft(e.target.value)}
211123
disabled={disabled || saving}
212124
placeholder="Describe how this risk is being treated — concrete controls, owners, timelines. Markdown supported."
213125
className="mt-4 block w-full overflow-hidden border-0 border-t border-border bg-transparent py-3.5 text-sm leading-[1.55] text-foreground outline-none disabled:opacity-60"
214126
style={{ resize: 'none', minHeight: 200 }}
215127
/>
216128
)}
217-
<div className={cn('flex items-center gap-2 border-t border-border pt-2.5', !hasValue && mode === 'preview' && 'mt-4')}>
129+
<div className={cn('flex items-center gap-2 border-t border-border pt-2.5', !isEditing && !hasValue && 'mt-4')}>
218130
<span className="font-mono text-[11px] tabular-nums text-muted-foreground">
219131
{wordCount} {wordCount === 1 ? 'word' : 'words'} · {charCount}{' '}
220132
{charCount === 1 ? 'char' : 'chars'}
221133
</span>
222134
<span className="flex-1" />
223-
<Button
224-
variant="ghost"
225-
size="sm"
226-
onClick={onRegenerate}
227-
disabled={disabled || regenerating}
228-
loading={regenerating}
229-
iconLeft={<Renew aria-hidden="true" />}
230-
>
231-
{hasValue ? 'Regenerate with AI' : 'Generate treatment plan'}
232-
</Button>
233-
{mode === 'preview' ? (
135+
{onRegenerate && (
136+
<Button
137+
variant="ghost"
138+
size="sm"
139+
onClick={handleRegenerate}
140+
disabled={disabled || regenerating}
141+
loading={regenerating}
142+
iconLeft={<Renew aria-hidden="true" />}
143+
>
144+
{hasValue ? 'Regenerate with AI' : 'Generate treatment plan'}
145+
</Button>
146+
)}
147+
{!isEditing ? (
234148
<Button
235149
variant="outline"
236150
size="sm"
237-
onClick={() => setMode('edit')}
151+
onClick={() => setDraft(value)}
238152
disabled={disabled}
239153
iconLeft={<Edit aria-hidden="true" />}
240154
>
@@ -246,7 +160,7 @@ export function DescriptionEditor({
246160
<Button
247161
variant="ghost"
248162
size="sm"
249-
onClick={handleCancelEdit}
163+
onClick={() => setDraft(null)}
250164
disabled={disabled || saving}
251165
>
252166
Cancel
@@ -267,9 +181,6 @@ export function DescriptionEditor({
267181
<RegenProgress
268182
regenRun={regenRun ?? null}
269183
onSettled={onRegenSettled}
270-
/* While `regenerating` is true but no run handle yet, the POST
271-
that triggers the task is in flight — show a generic starter
272-
until the runId arrives. */
273184
fallbackHeadline="Starting AI scan…"
274185
/>
275186
)}
@@ -309,7 +220,7 @@ function RegenProgress({
309220
};
310221
onSettled?.({ success: false, reason: reasons[status] ?? 'The AI run failed.' });
311222
}
312-
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps
223+
}, [status, onSettled]);
313224

314225
const { headline, sub } = regenRun
315226
? regenStatusCopy(status)
@@ -336,11 +247,6 @@ function RegenProgress({
336247
);
337248
}
338249

339-
/**
340-
* Lightweight markdown preview tuned for treatment-plan prose. Headings,
341-
* bullets, ordered lists, links, bold/italic, code spans. No raw HTML or
342-
* complex blocks — the AI prompts already shape output to this.
343-
*/
344250
function MarkdownPreview({ content }: { content: string }) {
345251
return (
346252
<div className="prose-sm max-w-none text-sm leading-[1.65] text-foreground">

‎apps/app/src/components/risks/treatment-plan/TreatmentPlanTab.spec.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,39 @@ describe('TreatmentPlanTab', () => {
120120
expect(screen.getByRole('button', { name: /Regenerate with AI/i })).toBeInTheDocument();
121121
});
122122

123+
it('hides the regenerate button for non-Mitigate strategies', () => {
124+
const entity: TreatmentPlanEntity = {
125+
...baseEntity,
126+
treatmentStrategy: RiskTreatmentType.accept,
127+
treatmentStrategyDescription: 'We accept this risk.',
128+
};
129+
render(<TreatmentPlanTab {...buildProps({ entity })} />);
130+
expect(
131+
screen.queryByRole('button', { name: /Generate treatment plan/i }),
132+
).toBeNull();
133+
expect(
134+
screen.queryByRole('button', { name: /Regenerate with AI/i }),
135+
).toBeNull();
136+
});
137+
138+
it('shows the correct description when switching strategies via strategyDescriptions', () => {
139+
const entity: TreatmentPlanEntity = {
140+
...baseEntity,
141+
treatmentStrategy: RiskTreatmentType.accept,
142+
treatmentStrategyDescription: 'We accept this risk.',
143+
strategyDescriptions: {
144+
[RiskTreatmentType.mitigate]: 'Mitigation plan here.',
145+
[RiskTreatmentType.accept]: 'We accept this risk.',
146+
},
147+
tasks: [],
148+
};
149+
render(<TreatmentPlanTab {...buildProps({ entity })} />);
150+
expect(screen.getByText('We accept this risk.')).toBeInTheDocument();
151+
152+
fireEvent.click(screen.getByRole('radio', { name: 'Mitigate' }));
153+
expect(screen.getByText('Mitigation plan here.')).toBeInTheDocument();
154+
});
155+
123156
it('shows task completion percent in the hero stats', () => {
124157
const entity: TreatmentPlanEntity = {
125158
...baseEntity,

‎apps/app/src/components/risks/treatment-plan/TreatmentPlanTab.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,10 @@ export function TreatmentPlanTab({
206206
<>
207207
<ColumnHeader number="02" title={planTitle} subtitle={planSubtitle} />
208208
<DescriptionEditor
209+
key={strategy}
209210
value={description}
210211
onSave={onUpdateDescription}
211-
onRegenerate={onRegenerate}
212+
onRegenerate={isMitigate ? onRegenerate : undefined}
212213
regenerating={regenerating}
213214
disabled={!canUpdate}
214215
regenRun={regenRun}

0 commit comments

Comments
 (0)