feat: manage mobile checklist steps inline (Closes #919)
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-15 22:38:52 +00:00
parent 6b924a4757
commit 4589c46f16
11 changed files with 454 additions and 4 deletions

View File

@ -6,6 +6,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const pending = new Map(); const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
function checklistOperation(value) {
const action = String(value?.action || '');
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
return {
action,
index:Number(value.index),
...(action === 'rename' ? { label:String(value.label || '') } : {}),
};
}
function reviewFingerprint(message) { function reviewFingerprint(message) {
return JSON.stringify({ return JSON.stringify({
body: String(message.body || ''), body: String(message.body || ''),
@ -66,11 +76,13 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') && item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0)); item.number === Number(message.number || 0));
if (queuedContent) { if (queuedContent) {
const operation = checklistOperation(message.checklistOperation);
const replacement = { const replacement = {
...queuedContent, ...queuedContent,
operationId: String(requestedOperationId || makeId()).slice(0, 128), operationId: String(requestedOperationId || makeId()).slice(0, 128),
title: String(message.title || ''), title: String(message.title || ''),
body: String(message.body || ''), body: String(message.body || ''),
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
}; };
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror); write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
return replacement; return replacement;
@ -115,6 +127,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
title: String(message.title || ''), title: String(message.title || ''),
baseBody: String(message.baseBody ?? message.body ?? ''), baseBody: String(message.baseBody ?? message.body ?? ''),
expectedUpdatedAt: String(message.expectedUpdatedAt || ''), expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
...(checklistOperation(message.checklistOperation) ? {
checklistOperations:[checklistOperation(message.checklistOperation)],
} : {}),
} : {}), } : {}),
}; };
items.push(item); items.push(item);

View File

@ -1,4 +1,4 @@
function mergeChecklistConflict({ baseBody, localBody, remoteBody }) { function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/; const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
function tasks(body) { function tasks(body) {
@ -20,6 +20,70 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
return result; return result;
} }
function replayOperations(body, requested, reportChanges) {
let lines = String(body || '').split('\n');
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
const replayed = [];
for (const operation of requested) {
const index = Number(operation?.index);
const target = logical[index];
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
let entries = visibleTasks(lines.join('\n'));
const matches = entries.filter(entry => entry.key === target.key);
if (matches.length !== 1) {
return { body:null, changes:replayed, conflict:{
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
} };
}
const match = matches[0];
if (operation.action === 'rename') {
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
const key = label.toLocaleLowerCase();
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
}
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
if (reportChanges) replayed.push({ label:target.label, renamed:label });
target.label = label;
target.key = key;
} else if (operation.action === 'remove') {
lines.splice(match.lineIndex, 1);
logical.splice(index, 1);
if (reportChanges) replayed.push({ label:target.label, removed:true });
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
const neighbor = logical[neighborIndex];
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
}
const neighborLine = neighborMatches[0].lineIndex;
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
if (reportChanges) replayed.push({
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
});
}
}
return { body:lines.join('\n'), changes:replayed, conflict:null };
}
if (Array.isArray(operations) && operations.length) {
const baseReplay = replayOperations(baseBody, operations, false);
const remoteReplay = replayOperations(remoteBody, operations, true);
const conflict = baseReplay.conflict || remoteReplay.conflict;
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
const residual = mergeChecklistConflict({
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
});
return {
body:residual.body,
changes:[...remoteReplay.changes, ...residual.changes],
conflicts:residual.conflicts,
};
}
const base = grouped(tasks(baseBody)); const base = grouped(tasks(baseBody));
const local = grouped(tasks(localBody)); const local = grouped(tasks(localBody));
const remoteEntries = tasks(remoteBody); const remoteEntries = tasks(remoteBody);

View File

@ -474,6 +474,13 @@ textarea { resize: vertical; min-height: 120px; }
.completed-filed-actions button { min-height:44px; min-width:0; } .completed-filed-actions button { min-height:44px; min-width:0; }
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); } #issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } .issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.checklist-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; }
.checklist-step-editor[hidden] { display:none; }
.checklist-step-editor input { width:100%; min-width:0; box-sizing:border-box; }
.checklist-step-editor-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.checklist-step-editor button, .checklist-step-editor input { min-height:44px; }
#remove-checklist-step { border-color:#b45309; }
#issue-sheet.read-only .checklist-step-editor { display:none; }
.checklist-add { display:grid; gap:8px; margin:10px 0 16px; } .checklist-add { display:grid; gap:8px; margin:10px 0 16px; }
.checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; } .checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; }
.checklist-add form[hidden] { display:none; } .checklist-add form[hidden] { display:none; }
@ -653,7 +660,9 @@ textarea { resize: vertical; min-height: 120px; }
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; } .markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; } .markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
.markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; } .markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; }
.markdown-content .task-list-manage { min-width:44px; min-height:44px; margin:-9px -9px -9px auto; padding:6px 8px; flex:0 0 auto; }
.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; } .markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; }
.markdown-content.checklist-pending .task-list-manage { opacity:.65; cursor:wait; }
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; } .markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } @media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }

