From 29e3f55e07c7dfd0d4d0d2bc2a97a03d0874f217 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 23 Aug 2026 11:02:33 +0000 Subject: [PATCH] feat: follow any open issue from search (Closes #1303) --- README.md | 5 ++- frontend/search-preview.js | 4 +- frontend/service-worker.js | 2 +- .../test_mobile_search_preview_navigation.py | 11 ++++- tests/test_command_palette.py | 43 +++++++++++++++++++ tests/test_comment_next.py | 2 +- tests/test_following_frontend.py | 2 +- tests/test_global_search.py | 3 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 32 +++++++------- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 18 files changed, 88 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 32d867b..7b3a254 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 59992e2..894a7e8 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -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'; }; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 3fc57d8..5cdff55 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-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; diff --git a/tests/e2e/test_mobile_search_preview_navigation.py b/tests/e2e/test_mobile_search_preview_navigation.py index 16a7dfb..e54b8db 100644 --- a/tests/e2e/test_mobile_search_preview_navigation.py +++ b/tests/e2e/test_mobile_search_preview_navigation.py @@ -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") diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 87a8ba4..c929ec1 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -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))}); diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index c247f30..f5e97c7 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v132" in worker + assert "stackchain-dashboard-shell-v133" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 00079fe..16e6777 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -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(): diff --git a/tests/test_global_search.py b/tests/test_global_search.py index 047bb9f..56ea6d3 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -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): diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index abe1ba2..fc473bf 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v132" in source + assert "stackchain-dashboard-shell-v133" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index f5896d5..f61a8dc 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v132" in worker + assert "stackchain-dashboard-shell-v133" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index e4c33e3..25cf1b9 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v132" in worker + assert "stackchain-dashboard-shell-v133" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 8dd15e3..7871f9c 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-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 diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 679b08d..4621501 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v132" in worker + assert "stackchain-dashboard-shell-v133" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 11ef28b..40962ed 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v132" in service_worker + assert "stackchain-dashboard-shell-v133" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 6f1c6e3..96e08fb 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2abfd0a..544e0a3 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -186,7 +186,7 @@ async function dispatchPush(payload) {{ def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-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 diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 29ee527..f531803 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v132';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v133';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index e7e2776..88f25c6 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v132" in source + assert "stackchain-dashboard-shell-v133" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0