diff --git a/README.md b/README.md
index 42e11b6..a4c1957 100644
--- a/README.md
+++ b/README.md
@@ -118,7 +118,9 @@ Following is a read-first, account-scoped collection: it is encrypted at rest, r
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
+final item completes the Following phase. For open work that still needs thought, **Keep for later & next**
+restores only the loaded revision to the unseen queue and continues the captured pass without changing
+Gitea state, ownership, planning, or watch status. 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`.
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index b408967..8a97c70 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -1052,6 +1052,7 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-reply .conversation-photo-actions { max-width:100%; }
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
+#keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; }
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
@media (max-width:420px) {
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 94ae1ed..a909a04 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -5661,6 +5661,7 @@
const renderSearchConversation = conversation =>
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
function renderSearchPreview(state) {
+ followingQueue.preview(state);
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
@@ -6154,6 +6155,7 @@
closeSearchPreview(false);
}
});
+
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
diff --git a/frontend/following.js b/frontend/following.js
index 0610398..17b9486 100644
--- a/frontend/following.js
+++ b/frontend/following.js
@@ -65,6 +65,23 @@
return true;
}
+ async function keepForLater(item) {
+ const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
+ const current = snapshot.items.find(exact);
+ const index = review?.active ? review.items.findIndex(exact) : -1;
+ if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
+ await options.onKeep(current);
+ current.has_unseen_change = true;
+ review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
+ current.number + '@' + current.updated_at);
+ review.items.splice(index, 1);
+ const next = review.items[index] || null;
+ if (!next) review.active = false;
+ publish('ready');
+ if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
+ return next;
+ }
+
async function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
@@ -117,7 +134,7 @@
}
return {
- load, open, startReview, previewLoaded:acknowledge, finishReview, retire,
+ load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length,
@@ -136,7 +153,17 @@
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
return payload;
};
+ const changeRevision = (item, action) => {
+ const [owner, repo] = item.repository.split('/');
+ return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
+ encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
+ '?kind=' + encodeURIComponent(item.kind), {
+ method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
+ body:JSON.stringify({updated_at:item.updated_at}),
+ });
+ };
let feature;
+ let activeReviewItem = null;
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
function render(state) {
hooks.onStatus?.(state.status);
@@ -176,17 +203,13 @@
(count === 1 ? ' unseen change' : ' unseen changes'));
hooks.onCount?.(count, feature.items());
},
- onOpen,
- onReviewComplete:hooks.onReviewComplete,
- onAcknowledge:item => {
- const [owner, repo] = item.repository.split('/');
- return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
- 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}),
- });
+ onOpen:item => {
+ activeReviewItem = item;
+ return onOpen(item);
},
+ onReviewComplete:hooks.onReviewComplete,
+ onAcknowledge:item => changeRevision(item, 'seen'),
+ onKeep:item => changeRevision(item, 'keep'),
});
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#review-following').addEventListener('click', () => {
@@ -196,6 +219,19 @@
});
});
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
+ const keepButton = query('#keep-following-for-later');
+ keepButton.addEventListener('click', async () => {
+ const status = query('#keep-following-status');
+ keepButton.disabled = true;
+ status.textContent = 'Keeping this change for later…';
+ try {
+ if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
+ status.textContent = 'Kept for later.';
+ } catch (_) {
+ status.textContent = 'Could not keep this change for later. Retry when ready.';
+ keepButton.disabled = false;
+ }
+ });
return {
load:feature.load,
review:feature.startReview,
@@ -209,7 +245,13 @@
},
session:feature.session,
previewLoaded:feature.previewLoaded,
+ keepForLater:feature.keepForLater,
retire:feature.retire,
+ preview(state) {
+ keepButton.hidden = state?.item?.following !== true;
+ keepButton.disabled = false;
+ if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
+ },
returnToFollowing() {
const completed = feature.finishReview();
if (completed) return 'completed-following';
diff --git a/frontend/index.html b/frontend/index.html
index 390a4a5..3b63734 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -960,7 +960,9 @@
+
+
Open in Gitea
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 905f9c8..7a58186 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-v135';
+const CACHE = 'stackchain-dashboard-shell-v136';
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 d82d515..b37efb5 100644
--- a/src/following_store.py
+++ b/src/following_store.py
@@ -97,7 +97,7 @@ class FollowingStore:
or len(last_seen_updated_at) > 64
):
raise ValueError("last seen update is invalid")
- return {
+ item = {
"repository": repository,
"kind": kind,
"number": number,
@@ -107,6 +107,12 @@ class FollowingStore:
"url": url,
"last_seen_updated_at": last_seen_updated_at,
}
+ kept_updated_at = raw.get("kept_updated_at")
+ if kept_updated_at is not None:
+ if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64:
+ raise ValueError("kept update is invalid")
+ item["kept_updated_at"] = kept_updated_at
+ return item
@classmethod
def _present(cls, snapshot: dict) -> dict:
@@ -114,8 +120,10 @@ class FollowingStore:
unchanged = []
for raw in snapshot["items"]:
stored = cls._normalize_item(raw)
- unseen = stored["updated_at"] != stored["last_seen_updated_at"]
- item = {key: value for key, value in stored.items() if key != "last_seen_updated_at"}
+ unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or
+ stored.get("kept_updated_at") == stored["updated_at"])
+ item = {key: value for key, value in stored.items()
+ if key not in {"last_seen_updated_at", "kept_updated_at"}}
item["has_unseen_change"] = unseen
(changed if unseen else unchanged).append(item)
changed.sort(key=lambda item: item["updated_at"], reverse=True)
@@ -186,6 +194,8 @@ class FollowingStore:
items.insert(0, item)
else:
item["last_seen_updated_at"] = items[index]["last_seen_updated_at"]
+ if "kept_updated_at" in items[index]:
+ item["kept_updated_at"] = items[index]["kept_updated_at"]
if items[index] == item:
return self._present({"revision": current["revision"], "items": items})
items.pop(index)
@@ -219,6 +229,8 @@ class FollowingStore:
if update is None:
continue
update["last_seen_updated_at"] = item["last_seen_updated_at"]
+ if "kept_updated_at" in item:
+ update["kept_updated_at"] = item["kept_updated_at"]
if update != item:
items[index] = update
changed = True
@@ -256,8 +268,45 @@ class FollowingStore:
for item in items:
if self._identity(item) != identity or item["updated_at"] != updated_at:
continue
- if item["last_seen_updated_at"] != updated_at:
+ if (item["last_seen_updated_at"] != updated_at or
+ item.get("kept_updated_at") == updated_at):
item["last_seen_updated_at"] = updated_at
+ item.pop("kept_updated_at", None)
+ revision += 1
+ connection.execute(
+ "UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
+ (revision, self._seal(login, items), login),
+ )
+ break
+ return self._present({"revision": revision, "items": items})
+
+ def keep_unseen(
+ self,
+ login: str,
+ repository: str,
+ number: int,
+ updated_at: str,
+ *,
+ kind: str = "issue",
+ ) -> dict:
+ """Restore only the exact loaded revision to the unseen review queue."""
+ login = self._login(login)
+ 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(
+ "SELECT revision, items FROM following_issues WHERE login = ?", (login,)
+ ).fetchone()
+ current, _legacy = self._snapshot(row, login)
+ items = [self._normalize_item(candidate) for candidate in current["items"]]
+ revision = current["revision"]
+ for item in items:
+ if self._identity(item) != identity or item["updated_at"] != updated_at:
+ continue
+ if item.get("kept_updated_at") != updated_at:
+ item["kept_updated_at"] = updated_at
revision += 1
connection.execute(
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 54483db..c0d6490 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -35,11 +35,11 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
- "static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
+ "static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js",
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
- "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/following.js", "static/mobile-composer-viewport.js",
+ "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
diff --git a/src/main.py b/src/main.py
index 12409d1..6182782 100644
--- a/src/main.py
+++ b/src/main.py
@@ -2725,6 +2725,34 @@ async def acknowledge_following_revision(
)
+@app.put("/api/v1/following/{owner}/{repo}/issues/{number}/keep")
+async def keep_following_revision(
+ payload: FollowingSeenRevision,
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+ kind: Literal["issue", "pull"] = Query(default="issue"),
+):
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(
+ _following_store().keep_unseen,
+ login,
+ f"{owner}/{repo}",
+ number,
+ payload.updated_at,
+ kind=kind,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (OSError, sqlite3.Error, PrivateStateEncryptionError):
+ raise HTTPException(
+ status_code=503,
+ detail="Following synchronization is unavailable",
+ headers={"Retry-After": "1"},
+ )
+
+
@app.get("/api/v1/completed-filed-reviews")
async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login()
diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py
index 624f9a6..d92f8c4 100644
--- a/tests/e2e/test_mobile_following_release.py
+++ b/tests/e2e/test_mobile_following_release.py
@@ -77,6 +77,15 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
{status:'ready'}, document.querySelector('#watch-search-result'))""")
expect(retire).to_have_text("Watch pull request")
assert retire.bounding_box()["height"] >= 44
+ keep = page.locator("#keep-following-for-later")
+ page.evaluate("document.querySelector('#keep-following-for-later').hidden = false")
+ expect(keep).to_be_visible()
+ expect(keep).to_have_text("Keep for later & next")
+ assert keep.bounding_box()["height"] >= 44
+ assert page.locator("#keep-following-status").evaluate(
+ "node => getComputedStyle(node).overflowWrap"
+ ) == "anywhere"
+ assert page.locator("#keep-following-status").bounding_box()["height"] >= 20
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
assert overflow is False
browser.close()
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index ddb7f28..5ed80be 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-v135" in worker
+ assert "stackchain-dashboard-shell-v136" in worker
diff --git a/tests/test_following_api.py b/tests/test_following_api.py
index 4b2081b..4119565 100644
--- a/tests/test_following_api.py
+++ b/tests/test_following_api.py
@@ -293,6 +293,35 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch
assert response.json()["items"][0]["has_unseen_change"] is False
+@pytest.mark.anyio
+async def test_following_keep_restores_only_the_loaded_revision(monkeypatch, tmp_path):
+ store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
+ item = {
+ "repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
+ "state": "open", "updated_at": "2026-08-23T03:00:00Z",
+ "url": "https://forge.example/stackchain/api/pulls/42",
+ }
+ store.set_watching("timmy", item, True)
+ changed = {**item, "updated_at": "2026-08-23T04:00:00Z"}
+ store.refresh("timmy", [changed])
+ store.acknowledge("timmy", item["repository"], item["number"], changed["updated_at"], kind="pull")
+
+ async def user():
+ return {"login": "timmy"}
+
+ monkeypatch.setattr(main, "_following_store", lambda: store)
+ monkeypatch.setattr(main, "current_user", user)
+ 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/keep?kind=pull",
+ json={"updated_at": changed["updated_at"]},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["items"][0]["has_unseen_change"] is True
+
+
@pytest.mark.anyio
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py
index 4aea579..ecf1644 100644
--- a/tests/test_following_frontend.py
+++ b/tests/test_following_frontend.py
@@ -96,6 +96,23 @@ def test_dashboard_dispatches_following_route_to_changed_first_review():
assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue")
+def test_following_preview_wires_keep_for_later_action_and_retryable_failure():
+ html = (ROOT / "frontend" / "index.html").read_text()
+ dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
+ following = MODULE.read_text()
+
+ assert 'id="keep-following-for-later"' in html
+ assert 'id="keep-following-status"' in html
+ assert "followingQueue.preview(state)" in dashboard
+ assert "query('#close-search-preview').click()" in following
+ assert "state?.item?.following !== true" in following
+ assert "feature.keepForLater(activeReviewItem)" in following
+ assert "Kept for later" in following
+ assert "Could not keep this change for later" in following
+ assert "changeRevision(item, 'keep')" in following
+ assert "keepForLater:feature.keepForLater" in following
+
+
def test_following_typed_identity_prevents_issue_pull_collisions():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
@@ -243,6 +260,42 @@ const feature = createFollowing({{
assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0}
+def test_following_keep_for_later_preserves_revision_and_advances_without_looping():
+ script = f"""
+const createFollowing = require({json.dumps(str(MODULE))});
+const state = {{opened:[], acknowledged:[], kept:[], counts:[]}};
+const items=[
+ {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
+ {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
+];
+const feature = createFollowing({{
+ fetchJson:async () => ({{revision:7,items}}),
+ onOpen:async item => state.opened.push(item.number),
+ onAcknowledge:async item => state.acknowledged.push(item.number),
+ onKeep:async item => state.kept.push(item.number),
+ onCount:value => state.counts.push(value),
+}});
+(async () => {{
+ await feature.load();
+ await feature.startReview();
+ const kept = await feature.keepForLater(feature.session().items[0]);
+ process.stdout.write(JSON.stringify({{
+ kept, session:feature.session(), items:feature.items(), 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["kept"]["number"] == 9
+ assert [item["number"] for item in result["session"]["items"]] == [9]
+ assert result["items"][0]["has_unseen_change"] is True
+ assert result["state"] == {
+ "opened": [42, 9], "acknowledged": [42, 9], "kept": [42], "counts": [2, 1, 2, 1]
+ }
+
+
def test_following_review_notifies_prepare_today_when_final_revision_is_loaded():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
@@ -367,13 +420,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 "changeRevision(item, 'seen')" 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-v135" in service_worker
+ assert "stackchain-dashboard-shell-v136" 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 ca74a79..8dfd44b 100644
--- a/tests/test_following_store.py
+++ b/tests/test_following_store.py
@@ -66,6 +66,36 @@ def test_acknowledgement_is_revision_conditional_and_later_change_is_unseen(tmp_
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
+def test_keep_for_later_restores_the_exact_seen_revision_as_unseen(tmp_path):
+ store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
+ store.set_watching("timmy", ITEM, True)
+ changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
+ store.refresh("timmy", [changed])
+ store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
+
+ snapshot = store.keep_unseen(
+ "timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
+ )
+
+ assert snapshot["items"][0]["has_unseen_change"] is True
+ assert store.get("timmy")["items"][0]["has_unseen_change"] is True
+
+
+def test_reopening_a_kept_revision_acknowledges_it_again(tmp_path):
+ store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
+ store.set_watching("timmy", ITEM, True)
+ changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
+ store.refresh("timmy", [changed])
+ store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
+ store.keep_unseen("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
+
+ snapshot = store.acknowledge(
+ "timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
+ )
+
+ assert snapshot["items"][0]["has_unseen_change"] is False
+
+
def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index 22d27ac..6769456 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -72,6 +72,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
+ assert b"function createFollowing" in first.feature_bundles["planning"].runtime_bytes
+ assert b"function createFollowing" not in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
assert b"function createPlanToday" not in first.runtime_bytes
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 0ba47a5..471d050 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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 62fc7a5..d8c844d 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-v135" in worker
+ assert "stackchain-dashboard-shell-v136" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 29d72d6..ce503bd 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-v135" in worker
+ assert "stackchain-dashboard-shell-v136" 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 a7acf74..8143398 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-v135" in worker
+ assert "stackchain-dashboard-shell-v136" 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 4c3b667..dee74a4 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-v135" in worker
+ assert "stackchain-dashboard-shell-v136" 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 c7945fb..b63e2de 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-v135" in service_worker
+ assert "stackchain-dashboard-shell-v136" in service_worker
@pytest.mark.anyio
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 77c02a6..d99e354 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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 4f589fc..e0e41c8 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" 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 88b36b4..b43e3dc 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-v135';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v136';" 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 6cb6680..c4698e0 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-v135" in source
+ assert "stackchain-dashboard-shell-v136" in source
assert "BASE + 'static/today-sync.js'" in source