diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js
index 294f0fb..9abe813 100644
--- a/frontend/authored-outbox.js
+++ b/frontend/authored-outbox.js
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
- const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review']);
+ const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
function reviewFingerprint(message) {
return JSON.stringify({
@@ -133,6 +133,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
+ if (item.kind === 'issue-close') {
+ return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
+ }
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
@@ -155,21 +158,29 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
result = delivery.message;
} else {
- const body = item.kind === 'pull-review' ? {
- body: item.body,
- decision: item.decision,
- expected_head_sha: item.expectedHeadSha,
- comments: item.comments,
- } : { body: item.body };
- result = await fetchJson(endpoint(item), {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'Idempotency-Key': item.operationId,
- },
- body: JSON.stringify(body),
- });
+ if (item.kind === 'issue-close') {
+ result = await fetchJson(endpoint(item), {
+ method: 'PATCH',
+ headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
+ });
+ if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
+ } else {
+ const body = item.kind === 'pull-review' ? {
+ body: item.body,
+ decision: item.decision,
+ expected_head_sha: item.expectedHeadSha,
+ comments: item.comments,
+ } : { body: item.body };
+ result = await fetchJson(endpoint(item), {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': item.operationId,
+ },
+ body: JSON.stringify(body),
+ });
+ }
}
if (!result) return { blocked: true };
clearConfirmedReviewState(item);
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index c43ed48..057d23c 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -249,6 +249,12 @@ function createBackgroundIssueSync({
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
};
}
+ if (item.kind === 'issue-close') {
+ return {
+ id: item.id, status, kind: 'message',
+ route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
+ };
+ }
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
@@ -267,6 +273,15 @@ function createBackgroundIssueSync({
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
+ if (item.kind === 'issue-close') {
+ return {
+ url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
+ options: {
+ method: 'PATCH',
+ headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
+ },
+ };
+ }
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
@@ -332,6 +347,11 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item);
try {
const delivered = await requestJson(request.url, request.options);
+ if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
+ const error = new Error('Issue closure was not confirmed.');
+ error.status = 422;
+ throw error;
+ }
await store.complete(item.id, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 02936c0..218dc83 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -120,6 +120,7 @@
let selectedUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
+ let selectedIssueOffline = false;
let selectedIssueDetail = null;
let issueConversation = null;
let issueTrigger = null;
@@ -781,6 +782,10 @@
warm: warmTodayOffline,
announce: message => { qs('#my-work-action-status').textContent = message; },
});
+ const closeOfflineIssue = createOfflineIssueClose({
+ enqueueDurably: message => authoredOutbox.enqueueDurably(message),
+ completeToday: (item, options) => completeTodayItem(item, options),
+ });
function reviewingActiveTodayItem() {
return workSession.checkpointed();
@@ -1060,7 +1065,7 @@
function setOfflineDetailControls(kind) {
const selectors = kind === 'issue' ? [
- '#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff',
+ '#edit-issue-content', '#release-issue', '#load-issue-handoff',
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
@@ -1250,6 +1255,7 @@
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const isUnfiled = item.kind === 'unfiled-issue';
const reviewOutbox = item.outbox_kind === 'pull-review';
+ const closureOutbox = item.outbox_kind === 'issue-close';
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
const outboxActions = item.quarantined ?
'' +
@@ -1266,7 +1272,8 @@
'' +
'' :
item.kind === 'authored-outbox' && !reviewOutbox ?
- '' +
+ '' +
'' +
'' :
reviewOutbox ?
@@ -1686,6 +1693,7 @@
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
+ selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null;
issueConversation = null;
issueTrigger = trigger;
@@ -1728,7 +1736,9 @@
qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = '';
qs('#close-issue').disabled = false;
- qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';
+ qs('#close-issue').textContent = offlineDetail ?
+ (workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
+ (workSession.active() ? 'Close & next' : 'Close issue');
qs('#close-issue-sheet').focus();
try {
const detail = offlineDetail || await issueController.load(item);
@@ -1777,6 +1787,7 @@
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
+ selectedIssueOffline = false;
selectedIssueDetail = null;
issueConversation = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
@@ -3458,6 +3469,26 @@
const closing = selectedIssue;
const button = qs('#close-issue');
button.disabled = true;
+ if (selectedIssueOffline) {
+ qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
+ try {
+ const outcome = await closeOfflineIssue(closing);
+ closeIssueSheet();
+ refreshMyWorkView({ reconcileSession:false });
+ if (!outcome.advanced) {
+ qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
+ } else if (outcome.admission.background) {
+ qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
+ } else {
+ qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
+ }
+ } catch (error) {
+ qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
+ button.disabled = false;
+ button.focus();
+ }
+ return;
+ }
qs('#issue-sheet-status').textContent = 'Closing issue…';
try {
await issueController.close(selectedIssue);
diff --git a/frontend/drafts.js b/frontend/drafts.js
index 1d5dc2f..97f6419 100644
--- a/frontend/drafts.js
+++ b/frontend/drafts.js
@@ -140,6 +140,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
.map(item => {
const isUpdate = item.kind === 'update-reply';
const isReview = item.kind === 'pull-review';
+ const isClosure = item.kind === 'issue-close';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
@@ -158,8 +159,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
- (item.status === 'attention' ? 'Needs attention' :
- (isReview ? 'Queued review' : 'Queued message')),
+ (item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
+ (isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
diff --git a/frontend/index.html b/frontend/index.html
index 43f5ace..585ad8d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -594,6 +594,7 @@
+
diff --git a/frontend/offline-issue-close.js b/frontend/offline-issue-close.js
new file mode 100644
index 0000000..b4c2749
--- /dev/null
+++ b/frontend/offline-issue-close.js
@@ -0,0 +1,19 @@
+function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
+ globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
+ return async function closeOfflineIssue(item) {
+ const admission = await enqueueDurably({
+ kind: 'issue-close',
+ repository: String(item.repository || ''),
+ number: Number(item.number || 0),
+ body: '',
+ operationId: String(createOperationId()).slice(0, 128),
+ });
+ const advanced = Boolean(completeToday(item, {
+ successMessage: 'Issue closure queued. Next Today item opened.',
+ failureMessage: 'Issue closure queued, but Today still needs completion.',
+ }));
+ return { admission, advanced };
+ };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index b1308ac..6203cdd 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v70';
+const CACHE = 'stackchain-dashboard-shell-v71';
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;
@@ -21,6 +21,7 @@ const SHELL = [
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
+ BASE + 'static/offline-issue-close.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py
index ea3f408..91fd1f0 100644
--- a/tests/test_authored_outbox.py
+++ b/tests/test_authored_outbox.py
@@ -126,6 +126,36 @@ if (queued) {{
assert output["progress"] is None
+def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
+ script = f"""
+const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
+const values = new Map(); const calls = [];
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+const outbox = createAuthoredOutbox({{
+ storage, getOwnerLogin:()=> 'timmy',
+ fetchJson: async (url, options) => {{ calls.push({{url,options}}); return {{number:27,state:'closed'}}; }},
+}});
+const queued = outbox.enqueue({{
+ kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
+}});
+outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
+ queued,calls,result,remaining:outbox.list()
+}})));
+"""
+ output = run_node(script)
+
+ assert output["queued"]["kind"] == "issue-close"
+ assert output["calls"] == [{
+ "url": "api/v1/repos/stackchain/dashboard/issues/27/close",
+ "options": {
+ "method": "PATCH",
+ "headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
+ },
+ }]
+ assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
+ assert output["remaining"] == []
+
+
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py
index 5e2cd7c..dd5454f 100644
--- a/tests/test_background_issue_sync.py
+++ b/tests/test_background_issue_sync.py
@@ -236,6 +236,36 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
}]
+def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
+ authored = {
+ "id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
+ "kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
+ }
+ script = f"""
+const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+let queued = {json.dumps(authored)}; const calls=[];
+const store = {{
+ claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
+ complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
+}};
+const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
+createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
+"""
+ output = run_node(script)
+
+ mutation = output["calls"][1]
+ assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
+ assert mutation["options"]["method"] == "PATCH"
+ assert mutation["options"]["headers"] == {
+ "Accept": "application/json", "Idempotency-Key": "close-op"
+ }
+ assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
+ assert output["result"]["receipts"] == [{
+ "id": "close-op", "status": "confirmed", "kind": "message",
+ "route": "#/my-work/issue/stackchain/dashboard/27",
+ }]
+
+
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
index d611333..48d7789 100644
--- a/tests/test_drafts.py
+++ b/tests/test_drafts.py
@@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item));
assert output["quarantined"] is False
+def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
+ script = f"""
+const createDraftInbox = require({json.dumps(str(DRAFTS))});
+const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
+ id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
+ ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200
+}}]}})]]);
+const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
+process.stdout.write(JSON.stringify(item));
+"""
+ output = run_node(script)
+
+ assert output["label"] == "Issue closure needs attention"
+ assert output["outbox_kind"] == "issue-close"
+ assert output["title"] == "stackchain/dashboard#27"
+ assert output["preview"] == "Closure rejected"
+ assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
+ assert output["quarantined"] is False
+
+
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
@@ -218,6 +239,8 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html
+ assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
+ assert "closureOutbox ? 'Open issue' : 'Open message'" in html
assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" in html
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 71cb273..399c9c0 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-v70" in source
+ assert "stackchain-dashboard-shell-v71" 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 4b7cf39..0131153 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -137,4 +137,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-v70" in worker
+ assert "stackchain-dashboard-shell-v71" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 76e788c..013bb5a 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -35,4 +35,4 @@ 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-v70" in worker
+ assert "stackchain-dashboard-shell-v71" in worker
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index c14b3d1..db29a77 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -1672,7 +1672,8 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
html = await dashboard()
- assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html
+ assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
+ assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
"qs('#close-pull-sheet').addEventListener", 1
)[0]
diff --git a/tests/test_offline_issue_close.py b/tests/test_offline_issue_close.py
new file mode 100644
index 0000000..9fefde7
--- /dev/null
+++ b/tests/test_offline_issue_close.py
@@ -0,0 +1,35 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+OFFLINE_CLOSE = Path(__file__).parents[1] / "frontend" / "offline-issue-close.js"
+
+
+def run_node(script: str):
+ result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
+ return json.loads(result.stdout)
+
+
+def test_offline_issue_close_waits_for_durable_admission_before_advancing_today():
+ script = f"""
+const createOfflineIssueClose = require({json.dumps(str(OFFLINE_CLOSE))});
+const events=[]; let release;
+const gate=new Promise(resolve=>release=resolve);
+const close=createOfflineIssueClose({{
+ enqueueDurably:async message=>{{events.push('admit');await gate;events.push('durable');return {{item:message,background:true}};}},
+ completeToday:item=>{{events.push('complete:' + item.number);return true;}},
+}});
+const item={{kind:'issue',repository:'stackchain/dashboard',number:27}};
+const pending=close(item).then(result=>{{events.push('resolved');return result;}});
+Promise.resolve().then(async()=>{{
+ const before=events.slice();release();const result=await pending;
+ process.stdout.write(JSON.stringify({{before,events,result}}));
+}});
+"""
+ output = run_node(script)
+
+ assert output["before"] == ["admit"]
+ assert output["events"] == ["admit", "durable", "complete:27", "resolved"]
+ assert output["result"]["advanced"] is True
+ assert output["result"]["admission"]["background"] is True
diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py
index e2b970b..8a16f47 100644
--- a/tests/test_offline_work.py
+++ b/tests/test_offline_work.py
@@ -301,3 +301,22 @@ async def test_offline_today_review_queues_durably_before_completing_and_advanci
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
assert "if (!advanced)" in handler
assert "Review queued, but Today still needs completion." in handler
+
+
+@pytest.mark.anyio
+async def test_offline_today_issue_queues_closure_before_completing_and_advancing():
+ html = await dashboard()
+
+ assert '' in html
+ assert "const closeOfflineIssue = createOfflineIssueClose({" in html
+ assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html
+ assert "completeToday: (item, options) => completeTodayItem(item, options)" in html
+ assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
+ handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
+ "qs('#close-pull-sheet').addEventListener", 1
+ )[0]
+ admission = handler.index("await closeOfflineIssue(closing)")
+ sheet_close = handler.index("closeIssueSheet()")
+ assert admission < sheet_close
+ assert "Issue closure queued for reconnect." in handler
+ assert "The issue remains in Today; retry." in handler
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index b4b6ee4..45ec1f5 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -168,6 +168,6 @@ 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-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/plan-today.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 0ca9c9e..3f14e4b 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -139,7 +139,7 @@ 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-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -147,14 +147,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v70" in source
+ assert "stackchain-dashboard-shell-v71" 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-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -163,21 +163,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-v70" in source
+ assert "stackchain-dashboard-shell-v71" 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-v70" in source
+ assert "stackchain-dashboard-shell-v71" 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-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -358,7 +358,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-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -389,6 +389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
+ "/dashboard/static/offline-issue-close.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 0fd8f3f..13473d0 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
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-v70" in source
+ assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/today-sync.js'" in source