feat: follow any open issue from search (Closes #1303)
All checks were successful
CI / lint (pull_request) Successful in 3m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m37s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 11:02:33 +00:00
parent 0a18d1ac0f
commit 29e3f55e07
18 changed files with 88 additions and 34 deletions

View File

@ -108,8 +108,9 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
Confirmed **Watch issue** actions also feed the mobile **Following** queue, completing the
Search → watch → revisit flow without assigning or scheduling work. Following is a read-first,
Confirmed **Watch issue** actions on any open issue also feed the mobile **Following** queue,
including work already assigned to you or a teammate. This completes the Search → watch → revisit
flow without changing ownership or scheduling work. Following is a read-first,
account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues,
and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed
**Stop watching** removes it, while failed or unconfirmed Gitea mutations leave the collection

View File

@ -36,7 +36,7 @@
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => ({
load:async detail => {
if (!(detail.kind === 'issue' && detail.state === 'open' && detail.claimable)) return detail;
if (!(detail.kind === 'issue' && detail.state === 'open')) return detail;
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
return {...detail, watching:result.watching === true};
},
@ -52,7 +52,7 @@
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
})[state.status] || '';
root.renderSearchPreviewWatch = (detail, state, button) => {
button.hidden = !(detail.kind === 'issue' && detail.state === 'open' && detail.claimable);
button.hidden = !(detail.kind === 'issue' && detail.state === 'open');
button.textContent = detail.watching ? 'Stop watching' : 'Watch issue';
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};

View File

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

View File

@ -32,7 +32,16 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
page.locator("#watch-search-result").evaluate("node => { node.hidden = false; node.textContent = 'Watch issue'; }")
page.add_script_tag(path=FRONTEND / "search-preview.js")
page.evaluate("""() => {
const assignedIssue = {
kind:'issue', state:'open', claimable:false, assigned_to_me:false,
assignees:['alexander'], watching:false,
};
globalThis.renderSearchPreviewWatch(
assignedIssue, {status:'ready'}, document.querySelector('#watch-search-result')
);
}""")
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
watch = page.locator("#watch-search-result")

View File

@ -901,6 +901,49 @@ def test_mobile_search_preview_exposes_touch_safe_watch_action():
assert ".search-preview-actions button" in css and "min-height:44px" in css
def test_search_preview_watch_is_available_for_every_open_issue_but_not_pulls_or_closed_issues():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const loaded = [];
const options = globalThis.searchPreviewSubscriptionOptions(async path => {{
loaded.push(path);
return {{watching:true}};
}});
const assigned = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',
claimable:false,assignees:['alexander']}};
const hydrated = await options.load(assigned);
const button = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewWatch(hydrated, {{status:'ready'}}, button);
const excluded = [];
for (const detail of [
{{...assigned,state:'closed'}},
{{...assigned,kind:'pull'}},
]) {{
await options.load(detail);
const candidate = {{hidden:false,textContent:'',disabled:false}};
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
excluded.push(candidate.hidden);
}}
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["loaded"] == [
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
]
assert payload["watching"] is True
assert payload["button"] == {
"hidden": False,
"textContent": "Stop watching",
"disabled": False,
}
assert payload["excluded"] == [True, True]
def test_search_preview_shares_canonical_url_without_closing_the_preview():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});

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 button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v132" in worker
assert "stackchain-dashboard-shell-v133" in worker

View File

@ -254,7 +254,7 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
assert "'Back to Following'" in dashboard
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v132" in service_worker
assert "stackchain-dashboard-shell-v133" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -259,7 +259,8 @@ async def test_search_preview_subscription_mutation_revalidates_target_and_confi
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"title": "Quiet issue", "state": "open", "claimable": True,
"title": "Assigned issue", "state": "open", "claimable": False,
"assignees": ["alexander"],
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
async def set_subscription(repository, number, desired):

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():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v132" in worker
assert "stackchain-dashboard-shell-v133" 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]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v132" in worker
assert "stackchain-dashboard-shell-v133" in worker
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 "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v132" in worker
assert "stackchain-dashboard-shell-v133" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" 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():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v132" in worker
assert "stackchain-dashboard-shell-v133" 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 "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v132" in service_worker
assert "stackchain-dashboard-shell-v133" in service_worker
@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():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -186,7 +186,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -194,20 +194,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -216,7 +216,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@ -225,7 +225,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@ -233,14 +233,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -248,7 +248,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -256,7 +256,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -266,14 +266,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -282,21 +282,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -1302,7 +1302,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" 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():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v132';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v133';" 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():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v132" in source
assert "stackchain-dashboard-shell-v133" in source
assert "BASE + 'static/today-sync.js'" in source