Merge pull request 'Add checklist steps inline from mobile issue detail' (#916) from timmy/915-mobile-add-checklist-step into main
Some checks failed
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 1m17s
CI / release-candidate (push) Has been skipped

This commit is contained in:
rockachopa 2026-08-15 21:13:17 +00:00
commit d28140bbd1
19 changed files with 272 additions and 21 deletions

View File

@ -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

View File

@ -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: [] };
}

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-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; }

View File

@ -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();
});

View File

@ -605,6 +605,16 @@
<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>
<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>
<label class="visually-hidden" for="add-checklist-step">Checklist step</label>
<input id="add-checklist-step" type="text" maxlength="240" autocomplete="off" placeholder="New checklist step" />
<button id="save-checklist-step" type="submit">Add</button>
<button id="cancel-checklist-step" type="button">Cancel</button>
</form>
<div id="add-checklist-step-status" class="small" aria-live="assertive"></div>
</section>
<section class="checklist-completion" id="checklist-completion" aria-label="Completed checklist" hidden>
<span id="checklist-completion-status" role="status" aria-live="polite">Checklist complete</span>
<button id="complete-checklist-issue" type="button">Close issue</button>

View File

@ -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.');

View File

@ -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',

View File

@ -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"}]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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

View File

@ -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

View File

@ -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))});

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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