diff --git a/README.md b/README.md index 7767130..11d9174 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,10 @@ list repository labels and open milestones, set or clear due dates on assigned i 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 +issue-comment API. In issue, pull-request, and unread-update conversations, typing +at least two characters after `@` offers repository-scoped teammate suggestions; +touch or keyboard selection inserts the login without leaving the draft. Mention +lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue 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 diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f72881b..563c899 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -249,6 +249,12 @@ textarea { resize: vertical; min-height: 120px; } .issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; } .issue-comment-composer { display:grid; gap:8px; margin-top:16px; } .issue-comment-composer button { min-height:44px; width:100%; } +.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); } +.mention-options[hidden] { display:none; } +.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; } +.mention-option:last-child { border-bottom:0; } +.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; } +.mention-status:empty { display:none; } .comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .comment-actions button { min-height:44px; width:100%; } .issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3523262..f0fa3d5 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -226,6 +226,29 @@ return payload; } + function loadMentionCandidates(repository, query) { + return fetchReviewJson( + 'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query), + {headers:{Accept:'application/json'}}, + ); + } + const issueMentions = createMentionComposer({ + textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'), + status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository, + loadCandidates:loadMentionCandidates, + }); + const pullMentions = createMentionComposer({ + textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'), + status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository, + loadCandidates:loadMentionCandidates, + }); + const updateMentions = createMentionComposer({ + textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'), + status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository, + loadCandidates:loadMentionCandidates, + }); + [issueMentions, pullMentions, updateMentions].forEach(controller => controller.start()); + async function api(url) { const response = await fetch(url, { headers: { Accept: 'application/json' } }); const payload = await response.json().catch(() => ({})); @@ -517,6 +540,7 @@ loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item), onOpen: item => { selectedUpdate = item; + updateMentions.dismiss(); qs('#update-sheet').classList.add('open'); qs('#update-sheet-key').textContent = item.key || ''; qs('#update-sheet-title').textContent = item.title || 'Unread update'; @@ -1902,6 +1926,7 @@ qs('#issue-planning').inert = false; qs('#issue-handoff').inert = false; selectedIssue = item; + issueMentions.dismiss(); selectedIssueOffline = Boolean(offlineDetail); selectedIssueDetail = null; issueConversation = null; @@ -2079,6 +2104,7 @@ if (!item) return; qs('#pull-review').inert = false; selectedPull = item; + pullMentions.dismiss(); pullTrigger = trigger; selectedPullDetail = null; pullConversation = null; diff --git a/frontend/index.html b/frontend/index.html index 156ce30..d584149 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -292,6 +292,8 @@

Add comment

+ +
@@ -462,6 +464,8 @@

Reply

+ +
@@ -496,6 +500,8 @@

Add comment

+ +
@@ -663,6 +669,7 @@ + diff --git a/frontend/mention-composer.js b/frontend/mention-composer.js new file mode 100644 index 0000000..eafb450 --- /dev/null +++ b/frontend/mention-composer.js @@ -0,0 +1,147 @@ +(function(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.createMentionComposer = api.create; +})(typeof globalThis !== 'undefined' ? globalThis : this, function() { + 'use strict'; + + function activeMention(value, caret) { + const before = String(value || '').slice(0, Math.max(0, caret)); + const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/); + if (!match) return null; + const start = before.length - match[2].length - 1; + return { start, end:before.length, query:match[2] }; + } + + function insertMention(value, mention, login) { + const text = String(value || ''); + const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end; + const inserted = '@' + login + ' '; + return { + value:text.slice(0, mention.start) + inserted + text.slice(suffixStart), + caret:mention.start + inserted.length, + }; + } + + function create(options) { + const textarea = options.textarea; + const listbox = options.listbox; + const status = options.status; + const setTimer = options.setTimer || setTimeout; + const clearTimer = options.clearTimer || clearTimeout; + const createOption = options.createOption || (() => document.createElement('button')); + let timer = null; + let requestId = 0; + let mention = null; + let candidates = []; + let activeIndex = -1; + + function dismiss() { + requestId += 1; + candidates = []; + activeIndex = -1; + listbox.replaceChildren(); + listbox.hidden = true; + textarea.setAttribute('aria-expanded', 'false'); + textarea.removeAttribute('aria-activedescendant'); + status.textContent = ''; + } + + function activate(index) { + if (!candidates.length) return; + activeIndex = (index + candidates.length) % candidates.length; + listbox.children.forEach((option, optionIndex) => { + option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false'); + }); + textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id); + } + + function select(index) { + if (!mention || !candidates[index]) return; + const result = insertMention(textarea.value, mention, candidates[index].login); + textarea.value = result.value; + textarea.setSelectionRange(result.caret, result.caret); + dismiss(); + textarea.focus(); + textarea.dispatchEvent?.(new Event('input', { bubbles:true })); + } + + function render(items) { + candidates = Array.isArray(items) ? items : []; + activeIndex = -1; + listbox.replaceChildren(); + candidates.forEach((candidate, index) => { + const option = createOption(); + option.type = 'button'; + option.id = listbox.id + '-option-' + index; + option.className = 'mention-option'; + option.dataset.login = candidate.login; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', 'false'); + option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' ยท ' + candidate.name : ''); + option.addEventListener('pointerdown', event => { + event.preventDefault(); + select(index); + }); + listbox.appendChild(option); + }); + listbox.hidden = !candidates.length; + textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false'); + status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.'; + } + + function schedule() { + if (timer !== null) clearTimer(timer); + mention = activeMention(textarea.value, textarea.selectionStart); + const repository = options.getRepository(); + if (!mention || !repository) { + dismiss(); + return; + } + const currentRequest = ++requestId; + timer = setTimer(async () => { + try { + const items = await options.loadCandidates(repository, mention.query); + if (currentRequest === requestId && repository === options.getRepository()) render(items); + } catch (_error) { + if (currentRequest === requestId) { + listbox.hidden = true; + textarea.setAttribute('aria-expanded', 'false'); + status.textContent = 'Teammate suggestions unavailable; you can keep typing.'; + } + } + }, options.delayMs ?? 180); + } + + function keydown(event) { + if (event.key === 'Escape' && !listbox.hidden) { + event.preventDefault(); + dismiss(); + } else if (event.key === 'ArrowDown' && candidates.length) { + event.preventDefault(); + activate(activeIndex + 1); + } else if (event.key === 'ArrowUp' && candidates.length) { + event.preventDefault(); + activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1); + } else if (event.key === 'Enter' && activeIndex >= 0) { + event.preventDefault(); + select(activeIndex); + } + } + + return { + start() { + textarea.setAttribute('autocomplete', 'off'); + textarea.setAttribute('aria-autocomplete', 'list'); + textarea.setAttribute('aria-controls', listbox.id); + textarea.setAttribute('aria-expanded', 'false'); + textarea.addEventListener('input', schedule); + textarea.addEventListener('click', schedule); + textarea.addEventListener('keydown', keydown); + }, + dismiss, + }; + } + + return { activeMention, insertMention, create }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 313839f..539207f 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-v75'; +const CACHE = 'stackchain-dashboard-shell-v76'; 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; @@ -57,6 +57,7 @@ const SHELL = [ BASE + 'static/install-app.js', BASE + 'static/mobile-search-viewport.js', BASE + 'static/mobile-composer-viewport.js', + BASE + 'static/mention-composer.js', BASE + 'static/background-issue-sync.js', ]; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index b035692..a77cfc6 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1129,6 +1129,40 @@ async def issue_handoff_candidates(repository: str) -> list[dict]: return candidates +async def mention_candidates( + repository: str, query: str, *, limit: int = 8 +) -> list[dict]: + response = await _get_client().get( + f"/api/v1/repos/{repository}/assignees", headers=_auth() + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise ValueError("Gitea assignees response was not a list") + needle = query.casefold() + matches = [] + for item in payload: + if not isinstance(item, dict): + continue + login = item.get("login") + if ( + not isinstance(login, str) + or not re.fullmatch(r"[A-Za-z0-9_.-]+", login) + ): + continue + full_name = item.get("full_name") + name = full_name if isinstance(full_name, str) and full_name else login + login_match = needle in login.casefold() + name_match = needle in name.casefold() + if login_match or name_match: + matches.append((0 if login.casefold().startswith(needle) else 1, login.casefold(), { + "login": login, + "name": name, + })) + matches.sort(key=lambda item: (item[0], item[1])) + return [item[2] for item in matches[:limit]] + + async def handoff_assigned_issue( repository: str, number: int, recipient: str ) -> dict: diff --git a/src/main.py b/src/main.py index a10aa9f..f5a25e6 100644 --- a/src/main.py +++ b/src/main.py @@ -2232,6 +2232,28 @@ async def issue_handoff_candidates( return JSONResponse(result) +@app.get("/api/v1/repos/{owner}/{repo}/mention-candidates") +async def repository_mention_candidates( + owner: str, + repo: str, + q: str = Query(min_length=2, max_length=39, pattern=r"^[A-Za-z0-9_.-]+$"), +) -> JSONResponse: + try: + result = await asyncio.wait_for( + gitea_proxy.mention_candidates( + f"{owner}/{repo}", q.casefold(), limit=8 + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except Exception: + return JSONResponse( + {"error": "Teammate suggestions are temporarily unavailable."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result, headers={"Cache-Control": "no-store"}) + + @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff") async def handoff_assigned_issue( handoff: IssueHandoff, diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 8d3c3ad..f2bce99 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -1141,6 +1141,32 @@ async def test_gitea_handoff_candidates_exclude_current_and_malformed_users(): ] +@pytest.mark.anyio +async def test_gitea_mention_candidates_match_login_or_name_and_are_bounded(): + async def handler(request): + assert request.url.path == "/api/v1/repos/stackchain/api/assignees" + return httpx.Response(200, json=[ + {"login": "alexa", "full_name": "Alexa Dev"}, + {"login": "buildbot", "full_name": "Alex Builder"}, + {"login": "alexb", "full_name": "Alex Backup"}, + {"login": "casey", "full_name": "Casey"}, + {"login": "AL", "full_name": "Al"}, + {"login": "bad space", "full_name": "Malformed"}, + {"full_name": "Missing login"}, + ]) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.mention_candidates("stackchain/api", "alex", limit=2) + finally: + await gitea_proxy.stop_client() + + assert result == [ + {"login": "alexa", "name": "Alexa Dev"}, + {"login": "alexb", "name": "Alex Backup"}, + ] + + @pytest.mark.anyio async def test_gitea_handoff_replaces_operator_and_preserves_coassignees(): requests = [] @@ -1255,6 +1281,31 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk ] +@pytest.mark.anyio +async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch): + calls = [] + + async def candidates(repository, query, *, limit): + calls.append((repository, query, limit)) + return [{"login": "alex", "name": "Alexander"}] + + monkeypatch.setattr(main.gitea_proxy, "mention_candidates", candidates, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + too_short = await client.get( + "/api/v1/repos/stackchain/api/mention-candidates?q=a" + ) + response = await client.get( + "/api/v1/repos/stackchain/api/mention-candidates?q=AlEx" + ) + + assert too_short.status_code == 422 + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == [{"login": "alex", "name": "Alexander"}] + assert calls == [("stackchain/api", "alex", 8)] + + @pytest.mark.anyio async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch): async def handoff(_repository, _number, _recipient): diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 67793e5..3296359 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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 4fa031b..cc3b653 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-v75" in worker + assert "stackchain-dashboard-shell-v76" in worker diff --git a/tests/test_mention_composer.py b/tests/test_mention_composer.py new file mode 100644 index 0000000..9e41411 --- /dev/null +++ b/tests/test_mention_composer.py @@ -0,0 +1,85 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MENTIONS = ROOT / "frontend" / "mention-composer.js" + + +def run_node(script: str) -> dict: + completed = subprocess.run( + ["node", "-e", script], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def test_active_mention_replaces_only_the_token_at_the_caret(): + script = f""" +const mentions=require({json.dumps(str(MENTIONS))}); +const text='Thanks @al for this; keep @casey informed'; +const caret=text.indexOf(' for'); +const active=mentions.activeMention(text, caret); +const inserted=mentions.insertMention(text, active, 'alex'); +process.stdout.write(JSON.stringify({{active,inserted}})); +""" + + result = run_node(script) + + assert result["active"] == {"start": 7, "end": 10, "query": "al"} + assert result["inserted"] == { + "value": "Thanks @alex for this; keep @casey informed", + "caret": 13, + } + + +def test_controller_ignores_stale_results_and_keyboard_selects_a_teammate(): + script = f""" +const mentions=require({json.dumps(str(MENTIONS))}); +class Element {{ + constructor() {{ this.listeners={{}}; this.children=[]; this.attrs={{}}; this.dataset={{}}; this.hidden=true; this.value=''; this.selectionStart=0; this.selectionEnd=0; this.textContent=''; }} + addEventListener(type, fn) {{ (this.listeners[type] ||= []).push(fn); }} + dispatch(type, extra={{}}) {{ const event={{preventDefault(){{ this.prevented=true; }}, ...extra}}; (this.listeners[type] || []).forEach(fn=>fn(event)); return event; }} + replaceChildren(...children) {{ this.children=children; }} + appendChild(child) {{ this.children.push(child); }} + setAttribute(name, value) {{ this.attrs[name]=String(value); }} + removeAttribute(name) {{ delete this.attrs[name]; }} + setSelectionRange(start, end) {{ this.selectionStart=start; this.selectionEnd=end; }} + focus() {{ this.focused=true; }} +}} +const textarea=new Element(), listbox=new Element(), status=new Element(); +const pending=[]; +const controller=mentions.create({{ + textarea,listbox,status,getRepository:()=> 'stackchain/api', + loadCandidates:(repository, query)=>new Promise(resolve=>pending.push({{repository,query,resolve}})), + setTimer:fn=>{{ fn(); return 1; }}, clearTimer:()=>{{}}, + createOption:()=>new Element(), +}}); +controller.start(); +(async()=>{{ + textarea.value='Ping @al'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input'); + textarea.value='Ping @alex'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input'); + pending[1].resolve([{{login:'alex',name:'Alexander'}}]); await Promise.resolve(); await Promise.resolve(); + pending[0].resolve([{{login:'alice',name:'Alice'}}]); await Promise.resolve(); await Promise.resolve(); + const before={{queries:pending.map(item=>item.query), options:listbox.children.map(item=>item.dataset.login), expanded:textarea.attrs['aria-expanded']}}; + textarea.dispatch('keydown', {{key:'ArrowDown'}}); + const enter=textarea.dispatch('keydown', {{key:'Enter'}}); + process.stdout.write(JSON.stringify({{before,value:textarea.value,caret:textarea.selectionStart,focused:textarea.focused,prevented:enter.prevented,hidden:listbox.hidden}})); +}})().catch(error=>{{ console.error(error); process.exit(1); }}); +""" + + result = run_node(script) + + assert result["before"] == { + "queries": ["al", "alex"], + "options": ["alex"], + "expanded": "true", + } + assert result["value"] == "Ping @alex " + assert result["caret"] == 11 + assert result["focused"] is True + assert result["prevented"] is True + assert result["hidden"] is True diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index dba568c..b83a29a 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,22 @@ 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-v75" in worker + assert "stackchain-dashboard-shell-v76" in worker + + +def test_all_conversation_composers_offer_accessible_mobile_mentions(): + html = HTML.read_text() + dashboard = DASHBOARD.read_text() + css = CSS.read_text() + + assert '' in html + for composer in ("issue-comment", "pull-comment", "update-reply"): + assert f'id="{composer}-mentions"' in html + assert f'id="{composer}-mention-status"' in html + assert f"textarea:qs('#{composer}')" in dashboard + assert f"listbox:qs('#{composer}-mentions')" in dashboard + assert dashboard.count("createMentionComposer({") == 3 + assert "mention-candidates?q=" in dashboard + assert ".mention-options" in css + assert "min-height:44px" in css + assert "max-width:100%" in css diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 574743c..53ddbd7 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -232,6 +232,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-v75" in source + assert "stackchain-dashboard-shell-v76" 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 f15a73b..22e4c6d 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" 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-v75" in source + assert "stackchain-dashboard-shell-v76" in source assert "BASE + 'static/queue-today.js'" in source @@ -425,6 +425,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/install-app.js", "/dashboard/static/mobile-search-viewport.js", "/dashboard/static/mobile-composer-viewport.js", + "/dashboard/static/mention-composer.js", "/dashboard/static/background-issue-sync.js", } diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 992f236..eaab404 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v75';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v76';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index abfc424..bbd3a80 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-v75" in source + assert "stackchain-dashboard-shell-v76" in source assert "BASE + 'static/today-sync.js'" in source