View File

@ -218,6 +218,7 @@
let selectedIssueOffline = false; let selectedIssueOffline = false;
let selectedIssueDetail = null; let selectedIssueDetail = null;
let dismissedChecklistBody = null; let dismissedChecklistBody = null;
let issueBlockerCandidates = []; let issueBlockerCandidates = [];
let issueBlockerSearchTimer = null; let issueBlockerSearchTimer = null;
let issueConversation = null; let issueConversation = null;
@ -3505,6 +3506,8 @@
qs('#completed-filed-progress').textContent = item.is_completed ? qs('#completed-filed-progress').textContent = item.is_completed ?
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : ''; 'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
qs('#issue-sheet-body').textContent = ''; qs('#issue-sheet-body').textContent = '';
checklistStepManagement.reset();
qs('#checklist-step-label').value = '';
qs('#open-add-checklist-step').disabled = true; qs('#open-add-checklist-step').disabled = true;
qs('#add-checklist-step-form').hidden = true; qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = ''; qs('#add-checklist-step').value = '';
@ -5672,6 +5675,14 @@
confirmed:applyIssueContent, confirmed:applyIssueContent,
restore:renderIssueBody, restore:renderIssueBody,
}); });
const checklistStepManagement = issueController.bindTaskManagement({
container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'),
earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'),
remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'),
status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
confirmed:applyIssueContent,
});
function closeAddChecklistStep() { function closeAddChecklistStep() {
qs('#add-checklist-step-form').hidden = true; qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = ''; qs('#add-checklist-step').value = '';

View File

@ -605,6 +605,18 @@
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button> <button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div> <div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div> <div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
<label for="checklist-step-label">Checklist step</label>
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
<div class="checklist-step-editor-actions">
<button id="save-checklist-step-edit" type="submit">Rename</button>
<button id="move-checklist-step-earlier" type="button">Move earlier</button>
<button id="move-checklist-step-later" type="button">Move later</button>
<button id="remove-checklist-step" type="button">Remove</button>
<button id="cancel-checklist-step-edit" type="button">Cancel</button>
</div>
<div id="checklist-step-edit-status" class="small" aria-live="assertive"></div>
</form>
<section class="checklist-add" aria-label="Add checklist step"> <section class="checklist-add" aria-label="Add checklist step">
<button id="open-add-checklist-step" type="button" disabled>Add step</button> <button id="open-add-checklist-step" type="button" disabled>Add step</button>
<form id="add-checklist-step-form" hidden> <form id="add-checklist-step-form" hidden>

View File

@ -37,7 +37,47 @@ function appendChecklistTask(body, label) {
return prefix + '- [ ] ' + normalized; return prefix + '- [ ] ' + normalized;
} }
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { function manageChecklistTask(raw, targetIndex, operation = {}) {
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^([-*+]\s+\[[ xX]\]\s+)(.*)$/);
if (!task) continue;
if (taskIndex === Number(targetIndex)) {
if (operation.action === 'rename') {
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
if (!label) throw new Error('Enter a checklist step.');
const key = label.toLocaleLowerCase();
let inFence = false;
const duplicate = parts.some((candidate, candidateIndex) => {
if (candidateIndex % 2) return false;
if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; }
if (inFence || candidateIndex === index) return false;
const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/);
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
});
if (duplicate) throw new Error('That checklist step already exists.');
parts[index] = task[1] + label;
} else if (operation.action === 'remove') {
if (index + 1 < parts.length) parts.splice(index, 2);
else if (index > 0) parts.splice(index - 1, 2);
} else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) {
[parts[index - 2], parts[index]] = [parts[index], parts[index - 2]];
} else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) {
[parts[index], parts[index + 2]] = [parts[index + 2], parts[index]];
}
return parts.join('');
}
taskIndex += 1;
}
return parts.join('');
}
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null; let commentRequest = null;
let closeRequest = null; let closeRequest = null;
let releaseRequest = null; let releaseRequest = null;
@ -165,6 +205,14 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
expectedUpdatedAt: detail.updated_at, expectedUpdatedAt: detail.updated_at,
}); });
}, },
manageTask(item, detail, taskIndex, operation) {
if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.'));
return this.updateContent(item, {
title: detail.title,
body: manageTaskTransform(detail.body, taskIndex, operation),
expectedUpdatedAt: detail.updated_at,
});
},
async addTask(item, detail, label) { async addTask(item, detail, label) {
const body = appendChecklistTask(detail.body, label); const body = appendChecklistTask(detail.body, label);
return this.updateContent(item, { return this.updateContent(item, {
@ -182,6 +230,19 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at }); title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } }; return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
}, },
async queueManagedTask(item, detail, taskIndex, operation) {
if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist management is unavailable.');
}
const body = manageTaskTransform(detail.body, taskIndex, operation);
const checklistOperation = {
action:String(operation?.action || ''), index:Number(taskIndex),
...(operation?.label ? { label:String(operation.label) } : {}),
};
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
async queueTask(item, detail, taskIndex, checked) { async queueTask(item, detail, taskIndex, checked) {
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') { if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist updates are unavailable.'); throw new Error('Offline checklist updates are unavailable.');
@ -226,11 +287,69 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
} }
}); });
}, },
bindTaskManagement({ container, editor, label, earlier, later, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
let taskIndex = null;
let trigger = null;
const reset = (returnFocus = false) => {
editor.hidden = true;
status.textContent = '';
if (returnFocus && trigger?.isConnected) trigger.focus();
taskIndex = null;
trigger = null;
};
container.addEventListener('click', event => {
const control = event.target.closest('button.task-list-manage');
if (!control || !current()?.detail?.updated_at) return;
taskIndex = Number(control.dataset.taskIndex);
trigger = control;
label.value = control.dataset.taskLabel || '';
earlier.disabled = control.dataset.taskFirst === 'true';
later.disabled = control.dataset.taskLast === 'true';
editor.hidden = false;
status.textContent = 'Rename, reorder, or remove this step.';
label.focus();
});
const run = async operation => {
const state = current();
const selectedIndex = taskIndex;
if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return;
if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return;
const controls = editor.querySelectorAll('button,input');
controls.forEach(control => { control.disabled = true; });
status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…';
try {
const result = await (state.offline ? this.queueManagedTask : this.manageTask).call(
this, state.item, state.detail, selectedIndex, operation
);
if (current()?.item !== state.item) return;
confirmed(state.item, state.detail, state.offline ? result.detail : result);
reset();
sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.';
const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex;
container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus();
} catch (error) {
if (current()?.item === state.item) {
status.textContent = error.message + ' Your change is still here; retry.';
retry.hidden = false;
label.focus();
}
} finally {
if (!editor.hidden) controls.forEach(control => { control.disabled = false; });
}
};
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
later.addEventListener('click', () => run({ action:'move-later' }));
remove.addEventListener('click', () => run({ action:'remove' }));
cancel.addEventListener('click', () => reset(true));
return { reset };
},
renderTasks(container, detail, interactive) { renderTasks(container, detail, interactive) {
container.classList.toggle('checklist-pending', detail.checklist_pending === true); container.classList.toggle('checklist-pending', detail.checklist_pending === true);
container.innerHTML = renderMarkdown( container.innerHTML = renderMarkdown(
detail.body || 'No description provided.', { detail.body || 'No description provided.', {
interactiveTasks: Boolean(interactive), interactiveTasks: Boolean(interactive),
manageTasks: Boolean(interactive),
} }
); );
}, },
@ -438,4 +557,5 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
createIssueSheet.createPlanningLoader = createPlanningLoader; createIssueSheet.createPlanningLoader = createPlanningLoader;
createIssueSheet.manageChecklistTask = manageChecklistTask;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;

View File

@ -43,8 +43,13 @@
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) + ' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') : '" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
' disabled'; ' disabled';
const manage = options.interactiveTasks && options.manageTasks ?
'<button type="button" class="task-list-manage" data-task-index="' + (firstTaskIndex + offset) +
'" data-task-label="' + escapeHtml(body) + '" aria-label="Manage step: ' + escapeHtml(body) + '"' +
(offset === 0 ? ' data-task-first="true"' : '') +
(offset === lines.length - 1 ? ' data-task-last="true"' : '') + '>Manage</button>' : '';
return '<li class="task-list-item"><input type="checkbox"' + control + return '<li class="task-list-item"><input type="checkbox"' + control +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>'; (checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
}).join(''); }).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>'; return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
} }

