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 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. 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 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 **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 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 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 }); 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 }; if (conflicts.length) return { body: null, changes, conflicts };
const desired = new Map(changes.map(change => [ const desired = new Map(changes.map(change => [
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked, change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
@ -52,6 +68,9 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
const marker = desired.get(entry.key) ? 'x' : ' '; const marker = desired.get(entry.key) ? 'x' : ' ';
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4]; 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: [] }; 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; } .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-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 { 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[hidden] { display:none; }
.checklist-completion button { min-height:44px; min-width:0; } .checklist-completion button { min-height:44px; min-width:0; }

View File

@ -7,6 +7,7 @@
mediaQuery: window.matchMedia('(max-width: 600px)'), mediaQuery: window.matchMedia('(max-width: 600px)'),
entries: [ 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('.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('#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('#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') }, { 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) { function renderIssueBody(detail) {
const interactive = !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); issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
qs('#open-add-checklist-step').disabled = !interactive;
renderChecklistCompletion(detail); renderChecklistCompletion(detail);
} }
@ -3503,6 +3505,10 @@
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 = '';
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('#checklist-completion').hidden = true;
qs('#issue-labels').textContent = ''; qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = ''; qs('#issue-assignees').textContent = '';
@ -5666,6 +5672,42 @@
confirmed:applyIssueContent, confirmed:applyIssueContent,
restore:renderIssueBody, 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('#complete-checklist-issue').addEventListener('click', () => {
qs('#close-issue').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> <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>
<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> <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> <span id="checklist-completion-status" role="status" aria-live="polite">Checklist complete</span>
<button id="complete-checklist-issue" type="button">Close issue</button> <button id="complete-checklist-issue" type="button">Close issue</button>

View File

@ -24,6 +24,19 @@ function escapeOptionHtml(value) {
})[character]); })[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() }) { 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 commentRequest = null;
let closeRequest = null; let closeRequest = null;
@ -152,6 +165,23 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
expectedUpdatedAt: detail.updated_at, 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) { 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.');

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@ -73,6 +73,7 @@ const SHELL = [
BASE + 'static/issue-attachment.js', BASE + 'static/issue-attachment.js',
BASE + 'static/issue-filing-review.js', BASE + 'static/issue-filing-review.js',
BASE + 'static/issue-sheet.js', BASE + 'static/issue-sheet.js',
BASE + 'static/checklist-conflict.js',
BASE + 'static/create-issue-sheet.js', BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js', BASE + 'static/create-and-start.js',
BASE + 'static/assign-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["missing"]["conflicts"] == [{"label": "Ship", "reason": "missing"}]
assert output["duplicate"]["body"] is None assert output["duplicate"]["body"] is None
assert output["duplicate"]["conflicts"] == [{"label": "Ship", "reason": "ambiguous"}] 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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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])) 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 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(): 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 "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker 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-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" 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 ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker 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 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 @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()
@ -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" 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(): def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f""" script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); 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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() 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.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.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(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() 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/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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-attachment.js",
"/dashboard/static/issue-filing-review.js", "/dashboard/static/issue-filing-review.js",
"/dashboard/static/issue-sheet.js", "/dashboard/static/issue-sheet.js",
"/dashboard/static/checklist-conflict.js",
"/dashboard/static/create-issue-sheet.js", "/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js", "/dashboard/static/create-and-start.js",
"/dashboard/static/assign-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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() 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 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(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/today-sync.js'" in source