From 1a7d23029acf731e02311e4d11bd3edba3099f0b Mon Sep 17 00:00:00 2001 From: Emrah Zunic Date: Mon, 13 Jul 2026 14:31:12 +0200 Subject: [PATCH 1/4] fix(ui): preserve other pending answers on needs-input submit --- src/ui/static/modules/actions.js | 58 +++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/src/ui/static/modules/actions.js b/src/ui/static/modules/actions.js index 731684ba..b0015441 100644 --- a/src/ui/static/modules/actions.js +++ b/src/ui/static/modules/actions.js @@ -8,6 +8,13 @@ let actionItems = []; let selectedAnswers = {}; // { taskId: [selectedKeys] } let answerAttachments = {}; // { taskId: [{ name, size, content (base64) }] } let workflowLaunchAttachments = {}; // { "processId:questionId": [{ name, size, content }] } +// Un-submitted selections for task-level batch questions (the "task-questions" +// action item). Keyed { taskId: { questionId: { key, customText } } }. Selections +// for a batch of pending_questions otherwise live only in the DOM's `.selected` +// class, so submitting one question — which triggers a full panel re-render — +// wipes the choices lined up on the sibling questions (issue #622). This store +// survives re-renders; renderTaskQuestionsItem re-applies it on every paint. +let taskQuestionDrafts = {}; let actionWidgetSuppressUntil = 0; const ANSWER_ALLOWED_EXTENSIONS = ['.md', '.docx', '.xlsx', '.pdf', '.txt']; @@ -400,14 +407,19 @@ function renderTaskQuestionsItem(item) { ${escapeHtml(item.task_name)}
- ${questions.map((q, idx) => ` + ${questions.map((q, idx) => { + // Restore any un-submitted selection for this question so a + // re-render (e.g. after a sibling question is submitted) + // does not wipe it — see taskQuestionDrafts (issue #622). + const draft = (taskQuestionDrafts[taskId] || {})[q.id] || {}; + return ` ${idx > 0 ? '
' : ''}
Q${idx + 1}. ${escapeHtml(q.question)}
${q.context ? `
${escapeHtml(q.context)}
` : ''}
${(q.options || []).map(opt => ` -
@@ -420,18 +432,45 @@ function renderTaskQuestionsItem(item) { `).join('')}
- +
- `).join('')} + `; + }).join('')}
`; } +/** + * Record an un-submitted selection for a batch (task-questions) question block + * so it survives a panel re-render. See taskQuestionDrafts / issue #622. + * @param {HTMLElement} questionBlock - The .task-question-block element + * @param {{key: string|null, customText: string}} draft - Chosen option key or free text + */ +function setTaskQuestionDraft(questionBlock, draft) { + const taskId = questionBlock?.dataset.taskId; + const questionId = questionBlock?.dataset.questionId; + if (!taskId || !questionId) return; + if (!taskQuestionDrafts[taskId]) taskQuestionDrafts[taskId] = {}; + taskQuestionDrafts[taskId][questionId] = draft; +} + +/** + * Drop the stored draft for a batch question block — once it is answered or its + * inputs are cleared, there is nothing to restore. See taskQuestionDrafts / #622. + * @param {HTMLElement} questionBlock - The .task-question-block element + */ +function clearTaskQuestionDraft(questionBlock) { + const taskId = questionBlock?.dataset.taskId; + const questionId = questionBlock?.dataset.questionId; + if (!taskId || !questionId) return; + if (taskQuestionDrafts[taskId]) delete taskQuestionDrafts[taskId][questionId]; +} + /** * Submit a single task question from the batch (task-questions type) * @param {string} taskId - Task ID @@ -479,6 +518,10 @@ async function submitTaskQuestion(taskId, questionId) { const result = await response.json(); if (result.success) { + // This question is resolved server-side now; drop its draft so the + // re-render below does not restore a stale selection (issue #622). + clearTaskQuestionDraft(questionBlock); + // Mark this question block as answered questionBlock.classList.add('answered'); questionBlock.innerHTML = `
Q answered ✓ — ${result.questions_remaining_count > 0 ? result.questions_remaining_count + ' question(s) still pending' : 'all done, task resuming...'}
`; @@ -877,6 +920,8 @@ function attachActionHandlers(container) { option.classList.add('selected'); const freetext = questionBlock.querySelector('.workflow-launch-freetext-input'); if (freetext) freetext.value = ''; + // Persist the choice so it survives a re-render (issue #622). + setTaskQuestionDraft(questionBlock, { key: option.dataset.key, customText: '' }); }); }); @@ -887,6 +932,11 @@ function attachActionHandlers(container) { if (questionBlock?.classList.contains('answered')) return; if (textarea.value.trim()) { questionBlock?.querySelectorAll('.answer-option').forEach(opt => opt.classList.remove('selected')); + // Persist the free-text draft so it survives a re-render (issue #622). + setTaskQuestionDraft(questionBlock, { key: null, customText: textarea.value }); + } else { + // Emptied with no option selected — drop the draft. + clearTaskQuestionDraft(questionBlock); } }); }); From 8d507c887335b2c19c966167aec75d885771a01b Mon Sep 17 00:00:00 2001 From: emrahzunicaplab Date: Mon, 13 Jul 2026 16:04:45 +0200 Subject: [PATCH 2/4] fix(ui): drop empty taskQuestionDrafts buckets after last clear Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ui/static/modules/actions.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ui/static/modules/actions.js b/src/ui/static/modules/actions.js index b0015441..8f102a0f 100644 --- a/src/ui/static/modules/actions.js +++ b/src/ui/static/modules/actions.js @@ -468,7 +468,16 @@ function clearTaskQuestionDraft(questionBlock) { const taskId = questionBlock?.dataset.taskId; const questionId = questionBlock?.dataset.questionId; if (!taskId || !questionId) return; - if (taskQuestionDrafts[taskId]) delete taskQuestionDrafts[taskId][questionId]; + + const taskDrafts = taskQuestionDrafts[taskId]; + if (!taskDrafts) return; + + delete taskDrafts[questionId]; + + // Avoid accumulating empty per-task draft maps. + if (Object.keys(taskDrafts).length === 0) { + delete taskQuestionDrafts[taskId]; + } } /** From 0e588366478d98f59c9b5fb842e6c969c242907a Mon Sep 17 00:00:00 2001 From: Emrah Zunic Date: Mon, 3 Aug 2026 11:08:44 +0200 Subject: [PATCH 3/4] fix(ui): prune stale task-question drafts against server payload --- src/ui/static/modules/actions.js | 52 +++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/ui/static/modules/actions.js b/src/ui/static/modules/actions.js index 8f102a0f..61f6d51c 100644 --- a/src/ui/static/modules/actions.js +++ b/src/ui/static/modules/actions.js @@ -263,6 +263,9 @@ async function fetchAndRenderActionItems() { } else { content.innerHTML = '
No pending actions
'; actionItems = []; + // Nothing is pending server-side, so no stored draft can still belong + // to a live question — drop them all (issue #622). + pruneTaskQuestionDrafts([]); } } catch (error) { console.error('Failed to fetch action items:', error); @@ -276,6 +279,10 @@ async function fetchAndRenderActionItems() { * @param {Array} items - Action items to render */ function renderActionItems(container, items) { + // The payload is the source of truth for what is still pending; discard any + // draft it no longer covers before repainting (issue #622). + pruneTaskQuestionDrafts(items); + container.innerHTML = items.map(item => { if (item.type === 'question') { return renderQuestionItem(item); @@ -411,7 +418,7 @@ function renderTaskQuestionsItem(item) { // Restore any un-submitted selection for this question so a // re-render (e.g. after a sibling question is submitted) // does not wipe it — see taskQuestionDrafts (issue #622). - const draft = (taskQuestionDrafts[taskId] || {})[q.id] || {}; + const draft = taskQuestionDrafts[taskId]?.[q.id] || {}; return ` ${idx > 0 ? '
' : ''}
@@ -480,6 +487,49 @@ function clearTaskQuestionDraft(questionBlock) { } } +/** + * Discard drafts that no longer match a pending question in the server payload. + * + * Question ids are only unique within a batch — Ensure-TaskInputPendingQuestionIds + * assigns `q1`, `q2`, … per batch — so a task that re-enters needs-input reuses the + * same ids for entirely different questions. A draft abandoned by an earlier batch + * (question answered from the CLI or another session, task resumed, new batch + * raised) would otherwise be re-applied to an unrelated question, and drafts for + * tasks that left needs-input would linger for the lifetime of the page. + * See taskQuestionDrafts / issue #622. + * + * @param {Array} items - Action items the panel is about to render + */ +function pruneTaskQuestionDrafts(items) { + const liveQuestionIds = {}; // { taskId: Set } + + (items || []).forEach(item => { + if (item?.type !== 'task-questions' || item.task_id == null) return; + const taskId = String(item.task_id); + if (!liveQuestionIds[taskId]) liveQuestionIds[taskId] = new Set(); + (item.questions || []).forEach(q => { + if (q?.id != null) liveQuestionIds[taskId].add(String(q.id)); + }); + }); + + Object.keys(taskQuestionDrafts).forEach(taskId => { + const liveIds = liveQuestionIds[taskId]; + if (!liveIds) { + delete taskQuestionDrafts[taskId]; + return; + } + + const taskDrafts = taskQuestionDrafts[taskId]; + Object.keys(taskDrafts).forEach(questionId => { + if (!liveIds.has(questionId)) delete taskDrafts[questionId]; + }); + + if (Object.keys(taskDrafts).length === 0) { + delete taskQuestionDrafts[taskId]; + } + }); +} + /** * Submit a single task question from the batch (task-questions type) * @param {string} taskId - Task ID From 39658baac0aa59eb7ed50a3d877d7828e3b2ece1 Mon Sep 17 00:00:00 2001 From: Emrah Zunic Date: Mon, 3 Aug 2026 14:06:22 +0200 Subject: [PATCH 4/4] fix(ui): derive task-question draft from the DOM, not per-handler guesses --- src/ui/static/modules/actions.js | 52 +++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/ui/static/modules/actions.js b/src/ui/static/modules/actions.js index 61f6d51c..887075a9 100644 --- a/src/ui/static/modules/actions.js +++ b/src/ui/static/modules/actions.js @@ -466,6 +466,38 @@ function setTaskQuestionDraft(questionBlock, draft) { taskQuestionDrafts[taskId][questionId] = draft; } +/** + * Point the stored draft at whatever a batch question block currently holds. + * + * The draft only restores a selection correctly if it never disagrees with the + * DOM, so derive it from the block instead of having each input handler decide + * for itself — a handler that stored the wrong thing (or nothing) for its own + * edge case would silently make the selection non-durable across a re-render. + * See taskQuestionDrafts / issue #622. + * + * @param {HTMLElement} questionBlock - The .task-question-block element + */ +function syncTaskQuestionDraft(questionBlock) { + if (!questionBlock) return; + + const selected = questionBlock.querySelector('.answer-option.selected'); + if (selected) { + // An option and free text are mutually exclusive: selecting one clears + // the other, so a live selection means there is no text to keep. + setTaskQuestionDraft(questionBlock, { key: selected.dataset.key, customText: '' }); + return; + } + + const textarea = questionBlock.querySelector('.workflow-launch-freetext-input'); + if (textarea?.value.trim()) { + setTaskQuestionDraft(questionBlock, { key: null, customText: textarea.value }); + return; + } + + // Nothing selected and no meaningful text — there is nothing to restore. + clearTaskQuestionDraft(questionBlock); +} + /** * Drop the stored draft for a batch question block — once it is answered or its * inputs are cleared, there is nothing to restore. See taskQuestionDrafts / #622. @@ -711,9 +743,16 @@ function renderWorkflowLaunchQuestionsItem(item) { * @param {HTMLElement} container - Container element */ function attachActionHandlers(container) { - // Answer option selection + // Answer option selection for single-question items. + // Batch (task-questions) blocks carry data-task-id too, so without this guard + // they get handled twice: once here and once by the task-question handler + // below. That wrote selectedAnswers entries which nothing reads for a batch + // task (submitTaskQuestion reads the DOM) and nothing ever cleans up, and it + // left draft correctness depending on the two listeners' attachment order. + // Workflow-launch questions are already excluded by the !taskId bail below. container.querySelectorAll('.answer-option').forEach(option => { option.addEventListener('click', (e) => { + if (option.closest('.task-question-block')) return; const optionsContainer = option.closest('.answer-options'); const isMultiSelect = optionsContainer?.dataset.multiSelect === 'true'; const taskId = option.closest('.action-item')?.dataset.taskId; @@ -980,7 +1019,7 @@ function attachActionHandlers(container) { const freetext = questionBlock.querySelector('.workflow-launch-freetext-input'); if (freetext) freetext.value = ''; // Persist the choice so it survives a re-render (issue #622). - setTaskQuestionDraft(questionBlock, { key: option.dataset.key, customText: '' }); + syncTaskQuestionDraft(questionBlock); }); }); @@ -989,14 +1028,13 @@ function attachActionHandlers(container) { textarea.addEventListener('input', () => { const questionBlock = textarea.closest('.task-question-block'); if (questionBlock?.classList.contains('answered')) return; + // Real text wins over a chosen option; whitespace alone is not an + // answer, so it leaves any existing selection standing. if (textarea.value.trim()) { questionBlock?.querySelectorAll('.answer-option').forEach(opt => opt.classList.remove('selected')); - // Persist the free-text draft so it survives a re-render (issue #622). - setTaskQuestionDraft(questionBlock, { key: null, customText: textarea.value }); - } else { - // Emptied with no option selected — drop the draft. - clearTaskQuestionDraft(questionBlock); } + // Persist whatever the block now holds so it survives a re-render (#622). + syncTaskQuestionDraft(questionBlock); }); });