diff --git a/README.md b/README.md
index d2fda8e..8669ea9 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,11 @@ inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
-creation and assignment permission. The New issue sheet can optionally select an open
+creation and assignment permission. Once a repository and meaningful title are selected,
+the New issue sheet checks for similar open issues in that repository. Candidate links keep
+the draft intact; the first create attempt pauses until the operator reviews them or explicitly
+chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
+when search is unavailable. The sheet can also optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js
index e1f55bd..64f0dc3 100644
--- a/frontend/create-issue-sheet.js
+++ b/frontend/create-issue-sheet.js
@@ -23,6 +23,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
let pending = null;
+ let duplicateRequest = 0;
+ let duplicateState = {status: 'idle', key: '', candidates: []};
+ let acknowledgedDuplicateKey = '';
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
@@ -140,6 +143,51 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
+ function duplicateKey(draft) {
+ const repository = String(draft?.repository || '').trim();
+ const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
+ return { repository, title, key: repository + '\n' + title.toLowerCase() };
+ }
+
+ async function findDuplicates(draft) {
+ const input = duplicateKey(draft);
+ const request = ++duplicateRequest;
+ if (!input.repository || input.title.length < 4) {
+ duplicateState = {status: 'idle', key: input.key, candidates: []};
+ return duplicateState;
+ }
+ try {
+ const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
+ if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
+ duplicateState = {
+ status: 'ready',
+ key: input.key,
+ partial: payload?.partial === true,
+ candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
+ item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
+ ).slice(0, 3),
+ };
+ return duplicateState;
+ } catch (error) {
+ if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
+ duplicateState = {status: 'failed', key: input.key, candidates: [], error};
+ return duplicateState;
+ }
+ }
+
+ function needsDuplicateAcknowledgement(draft) {
+ const {key} = duplicateKey(draft);
+ return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
+ duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
+ }
+
+ function acknowledgeDuplicates(draft) {
+ const {key} = duplicateKey(draft);
+ if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
+ acknowledgedDuplicateKey = key;
+ }
+ }
+
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
@@ -166,7 +214,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
- saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
+ saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
+ needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 0084993..0e48ad8 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -274,6 +274,12 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.create-issue-label-option input { width:20px; height:20px; margin:0; }
+.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
+.create-issue-duplicates[hidden] { display:none; }
+.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
+.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
+.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
+.create-issue-duplicate-card a { color:#93c5fd; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 2b9ca83..f455245 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1717,6 +1717,67 @@
});
}
+ function currentIssueCaptureDraft() {
+ return {
+ repository: qs('#create-issue-repository').value,
+ title: qs('#create-issue-title').value.trim(),
+ body: qs('#create-issue-body').value.trim(),
+ labelIds: selectedIssueLabelIds(),
+ milestoneId: Number(qs('#create-issue-milestone').value) || null,
+ dueDate: qs('#create-issue-due-date').value,
+ };
+ }
+
+ let duplicateCheckTimer = null;
+ function renderIssueDuplicates(state) {
+ if (state.status === 'stale') return;
+ const panel = qs('#create-issue-duplicates');
+ const list = qs('#create-issue-duplicate-list');
+ const status = qs('#create-issue-duplicate-status');
+ const anyway = qs('#create-issue-anyway');
+ list.replaceChildren();
+ anyway.hidden = true;
+ if (state.status === 'idle') {
+ panel.hidden = true;
+ return;
+ }
+ panel.hidden = false;
+ if (state.status === 'failed') {
+ status.textContent = 'Could not check for existing issues. You can still create this issue.';
+ return;
+ }
+ if (!state.candidates.length) {
+ status.textContent = 'No similar open issues found in this repository.';
+ return;
+ }
+ status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length +
+ ' similar open issue' + (state.candidates.length === 1 ? '' : 's') +
+ ' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.');
+ state.candidates.forEach(item => {
+ const card = document.createElement('article');
+ card.className = 'create-issue-duplicate-card';
+ const key = document.createElement('span');
+ key.className = 'small';
+ key.textContent = item.repository + ' #' + item.number;
+ const link = document.createElement('a');
+ link.textContent = item.title || 'Review existing issue';
+ link.href = safeSearchUrl(item.url) || '#';
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.addEventListener('click', saveIssueCaptureDraft);
+ card.append(key, link);
+ list.appendChild(card);
+ });
+ }
+
+ function scheduleIssueDuplicateCheck() {
+ clearTimeout(duplicateCheckTimer);
+ const draft = currentIssueCaptureDraft();
+ duplicateCheckTimer = setTimeout(async () => {
+ renderIssueDuplicates(await issueCapture.findDuplicates(draft));
+ }, 350);
+ }
+
async function loadIssueLabels(repository, selectedIds = []) {
const list = qs('#create-issue-label-list');
const status = qs('#create-issue-label-status');
@@ -1802,6 +1863,7 @@
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
+ scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-issue-sheet').classList.add('open');
@@ -1817,6 +1879,8 @@
return;
}
qs('#create-issue-sheet').classList.remove('open');
+ clearTimeout(duplicateCheckTimer);
+ qs('#create-issue-duplicates').hidden = true;
creatingIssue = false;
qs('#new-issue').focus();
}
@@ -2565,30 +2629,39 @@
closeCreateIssueSheet(true, !discardEditedDraft);
});
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
- qs(selector).addEventListener('input', saveIssueCaptureDraft)
+ qs(selector).addEventListener('input', () => {
+ saveIssueCaptureDraft();
+ if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
+ })
);
qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value);
loadIssueMilestones(event.target.value);
saveIssueCaptureDraft();
+ scheduleIssueDuplicateCheck();
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
+ qs('#create-issue-anyway').addEventListener('click', () => {
+ const captureDraft = currentIssueCaptureDraft();
+ issueCapture.acknowledgeDuplicates(captureDraft);
+ qs('#create-issue-anyway').hidden = true;
+ qs('#create-issue-form').requestSubmit();
+ });
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
- const captureDraft = {
- repository: qs('#create-issue-repository').value,
- title: qs('#create-issue-title').value.trim(),
- body: qs('#create-issue-body').value.trim(),
- labelIds: selectedIssueLabelIds(),
- milestoneId: Number(qs('#create-issue-milestone').value) || null,
- dueDate: qs('#create-issue-due-date').value,
- };
+ const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
qs('#create-issue-title').focus();
return;
}
+ if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
+ qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
+ qs('#create-issue-anyway').hidden = false;
+ qs('#create-issue-anyway').focus();
+ return;
+ }
const button = qs('#submit-new-issue');
button.disabled = true;
qs('#create-issue-status').textContent = 'Saving for background delivery…';
diff --git a/frontend/index.html b/frontend/index.html
index 922049c..60343ec 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -355,6 +355,12 @@
Choose a repository to load labels.
+
+ Possible existing issues
+
+
+
+
The issue will be assigned to you.
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index b0e6a2b..d0afc25 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-v51';
+const CACHE = 'stackchain-dashboard-shell-v52';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index c215cde..c40be84 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -233,5 +233,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-v51" in source
+ assert "stackchain-dashboard-shell-v52" 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 ce3581e..6e128ab 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-v51" in worker
+ assert "stackchain-dashboard-shell-v52" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 739bac4..2c0e7f3 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-v51" in worker
+ assert "stackchain-dashboard-shell-v52" in worker
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index a6e77d6..5491d46 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -1966,6 +1966,113 @@ Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.lo
assert output["stored"]["number"] == 221
+def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository():
+ script = f"""
+const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+const calls = [];
+const capture = createIssueCapture({{
+ storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
+ fetchJson: url => {{
+ calls.push(url);
+ return Promise.resolve({{items:[
+ {{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}},
+ {{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}},
+ {{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}},
+ {{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}},
+ {{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}},
+ {{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}},
+ {{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}},
+ ]}});
+ }},
+}});
+capture.findDuplicates({{repository:'stackchain/api', title:' Investigate checkout latency '}}).then(state =>
+ process.stdout.write(JSON.stringify({{calls, state}}))
+);
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ output = json.loads(result.stdout)
+
+ assert output["calls"] == [
+ "api/v1/search?q=Investigate%20checkout%20latency&limit=10"
+ ]
+ assert output["state"]["status"] == "ready"
+ assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12]
+
+
+def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement():
+ script = f"""
+const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+const pending = [];
+const capture = createIssueCapture({{
+ storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
+ fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})),
+}});
+const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}};
+const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}};
+const first = capture.findDuplicates(firstDraft);
+const latest = capture.findDuplicates(latestDraft);
+pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}});
+latest.then(latestState => {{
+ const blocked = capture.needsDuplicateAcknowledgement(latestDraft);
+ capture.acknowledgeDuplicates(latestDraft);
+ const allowed = capture.needsDuplicateAcknowledgement(latestDraft);
+ const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}};
+ const changedSearch = capture.findDuplicates(changedDraft);
+ pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}});
+ changedSearch.then(() => {{
+ const changed = capture.needsDuplicateAcknowledgement(changedDraft);
+ pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}});
+ first.then(staleState => {{
+ const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}});
+ pending[3].reject(new Error('offline'));
+ failure.then(failedState => process.stdout.write(JSON.stringify({{
+ latestState, staleState, blocked, allowed, changed, failedState,
+ failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}),
+ }})));
+ }});
+ }});
+}});
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ output = json.loads(result.stdout)
+
+ assert output["latestState"]["status"] == "ready"
+ assert output["staleState"]["status"] == "stale"
+ assert output["blocked"] is True
+ assert output["allowed"] is False
+ assert output["changed"] is True
+ assert output["failedState"]["status"] == "failed"
+ assert output["failureBlocks"] is False
+
+
+def test_issue_capture_keeps_partial_duplicate_search_advisory():
+ script = f"""
+const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+const draft = {{repository:'stackchain/api', title:'Checkout latency'}};
+const capture = createIssueCapture({{
+ storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
+ fetchJson: () => Promise.resolve({{partial:true, items:[
+ {{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}},
+ ]}}),
+}});
+capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{
+ state, blocked:capture.needsDuplicateAcknowledgement(draft),
+}})));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ output = json.loads(result.stdout)
+
+ assert output["state"]["partial"] is True
+ assert [item["number"] for item in output["state"]["candidates"]] == [24]
+ assert output["blocked"] is False
+
+
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
@@ -3148,6 +3255,25 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert '.create-issue-label-option' in html and 'min-height:44px' in html
+@pytest.mark.anyio
+async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
+ html = await dashboard()
+
+ assert 'id="create-issue-duplicates"' in html
+ assert 'id="create-issue-duplicate-list"' in html
+ assert 'aria-live="polite"' in html
+ assert 'id="create-issue-anyway"' in html and 'Create anyway' in html
+ assert "issueCapture.findDuplicates(draft)" in html
+ assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
+ assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
+ assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
+ "issueOutbox.enqueueDurably(captureDraft)"
+ )
+ assert ".create-issue-duplicate-card" in html
+ assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
+ assert "overflow-wrap:anywhere" in html
+
+
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index c0b37de..c4ebfdf 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -105,17 +105,25 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
+def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
+ source = WORKER.read_text()
+
+ assert "stackchain-dashboard-shell-v52" in source
+ assert "BASE + 'static/create-issue-sheet.js'" in source
+ assert "BASE + 'static/dashboard.js'" in source
+
+
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v51" in source
+ assert "stackchain-dashboard-shell-v52" 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-v51" in source
+ assert "stackchain-dashboard-shell-v52" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -124,21 +132,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-v51" in source
+ assert "stackchain-dashboard-shell-v52" 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-v51" in source
+ assert "stackchain-dashboard-shell-v52" 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-v51" in source
+ assert "stackchain-dashboard-shell-v52" in source
assert "BASE + 'static/update-ownership.js'" in source
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 204beed..8b275ba 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -63,7 +63,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-v51" in source
+ assert "stackchain-dashboard-shell-v52" in source
assert "BASE + 'static/today-sync.js'" in source