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