Mention teammates from active Today progress updates #1065

Merged
rockachopa merged 1 commits from timmy/1064-today-progress-mentions into main 2026-08-18 05:47:57 +00:00
8 changed files with 104 additions and 27 deletions

View File

@ -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 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. 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 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 issue-comment API. In issue, pull-request, unread-update, and active Today progress
at least two characters after `@` offers repository-scoped teammate suggestions; conversations, typing at least two characters after `@` offers repository-scoped
touch or keyboard selection inserts the login without leaving the draft. Mention teammate suggestions; touch or keyboard selection inserts the login without leaving
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue 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: 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 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 authenticated repository search. Search results include only visible repository identities, stale

View File

@ -497,22 +497,19 @@
{headers:{Accept:'application/json'}}, {headers:{Accept:'application/json'}},
); );
} }
const issueMentions = createMentionComposer({ const [issueMentions, pullMentions, updateMentions, todayProgressMentions] = [
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'), ['issue-comment',()=>selectedIssue?.repository],
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository, ['pull-comment',()=>selectedPull?.repository],
loadCandidates:loadMentionCandidates, ['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,
}); });
const pullMentions = createMentionComposer({ controller.start();
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'), return controller;
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) { async function api(url) {
const response = await fetch(url, { headers: { Accept: 'application/json' } }); const response = await fetch(url, { headers: { Accept: 'application/json' } });
@ -1967,7 +1964,7 @@
} }
const todayProgressView = createTodayProgressView({ const todayProgressView = createTodayProgressView({
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos, progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
voice:todayProgressVoice, voice:todayProgressVoice, mentions:todayProgressMentions,
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown), activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
announce:message => { qs('#my-work-action-status').textContent = message; }, announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(), onAdmitted:() => refreshMyWorkView(),

View File

@ -1558,6 +1558,8 @@
</section> </section>
<label for="today-progress-body">Update</label> <label for="today-progress-body">Update</label>
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea> <textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
<div class="mention-options" id="today-progress-body-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="today-progress-body-mention-status" aria-live="polite"></div>
<section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden> <section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden>
<div class="voice-conversation-controls"> <div class="voice-conversation-controls">
<button id="start-voice-today-progress" type="button">Dictate update</button> <button id="start-voice-today-progress" type="button">Dictate update</button>

View File

@ -247,14 +247,18 @@ function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
return activity; 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 sheet = qs('#today-progress-sheet');
const body = qs('#today-progress-body'); const body = qs('#today-progress-body');
const status = qs('#today-progress-status'); const status = qs('#today-progress-status');
const launcher = qs('[data-mobile-today-update]'); const launcher = qs('[data-mobile-today-update]');
let openedTarget = null; 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 () => { const checkpoint = async () => {
if (!openedTarget) return false; if (!openedTarget) return false;
try { try {
@ -271,6 +275,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
const close = () => { const close = () => {
voice?.cancel?.(); voice?.cancel?.();
activity?.close?.(); activity?.close?.();
mentions?.dismiss?.();
if (sheet.open) sheet.close(); if (sheet.open) sheet.close();
openedTarget = null; openedTarget = null;
}; };
@ -278,6 +283,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
launcher.addEventListener('click', async () => { launcher.addEventListener('click', async () => {
const target = currentTarget(); const target = currentTarget();
if (!target) return; if (!target) return;
mentions?.dismiss?.();
openedTarget = target; openedTarget = target;
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : ''); qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
body.value = progress.load(target.identity); body.value = progress.load(target.identity);

View File

@ -89,6 +89,11 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
self._json(200, REPOSITORY) self._json(200, REPOSITORY)
elif path == "/api/v1/repos/search": elif path == "/api/v1/repos/search":
self._json(200, {"data": [REPOSITORY], "ok": True}) 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": elif path == "/api/v1/repos/issues/search":
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"] is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
is_available_scan = not any( is_available_scan = not any(

View File

@ -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", "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() page.locator("[data-mobile-today-update]").click()
expect(page.locator("#today-progress-sheet")).to_be_visible() expect(page.locator("#today-progress-sheet")).to_be_visible()
owned = page.locator('[data-comment-id="701"]') 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("#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_have_attribute("data-overlay-hidden", "true")
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters") 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") receipt = page.locator("#today-completion-undo")
expect(receipt).to_be_visible() expect(receipt).to_be_visible()

View File

@ -54,12 +54,13 @@ def test_all_conversation_composers_offer_accessible_mobile_mentions():
css = CSS.read_text() css = CSS.read_text()
assert '<script src="static/mention-composer.js"></script>' in html assert '<script src="static/mention-composer.js"></script>' 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}-mentions"' in html
assert f'id="{composer}-mention-status"' in html assert f'id="{composer}-mention-status"' in html
assert f"textarea:qs('#{composer}')" in dashboard assert f"['{composer}'," in dashboard
assert f"listbox:qs('#{composer}-mentions')" in dashboard assert dashboard.count("createMentionComposer({") == 1
assert dashboard.count("createMentionComposer({") == 3 assert "['today-progress-body',()=>currentTodayProgressTarget()?.repository]" in dashboard
assert "mentions:todayProgressMentions" in dashboard
assert "mention-candidates?q=" in dashboard assert "mention-candidates?q=" in dashboard
assert ".mention-options" in css assert ".mention-options" in css
assert "min-height:44px" in css assert "min-height:44px" in css

View File

@ -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(): def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
script = f""" script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))}); const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});