diff --git a/README.md b/README.md
index ca3cc82..c569e3c 100644
--- a/README.md
+++ b/README.md
@@ -115,7 +115,12 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After a reload or installed-app
-restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
+restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
+assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
+**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without
+exposing or replacing the full issue description. Offline additions enter the same account-bound durable
+issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the
+latest remote body while preserving remote prose and task-state changes.
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. **Reply & next** provides the same one-action continuation
diff --git a/frontend/checklist-conflict.js b/frontend/checklist-conflict.js
index 3e9f423..5fc9a20 100644
--- a/frontend/checklist-conflict.js
+++ b/frontend/checklist-conflict.js
@@ -42,6 +42,22 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
}
+ for (const [key, localMatches] of local) {
+ if (base.has(key)) continue;
+ if (localMatches.length !== 1) {
+ conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
+ continue;
+ }
+ const remoteMatches = remote.get(key) || [];
+ if (remoteMatches.length > 1) {
+ conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
+ continue;
+ }
+ if (remoteMatches.length === 0) {
+ changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
+ }
+ }
+
if (conflicts.length) return { body: null, changes, conflicts };
const desired = new Map(changes.map(change => [
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
@@ -52,6 +68,9 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
const marker = desired.get(entry.key) ? 'x' : ' ';
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
});
+ changes.filter(change => change.added).forEach(change => {
+ lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
+ });
return { body: lines.join('\n'), changes, conflicts: [] };
}
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 3d79b38..be7065e 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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-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; }
+.checklist-add input { min-width:0; width:100%; box-sizing:border-box; }
+.checklist-add button, .checklist-add input { min-height:44px; }
+#issue-sheet.read-only .checklist-add { display:none; }
+@media(max-width:340px) { .checklist-add form { grid-template-columns:1fr 1fr; } .checklist-add input { grid-column:1 / -1; } }
.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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index ac3ba51..7f12d9d 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -7,6 +7,7 @@
mediaQuery: window.matchMedia('(max-width: 600px)'),
entries: [
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
+ { panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.checklist-add'), composer:qs('#add-checklist-step'), submit:qs('#save-checklist-step'), status:qs('#add-checklist-step-status') },
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
@@ -3455,6 +3456,7 @@
function renderIssueBody(detail) {
const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at;
issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
+ qs('#open-add-checklist-step').disabled = !interactive;
renderChecklistCompletion(detail);
}
@@ -3503,6 +3505,10 @@
qs('#completed-filed-progress').textContent = item.is_completed ?
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
qs('#issue-sheet-body').textContent = '';
+ qs('#open-add-checklist-step').disabled = true;
+ qs('#add-checklist-step-form').hidden = true;
+ qs('#add-checklist-step').value = '';
+ qs('#add-checklist-step-status').textContent = '';
qs('#checklist-completion').hidden = true;
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
@@ -5666,6 +5672,42 @@
confirmed:applyIssueContent,
restore:renderIssueBody,
});
+ function closeAddChecklistStep() {
+ qs('#add-checklist-step-form').hidden = true;
+ qs('#add-checklist-step').value = '';
+ qs('#open-add-checklist-step').focus();
+ }
+ qs('#open-add-checklist-step').addEventListener('click', () => {
+ qs('#add-checklist-step-form').hidden = false;
+ qs('#add-checklist-step-status').textContent = 'Add one required step.';
+ qs('#add-checklist-step').focus();
+ });
+ qs('#cancel-checklist-step').addEventListener('click', closeAddChecklistStep);
+ qs('#add-checklist-step-form').addEventListener('submit', async event => {
+ event.preventDefault();
+ const state = {item:selectedIssue, detail:selectedIssueDetail, offline:selectedIssueOffline};
+ if (!state.item || !state.detail?.updated_at) return;
+ const submit = qs('#save-checklist-step');
+ submit.disabled = true;
+ qs('#add-checklist-step-status').textContent = state.offline ? 'Queueing checklist step…' : 'Adding checklist step…';
+ try {
+ const result = await (selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(
+ issueController, state.item, state.detail, qs('#add-checklist-step').value
+ );
+ if (selectedIssue !== state.item) return;
+ applyIssueContent(state.item, state.detail, state.offline ? result.detail : result);
+ closeAddChecklistStep();
+ qs('#add-checklist-step-status').textContent = state.offline ?
+ 'Checklist step queued. Pending sync.' : 'Checklist step added.';
+ } catch (error) {
+ if (selectedIssue === state.item) {
+ qs('#add-checklist-step-status').textContent = error.message;
+ qs('#add-checklist-step').focus();
+ }
+ } finally {
+ submit.disabled = false;
+ }
+ });
qs('#complete-checklist-issue').addEventListener('click', () => {
qs('#close-issue').click();
});
diff --git a/frontend/index.html b/frontend/index.html
index 6c0cb3a..fbda043 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -605,6 +605,16 @@
+
Checklist complete
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js
index 662990f..7ca5034 100644
--- a/frontend/issue-sheet.js
+++ b/frontend/issue-sheet.js
@@ -24,6 +24,19 @@ function escapeOptionHtml(value) {
})[character]);
}
+function appendChecklistTask(body, label) {
+ const normalized = String(label || '').trim().replace(/\s+/g, ' ');
+ if (!normalized) throw new Error('Enter a checklist step.');
+ const key = normalized.toLocaleLowerCase();
+ const duplicate = String(body || '').split('\n').some(line => {
+ const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
+ return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
+ });
+ if (duplicate) throw new Error('That checklist step already exists.');
+ const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
+ 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() }) {
let commentRequest = null;
let closeRequest = null;
@@ -152,6 +165,23 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
expectedUpdatedAt: detail.updated_at,
});
},
+ async addTask(item, detail, label) {
+ const body = appendChecklistTask(detail.body, label);
+ return this.updateContent(item, {
+ title: detail.title,
+ body,
+ expectedUpdatedAt: detail.updated_at,
+ });
+ },
+ async queueAddedTask(item, detail, label) {
+ if (typeof enqueueDurably !== 'function') {
+ throw new Error('Offline checklist updates are unavailable.');
+ }
+ const body = appendChecklistTask(detail.body, label);
+ await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
+ title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
+ 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.');
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 808f0a0..c5c2774 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v107';
+const CACHE = 'stackchain-dashboard-shell-v108';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -73,6 +73,7 @@ const SHELL = [
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-filing-review.js',
BASE + 'static/issue-sheet.js',
+ BASE + 'static/checklist-conflict.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
diff --git a/tests/test_checklist_conflict.py b/tests/test_checklist_conflict.py
index cdd7666..f6c9680 100644
--- a/tests/test_checklist_conflict.py
+++ b/tests/test_checklist_conflict.py
@@ -47,3 +47,38 @@ process.stdout.write(JSON.stringify({{missing,duplicate}}));
assert output["missing"]["conflicts"] == [{"label": "Ship", "reason": "missing"}]
assert output["duplicate"]["body"] is None
assert output["duplicate"]["conflicts"] == [{"label": "Ship", "reason": "ambiguous"}]
+
+
+def test_merge_appends_a_locally_added_step_to_remote_edits_without_reverting_them():
+ script = f"""
+const mergeChecklistConflict = require({json.dumps(str(MERGER))});
+const result = mergeChecklistConflict({{
+ baseBody:'Intro\\n- [ ] Build',
+ localBody:'Intro\\n- [ ] Build\\n- [ ] Verify rollback',
+ remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Notify support',
+}});
+process.stdout.write(JSON.stringify(result));
+"""
+ output = run_node(script)
+
+ assert output == {
+ "body": "Updated intro\n- [x] Build\n- [ ] Notify support\n- [ ] Verify rollback",
+ "changes": [{"label": "Verify rollback", "checked": False, "added": True}],
+ "conflicts": [],
+ }
+
+
+def test_merge_rejects_an_ambiguous_locally_added_duplicate():
+ script = f"""
+const mergeChecklistConflict = require({json.dumps(str(MERGER))});
+const result = mergeChecklistConflict({{
+ baseBody:'- [ ] Build',
+ localBody:'- [ ] Build\\n- [ ] Verify rollback\\n- [ ] verify rollback ',
+ remoteBody:'- [x] Build',
+}});
+process.stdout.write(JSON.stringify(result));
+"""
+ output = run_node(script)
+
+ assert output["body"] is None
+ assert output["conflicts"] == [{"label": "Verify rollback", "reason": "ambiguous"}]
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index ceed78f..0886cd8 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v107" in worker
+ assert "stackchain-dashboard-shell-v108" in worker
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 537a336..b2068a0 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index d8a552d..8c71d51 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -182,4 +182,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v107" in worker
+ assert "stackchain-dashboard-shell-v108" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index ff2f75c..7f7a689 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v107" in worker
+ assert "stackchain-dashboard-shell-v108" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index 3787b89..6a78a17 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
- assert "stackchain-dashboard-shell-v107" in worker
+ assert "stackchain-dashboard-shell-v108" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py
index 5a87b66..612eb0c 100644
--- a/tests/test_mobile_start_day.py
+++ b/tests/test_mobile_start_day.py
@@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
- assert "stackchain-dashboard-shell-v107" in service_worker
+ assert "stackchain-dashboard-shell-v108" in service_worker
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 355efed..aebb9c0 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -2225,6 +2225,26 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
+@pytest.mark.anyio
+async def test_mobile_issue_detail_adds_a_checklist_step_inline_with_accessible_touch_controls():
+ html = await dashboard()
+
+ assert 'id="open-add-checklist-step"' in html
+ assert 'id="add-checklist-step-form"' in html
+ assert 'id="add-checklist-step" type="text"' in html
+ assert 'id="save-checklist-step" type="submit"' in html
+ assert 'id="cancel-checklist-step"' in html
+ assert 'id="add-checklist-step-status" class="small" aria-live="assertive"' in html
+ assert "(selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(" in html
+ assert "applyIssueContent(state.item, state.detail, state.offline ? result.detail : result)" in html
+ assert "Checklist step queued. Pending sync." in html
+ assert "qs('#add-checklist-step').focus()" in html
+ assert "qs('#open-add-checklist-step').focus()" in html
+ assert '.checklist-add button, .checklist-add input { min-height:44px;' in html
+ assert '#issue-sheet.read-only .checklist-add { display:none;' in html
+ assert 'grid-template-columns:minmax(0,1fr) auto auto' in html
+
+
@pytest.mark.anyio
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
html = await dashboard()
@@ -2447,6 +2467,77 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z"
+def test_issue_sheet_adds_a_validated_unchecked_step_without_replacing_existing_content():
+ script = f"""
+const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+const calls = [];
+const controller = createIssueSheet({{
+ storage:null,
+ fetchJson:(url, options) => {{
+ calls.push({{url, body:JSON.parse(options.body)}});
+ return Promise.resolve({{number:17,title:'Release',body:'Intro\\n\\n- [x] Build\\n- [ ] Verify rollback',updated_at:'new'}});
+ }},
+}});
+const item = {{repository:'stackchain/api',number:17}};
+const detail = {{title:'Release',body:'Intro\\n\\n- [x] Build',updated_at:'old'}};
+Promise.all([
+ controller.addTask(item, detail, ' Verify rollback '),
+ controller.addTask(item, detail, ' build ').catch(error => ({{error:error.message}})),
+ controller.addTask(item, detail, ' ').catch(error => ({{error:error.message}})),
+]).then(results => process.stdout.write(JSON.stringify({{calls,results}})));
+"""
+ 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": "Intro\n\n- [x] Build\n- [ ] Verify rollback",
+ "expected_updated_at": "old",
+ },
+ }]
+ assert output["results"][0]["body"].endswith("- [ ] Verify rollback")
+ assert output["results"][1] == {"error": "That checklist step already exists."}
+ assert output["results"][2] == {"error": "Enter a checklist step."}
+
+
+def test_offline_issue_sheet_adds_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,
+ enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit=resolve); }},
+}});
+const item = {{repository:'stackchain/api',number:17}};
+const detail = {{title:'Release',body:'Intro',updated_at:'old'}};
+let settled = false;
+const pending = controller.queueAddedTask(item, detail, 'Verify rollback').then(result => {{settled=true;return result;}});
+const before = settled;
+admit({{item:{{id:'add-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": "Intro", "body": "Intro\n- [ ] Verify rollback",
+ "expectedUpdatedAt": "old",
+ }]
+ assert output["result"] == {
+ "queued": True,
+ "detail": {
+ "title": "Release", "body": "Intro\n- [ ] Verify rollback",
+ "updated_at": "old", "checklist_pending": True,
+ },
+ }
+
+
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 9ac4cd6..6ca607e 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 11f2496..caadc02 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -155,7 +155,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -164,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -179,22 +179,32 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
+def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
+ source = WORKER.read_text()
+
+ assert "stackchain-dashboard-shell-v108" in source
+ assert "BASE + 'static/issue-sheet.js'" in source
+ assert "BASE + 'static/checklist-conflict.js'" in source
+ assert "BASE + 'static/dashboard.js'" in source
+ assert "BASE + 'static/dashboard.css'" in source
+
+
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -203,21 +213,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -881,7 +891,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -961,6 +971,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-filing-review.js",
"/dashboard/static/issue-sheet.js",
+ "/dashboard/static/checklist-conflict.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js",
"/dashboard/static/assign-and-start.js",
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index ce5a8bb..4470adf 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
- assert "const CACHE = 'stackchain-dashboard-shell-v107';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v108';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 974e1c8..5e72c5d 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v107" in source
+ assert "stackchain-dashboard-shell-v108" in source
assert "BASE + 'static/today-sync.js'" in source