Autocomplete teammate mentions in mobile conversations #448

Merged
timmy merged 1 commits from timmy/447-mobile-mentions into main 2026-08-10 03:04:27 +00:00
17 changed files with 418 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@ -292,6 +292,8 @@
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
@ -462,6 +464,8 @@
<h2 id="update-reply-title">Reply</h2>
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<button id="send-update-reply" type="button">Send reply</button>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
@ -496,6 +500,8 @@
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
<div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
<button id="send-pull-comment-next" type="button" hidden>Comment &amp; next</button>
@ -663,6 +669,7 @@
<script src="static/install-app.js"></script>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v75" in source
assert "stackchain-dashboard-shell-v76" in source
assert "BASE + 'static/later-sync.js'" in source

View File

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

View File

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

View File

@ -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 '<script src="static/mention-composer.js"></script>' 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

View File

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

View File

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

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v75';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v76';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

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