feat: add mobile Following queue for watched issues (Closes #1293)
Some checks failed
CI / lint (pull_request) Successful in 4m15s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Failing after 6m46s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 04:04:09 +00:00
parent 4b4e55dbe2
commit 5c43a5678a
19 changed files with 562 additions and 14 deletions

View File

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

View File

@ -107,6 +107,15 @@ device can reopen the exact Search with one tap while stale writes surface a con
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path. `.stackchain-state/saved-searches.sqlite3` path.
Confirmed **Watch issue** actions also feed the mobile **Following** queue, completing the
Search → watch → revisit flow without assigning 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 it, while failed or unconfirmed Gitea mutations leave the collection
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. Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day

View File

@ -1083,6 +1083,16 @@ textarea { resize: vertical; min-height: 120px; }
} }
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } } @media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; }
.following-sheet::backdrop { background:rgba(3,9,18,.74); }
.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.following-panel h2, .following-panel p { margin:0; }
.following-panel header button, #retry-following { min-height:44px; }
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
.following-card strong, .following-card small { overflow-wrap:anywhere; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; } .markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; } .markdown-content > :first-child { margin-top:0; }
.markdown-content > :last-child { margin-bottom:0; } .markdown-content > :last-child { margin-bottom:0; }
@ -1367,6 +1377,7 @@ textarea { resize: vertical; min-height: 120px; }
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); } .composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); } .mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } .mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } .mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); } .mobile-first-task::backdrop { background:rgba(3,9,18,.78); }

View File

@ -105,6 +105,7 @@
} }
let mobileQueueCounts = {}; let mobileQueueCounts = {};
let mobilePreparationItems = {}; let mobilePreparationItems = {};
const followingQueue = attachFollowing(item => searchPreview.open(item));
const mobileDeliveryRecovery = createMobileDeliveryRecovery({ const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries, getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item), getIndex: item => lastDrafts.indexOf(item),
@ -132,6 +133,7 @@
openToday: () => mobileWorkEntry.open(), openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession, openAgenda: openAgendaSession,
openUpdates: openUpdateTriage, openUpdates: openUpdateTriage,
openFollowing:followingQueue.open,
openFiled: openFiledFollowUp, openFiled: openFiledFollowUp,
selectFilter: selectMobileQueue, selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
@ -5719,7 +5721,8 @@
'/issues/' + encodeURIComponent(detail.number) + '/' + action, '/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } } { method:'PATCH', headers:{ Accept:'application/json' } }
), ),
watch:searchSubscription.watch, watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
followingQueue.load().catch(() => {}).then(() => result)),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto), ...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => { queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item; searchReplyAttachmentTarget = item;

108
frontend/following.js Normal file
View File

@ -0,0 +1,108 @@
(function (root, factory) {
const exports = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exports.createFollowing;
module.exports.attachFollowing = exports.attachFollowing;
} else {
root.createFollowing = exports.createFollowing;
root.attachFollowing = exports.attachFollowing;
}
})(typeof self !== 'undefined' ? self : this, function () {
function createFollowing(options) {
let generation = 0;
let snapshot = {revision:0, items:[]};
function publish(status, error) {
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
if (error) state.error = error;
options.render?.(state);
if (status === 'ready') options.onCount?.(snapshot.items.length);
return state;
}
async function load() {
const requestGeneration = ++generation;
publish('loading');
try {
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
if (requestGeneration !== generation) return snapshot;
snapshot = {
revision:Number(result?.revision) || 0,
items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [],
};
publish('ready');
return snapshot;
} catch (error) {
if (requestGeneration === generation) publish('error', error);
throw error;
}
}
function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
Promise.resolve(options.onOpen?.({...item, kind:'issue'})).catch(() => {});
return true;
}
return {load, open, count:() => snapshot.items.length};
}
function attachFollowing(onOpen) {
const document = globalThis.document;
const query = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]);
const formatTime = value => new Date(value).toLocaleString();
const fetchJson = async (url, options) => {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
return payload;
};
let feature;
function render(state) {
const list = query('#following-list');
const status = query('#following-status');
query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
status.textContent = 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.';
list.innerHTML = state.items.map((item, index) =>
'<button class="following-card" type="button" data-following-index="' + index + '"><span><strong>' +
escapeHtml(item.title) + '</strong><small>' + escapeHtml(item.repository + ' #' + item.number +
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
'</small></span><span aria-hidden="true"></span></button>').join('');
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
query('#following-sheet').close();
feature.open(Number(button.dataset.followingIndex));
}));
}
feature = createFollowing({
fetchJson, render,
onCount:count => {
const value = query('[data-mobile-queue-count="following"]');
value.textContent = count;
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
(count === 1 ? ' watched issue' : ' watched issues'));
},
onOpen,
});
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
feature.load().catch(() => {});
return {
load:feature.load,
open() {
const sheet = query('#following-sheet');
if (!sheet.open) sheet.showModal();
feature.load().catch(() => {});
return 'opened-following';
},
};
}
return {createFollowing, attachFollowing};
});

