Keep a Following change for later and continue review #1321

Merged
rockachopa merged 1 commits from timmy/1320-keep-following-for-later into main 2026-08-23 20:35:34 +00:00
25 changed files with 296 additions and 47 deletions

View File

@ -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 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 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 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 and current review position unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`. `STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.

View File

@ -1052,6 +1052,7 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-reply .conversation-photo-actions { max-width:100%; } .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-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; } .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-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; } .search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
@media (max-width:420px) { @media (max-width:420px) {

View File

@ -5661,6 +5661,7 @@
const renderSearchConversation = conversation => const renderSearchConversation = conversation =>
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown); renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
function renderSearchPreview(state) { function renderSearchPreview(state) {
followingQueue.preview(state);
const sheet = qs('#search-preview'); const sheet = qs('#search-preview');
const status = qs('#search-preview-status'); const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result'); const claimButton = qs('#claim-search-result');
@ -6154,6 +6155,7 @@
closeSearchPreview(false); closeSearchPreview(false);
} }
}); });
qs('#share-search-result').addEventListener('click', () => { qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {}); searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
}); });

View File

@ -65,6 +65,23 @@
return true; 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) { async function open(index) {
const item = snapshot.items[Number(index)]; const item = snapshot.items[Number(index)];
if (!item) return false; if (!item) return false;
@ -117,7 +134,7 @@
} }
return { 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, session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})), items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length, count:() => snapshot.items.length,
@ -136,7 +153,17 @@
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.'); if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
return payload; 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 feature;
let activeReviewItem = null;
const show = () => query('#following-sheet').open || query('#following-sheet').showModal(); const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
function render(state) { function render(state) {
hooks.onStatus?.(state.status); hooks.onStatus?.(state.status);
@ -176,17 +203,13 @@
(count === 1 ? ' unseen change' : ' unseen changes')); (count === 1 ? ' unseen change' : ' unseen changes'));
hooks.onCount?.(count, feature.items()); hooks.onCount?.(count, feature.items());
}, },
onOpen, onOpen:item => {
onReviewComplete:hooks.onReviewComplete, activeReviewItem = item;
onAcknowledge:item => { return onOpen(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}),
});
}, },
onReviewComplete:hooks.onReviewComplete,
onAcknowledge:item => changeRevision(item, 'seen'),
onKeep:item => changeRevision(item, 'keep'),
}); });
query('#close-following').addEventListener('click', () => query('#following-sheet').close()); query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#review-following').addEventListener('click', () => { query('#review-following').addEventListener('click', () => {
@ -196,6 +219,19 @@
}); });
}); });
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {})); 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 { return {
load:feature.load, load:feature.load,
review:feature.startReview, review:feature.startReview,
@ -209,7 +245,13 @@
}, },
session:feature.session, session:feature.session,
previewLoaded:feature.previewLoaded, previewLoaded:feature.previewLoaded,
keepForLater:feature.keepForLater,
retire:feature.retire, 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() { returnToFollowing() {
const completed = feature.finishReview(); const completed = feature.finishReview();
if (completed) return 'completed-following'; if (completed) return 'completed-following';

View File

@ -960,7 +960,9 @@
<button id="plan-search-result" type="button" hidden>Plan ahead</button> <button id="plan-search-result" type="button" hidden>Plan ahead</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button> <button id="start-search-result" type="button" hidden>Assign &amp; start</button>
<button id="watch-search-result" type="button" hidden>Watch issue</button> <button id="watch-search-result" type="button" hidden>Watch issue</button>
<button id="keep-following-for-later" type="button" hidden>Keep for later &amp; next</button>
</div> </div>
<span id="keep-following-status" class="small" role="status" aria-live="polite"></span>
<button id="share-search-result" type="button" disabled>Share result</button> <button id="share-search-result" type="button" disabled>Share result</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a> <a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div> </div>

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -97,7 +97,7 @@ class FollowingStore:
or len(last_seen_updated_at) > 64 or len(last_seen_updated_at) > 64
): ):
raise ValueError("last seen update is invalid") raise ValueError("last seen update is invalid")
return { item = {
"repository": repository, "repository": repository,
"kind": kind, "kind": kind,
"number": number, "number": number,
@ -107,6 +107,12 @@ class FollowingStore:
"url": url, "url": url,
"last_seen_updated_at": last_seen_updated_at, "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 @classmethod
def _present(cls, snapshot: dict) -> dict: def _present(cls, snapshot: dict) -> dict:
@ -114,8 +120,10 @@ class FollowingStore:
unchanged = [] unchanged = []
for raw in snapshot["items"]: for raw in snapshot["items"]:
stored = cls._normalize_item(raw) stored = cls._normalize_item(raw)
unseen = stored["updated_at"] != stored["last_seen_updated_at"] unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or
item = {key: value for key, value in stored.items() if key != "last_seen_updated_at"} 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 item["has_unseen_change"] = unseen
(changed if unseen else unchanged).append(item) (changed if unseen else unchanged).append(item)
changed.sort(key=lambda item: item["updated_at"], reverse=True) changed.sort(key=lambda item: item["updated_at"], reverse=True)
@ -186,6 +194,8 @@ class FollowingStore:
items.insert(0, item) items.insert(0, item)
else: else:
item["last_seen_updated_at"] = items[index]["last_seen_updated_at"] 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: if items[index] == item:
return self._present({"revision": current["revision"], "items": items}) return self._present({"revision": current["revision"], "items": items})
items.pop(index) items.pop(index)
@ -219,6 +229,8 @@ class FollowingStore:
if update is None: if update is None:
continue continue
update["last_seen_updated_at"] = item["last_seen_updated_at"] 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: if update != item:
items[index] = update items[index] = update
changed = True changed = True
@ -256,8 +268,45 @@ class FollowingStore:
for item in items: for item in items:
if self._identity(item) != identity or item["updated_at"] != updated_at: if self._identity(item) != identity or item["updated_at"] != updated_at:
continue 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["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 revision += 1
connection.execute( connection.execute(
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?", "UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",

View File

@ -35,11 +35,11 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"planning": ( "planning": (
"static/plan-today.js", "static/plan-today-readiness.js", "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", "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": ( "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/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/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", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",

View File

@ -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") @app.get("/api/v1/completed-filed-reviews")
async def get_completed_filed_reviews(response: Response): async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login() login = await _confirmed_login()

View File

@ -77,6 +77,15 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
{status:'ready'}, document.querySelector('#watch-search-result'))""") {status:'ready'}, document.querySelector('#watch-search-result'))""")
expect(retire).to_have_text("Watch pull request") expect(retire).to_have_text("Watch pull request")
assert retire.bounding_box()["height"] >= 44 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") overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
assert overflow is False assert overflow is False
browser.close() browser.close()

View File

@ -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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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

View File

@ -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 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 @pytest.mark.anyio
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path): 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) store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)

View File

@ -96,6 +96,23 @@ def test_dashboard_dispatches_following_route_to_changed_first_review():
assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue") 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(): def test_following_typed_identity_prevents_issue_pull_collisions():
script = f""" script = f"""
const createFollowing = require({json.dumps(str(MODULE))}); const createFollowing = require({json.dumps(str(MODULE))});
@ -243,6 +260,42 @@ const feature = createFollowing({{
assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0} 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(): def test_following_review_notifies_prepare_today_when_final_revision_is_loaded():
script = f""" script = f"""
const createFollowing = require({json.dumps(str(MODULE))}); 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 "query('#review-following').addEventListener('click'" in following
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
assert "onOpened:item => followingQueue.previewLoaded(item)" 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 "afterUnwatch:item => followingQueue.retire(item)" in dashboard
assert "followingQueue.returnToFollowing()" in dashboard assert "followingQueue.returnToFollowing()" in dashboard
assert "'Back to Following'" in dashboard assert "'Back to Following'" in dashboard
assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && 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(): def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -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 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): def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY) store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True) store.set_watching("timmy", ITEM, True)

View File

@ -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" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].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 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 createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
assert b"function createPlanToday" not in first.runtime_bytes assert b"function createPlanToday" not in first.runtime_bytes
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes

View File

@ -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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/later-sync.js'" in source

View File

@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

View File

@ -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])) 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 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(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker 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-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() 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 assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -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 ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker 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 @pytest.mark.anyio

View File

@ -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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() 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.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -186,7 +186,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell(): def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text() 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(): def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() 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/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.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(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() 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-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() 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/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() 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/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() 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/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/queue-today.js'" in source

View File

@ -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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() 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 assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/today-sync.js'" in source