feat: manage mobile checklist steps inline (Closes #919)
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 committed 2026-08-15 22:38:52 +00:00
1 parent 6b924a4757
commit 4589c46f16
11 files changed
+454 -4

No files matched your search

+15
View File
@@ -6,6 +6,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
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']);
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) {
return JSON.stringify({
body: String(message.body || ''),
@@ -66,11 +76,13 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0));
if (queuedContent) {
const operation = checklistOperation(message.checklistOperation);
const replacement = {
...queuedContent,
operationId: String(requestedOperationId || makeId()).slice(0, 128),
title: String(message.title || ''),
body: String(message.body || ''),
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
};
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
return replacement;
@@ -115,6 +127,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
title: String(message.title || ''),
baseBody: String(message.baseBody ?? message.body ?? ''),
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
...(checklistOperation(message.checklistOperation) ? {
checklistOperations:[checklistOperation(message.checklistOperation)],
} : {}),
} : {}),
};
items.push(item);
+65 -1
View File
@@ -1,4 +1,4 @@
function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
function tasks(body) {
@@ -20,6 +20,70 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
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 local = grouped(tasks(localBody));
const remoteEntries = tasks(remoteBody);
+9
View File
@@ -474,6 +474,13 @@ textarea { resize: vertical; min-height: 120px; }
.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-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 form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; }
.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 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-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-manage { opacity:.65; cursor:wait; }
.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%; } }
.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); }
+11
View File
@@ -218,6 +218,7 @@
let selectedIssueOffline = false;
let selectedIssueDetail = null;
let dismissedChecklistBody = null;
let issueBlockerCandidates = [];
let issueBlockerSearchTimer = null;
let issueConversation = null;
@@ -3505,6 +3506,8 @@
qs('#completed-filed-progress').textContent = item.is_completed ?
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
qs('#issue-sheet-body').textContent = '';
checklistStepManagement.reset();
qs('#checklist-step-label').value = '';
qs('#open-add-checklist-step').disabled = true;
qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = '';
@@ -5672,6 +5675,14 @@
confirmed:applyIssueContent,
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() {
qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = '';
+12
View File
@@ -605,6 +605,18 @@
<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="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">
<button id="open-add-checklist-step" type="button" disabled>Add step</button>
<form id="add-checklist-step-form" hidden>
+121 -1
View File
@@ -37,7 +37,47 @@ function appendChecklistTask(body, label) {
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 closeRequest = null;
let releaseRequest = null;
@@ -165,6 +205,14 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
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) {
const body = appendChecklistTask(detail.body, label);
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 });
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) {
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
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) {
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
container.innerHTML = renderMarkdown(
detail.body || 'No description provided.', {
interactiveTasks: Boolean(interactive),
manageTasks: Boolean(interactive),
}
);
},
@@ -438,4 +557,5 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
createIssueSheet.createPlanningLoader = createPlanningLoader;
createIssueSheet.manageChecklistTask = manageChecklistTask;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+6 -1
View File
@@ -43,8 +43,13 @@
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
' 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 +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
(checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
}).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
}