Watch assigned issues and pull requests from mobile detail #1310

Merged
rockachopa merged 2 commits from timmy/1309-watch-from-mobile-detail into main 2026-08-23 15:14:35 +00:00
10 changed files with 303 additions and 5 deletions

View File

@ -57,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py -q
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py -q
release-candidate:
runs-on: ubuntu-latest

View File

@ -108,9 +108,11 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results feed the mobile
**Following** queue, including work already assigned to you or a teammate. This completes the
Search → watch → revisit flow without changing ownership, review assignment, or scheduling work.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
changing ownership, review assignment, or scheduling work.
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work

View File

@ -955,6 +955,8 @@ textarea { resize: vertical; min-height: 120px; }
.filed-claim-actions button { min-width:0; min-height:44px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }

View File

@ -122,6 +122,18 @@
},
onReviewComplete:() => mobileStartDay.completePhase('following'),
});
function bindDetailWatch(kind, button, status) {
const feature=createDetailWatch({fetchJson:fetchReviewJson,refreshFollowing:()=>followingQueue.load(),onState:(state,watching,error)=>{
button.disabled=['loading','watching','unwatching'].includes(state);
button.textContent=watching?'Stop watching':'Watch '+(kind==='pull'?'pull request':'issue');
status.textContent=state==='ready'?(watching?'Watching · available in Following.':''):
searchPreviewWatchStatus({status:state==='error'?'watch-error':state,error,detail:{kind}});
}});
button.addEventListener('click',()=>feature.toggle().catch(()=>{}));
return feature;
}
const issueDetailWatch = bindDetailWatch('issue',qs('#watch-issue-detail'),qs('#issue-watch-status'));
const pullDetailWatch = bindDetailWatch('pull',qs('#watch-pull-detail'),qs('#pull-watch-status'));
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item),
@ -4275,6 +4287,15 @@
issueConversation = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
if (offlineDetail) {
qs('#watch-issue-detail').hidden = true;
qs('#issue-watch-status').textContent = 'Reconnect to change watch status.';
} else {
qs('#watch-issue-detail').hidden = item.state !== 'open';
void issueDetailWatch.open(item).catch(error => {
if (selectedIssue === item) qs('#issue-watch-status').textContent = error.message + ' Retry.';
});
}
qs('#issue-sheet-key').textContent = item.key || '';
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
qs('#issue-sheet-status').textContent = 'Loading issue…';
@ -4597,6 +4618,15 @@
pullConversation = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
if (offlineDetail) {
qs('#watch-pull-detail').hidden = true;
qs('#pull-watch-status').textContent = 'Reconnect to change watch status.';
} else {
qs('#watch-pull-detail').hidden = item.state !== 'open';
void pullDetailWatch.open(item).catch(error => {
if (selectedPull === item) qs('#pull-watch-status').textContent = error.message + ' Retry.';
});
}
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';

View File

@ -1181,11 +1181,13 @@
</section>
<div class="issue-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-issue-detail" type="button">Watch issue</button>
<button id="release-issue" type="button">Release assignment</button>
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<div id="issue-watch-status" class="detail-watch-status small" aria-live="polite"></div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
@ -1719,10 +1721,12 @@
</details>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-pull-detail" type="button">Watch pull request</button>
<button id="release-pull" type="button">Release assignment</button>
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<div id="pull-watch-status" class="detail-watch-status small" aria-live="polite"></div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>

View File

