diff --git a/src/ui/static/modules/actions.js b/src/ui/static/modules/actions.js
index 731684ba..887075a9 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'];
@@ -256,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);
@@ -269,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);
@@ -400,14 +414,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 +439,129 @@ 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;
+}
+
+/**
+ * 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.
+ * @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;
+
+ 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];
+ }
+}
+
+/**
+ * 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
@@ -479,6 +609,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 = `
`;
@@ -609,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;
@@ -877,6 +1018,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).
+ syncTaskQuestionDraft(questionBlock);
});
});
@@ -885,9 +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 whatever the block now holds so it survives a re-render (#622).
+ syncTaskQuestionDraft(questionBlock);
});
});