@@ -11,17 +11,10 @@ import { cn } from '@/lib/utils';
1111interface 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- */
4330const TEXTAREA_MAX_PX = 480 ;
4431
4532function 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- */
344250function MarkdownPreview ( { content } : { content : string } ) {
345251 return (
346252 < div className = "prose-sm max-w-none text-sm leading-[1.65] text-foreground" >
0 commit comments