@ -53,6 +53,33 @@
})});
return options;
};
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
const api=root.searchPreviewSubscriptionOptions(fetchJson);
let item,watching=false,mutation;
return {
async open(next) {
item=next; onState('loading',watching);
const result=await api.load(next).catch(error=>{
if(item===next)onState('error',watching,error);throw error;
});
if (item !== next) return;
watching=result.watching === true; onState('ready',watching);
},
toggle() {
if (mutation) return mutation;
const next=!watching;
onState(next?'watching':'unwatching',watching);
mutation=api.watch(item,next).then(async result => {
if (result?.watching !== next || result?.following_synced !== true)
throw new Error(result?.error || 'Unconfirmed.');
watching=next; await refreshFollowing();
onState(watching?'watched':'unwatched',watching);
}).catch(error => {onState('error',watching,error);throw error;})
.finally(()=>{mutation=null;});
return mutation;
},
};
};
root.searchPreviewWatchStatus = state => ({
watching:'Starting watch…', unwatching:'Stopping watch…',
watched:'Watching · available in Following. Future activity will appear in Updates.',

View File

@ -125,6 +125,8 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
number = 0
comments = self.server.activity_comments.get(number, [])
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/subscriptions/check"):
self._json(200, {"subscribed": False, "ignored": False})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
self._json(200, [])
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):

View File

@ -0,0 +1,44 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile detail-watch journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_issue_and_pull_detail_watch_controls_are_phone_usable(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
for sheet, button, expected in [
("#issue-sheet", "#watch-issue-detail", "Watch issue"),
("#pull-sheet", "#watch-pull-detail", "Watch pull request"),
]:
page.locator(sheet).evaluate("node => node.classList.add('open')")
control = page.locator(button)
expect(control).to_be_visible()
expect(control).to_have_text(expected)
assert control.bounding_box()["height"] >= 44
control.focus()
expect(control).to_be_focused()
page.locator(sheet).evaluate("node => node.classList.remove('open')")
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
browser.close()

View File

@ -72,7 +72,8 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
"tests/e2e/test_mobile_week_ahead_release.py "
"tests/e2e/test_mobile_today_week_reschedule_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py "
"tests/e2e/test_mobile_following_release.py -q"
"tests/e2e/test_mobile_following_release.py "
"tests/e2e/test_mobile_detail_watch_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release

186
tests/test_detail_watch.py Normal file
View File

@ -0,0 +1,186 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "search-preview.js"
def run(script: str) -> dict:
harness = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state = {{ requests:[], renders:[], refreshes:0 }};
const feature = createDetailWatch({{
fetchJson: async (path, options={{}}) => {{
state.requests.push({{path,method:options.method || 'GET'}});
return {{watching:false,following_synced:true}};
}},
refreshFollowing: async () => {{ state.refreshes += 1; }},
onState: (status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(completed.stdout)
def test_detail_watch_loads_authoritative_typed_subscription_state():
result = run("""
await feature.open({repository:'stackchain/api',kind:'pull',number:84,state:'open'});
process.stdout.write(JSON.stringify(state));
""")
assert result["requests"] == [{
"path": "api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull",
"method": "GET",
}]
assert result["renders"] == [
{"status": "loading", "watching": False},
{"status": "ready", "watching": False},
]
assert result["refreshes"] == 0
def test_detail_watch_mutation_is_single_flight_and_refreshes_following_after_confirmation():
script = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state={{requests:[],renders:[],refreshes:0}};
let finishMutation;
const feature=createDetailWatch({{
fetchJson:async (path,options={{}}) => {{
state.requests.push({{path,method:options.method || 'GET'}});
if (!options.method) return {{watching:false}};
return new Promise(resolve => {{finishMutation=() => resolve({{watching:true,following_synced:true,following_count:3}});}});
}},
refreshFollowing:async () => {{state.refreshes += 1;}},
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
const item={{repository:'stackchain/api',kind:'issue',number:42,state:'open'}};
await feature.open(item);
const first=feature.toggle();
const second=feature.toggle();
await new Promise(resolve => setImmediate(resolve));
state.samePromise=first === second;
finishMutation();
await Promise.all([first,second]);
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["samePromise"] is True
assert [request["method"] for request in result["requests"]] == ["GET", "PUT"]
assert result["refreshes"] == 1
assert result["renders"][-2]["status"] == "watching"
assert result["renders"][-1]["status"] == "watched"
assert result["renders"][-1]["watching"] is True
def test_detail_watch_partial_sync_never_claims_success_or_refreshes_following():
script = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state={{renders:[],refreshes:0}};
const feature=createDetailWatch({{
fetchJson:async (_path,options={{}}) => options.method
? {{watching:true,following_synced:false,error:'Following could not sync.'}}
: {{watching:false}},
refreshFollowing:async () => {{state.refreshes += 1;}},
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
await feature.open({{repository:'stackchain/api',kind:'issue',number:42,state:'open'}});
try {{ await feature.toggle(); }} catch (error) {{ state.rejected=error.message; }}
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["rejected"] == "Following could not sync."
assert result["refreshes"] == 0
assert result["renders"][-1]["status"] == "error"
assert result["renders"][-1]["watching"] is False
assert all(state["status"] != "watched" for state in result["renders"])
def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
search_preview = (ROOT / "frontend" / "search-preview.js").read_text()
assert '<button id="watch-issue-detail" type="button">Watch issue</button>' in html
assert '<button id="watch-pull-detail" type="button">Watch pull request</button>' in html
assert 'id="issue-watch-status"' in html
assert 'id="pull-watch-status"' in html
assert 'static/search-preview.js' in bundle
assert 'root.createDetailWatch' in search_preview
assert 'function bindDetailWatch(' in dashboard
assert 'issueDetailWatch.open(item)' in dashboard
assert 'pullDetailWatch.open(item)' in dashboard
assert '#watch-issue-detail' in css
assert '#watch-pull-detail' in css
assert 'tests/e2e/test_mobile_detail_watch_release.py' in workflow
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():
script = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state={{renders:[]}};
const completions={{}};
const feature=createDetailWatch({{
fetchJson:path => new Promise(resolve => {{completions[path]=resolve;}}),
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
const first=feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}});
const second=feature.open({{repository:'stackchain/api',kind:'issue',number:2,state:'open'}});
completions[Object.keys(completions)[1]]({{watching:true}});
await second;
completions[Object.keys(completions)[0]]({{watching:false}});
await first;
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
ready = [state for state in result["renders"] if state["status"] == "ready"]
assert len(ready) == 1
assert ready[0]["watching"] is True
def test_detail_watch_reports_subscription_load_failure_as_recoverable_error():
script = f"""
require({json.dumps(str(MODULE))});
const state={{renders:[]}};
const feature=globalThis.createDetailWatch({{
fetchJson:async () => {{throw new Error('temporarily unavailable');}},
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
try {{ await feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}}); }}
catch (error) {{ state.rejected=error.message; }}
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["rejected"] == "temporarily unavailable"
assert result["renders"][-1] == {
"status": "error", "watching": False, "error": "temporarily unavailable"
}