View File

@ -2016,6 +2016,7 @@
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button> <button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button> <button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button> <button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="following" type="button" aria-label="Following, 0 watched issues"><span><strong>Following</strong><small>Issues you watch</small></span><span data-mobile-queue-count="following">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button> <button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button> <button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button> <button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
@ -2025,6 +2026,15 @@
</section> </section>
</dialog> </dialog>
<dialog class="following-sheet" id="following-sheet" aria-labelledby="following-title">
<section class="following-panel">
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
<p id="following-status" class="small" role="status" aria-live="polite"></p>
<div id="following-list" class="following-list"></div>
<button id="retry-following" type="button" hidden>Retry</button>
</section>
</dialog>
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title"> <dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
<section class="mobile-delivery-recovery-panel"> <section class="mobile-delivery-recovery-panel">
<header> <header>
@ -2076,6 +2086,7 @@
<script src="static/commands.js"></script> <script src="static/commands.js"></script>
<script src="static/saved-searches.js"></script> <script src="static/saved-searches.js"></script>
<script src="static/search-preview.js"></script> <script src="static/search-preview.js"></script>
<script src="static/following.js"></script>
<script src="static/search-reply-draft-store.js"></script> <script src="static/search-reply-draft-store.js"></script>
<script src="static/conversation-reply-draft-store.js"></script> <script src="static/conversation-reply-draft-store.js"></script>
<script src="static/conversation-photo-drafts.js"></script> <script src="static/conversation-photo-drafts.js"></script>

View File

@ -33,6 +33,7 @@
if (name === 'today') return options.openToday(); if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda(); if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates(); if (name === 'update' && options.openUpdates) return options.openUpdates();
if (name === 'following' && options.openFollowing) return options.openFollowing();
if (name === 'filed' && options.openFiled) return options.openFiled(); if (name === 'filed' && options.openFiled) return options.openFiled();
options.selectFilter(name); options.selectFilter(name);
const action = options.firstAction(name); const action = options.firstAction(name);

View File