View File

@ -145,6 +145,35 @@ const outbox = createAuthoredOutbox({{
assert output["mirrors"][1][0]["operationId"] == "check-2" assert output["mirrors"][1][0]["operationId"] == "check-2"
def test_durable_issue_content_coalesces_ordered_checklist_management_intent():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const ids = ['manage-1','manage-2'];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createAuthoredOutbox({{
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=>ids.shift(),
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}}}},
}});
(async()=>{{
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
baseBody:'- [ ] Build\\n- [ ] Test',body:'- [ ] Compile\\n- [ ] Test',expectedUpdatedAt:'old',
checklistOperation:{{action:'rename',index:0,label:'Compile'}}}});
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
baseBody:'- [ ] Compile\\n- [ ] Test',body:'- [ ] Test\\n- [ ] Compile',expectedUpdatedAt:'old',
checklistOperation:{{action:'move-later',index:0}}}});
process.stdout.write(JSON.stringify(outbox.list()[0]));
}})();
"""
output = run_node(script)
assert output["baseBody"] == "- [ ] Build\n- [ ] Test"
assert output["body"] == "- [ ] Test\n- [ ] Compile"
assert output["checklistOperations"] == [
{"action": "rename", "index": 0, "label": "Compile"},
{"action": "move-later", "index": 0},
]
def test_failed_durable_issue_content_replacement_restores_previous_intent(): def test_failed_durable_issue_content_replacement_restores_previous_intent():
script = f""" script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -82,3 +82,29 @@ process.stdout.write(JSON.stringify(result));
assert output["body"] is None assert output["body"] is None
assert output["conflicts"] == [{"label": "Verify rollback", "reason": "ambiguous"}] assert output["conflicts"] == [{"label": "Verify rollback", "reason": "ambiguous"}]
def test_merge_replays_rename_and_reorder_intent_onto_unrelated_remote_edits():
script = f"""
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
const result = mergeChecklistConflict({{
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
localBody:'Intro\\n- [ ] Test\\n- [ ] Compile',
remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Test\\nRemote note',
operations:[
{{action:'rename',index:0,label:'Compile'}},
{{action:'move-later',index:0}},
],
}});
process.stdout.write(JSON.stringify(result));
"""
output = run_node(script)
assert output == {
"body": "Updated intro\n- [ ] Test\n- [x] Compile\nRemote note",
"changes": [
{"label": "Build", "renamed": "Compile"},
{"label": "Compile", "moved": "later"},
],
"conflicts": [],
}

