diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index af77298..3d79b38 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -474,6 +474,12 @@ 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-completion { position:fixed; right:0; bottom:0; z-index:58; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) repeat(2,minmax(0,auto)); align-items:center; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid #4ade80; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
+.checklist-completion[hidden] { display:none; }
+.checklist-completion button { min-height:44px; min-width:0; }
+#issue-sheet:has(.checklist-completion:not([hidden])) .issue-sheet-panel { padding-bottom:calc(118px + env(safe-area-inset-bottom)); }
+#issue-sheet:has(.checklist-completion:not([hidden])) .work-session-nav { display:none; }
+@media(max-width:420px) { .checklist-completion { grid-template-columns:1fr 1fr; } .checklist-completion span { grid-column:1 / -1; } }
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
.issue-blockers h2 { margin-top:0; }
.issue-blocker-list { display:grid; gap:8px; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 4f7a035..7fd0ec6 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -216,6 +216,7 @@
let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null;
+ let dismissedChecklistBody = null;
let issueBlockerCandidates = [];
let issueBlockerSearchTimer = null;
let issueConversation = null;
@@ -3398,9 +3399,22 @@
}
}
+ function renderChecklistCompletion(detail) {
+ const interactive = !issueController.readOnly(selectedIssue) && detail?.state === 'open' && detail?.updated_at;
+ const completion = issueController.checklistCompletion(qs('#issue-sheet-body'), {
+ interactive, today:workSession.active(), offline:selectedIssueOffline,
+ dismissed:dismissedChecklistBody === detail?.body,
+ });
+ const bar = qs('#checklist-completion');
+ bar.hidden = !completion.visible;
+ qs('#checklist-completion-status').textContent = completion.status;
+ qs('#complete-checklist-issue').textContent = completion.label;
+ }
+
function renderIssueBody(detail) {
- issueController.renderTasks(qs('#issue-sheet-body'), detail,
- !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at);
+ const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at;
+ issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
+ renderChecklistCompletion(detail);
}
function applyIssueContent(editing, detail, confirmed) {
@@ -3427,6 +3441,7 @@
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
+ dismissedChecklistBody = null;
issueMentions.dismiss();
selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null;
@@ -3447,6 +3462,7 @@
qs('#completed-filed-progress').textContent = item.is_completed ?
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
qs('#issue-sheet-body').textContent = '';
+ qs('#checklist-completion').hidden = true;
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
qs('#issue-comments').textContent = '';
@@ -5609,6 +5625,14 @@
confirmed:applyIssueContent,
restore:renderIssueBody,
});
+ qs('#complete-checklist-issue').addEventListener('click', () => {
+ qs('#close-issue').click();
+ });
+ qs('#keep-checklist-issue-open').addEventListener('click', () => {
+ dismissedChecklistBody = selectedIssueDetail?.body;
+ if (selectedIssueDetail) renderChecklistCompletion(selectedIssueDetail);
+ qs('#issue-sheet-body').focus();
+ });
qs('#issue-planning').addEventListener('toggle', event => {
if (event.currentTarget.open) loadIssuePlanning();
});
diff --git a/frontend/index.html b/frontend/index.html
index 1016eb0..6c0cb3a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -605,6 +605,11 @@
+
+ Checklist complete
+
+
+
Blocked by
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js
index 5f69614..2365187 100644
--- a/frontend/issue-sheet.js
+++ b/frontend/issue-sheet.js
@@ -204,6 +204,16 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
}
);
},
+ checklistCompletion(container, { interactive, today, offline, dismissed = false }) {
+ const tasks = Array.from(container.querySelectorAll('input.task-list-toggle'));
+ const visible = Boolean(interactive && !dismissed && tasks.length && tasks.every(task => task.checked));
+ return {
+ visible,
+ status: offline ? 'Checklist complete · pending sync' : 'Checklist complete',
+ label: today ? (offline ? 'Queue close & next' : 'Close & next') :
+ (offline ? 'Queue issue closure' : 'Close issue'),
+ };
+ },
mergeContent(snapshot, item, detail, confirmed, replace) {
return {
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 0016d1d..6ff20d7 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -2224,6 +2224,84 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
+def test_completed_issue_checklist_offers_close_and_next_during_today():
+ script = f"""
+const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+const controller = createIssueSheet({{storage:null}});
+const container = {{querySelectorAll:() => [{{checked:true}}, {{checked:true}}]}};
+process.stdout.write(JSON.stringify(controller.checklistCompletion(container, {{
+ interactive:true, today:true, offline:false,
+}})));
+"""
+ output = json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+ assert output == {
+ "visible": True,
+ "status": "Checklist complete",
+ "label": "Close & next",
+ }
+
+
+def test_checklist_completion_stays_hidden_when_kept_open():
+ script = f"""
+const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+const controller = createIssueSheet({{storage:null}});
+const container = {{querySelectorAll:() => [{{checked:true}}]}};
+process.stdout.write(JSON.stringify(controller.checklistCompletion(container, {{
+ interactive:true, today:false, offline:false, dismissed:true,
+}})));
+"""
+ output = json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+ assert output["visible"] is False
+
+
+def test_checklist_completion_copy_matches_online_and_offline_close_modes():
+ script = f"""
+const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+const controller = createIssueSheet({{storage:null}});
+const container = {{querySelectorAll:() => [{{checked:true}}]}};
+process.stdout.write(JSON.stringify({{
+ outside:controller.checklistCompletion(container, {{interactive:true,today:false,offline:false}}),
+ offlineToday:controller.checklistCompletion(container, {{interactive:true,today:true,offline:true}}),
+ offlineOutside:controller.checklistCompletion(container, {{interactive:true,today:false,offline:true}}),
+}}));
+"""
+ output = json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+ assert output["outside"]["label"] == "Close issue"
+ assert output["offlineToday"] == {
+ "visible": True,
+ "status": "Checklist complete · pending sync",
+ "label": "Queue close & next",
+ }
+ assert output["offlineOutside"]["label"] == "Queue issue closure"
+
+
+@pytest.mark.anyio
+async def test_mobile_checklist_completion_bar_reuses_confirmed_close_flow():
+ html = await dashboard()
+
+ assert 'id="checklist-completion"' in html
+ assert 'id="checklist-completion-status" role="status"' in html
+ assert 'id="complete-checklist-issue"' in html
+ assert 'id="keep-checklist-issue-open"' in html
+ assert "function renderChecklistCompletion(detail)" in html
+ assert "issueController.checklistCompletion(qs('#issue-sheet-body')" in html
+ assert "qs('#complete-checklist-issue').addEventListener('click', () =>" in html
+ assert "qs('#close-issue').click()" in html
+ assert "dismissedChecklistBody = selectedIssueDetail?.body" in html
+ assert ".checklist-completion { position:fixed;" in html
+ assert ".checklist-completion button { min-height:44px;" in html
+ assert "padding-bottom:calc(118px + env(safe-area-inset-bottom))" in html
+
+
def test_offline_issue_checklist_toggle_waits_for_durable_admission_and_returns_pending_detail():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});