@ -46,8 +46,8 @@
}); });
root.searchPreviewWatchStatus = state => ({ root.searchPreviewWatchStatus = state => ({
watching:'Starting watch…', unwatching:'Stopping watch…', watching:'Starting watch…', unwatching:'Stopping watch…',
watched:'Watching. Future activity will appear in Updates.', watched:'Watching · available in Following. Future activity will appear in Updates.',
unwatched:'Stopped watching. Assignment and planning are unchanged.', unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.', 'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
})[state.status] || ''; })[state.status] || '';
root.renderSearchPreviewWatch = (detail, state, button) => { root.renderSearchPreviewWatch = (detail, state, button) => {

View File

@ -130,6 +130,7 @@ const SHELL = [
BASE + 'static/commands.js', BASE + 'static/commands.js',
BASE + 'static/saved-searches.js', BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js', BASE + 'static/search-preview.js',
BASE + 'static/following.js',
BASE + 'static/search-reply-draft-store.js', BASE + 'static/search-reply-draft-store.js',
BASE + 'static/conversation-reply-draft-store.js', BASE + 'static/conversation-reply-draft-store.js',
BASE + 'static/conversation-photo-drafts.js', BASE + 'static/conversation-photo-drafts.js',

154
src/following_store.py Normal file
View File

@ -0,0 +1,154 @@
"""Encrypted, account-scoped registry of explicitly followed Gitea issues."""
import re
import sqlite3
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_STATES = {"open", "closed"}
class FollowingStore:
def __init__(
self,
path: str | Path,
*,
limit: int = 50,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="following",
)
self._initialize()
def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout)
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS following_issues (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
items TEXT NOT NULL
)
"""
)
@staticmethod
def _login(login: str) -> str:
normalized = str(login).strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "items": []}, False
items, legacy = self._cipher.open(row[1], binding=f"items:{login}")
if not isinstance(items, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), "items": items}, legacy
def _seal(self, login: str, items: list[dict]) -> str:
return self._cipher.seal(items, binding=f"items:{login}")
@staticmethod
def _normalize_item(raw: dict) -> dict:
if not isinstance(raw, dict):
raise ValueError("following item must be an object")
repository = raw.get("repository")
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
raise ValueError("repository is invalid")
number = raw.get("number")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ValueError("number 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")
state = raw.get("state")
if state not in _STATES:
raise ValueError("state is invalid")
updated_at = raw.get("updated_at")
if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64:
raise ValueError("updated_at is invalid")
url = raw.get("url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048:
raise ValueError("url is invalid")
return {
"repository": repository,
"number": number,
"title": title.strip(),
"state": state,
"updated_at": updated_at,
"url": url,
}
@staticmethod
def _identity(item: dict) -> tuple[str, int]:
return item["repository"].lower(), item["number"]
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
snapshot, legacy = self._snapshot(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE following_issues SET items = ? WHERE login = ? AND items = ?",
(self._seal(login, snapshot["items"]), login, row[1]),
)
return snapshot
def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict:
login = self._login(login)
item = self._normalize_item(raw_item)
if not isinstance(watching, bool):
raise ValueError("watching is invalid")
identity = self._identity(item)
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 = list(current["items"])
index = next(
(position for position, candidate in enumerate(items)
if self._identity(candidate) == identity),
None,
)
if watching:
if index is None:
if len(items) >= self.limit:
raise ValueError(f"following is limited to {self.limit} issues")
items.insert(0, item)
elif items[index] == item:
return current
else:
items.pop(index)
items.insert(0, item)
elif index is None:
return current
else:
items.pop(index)
revision = current["revision"] + 1
connection.execute(
"INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items",
(login, revision, self._seal(login, items)),
)
return {"revision": revision, "items": items}

View File

@ -39,7 +39,7 @@ FEATURE_SOURCES = {
"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/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/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

@ -63,6 +63,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
from src.push_subscription_store import build_push_subscription_store from src.push_subscription_store import build_push_subscription_store
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.following_store import FollowingStore
from src.unfiled_draft_store import ( from src.unfiled_draft_store import (
UnfiledDraftConflict, UnfiledDraftConflict,
UnfiledDraftEncryptionError, UnfiledDraftEncryptionError,
@ -2557,6 +2558,12 @@ def _saved_search_store() -> SavedSearchStore:
) )
def _following_store() -> FollowingStore:
return FollowingStore(
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
)
def _completed_filed_review_store() -> CompletedFiledReviewStore: def _completed_filed_review_store() -> CompletedFiledReviewStore:
return CompletedFiledReviewStore( return CompletedFiledReviewStore(
os.getenv( os.getenv(
@ -2601,6 +2608,21 @@ async def _confirmed_login() -> str:
return login.strip().lower() return login.strip().lower()
@app.get("/api/v1/following")
async def get_following(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_following_store().get, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Following synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return snapshot
@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()
@ -3662,7 +3684,7 @@ async def global_search_preview(
async def _search_preview_subscription_target( async def _search_preview_subscription_target(
owner: str, repo: str, number: int, kind: str owner: str, repo: str, number: int, kind: str
) -> str: ) -> tuple[str, dict]:
repository = f"{owner}/{repo}" repository = f"{owner}/{repo}"
preview = await gitea_proxy.work_preview(repository, kind, number) preview = await gitea_proxy.work_preview(repository, kind, number)
if ( if (
@ -3673,7 +3695,7 @@ async def _search_preview_subscription_target(
or preview.get("state") != "open" or preview.get("state") != "open"
): ):
raise HTTPException(status_code=404, detail="Watchable search result not found") raise HTTPException(status_code=404, detail="Watchable search result not found")
return repository return repository, preview
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription") @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription")
@ -3684,7 +3706,7 @@ async def global_search_preview_subscription(
kind: Literal["issue", "pull"] = Query(), kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse: ) -> JSONResponse:
try: try:
repository = await _search_preview_subscription_target(owner, repo, number, kind) repository, _preview = await _search_preview_subscription_target(owner, repo, number, kind)
result = await asyncio.wait_for( result = await asyncio.wait_for(
gitea_proxy.issue_subscription(repository, number), gitea_proxy.issue_subscription(repository, number),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
@ -3713,11 +3735,29 @@ async def mutate_global_search_preview_subscription(
) -> JSONResponse: ) -> JSONResponse:
watching = request.method == "PUT" watching = request.method == "PUT"
try: try:
repository = await _search_preview_subscription_target(owner, repo, number, kind) repository, preview = await _search_preview_subscription_target(owner, repo, number, kind)
login = await _confirmed_login()
store = _following_store()
await asyncio.to_thread(store.get, login)
result = await asyncio.wait_for( result = await asyncio.wait_for(
gitea_proxy.set_issue_subscription(repository, number, watching), gitea_proxy.set_issue_subscription(repository, number, watching),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS, timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
) )
if result.get("watching") is not watching:
raise RuntimeError("Gitea did not confirm subscription state")
following = await asyncio.to_thread(
store.set_watching,
login,
{
"repository": repository,
"number": number,
"title": preview.get("title", ""),
"state": preview.get("state", ""),
"updated_at": preview.get("updated_at", ""),
"url": preview.get("url", ""),
},
watching,
)
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception:
@ -3726,7 +3766,11 @@ async def mutate_global_search_preview_subscription(
status_code=503, status_code=503,
headers={"Retry-After": "1"}, headers={"Retry-After": "1"},
) )
return JSONResponse(result) return JSONResponse({
**result,
"following_revision": following["revision"],
"following_count": len(following["items"]),
})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation") @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")

View File

@ -0,0 +1,36 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile Following journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
def test_following_queue_is_phone_usable_at_narrow_viewport():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
row = page.locator('[data-mobile-queue="following"]')
expect(row).to_have_count(1)
page.locator("#following-list").evaluate("""node => {
node.innerHTML = '<button class="following-card" type="button"><span><strong>Quiet issue with a long mobile title</strong><small>stackchain/api #42 · open · just now</small></span><span aria-hidden="true"></span></button>';
}""")
page.locator("#following-sheet").evaluate("node => node.showModal()")
expect(page.locator("#following-sheet")).to_be_visible()
expect(page.locator(".following-card")).to_be_visible()
assert page.locator(".following-card").bounding_box()["height"] >= 44
assert page.locator("#close-following").bounding_box()["height"] >= 44
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
assert overflow is False
browser.close()

View File

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

View File

@ -0,0 +1,55 @@
import httpx
import pytest
from src import main
from src.following_store import FollowingStore
@pytest.mark.anyio
async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32)
detail = {
"repository": "stackchain/api",
"kind": "issue",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
}
async def preview(repository, kind, number):
assert (repository, kind, number) == ("stackchain/api", "issue", 42)
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, raising=False)
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/42/preview/subscription?kind=issue"
)
following = await client.get("/api/v1/following")
assert watched.status_code == 200
assert watched.json() == {"watching": True, "following_revision": 1, "following_count": 1}
assert following.status_code == 200
assert following.headers["cache-control"] == "no-store"
assert following.json() == {"revision": 1, "items": [{
"repository": "stackchain/api",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}]}

View File

@ -0,0 +1,71 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "following.js"
def run(script: str) -> dict:
harness = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
const feature = createFollowing({{
fetchJson: async path => {{
state.requests.push(path);
return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42'}}]}};
}},
render: snapshot => state.renders.push(snapshot),
onCount: count => state.counts.push(count),
onOpen: item => state.opened.push(item),
}});
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(completed.stdout)
def test_following_loads_account_collection_and_opens_existing_preview():
result = run("""
await feature.load();
feature.open(0);
process.stdout.write(JSON.stringify(state));
""")
assert result["requests"] == ["api/v1/following"]
assert result["counts"] == [1]
assert result["renders"][-1]["status"] == "ready"
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
assert result["opened"] == [{
"repository": "stackchain/api",
"number": 42,
"title": "Quiet issue",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/issue/42",
"kind": "issue",
}]
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
script = f"""
const createLauncher = require({json.dumps(str(launcher))});
const calls = [];
const feature = createLauncher({{
getCounts:() => ({{following:7}}),
openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
selectFilter:name => calls.push(name), firstAction:() => null,
announce:() => {{}}, openFindWork:() => calls.push('find'),
}});
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"opened": "opened-following",
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
"calls": ["following"],
}

View File

@ -0,0 +1,30 @@
import sqlite3
from src.following_store import FollowingStore
KEY = b"f" * 32
ITEM = {
"repository": "stackchain/api",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
path = tmp_path / "following.sqlite3"
store = FollowingStore(path, encryption_key=KEY, limit=50)
first = store.set_watching("Timmy", ITEM, True)
repeated = store.set_watching("timmy", ITEM, True)
assert first == repeated == {"revision": 1, "items": [ITEM]}
assert store.get("other") == {"revision": 0, "items": []}
stored = sqlite3.connect(path).execute(
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
).fetchone()[0]
assert ITEM["title"] not in stored
assert ITEM["repository"] not in stored

View File

@ -251,19 +251,26 @@ async def test_search_preview_subscription_reads_server_truth_without_caching(mo
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)]) @pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth( async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
monkeypatch, method, watching monkeypatch, tmp_path, method, watching
): ):
calls = [] calls = []
store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
async def preview(repository, kind, number): async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number)) calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number, return {"repository": repository, "kind": kind, "number": number,
"state": "open", "claimable": True} "title": "Quiet issue", "state": "open", "claimable": True,
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
async def set_subscription(repository, number, desired): async def set_subscription(repository, number, desired):
calls.append(("set", repository, number, desired)) calls.append(("set", repository, number, desired))
return {"watching": desired} return {"watching": desired}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False) monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)
@ -275,7 +282,12 @@ async def test_search_preview_subscription_mutation_revalidates_target_and_confi
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["cache-control"] == "no-store" assert response.headers["cache-control"] == "no-store"
assert response.json() == {"watching": watching} expected_count = 1 if watching else 0
assert response.json() == {
"watching": watching,
"following_revision": expected_count,
"following_count": expected_count,
}
assert calls == [ assert calls == [
("preview", "stackchain/api", "issue", 42), ("preview", "stackchain/api", "issue", 42),
("set", "stackchain/api", 42, watching), ("set", "stackchain/api", 42, watching),

View File

@ -1333,6 +1333,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/commands.js", "/dashboard/static/commands.js",
"/dashboard/static/saved-searches.js", "/dashboard/static/saved-searches.js",
"/dashboard/static/search-preview.js", "/dashboard/static/search-preview.js",
"/dashboard/static/following.js",
"/dashboard/static/search-reply-draft-store.js", "/dashboard/static/search-reply-draft-store.js",
"/dashboard/static/conversation-reply-draft-store.js", "/dashboard/static/conversation-reply-draft-store.js",
"/dashboard/static/conversation-photo-drafts.js", "/dashboard/static/conversation-photo-drafts.js",