View File

@ -40,6 +40,22 @@ def toggle_task(payload, task_index, checked):
) )
def manage_task(payload, task_index, operation):
script = (
f"const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;"
f"process.stdout.write(JSON.stringify(manage({json.dumps(payload)}, "
f"{task_index}, {json.dumps(operation)})));"
)
return json.loads(
subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
).stdout
)
class ScriptSourceParser(HTMLParser): class ScriptSourceParser(HTMLParser):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -114,6 +130,19 @@ def test_interactive_markdown_tasks_expose_accessible_source_indices():
) )
def test_manageable_markdown_tasks_expose_touch_action_with_position_boundaries():
rendered = render_markdown(
"- [ ] Verify production\n- [x] Notify support",
{"interactiveTasks": True, "manageTasks": True},
)
assert 'class="task-list-manage" data-task-index="0"' in rendered
assert 'aria-label="Manage step: Verify production"' in rendered
assert 'data-task-first="true"' in rendered
assert 'class="task-list-manage" data-task-index="1"' in rendered
assert 'data-task-last="true"' in rendered
def test_interactive_task_toggle_changes_exact_source_marker_only(): def test_interactive_task_toggle_changes_exact_source_marker_only():
body = ( body = (
"```md\r\n- [ ] example only\r\n```\r\n" "```md\r\n- [ ] example only\r\n```\r\n"
@ -126,6 +155,51 @@ def test_interactive_task_toggle_changes_exact_source_marker_only():
) )
def test_manage_task_renames_exact_visible_task_and_preserves_nested_step_line_endings_and_fences():
body = (
"```md\r\n- [ ] example only\r\n```\r\n"
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Release\r\n"
)
assert manage_task(body, 1, {"action": "rename", "label": "Ship"}) == (
"```md\r\n- [ ] example only\r\n```\r\n"
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Ship\r\n"
)
def test_manage_task_removes_only_the_selected_task_line():
body = "Plan\n- [ ] Build\n- [x] Test\n\nNotes"
assert manage_task(body, 0, {"action": "remove"}) == "Plan\n- [x] Test\n\nNotes"
def test_manage_task_moves_a_step_within_its_list_without_changing_step_content():
body = "Plan\n- [ ] Build\n- [x] Test\n- [ ] Release\n\nNotes"
assert manage_task(body, 2, {"action": "move-earlier"}) == (
"Plan\n- [ ] Build\n- [ ] Release\n- [x] Test\n\nNotes"
)
assert manage_task(body, 0, {"action": "move-later"}) == (
"Plan\n- [x] Test\n- [ ] Build\n- [ ] Release\n\nNotes"
)
def test_manage_task_rejects_an_empty_or_duplicate_renamed_label():
script = f"""
const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;
const body = '- [ ] Build\\n- [x] Test';
const errors = ['', ' test '].map(label => {{
try {{ manage(body, 0, {{action:'rename',label}}); return null; }}
catch (error) {{ return error.message; }}
}});
process.stdout.write(JSON.stringify(errors));
"""
assert json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout) == ["Enter a checklist step.", "That checklist step already exists."]
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert(): def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
rendered = render_markdown( rendered = render_markdown(
"[Forge](https://forge.example/work?q=1&safe=yes) " "[Forge](https://forge.example/work?q=1&safe=yes) "

View File

@ -2245,6 +2245,27 @@ async def test_mobile_issue_detail_adds_a_checklist_step_inline_with_accessible_
assert 'grid-template-columns:minmax(0,1fr) auto auto' in html assert 'grid-template-columns:minmax(0,1fr) auto auto' in html
@pytest.mark.anyio
async def test_mobile_issue_detail_manages_checklist_steps_with_touch_safe_inline_editor():
html = await dashboard()
controller = ISSUE_SHEET.read_text()
assert 'id="checklist-step-editor"' in html
assert 'id="checklist-step-label" type="text"' in html
assert 'id="move-checklist-step-earlier"' in html
assert 'id="move-checklist-step-later"' in html
assert 'id="remove-checklist-step"' in html
assert 'id="cancel-checklist-step-edit"' in html
assert "manageTasks: Boolean(interactive)" in controller
assert "issueController.bindTaskManagement({" in html
assert "event.target.closest('button.task-list-manage')" in controller
assert "this.queueManagedTask : this.manageTask" in controller
assert "globalThis.confirm('Remove this checklist step?')" in controller
assert ".task-list-manage { min-width:44px; min-height:44px;" in html
assert ".checklist-step-editor button, .checklist-step-editor input { min-height:44px;" in html
assert "label.focus()" in controller
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict(): async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
html = await dashboard() html = await dashboard()
@ -2422,7 +2443,7 @@ process.stdout.write(JSON.stringify({{options,classes,html:container.innerHTML}}
["node", "-e", script], check=True, capture_output=True, text=True ["node", "-e", script], check=True, capture_output=True, text=True
).stdout) ).stdout)
assert output["options"] == {"interactiveTasks": True} assert output["options"] == {"interactiveTasks": True, "manageTasks": True}
assert output["classes"] == [["checklist-pending", True]] assert output["classes"] == [["checklist-pending", True]]
assert "task-list-toggle" in output["html"] assert "task-list-toggle" in output["html"]
@ -2467,6 +2488,70 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z" assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z"
def test_issue_sheet_manages_a_step_through_one_revision_guarded_update():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
const controller = createIssueSheet({{
storage:null,
manageTask:(body,index,operation) => body + `|${{index}}:${{operation.action}}:${{operation.label || ''}}`,
fetchJson:(url,options) => {{
calls.push({{url,body:JSON.parse(options.body)}});
return Promise.resolve({{number:17,title:'Release',body:'- [ ] Build|0:rename:Compile',updated_at:'new'}});
}},
}});
const item = {{repository:'stackchain/api',number:17}};
const detail = {{title:'Release',body:'- [ ] Build',updated_at:'old'}};
controller.manageTask(item,detail,0,{{action:'rename',label:'Compile'}})
.then(result => process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues/17/content",
"body": {
"title": "Release", "body": "- [ ] Build|0:rename:Compile",
"expected_updated_at": "old",
},
}]
assert output["result"]["updated_at"] == "new"
def test_offline_issue_sheet_manages_a_step_only_after_durable_admission():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let admit; const queued = [];
const controller = createIssueSheet({{
storage:null,
manageTask:(body,index,operation) => '- [ ] Compile\\n- [x] Test',
enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit=resolve); }},
}});
const item = {{repository:'stackchain/api',number:17}};
const detail = {{title:'Release',body:'- [ ] Build\\n- [x] Test',updated_at:'old'}};
let settled = false;
const pending = controller.queueManagedTask(item,detail,0,{{action:'rename',label:'Compile'}})
.then(result => {{settled=true;return result;}});
const before = settled;
admit({{item:{{id:'manage-step'}}}});
pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["before"] is False
assert output["queued"] == [{
"kind": "issue-content", "repository": "stackchain/api", "number": 17,
"title": "Release", "baseBody": "- [ ] Build\n- [x] Test",
"body": "- [ ] Compile\n- [x] Test", "expectedUpdatedAt": "old",
"checklistOperation": {"action": "rename", "index": 0, "label": "Compile"},
}]
assert output["result"]["detail"]["checklist_pending"] is True
assert output["result"]["detail"]["body"] == "- [ ] Compile\n- [x] Test"
def test_issue_sheet_adds_a_validated_unchecked_step_without_replacing_existing_content(): def test_issue_sheet_adds_a_validated_unchecked_step_without_replacing_existing_content():
script = f""" script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});