@@ -109,7 +109,7 @@ export function createTaskPanel(context) {
109109 if ( fetched ) {
110110 detail . activeTaskPanelTask = fetched ;
111111 detail . activeTaskPanelArtifacts = [ ] ;
112- renderTaskPanel ( ) ;
112+ renderTaskPanel ( { preserveDrafts : true } ) ;
113113 }
114114 const artifacts = fetched ? await loadArtifactsForTask ( fetched ) : [ ] ;
115115 if (
@@ -119,7 +119,7 @@ export function createTaskPanel(context) {
119119 )
120120 return ;
121121 detail . activeTaskPanelArtifacts = artifacts ;
122- renderTaskPanel ( ) ;
122+ renderTaskPanel ( { preserveDrafts : true } ) ;
123123 } catch ( err ) {
124124 if ( isWorkspaceRouteFresh ( token ) && detail . activeTaskPanelId === taskId ) {
125125 taskPanelTitle . textContent =
@@ -141,11 +141,96 @@ export function createTaskPanel(context) {
141141 }
142142 }
143143
144- function renderTaskPanel ( ) {
144+ // Asynchronous rerenders (artifact and file hydration, snapshot refreshes)
145+ // rebuild this panel while an operator may be mid-edit. Rebuilding blindly
146+ // throws away pending drafts. Preserve all tagged field values, keep
147+ // detach-driven blur/change from committing a half-typed edit, then return
148+ // focus and selection only to the field the operator was actually using.
149+ // Explicit renders keep their existing ownership semantics: a successful
150+ // mutation or an operator's Discard must be allowed to replace drafts.
151+ let taskPanelRebuilding = false ;
152+
153+ function isInsideTaskPanelBody ( node ) {
154+ let current = node ;
155+ while ( current ) {
156+ if ( current === taskPanelBody ) return true ;
157+ current = current . parentElement || null ;
158+ }
159+ return false ;
160+ }
161+
162+ function readFieldSelection ( field ) {
163+ try {
164+ if ( typeof field . selectionStart !== "number" ) return null ;
165+ return {
166+ start : field . selectionStart ,
167+ end : field . selectionEnd ,
168+ direction : field . selectionDirection || "none" ,
169+ } ;
170+ } catch {
171+ return null ;
172+ }
173+ }
174+
175+ function captureTaskPanelFields ( ) {
176+ const fields = new Map ( ) ;
177+ for ( const field of taskPanelBody . querySelectorAll ( "[data-panel-field]" ) ) {
178+ const key = field . dataset ?. panelField ;
179+ if ( ! key || fields . has ( key ) ) continue ;
180+ fields . set ( key , {
181+ value : typeof field . value === "string" ? field . value : "" ,
182+ selection : readFieldSelection ( field ) ,
183+ } ) ;
184+ }
185+ return fields ;
186+ }
187+
188+ function getFocusedPanelFieldKey ( ) {
189+ const active = document . activeElement ;
190+ return active && isInsideTaskPanelBody ( active )
191+ ? active . dataset ?. panelField || null
192+ : null ;
193+ }
194+
195+ function restoreTaskPanelFields ( capturedFields , focusedFieldKey ) {
196+ let focusedField = null ;
197+ for ( const [ key , capture ] of capturedFields ) {
198+ const field = taskPanelBody . querySelector (
199+ `[data-panel-field="${ key } "]` ,
200+ ) ;
201+ if ( ! field || field . disabled ) continue ;
202+ if ( field . value !== capture . value ) field . value = capture . value ;
203+ if ( key !== focusedFieldKey ) continue ;
204+ focusedField = field ;
205+ if ( capture . selection && typeof field . setSelectionRange === "function" ) {
206+ try {
207+ field . setSelectionRange (
208+ capture . selection . start ,
209+ capture . selection . end ,
210+ capture . selection . direction ,
211+ ) ;
212+ } catch {
213+ // Field types without a text selection keep the restored value only.
214+ }
215+ }
216+ }
217+ if ( focusedField ) focusedField . focus ( ) ;
218+ }
219+
220+ function renderTaskPanel ( options = { } ) {
145221 const task = detail . activeTaskPanelTask ;
222+ const preserveDrafts = options . preserveDrafts === true ;
223+ const capturedFields = preserveDrafts ? captureTaskPanelFields ( ) : new Map ( ) ;
224+ const focusedFieldKey = getFocusedPanelFieldKey ( ) ;
146225 taskPanelTitle . textContent = task ? workTaskTitle ( task ) : "Task" ;
147- taskPanelBody . replaceChildren ( ) ;
226+ taskPanelRebuilding = true ;
227+ try {
228+ taskPanelBody . replaceChildren ( ) ;
229+ } finally {
230+ taskPanelRebuilding = false ;
231+ }
148232 if ( ! task ) return ;
233+ let conflictClaimedFocus = false ;
149234
150235 if ( ! isCanonicalWorkTask ( task ) ) {
151236 throw new Error ( "Task payload is not in the canonical versioned shape" ) ;
@@ -189,6 +274,7 @@ export function createTaskPanel(context) {
189274 recovery . append ( heading , latestState , retained , controls ) ;
190275 taskPanelBody . append ( recovery ) ;
191276 recovery . focus ( ) ;
277+ conflictClaimedFocus = true ;
192278 }
193279
194280 const routeContextParts = [
@@ -278,15 +364,28 @@ export function createTaskPanel(context) {
278364 label . textContent = `${ task . requiredLinkName } ` ;
279365 const input = document . createElement ( "input" ) ;
280366 input . type = "url" ;
367+ input . dataset . panelField = "required-link" ;
368+ const savedLink = task . link || "" ;
281369 input . value =
282370 detail . activeTaskPanelDraft ?. kind === "link"
283371 ? detail . activeTaskPanelDraft . payload . link
284- : task . link || "" ;
372+ : savedLink ;
285373 input . placeholder = "https://..." ;
286374 input . disabled = detail . activeTaskMutationBusy ;
287- input . addEventListener ( "change" , ( ) =>
288- saveTaskLink ( task . id , input . value , task . version ) ,
289- ) ;
375+ // `change` is the normal browser commit. `blur` also covers a draft that
376+ // was restored after a background rerender, where the browser no longer
377+ // treats the restored text as a user edit. `committedLink` tracks the
378+ // last value sent so neither path submits the same link twice.
379+ let committedLink = savedLink ;
380+ const commitLink = ( ) => {
381+ if ( taskPanelRebuilding ) return undefined ;
382+ const next = input . value ;
383+ if ( next === committedLink ) return undefined ;
384+ committedLink = next ;
385+ return saveTaskLink ( task . id , next , task . version ) ;
386+ } ;
387+ input . addEventListener ( "change" , commitLink ) ;
388+ input . addEventListener ( "blur" , commitLink ) ;
290389 input . addEventListener ( "keydown" , ( event ) => {
291390 if ( event . key === "Enter" ) {
292391 event . preventDefault ( ) ;
@@ -347,6 +446,7 @@ export function createTaskPanel(context) {
347446 nextLabel . textContent = "Next" ;
348447 const nextInput = document . createElement ( "input" ) ;
349448 nextInput . type = "date" ;
449+ nextInput . dataset . panelField = "follow-up-next" ;
350450 nextInput . value =
351451 detail . activeTaskPanelDraft ?. kind === "follow-up-sent"
352452 ? detail . activeTaskPanelDraft . payload . nextFollowUpAt
@@ -461,6 +561,10 @@ export function createTaskPanel(context) {
461561 instructions . append ( link ) ;
462562 taskPanelBody . append ( instructions ) ;
463563 }
564+
565+ if ( ! conflictClaimedFocus && preserveDrafts ) {
566+ restoreTaskPanelFields ( capturedFields , focusedFieldKey ) ;
567+ }
464568 }
465569
466570 function renderTaskInstructionDoc ( task ) {
0 commit comments