diff --git a/README.md b/README.md index 33cd37e..5674751 100644 --- a/README.md +++ b/README.md @@ -108,16 +108,16 @@ 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** actions on any open issue also feed the mobile **Following** queue, -including work already assigned to you or a teammate. This completes the Search → watch → revisit -flow without changing ownership or scheduling work. Following is a read-first, -account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues, -and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed -**Stop watching** removes an open item. When watched work closes, the sequential review exposes -**Stop watching & next** so the completed item can be retired without leaving the preview; the next -captured change opens immediately, and retiring the final item completes the Following phase. Failed -or unconfirmed Gitea mutations leave the collection and current review position unchanged. Following -counts never influence the recommended Work queue. Set +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. +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 +closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be +retired without leaving the preview; the next captured change opens immediately, and retiring the +final item completes the Following phase. Failed or unconfirmed Gitea mutations leave the collection +and current review position unchanged. Following counts never influence the recommended Work queue. Set `STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`. Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. diff --git a/frontend/following.js b/frontend/following.js index 6d4a8c0..71c38c9 100644 --- a/frontend/following.js +++ b/frontend/following.js @@ -13,6 +13,11 @@ let snapshot = {revision:0, items:[]}; let review = null; + function sameItem(left, right) { + return (left?.kind || 'issue') === (right?.kind || 'issue') && + left?.repository === right?.repository && Number(left?.number) === Number(right?.number); + } + function publish(status, error) { const state = {status, revision:snapshot.revision, items:[...snapshot.items]}; state.degraded = snapshot.degraded === true; @@ -36,7 +41,8 @@ if (requestGeneration !== generation) return snapshot; snapshot = { revision:Number(result?.revision) || 0, - items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [], + items:Array.isArray(result?.items) ? result.items.slice(0, 50) + .map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [], degraded:result?.degraded === true, refreshFailures:Number(result?.refresh_failures) || 0, }; @@ -49,13 +55,12 @@ } async function acknowledge(item) { - const current = snapshot.items.find(candidate => - candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) && + const current = snapshot.items.find(candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at); if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false; await options.onAcknowledge(current); current.has_unseen_change = false; - review?.acknowledged.add(current.repository + '#' + current.number + '@' + current.updated_at); + review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at); publish('ready'); return true; } @@ -63,7 +68,7 @@ async function open(index) { const item = snapshot.items[Number(index)]; if (!item) return false; - await options.onOpen?.({...item, kind:'issue', following:true}); + await options.onOpen?.({...item, following:true}); await acknowledge(item); return true; } @@ -71,11 +76,10 @@ async function startReview() { const items = snapshot.items .filter(item => item.has_unseen_change === true) - .map(item => ({...item, kind:'issue', following:true})); + .map(item => ({...item, following:true})); if (!items.length) return false; review = {items, more:false, active:true, acknowledged:new Set()}; - await open(snapshot.items.indexOf(snapshot.items.find(item => - item.repository === items[0].repository && Number(item.number) === Number(items[0].number)))); + await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0])))); return true; } @@ -92,8 +96,7 @@ } function retire(item) { - const same = candidate => candidate.repository === item?.repository && - Number(candidate.number) === Number(item?.number); + const same = candidate => sameItem(candidate, item); const index = review?.active ? review.items.findIndex(same) : -1; snapshot.items = snapshot.items.filter(candidate => !same(candidate)); if (index < 0) { @@ -140,21 +143,22 @@ const status = query('#following-status'); const reviewButton = query('#review-following'); query('#retry-following').hidden = state.status !== 'error'; - if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…'); + if (state.status === 'loading') return void (status.textContent = 'Loading watched items…'); if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.'); const unseen = state.items.filter(item => item.has_unseen_change === true).length; reviewButton.hidden = unseen === 0; reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes'; status.textContent = (state.reviewSummary ? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. ' - : '') + (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length - ? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.') - : 'No watched issues yet. Watch one from Search to keep it here.'); + : '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length + ? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.') + : 'No watched items yet. Watch an issue or pull request from Search to keep it here.'); list.innerHTML = state.items.map((item, index) => '').join(''); list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => { @@ -176,7 +180,8 @@ onAcknowledge:item => { const [owner, repo] = item.repository.split('/'); return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' + - encodeURIComponent(repo) + '/issues/' + item.number + '/seen', { + encodeURIComponent(repo) + '/issues/' + item.number + + '/seen?kind=' + encodeURIComponent(item.kind), { method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'}, body:JSON.stringify({updated_at:item.updated_at}), }); diff --git a/frontend/index.html b/frontend/index.html index f1be339..d4da8d1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2016,7 +2016,7 @@ - + diff --git a/frontend/search-preview.js b/frontend/search-preview.js index b73df5c..b3a817a 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -37,10 +37,10 @@ root.searchPreviewSubscriptionOptions = fetchJson => { const options = { load:async detail => { - if (detail.kind === 'issue' && detail.state === 'closed' && detail.following === true) { + if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) { return {...detail, watching:true}; } - if (!(detail.kind === 'issue' && detail.state === 'open')) return detail; + if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail; const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}}); return {...detail, watching:result.watching === true}; }, @@ -57,15 +57,17 @@ watching:'Starting watch…', unwatching:'Stopping watch…', watched:'Watching · available in Following. Future activity will appear in Updates.', unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.', - 'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.', + 'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' + + (state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.', 'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.', })[state.status] || ''; root.renderSearchPreviewWatch = (detail, state, button) => { - const retiring = detail.kind === 'issue' && detail.state === 'closed' && + const watchableKind = ['issue', 'pull'].includes(detail.kind); + const retiring = watchableKind && detail.state === 'closed' && detail.following === true && detail.watching === true; - button.hidden = !(detail.kind === 'issue' && detail.state === 'open') && !retiring; + button.hidden = !(watchableKind && detail.state === 'open') && !retiring; button.textContent = retiring ? 'Stop watching & next' : - (detail.watching ? 'Stop watching' : 'Watch issue'); + (detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue')); button.disabled = state.status === 'watching' || state.status === 'unwatching'; }; root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c413630..363b1eb 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v134'; +const CACHE = 'stackchain-dashboard-shell-v135'; 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; diff --git a/src/following_store.py b/src/following_store.py index 88a7961..d82d515 100644 --- a/src/following_store.py +++ b/src/following_store.py @@ -10,6 +10,7 @@ from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _STATES = {"open", "closed"} +_KINDS = {"issue", "pull"} class FollowingStore: @@ -74,6 +75,9 @@ class FollowingStore: number = raw.get("number") if not isinstance(number, int) or isinstance(number, bool) or number < 1: raise ValueError("number is invalid") + kind = raw.get("kind", "issue") + if kind not in _KINDS: + raise ValueError("kind is invalid") title = raw.get("title") if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300: raise ValueError("title is invalid") @@ -95,6 +99,7 @@ class FollowingStore: raise ValueError("last seen update is invalid") return { "repository": repository, + "kind": kind, "number": number, "title": title.strip(), "state": state, @@ -117,8 +122,8 @@ class FollowingStore: return {"revision": snapshot["revision"], "items": changed + unchanged} @staticmethod - def _identity(item: dict) -> tuple[str, int]: - return item["repository"].lower(), item["number"] + def _identity(item: dict) -> tuple[str, str, int]: + return item.get("kind", "issue"), item["repository"].lower(), item["number"] def get(self, login: str) -> dict: login = self._login(login) @@ -226,10 +231,20 @@ class FollowingStore: ) return self._present({"revision": revision, "items": items}) - def acknowledge(self, login: str, repository: str, number: int, updated_at: str) -> dict: + def acknowledge( + self, + login: str, + repository: str, + number: int, + updated_at: str, + *, + kind: str = "issue", + ) -> dict: """Mark only the exact upstream revision successfully opened by the operator.""" login = self._login(login) - identity = (str(repository).lower(), number) + if kind not in _KINDS: + raise ValueError("kind is invalid") + identity = (kind, str(repository).lower(), number) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( diff --git a/src/main.py b/src/main.py index 7192e47..68902de 100644 --- a/src/main.py +++ b/src/main.py @@ -2623,11 +2623,12 @@ async def get_following(response: Response): async def refresh_item(item: dict) -> dict: async with semaphore: preview = await asyncio.wait_for( - gitea_proxy.work_preview(item["repository"], "issue", item["number"]), + gitea_proxy.work_preview(item["repository"], item["kind"], item["number"]), timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, ) return store._normalize_item({ "repository": item["repository"], + "kind": item["kind"], "number": item["number"], "title": preview.get("title", ""), "state": preview.get("state", ""), @@ -2658,6 +2659,7 @@ async def acknowledge_following_revision( owner: str, repo: str, number: int = PathParam(gt=0), + kind: Literal["issue", "pull"] = Query(default="issue"), ): login = await _confirmed_login() try: @@ -2667,6 +2669,7 @@ async def acknowledge_following_revision( f"{owner}/{repo}", number, payload.updated_at, + kind=kind, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @@ -3743,9 +3746,8 @@ async def _search_preview_subscription_target( repository = f"{owner}/{repo}" preview = await gitea_proxy.work_preview(repository, kind, number) if ( - kind != "issue" - or preview.get("repository") != repository - or preview.get("kind") != "issue" + preview.get("repository") != repository + or preview.get("kind") != kind or preview.get("number") != number or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"}) ): @@ -3797,6 +3799,7 @@ async def mutate_global_search_preview_subscription( store = _following_store() following_item = { "repository": repository, + "kind": kind, "number": number, "title": preview.get("title", ""), "state": preview.get("state", ""), diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py index 9892a25..7d56246 100644 --- a/tests/e2e/test_mobile_following_release.py +++ b/tests/e2e/test_mobile_following_release.py @@ -13,25 +13,39 @@ ROOT = Path(__file__).parents[2] FRONTEND = ROOT / "frontend" -def test_following_queue_is_phone_usable_at_narrow_viewport(): +@pytest.mark.parametrize("viewport", [ + {"width": 320, "height": 568}, + {"width": 390, "height": 844}, +]) +def test_following_queue_is_phone_usable_at_narrow_viewport(viewport): with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) - page = browser.new_page(viewport={"width": 320, "height": 568}) + 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") + page.add_script_tag(path=FRONTEND / "following.js") row = page.locator('[data-mobile-queue="following"]') expect(row).to_have_count(1) - page.locator("#following-list").evaluate("""node => { - node.innerHTML = ''; + expect(row).to_contain_text("Issues and pull requests you watch") + page.evaluate("""() => { + globalThis.fetch = async () => ({ + ok:true, + json:async () => ({revision:1,items:[{ + repository:'stackchain/api', kind:'pull', number:42, + title:'Changed pull request with a long mobile title', state:'open', + updated_at:'2026-08-23T05:00:00Z', has_unseen_change:true + }]}) + }); + globalThis.followingReleaseQueue = attachFollowing(() => {}); + globalThis.followingReleaseQueue.open(); }""") - page.locator("#review-following").evaluate("node => node.hidden = false") - page.locator("#following-sheet").evaluate("node => node.showModal()") expect(page.locator("#following-sheet")).to_be_visible() expect(page.locator(".following-card")).to_be_visible() expect(page.locator(".following-card")).to_contain_text("New activity") + expect(page.locator(".following-card")).to_contain_text("Pull request") expect(page.locator("#review-following")).to_have_text("Review new activity") assert page.locator("#review-following").bounding_box()["height"] >= 44 assert page.locator(".following-card").bounding_box()["height"] >= 44 @@ -52,6 +66,11 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(): expect(retire).to_be_visible() expect(retire).to_have_text("Stop watching & next") assert retire.bounding_box()["height"] >= 44 + page.evaluate("""() => renderSearchPreviewWatch( + {repository:'stackchain/api', number:42, kind:'pull', state:'open', watching:false}, + {status:'ready'}, document.querySelector('#watch-search-result'))""") + expect(retire).to_have_text("Watch pull request") + assert retire.bounding_box()["height"] >= 44 overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth") assert overflow is False browser.close() diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 773e9ff..b6edbc7 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -864,7 +864,7 @@ def test_search_preview_preserves_authoritative_watch_and_offers_following_repai const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); (async () => {{ const states = []; -const detail = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',claimable:true,watching:false}}; +const detail = {{repository:'stackchain/api',number:42,kind:'pull',state:'open',claimable:true,watching:false}}; const preview = createSearchPreview({{ fetchJson:async()=>detail, watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}), @@ -886,7 +886,7 @@ process.stdout.write(JSON.stringify({{ assert json.loads(result.stdout) == { "status": "watch-partial", "watching": True, - "message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.", + "message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch pull request to repair.", } @@ -901,7 +901,7 @@ def test_mobile_search_preview_exposes_touch_safe_watch_action(): assert ".search-preview-actions button" in css and "min-height:44px" in css -def test_search_preview_watch_is_available_for_open_issues_and_closed_following_retirement(): +def test_search_preview_watch_is_available_for_open_issues_pulls_and_closed_following_retirement(): script = f""" require({json.dumps(str(SEARCH_PREVIEW))}); (async () => {{ @@ -936,7 +936,8 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) assert payload["loaded"] == [ - "api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" + "api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue", + "api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=pull", ] assert payload["watching"] is True assert payload["button"] == { @@ -944,7 +945,7 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e "textContent": "Stop watching", "disabled": False, } - assert payload["excluded"] == [True, True] + assert payload["excluded"] == [True, False] assert payload["closedFollowing"]["watching"] is True assert payload["retire"] == { "hidden": False, diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 4b506d9..ddb7f28 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v134" in worker + assert "stackchain-dashboard-shell-v135" in worker diff --git a/tests/test_following_api.py b/tests/test_following_api.py index 12c51fa..4b2081b 100644 --- a/tests/test_following_api.py +++ b/tests/test_following_api.py @@ -52,6 +52,7 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch, assert following.headers["cache-control"] == "no-store" assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{ "repository": "stackchain/api", + "kind": "issue", "number": 42, "title": "Make mobile review useful", "state": "open", @@ -61,6 +62,47 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch, }]} +@pytest.mark.anyio +async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"p" * 32) + detail = { + "repository": "stackchain/api", "kind": "pull", "number": 84, + "title": "Ship typed Following", "state": "open", + "updated_at": "2026-08-23T05:00:00Z", + "url": "https://forge.example/stackchain/api/pulls/84", + } + previews = [] + + async def preview(repository, kind, number): + previews.append((repository, kind, number)) + return detail + + async def set_subscription(repository, number, watching): + return {"watching": watching} + + async def user(): + return {"login": "timmy"} + + monkeypatch.setattr(main, "_following_store", lambda: store) + monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) + monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription) + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + watched = await client.put( + "/api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull" + ) + following = await client.get("/api/v1/following") + + assert watched.status_code == 200 + assert following.status_code == 200 + assert following.json()["items"][0]["kind"] == "pull" + assert previews == [ + ("stackchain/api", "pull", 84), + ("stackchain/api", "pull", 84), + ] + + @pytest.mark.anyio async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1) @@ -228,7 +270,7 @@ async def test_following_refreshes_changed_items_and_preserves_failed_items(monk async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32) item = { - "repository": "stackchain/api", "number": 42, "title": "Changed", + "repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed", "state": "open", "updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/stackchain/api/issues/42", } @@ -243,7 +285,7 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.put( - "/api/v1/following/stackchain/api/issues/42/seen", + "/api/v1/following/stackchain/api/issues/42/seen?kind=pull", json={"updated_at": "2026-08-23T04:00:00Z"}, ) diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 65d1d8c..091a676 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -50,6 +50,61 @@ process.stdout.write(JSON.stringify(state)); }] +def test_following_opens_pull_requests_in_the_pull_workspace(): + script = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const opened=[]; +const feature=createFollowing({{ + fetchJson:async () => ({{revision:1,items:[{{ + repository:'stackchain/api',kind:'pull',number:84,title:'Typed review',state:'open', + updated_at:'2026-08-23T05:00:00Z',url:'https://forge.example/pulls/84',has_unseen_change:true + }}]}}), + onOpen:async item => opened.push(item), onAcknowledge:async () => {{}}, +}}); +(async () => {{ + await feature.load(); + await feature.startReview(); + process.stdout.write(JSON.stringify({{opened,session:feature.session()}})); +}})().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["opened"][0]["kind"] == "pull" + assert result["opened"][0]["following"] is True + assert result["session"]["items"][0]["kind"] == "pull" + + +def test_following_typed_identity_prevents_issue_pull_collisions(): + script = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const acknowledged=[]; +const items=['issue','pull'].map(kind => ({{ + repository:'stackchain/api',kind,number:84,title:kind,state:'closed', + updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true +}})); +const feature=createFollowing({{ + fetchJson:async () => ({{revision:1,items}}), + onOpen:async () => {{}}, onAcknowledge:async item => acknowledged.push(item.kind), +}}); +(async () => {{ + await feature.load(); + await feature.previewLoaded({{...items[1]}}); + feature.retire(items[1]); + process.stdout.write(JSON.stringify({{acknowledged,items:feature.items()}})); +}})().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["acknowledged"] == ["pull"] + assert [(item["kind"], item["has_unseen_change"]) for item in result["items"]] == [ + ("issue", True) + ] + + def test_following_opens_explicitly_but_never_becomes_work_recommendation(): launcher = ROOT / "frontend" / "mobile-queue-launcher.js" script = f""" @@ -292,12 +347,13 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow(): assert "query('#review-following').addEventListener('click'" in following assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard + assert "'/seen?kind=' + encodeURIComponent(item.kind)" in following assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard assert "followingQueue.returnToFollowing()" in dashboard assert "'Back to Following'" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard - assert "stackchain-dashboard-shell-v134" in service_worker + assert "stackchain-dashboard-shell-v135" in service_worker def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): diff --git a/tests/test_following_store.py b/tests/test_following_store.py index 1b58fc1..ca74a79 100644 --- a/tests/test_following_store.py +++ b/tests/test_following_store.py @@ -21,7 +21,10 @@ def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path): first = store.set_watching("Timmy", ITEM, True) repeated = store.set_watching("timmy", ITEM, True) - assert first == repeated == {"revision": 1, "items": [{**ITEM, "has_unseen_change": False}]} + assert first == repeated == { + "revision": 1, + "items": [{**ITEM, "kind": "issue", "has_unseen_change": False}], + } assert store.get("other") == {"revision": 0, "items": []} stored = sqlite3.connect(path).execute( "SELECT items FROM following_issues WHERE login = ?", ("timmy",) @@ -72,3 +75,45 @@ def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path): snapshot = store.set_watching("timmy", changed, True) assert snapshot["items"][0]["has_unseen_change"] is True + + +def test_following_preserves_kind_and_uses_it_in_item_identity(tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY) + issue = {**ITEM, "kind": "issue"} + pull = { + **ITEM, + "kind": "pull", + "title": "Review the mobile flow", + "url": "https://forge.example/stackchain/api/pulls/42", + } + + store.set_watching("timmy", issue, True) + snapshot = store.set_watching("timmy", pull, True) + + assert [(item["kind"], item["number"]) for item in snapshot["items"]] == [ + ("pull", 42), + ("issue", 42), + ] + + +def test_following_reads_legacy_items_as_issues(tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY) + store.set_watching("timmy", ITEM, True) + + assert store.get("timmy")["items"][0]["kind"] == "issue" + + +def test_acknowledgement_only_marks_the_requested_item_kind_seen(tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY) + for kind in ("issue", "pull"): + store.set_watching("timmy", {**ITEM, "kind": kind}, True) + store.refresh("timmy", [{**ITEM, "kind": kind, "updated_at": "2026-08-23T06:00:00Z"}]) + + snapshot = store.acknowledge( + "timmy", ITEM["repository"], ITEM["number"], "2026-08-23T06:00:00Z", kind="pull" + ) + + assert {item["kind"]: item["has_unseen_change"] for item in snapshot["items"]} == { + "issue": True, + "pull": False, + } diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index cf8dd48..0ba47a5 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,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-v134" in source + assert "stackchain-dashboard-shell-v135" 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 5716c5e..62fc7a5 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,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-v134" in worker + assert "stackchain-dashboard-shell-v135" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index f5b2e49..29d72d6 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ 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-v134" in worker + assert "stackchain-dashboard-shell-v135" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 151bbab..a7acf74 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v134" in worker + assert "stackchain-dashboard-shell-v135" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index e1755b0..4c3b667 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v134" in worker + assert "stackchain-dashboard-shell-v135" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index e9c53ad..c7945fb 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v134" in service_worker + assert "stackchain-dashboard-shell-v135" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index a2de650..77c02a6 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.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 48ca0a0..1cfb479 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -186,7 +186,7 @@ async function dispatchPush(payload) {{ def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -194,20 +194,20 @@ def test_week_unplan_undo_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/week-plan.js'" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -216,7 +216,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -225,7 +225,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -233,14 +233,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -248,7 +248,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-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -256,7 +256,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -266,14 +266,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v134" in source + assert "stackchain-dashboard-shell-v135" 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-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -282,21 +282,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-v134" in source + assert "stackchain-dashboard-shell-v135" 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-v134" in source + assert "stackchain-dashboard-shell-v135" 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-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1302,7 +1302,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-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 619f5e7..88b36b4 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-v134';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v135';" 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 bdce287..6cb6680 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); 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-v134" in source + assert "stackchain-dashboard-shell-v135" in source assert "BASE + 'static/today-sync.js'" in source