diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index c7e24f2..4503ccf 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -5617,6 +5617,7 @@
const queueButton = qs('#queue-search-result');
const planButton = qs('#plan-search-result');
const startButton = qs('#start-search-result');
+ const watchButton = qs('#watch-search-result');
const shareButton = qs('#share-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
@@ -5636,6 +5637,8 @@
planButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
+ watchButton.hidden = true;
+ watchButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document);
@@ -5690,6 +5693,7 @@
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
+ renderSearchPreviewWatch(detail, state, watchButton);
const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
@@ -5698,13 +5702,15 @@
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
+ else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state);
else if (detail.claimable) status.textContent = 'Open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.';
}
+ const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
const searchPreview = createSearchPreview({
- fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
+ fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
headers:{Accept:'application/json'},
}),
@@ -5713,6 +5719,7 @@
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
+ watch:searchSubscription.watch,
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item;
@@ -6078,6 +6085,7 @@
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
+ wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail);
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');
diff --git a/frontend/index.html b/frontend/index.html
index 179e38b..bda8c10 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -957,7 +957,7 @@
-
+
Open in Gitea
diff --git a/frontend/search-preview.js b/frontend/search-preview.js
index e758719..2dccca1 100644
--- a/frontend/search-preview.js
+++ b/frontend/search-preview.js
@@ -32,6 +32,33 @@
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
+ root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
+ '/subscription?kind=' + encodeURIComponent(item.kind);
+ root.searchPreviewSubscriptionOptions = fetchJson => ({
+ load:async detail => {
+ if (!(detail.kind === 'issue' && detail.state === 'open' && detail.claimable)) return detail;
+ const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
+ return {...detail, watching:result.watching === true};
+ },
+ watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
+ method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
+ }),
+ });
+ root.searchPreviewWatchStatus = state => ({
+ watching:'Starting watch…', unwatching:'Stopping watch…',
+ watched:'Watching. Future activity will appear in Updates.',
+ unwatched:'Stopped watching. Assignment and planning are unchanged.',
+ '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.textContent = detail.watching ? 'Stop watching' : 'Watch issue';
+ button.disabled = state.status === 'watching' || state.status === 'unwatching';
+ };
+ root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
+ const detail = getDetail();
+ if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
+ });
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
@@ -92,7 +119,7 @@
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
- return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
+ return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@@ -105,6 +132,7 @@
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
+ let watchRequest = null;
let conversation = null;
function sameItem(left, right) {
@@ -348,6 +376,23 @@
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
+ setWatching(watching) {
+ if (watchRequest) return watchRequest;
+ if (!current || typeof watch !== 'function') {
+ return Promise.reject(new Error('Watching is unavailable.'));
+ }
+ const detail = current;
+ publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
+ watchRequest = watch(detail, watching).then(result => {
+ current = { ...current, watching:result?.watching === true };
+ publish({ status:watching ? 'watched' : 'unwatched', item:current, detail:current, result });
+ return result;
+ }).catch(error => {
+ publish({ status:'watch-error', item:current, detail, error });
+ throw error;
+ }).finally(() => { watchRequest = null; });
+ return watchRequest;
+ },
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index aa3793d..788a2eb 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -1007,6 +1007,39 @@ async def _notification_subscription(repository: str, number: str) -> dict:
return value if isinstance(value, dict) else {}
+async def issue_subscription(repository: str, number: int) -> dict:
+ """Return the current operator's authoritative issue subscription state."""
+ value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
+ if not isinstance(value, dict):
+ raise ValueError("Gitea subscription response was not an object")
+ return {
+ "watching": value.get("subscribed") is True and value.get("ignored") is not True
+ }
+
+
+async def set_issue_subscription(repository: str, number: int, watching: bool) -> dict:
+ """Set and then confirm the current operator's issue subscription state."""
+ user = await fetch("user")
+ login = user.get("login") if isinstance(user, dict) else None
+ if not isinstance(login, str) or not login:
+ raise ValueError("Gitea did not identify the current operator")
+ path = (
+ f"/api/v1/repos/{repository}/issues/{number}/subscriptions/"
+ f"{quote(login, safe='')}"
+ )
+ client = _get_client()
+ response = (
+ await client.put(path, headers=_auth())
+ if watching
+ else await client.delete(path, headers=_auth())
+ )
+ response.raise_for_status()
+ confirmed = await issue_subscription(repository, number)
+ if confirmed["watching"] is not watching:
+ raise ValueError("Gitea did not confirm the requested subscription state")
+ return confirmed
+
+
async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
diff --git a/src/main.py b/src/main.py
index 4bc5fb6..8dac989 100644
--- a/src/main.py
+++ b/src/main.py
@@ -3660,6 +3660,75 @@ async def global_search_preview(
return JSONResponse(preview)
+async def _search_preview_subscription_target(
+ owner: str, repo: str, number: int, kind: str
+) -> str:
+ repository = f"{owner}/{repo}"
+ preview = await gitea_proxy.work_preview(repository, kind, number)
+ if (
+ kind != "issue"
+ or preview.get("repository") != repository
+ or preview.get("kind") != "issue"
+ or preview.get("number") != number
+ or preview.get("state") != "open"
+ ):
+ raise HTTPException(status_code=404, detail="Watchable search result not found")
+ return repository
+
+
+@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription")
+async def global_search_preview_subscription(
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+ kind: Literal["issue", "pull"] = Query(),
+) -> JSONResponse:
+ try:
+ repository = await _search_preview_subscription_target(owner, repo, number, kind)
+ result = await asyncio.wait_for(
+ gitea_proxy.issue_subscription(repository, number),
+ timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
+ )
+ except HTTPException:
+ raise
+ except Exception:
+ return JSONResponse(
+ {"error": "Watch status is temporarily unavailable. Please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result)
+
+
+@app.api_route(
+ "/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription",
+ methods=["PUT", "DELETE"],
+)
+async def mutate_global_search_preview_subscription(
+ request: Request,
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+ kind: Literal["issue", "pull"] = Query(),
+) -> JSONResponse:
+ watching = request.method == "PUT"
+ try:
+ repository = await _search_preview_subscription_target(owner, repo, number, kind)
+ result = await asyncio.wait_for(
+ gitea_proxy.set_issue_subscription(repository, number, watching),
+ timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
+ )
+ except HTTPException:
+ raise
+ except Exception:
+ return JSONResponse(
+ {"error": "Watch status was not changed. Please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result)
+
+
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
async def global_search_preview_conversation(
owner: str,
diff --git a/tests/e2e/test_mobile_search_preview_navigation.py b/tests/e2e/test_mobile_search_preview_navigation.py
index 0d47826..16a7dfb 100644
--- a/tests/e2e/test_mobile_search_preview_navigation.py
+++ b/tests/e2e/test_mobile_search_preview_navigation.py
@@ -32,8 +32,14 @@ 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 / "mobile-search-preview-nav.js")
+ watch = page.locator("#watch-search-result")
+ expect(watch).to_be_visible()
+ expect(watch).to_have_text("Watch issue")
+ watch_bounds = watch.bounding_box()
+ assert watch_bounds and watch_bounds["height"] >= 44
navigation = page.locator(".mobile-search-preview-nav")
expect(navigation).to_be_visible()
buttons = navigation.locator("button")
diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py
index 6cc06ad..17383ef 100644
--- a/tests/test_command_palette.py
+++ b/tests/test_command_palette.py
@@ -806,6 +806,49 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
+def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
+ script = f"""
+const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
+(async () => {{
+let calls = 0; let rejectWatch; const states = [];
+const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}};
+const preview = createSearchPreview({{
+ fetchJson:async()=>detail, mutate:async()=>{{}},
+ watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}},
+ onState:state=>states.push(state),
+}});
+await preview.open(detail);
+const first=preview.setWatching(true); const second=preview.setWatching(true);
+if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight');
+rejectWatch(new Error('offline')); await Promise.allSettled([first,second]);
+const final=states.at(-1);
+process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number,
+ watching:final.detail.watching,error:final.error.message}}));
+}})().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
+ assert json.loads(result.stdout) == {
+ "calls": 1,
+ "status": "watch-error",
+ "number": 42,
+ "watching": False,
+ "error": "offline",
+ }
+
+
+def test_mobile_search_preview_exposes_touch_safe_watch_action():
+ html = dashboard_bundle_text()
+ css = (FRONTEND / "dashboard.css").read_text()
+ dashboard = (FRONTEND / "dashboard.js").read_text()
+
+ assert '' in html
+ assert "wireSearchPreviewWatch" in dashboard
+ assert "preview.setWatching" in SEARCH_PREVIEW.read_text()
+ assert ".search-preview-actions button" in css and "min-height:44px" in css
+
+
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_global_search.py b/tests/test_global_search.py
index ffb4105..478799d 100644
--- a/tests/test_global_search.py
+++ b/tests/test_global_search.py
@@ -218,6 +218,96 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
assert response.json()["claimable"] is True
+@pytest.mark.anyio
+async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch):
+ calls = []
+
+ async def preview(repository, kind, number):
+ calls.append(("preview", repository, kind, number))
+ return {"repository": repository, "kind": kind, "number": number,
+ "state": "open", "claimable": True}
+
+ async def subscription(repository, number):
+ calls.append(("subscription", repository, number))
+ return {"watching": True}
+
+ monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
+ monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, raising=False)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get(
+ "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
+ )
+
+ assert response.status_code == 200
+ assert response.headers["cache-control"] == "no-store"
+ assert response.json() == {"watching": True}
+ assert calls == [
+ ("preview", "stackchain/api", "issue", 42),
+ ("subscription", "stackchain/api", 42),
+ ]
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
+async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
+ monkeypatch, method, watching
+):
+ calls = []
+
+ async def preview(repository, kind, number):
+ calls.append(("preview", repository, kind, number))
+ return {"repository": repository, "kind": kind, "number": number,
+ "state": "open", "claimable": True}
+
+ async def set_subscription(repository, number, desired):
+ calls.append(("set", repository, number, desired))
+ return {"watching": desired}
+
+ monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
+ monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.request(
+ method,
+ "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
+ )
+
+ assert response.status_code == 200
+ assert response.headers["cache-control"] == "no-store"
+ assert response.json() == {"watching": watching}
+ assert calls == [
+ ("preview", "stackchain/api", "issue", 42),
+ ("set", "stackchain/api", 42, watching),
+ ]
+
+
+@pytest.mark.anyio
+async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation():
+ requests = []
+
+ async def handler(request):
+ requests.append((request.method, request.url.path))
+ if request.url.path.endswith("/user"):
+ return httpx.Response(200, json={"login": "timmy"})
+ if request.url.path.endswith("/subscriptions/check"):
+ return httpx.Response(200, json={"subscribed": True, "ignored": False})
+ return httpx.Response(201, json={})
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True)
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert result == {"watching": True}
+ assert requests == [
+ ("GET", "/api/v1/user"),
+ ("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"),
+ ("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"),
+ ]
+
+
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["issue", "pull"])
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(