diff --git a/README.md b/README.md
index 6f82d01..67ce077 100644
--- a/README.md
+++ b/README.md
@@ -40,10 +40,10 @@ identity and posts one ordered Markdown evidence comment. After a partial failur
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
-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
+issue-comment API. In issue, pull-request, unread-update, and active Today progress
+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. A fresh capture never silently targets the first repository:
the operator must explicitly choose one, either from the paginated browser or through the bounded
authenticated repository search. Search results include only visible repository identities, stale
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 7db6368..dcfb481 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -497,22 +497,19 @@
{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 [issueMentions, pullMentions, updateMentions, todayProgressMentions] = [
+ ['issue-comment',()=>selectedIssue?.repository],
+ ['pull-comment',()=>selectedPull?.repository],
+ ['update-reply',()=>selectedUpdate?.repository],
+ ['today-progress-body',()=>currentTodayProgressTarget()?.repository],
+ ].map(([id,getRepository]) => {
+ const controller = createMentionComposer({
+ textarea:qs('#'+id), listbox:qs('#'+id+'-mentions'),
+ status:qs('#'+id+'-mention-status'), getRepository, loadCandidates:loadMentionCandidates,
+ });
+ controller.start();
+ return controller;
});
- 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' } });
@@ -1967,7 +1964,7 @@
}
const todayProgressView = createTodayProgressView({
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
- voice:todayProgressVoice,
+ voice:todayProgressVoice, mentions:todayProgressMentions,
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(),
diff --git a/frontend/index.html b/frontend/index.html
index f359aea..fc3e765 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1558,6 +1558,8 @@
+
+
diff --git a/frontend/today-progress.js b/frontend/today-progress.js
index aa84984..eb3c4eb 100644
--- a/frontend/today-progress.js
+++ b/frontend/today-progress.js
@@ -247,14 +247,18 @@ function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
return activity;
}
-function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, announce = () => {}, onAdmitted = () => {} }) {
+function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {} }) {
const sheet = qs('#today-progress-sheet');
const body = qs('#today-progress-body');
const status = qs('#today-progress-status');
const launcher = qs('[data-mobile-today-update]');
let openedTarget = null;
- const update = () => { launcher.hidden = !currentTarget(); };
+ const update = () => {
+ const target = currentTarget();
+ launcher.hidden = !target;
+ if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
+ };
const checkpoint = async () => {
if (!openedTarget) return false;
try {
@@ -271,6 +275,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
const close = () => {
voice?.cancel?.();
activity?.close?.();
+ mentions?.dismiss?.();
if (sheet.open) sheet.close();
openedTarget = null;
};
@@ -278,6 +283,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
launcher.addEventListener('click', async () => {
const target = currentTarget();
if (!target) return;
+ mentions?.dismiss?.();
openedTarget = target;
qs('#today-progress-target').textContent = target.label + (target.title ? ' ยท ' + target.title : '');
body.value = progress.load(target.identity);
diff --git a/tests/e2e/fake_gitea.py b/tests/e2e/fake_gitea.py
index b36295a..bbcebcd 100644
--- a/tests/e2e/fake_gitea.py
+++ b/tests/e2e/fake_gitea.py
@@ -89,6 +89,11 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
self._json(200, REPOSITORY)
elif path == "/api/v1/repos/search":
self._json(200, {"data": [REPOSITORY], "ok": True})
+ elif path == "/api/v1/repos/acme/mobile/assignees":
+ self._json(200, [
+ USER,
+ {"id": 2, "login": "alex", "full_name": "Alexander", "active": True},
+ ])
elif path == "/api/v1/repos/issues/search":
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
is_available_scan = not any(
diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py
index 2873ed0..4342d81 100644
--- a/tests/e2e/test_mobile_today_handoff_release.py
+++ b/tests/e2e/test_mobile_today_handoff_release.py
@@ -96,6 +96,33 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
"created_at": "2026-08-18T00:00:00Z",
}]
}
+ page.locator("[data-mobile-today-update]").click()
+ expect(page.locator("#today-progress-sheet")).to_be_visible()
+ progress = page.locator("#today-progress-body")
+ progress.fill("Pairing with @al")
+ mention = page.locator('#today-progress-body-mentions [data-login="alex"]')
+ try:
+ expect(mention).to_be_visible()
+ except AssertionError as error:
+ raise AssertionError({
+ "status": page.locator("#today-progress-body-mention-status").text_content(),
+ "requests": fake.requests[-20:],
+ "browser_errors": browser_errors,
+ "failed_responses": failed_responses,
+ }) from error
+ mention_bounds = mention.bounding_box()
+ assert mention_bounds and mention_bounds["height"] >= 44
+ mention.click()
+ expect(progress).to_have_value("Pairing with @alex ")
+ page.locator("#post-today-progress").click()
+ expect(page.locator("#today-progress-sheet")).to_be_hidden()
+ admitted = page.evaluate("""() => {
+ const record = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null');
+ return record?.items?.find(item => item.kind === 'issue-comment' && item.number === 41);
+ }""")
+ assert admitted and admitted["repository"] == "acme/mobile"
+ assert admitted["body"] == "Pairing with @alex"
+
page.locator("[data-mobile-today-update]").click()
expect(page.locator("#today-progress-sheet")).to_be_visible()
owned = page.locator('[data-comment-id="701"]')
@@ -192,7 +219,10 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
expect(page.locator("#issue-comment")).to_have_value("")
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
- assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
+ assert fake.comments == [
+ (41, "Pairing with @alex"),
+ (41, "Handoff complete; continuing with the next Today item."),
+ ]
receipt = page.locator("#today-completion-undo")
expect(receipt).to_be_visible()
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index b7d73fb..d1d3e3d 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -54,12 +54,13 @@ def test_all_conversation_composers_offer_accessible_mobile_mentions():
css = CSS.read_text()
assert '' in html
- for composer in ("issue-comment", "pull-comment", "update-reply"):
+ for composer in ("issue-comment", "pull-comment", "update-reply", "today-progress-body"):
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 f"['{composer}'," in dashboard
+ assert dashboard.count("createMentionComposer({") == 1
+ assert "['today-progress-body',()=>currentTodayProgressTarget()?.repository]" in dashboard
+ assert "mentions:todayProgressMentions" in dashboard
assert "mention-candidates?q=" in dashboard
assert ".mention-options" in css
assert "min-height:44px" in css
diff --git a/tests/test_today_progress.py b/tests/test_today_progress.py
index e808322..bf8634e 100644
--- a/tests/test_today_progress.py
+++ b/tests/test_today_progress.py
@@ -245,6 +245,42 @@ const view=createView({{
}
+def test_progress_sheet_dismisses_mentions_when_opened_switched_or_closed():
+ script = f"""
+const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
+class Element {{
+ constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.listeners={{}}; }}
+ addEventListener(name,callback) {{ this.listeners[name]=callback; }}
+ click() {{ return this.listeners.click?.(); }}
+ showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
+}}
+const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
+ '#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress'];
+const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
+const issue={{identity:'issue:stackchain/dashboard:1064:',kind:'issue',repository:'stackchain/dashboard',number:1064,label:'#1064',title:'Mentions'}};
+const pull={{identity:'pull:stackchain/dashboard:77:',kind:'pull',repository:'stackchain/dashboard',number:77,label:'#77',title:'Other'}};
+let target=issue; let dismissals=0;
+const view=createView({{
+ progress:{{load:()=>'',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
+ mentions:{{dismiss:()=>dismissals++}},
+}});
+(async()=>{{
+ await elements['[data-mobile-today-update]'].click();
+ const afterOpen=dismissals;
+ target=pull; view.update();
+ const afterSwitch=dismissals;
+ await elements['#cancel-today-progress'].click();
+ process.stdout.write(JSON.stringify({{afterOpen,afterSwitch,afterClose:dismissals,closed:!elements['#today-progress-sheet'].open}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ assert run_node(script) == {
+ "afterOpen": 1,
+ "afterSwitch": 2,
+ "afterClose": 3,
+ "closed": True,
+ }
+
+
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});