diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index f664416..e536d4e 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -455,6 +455,9 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header-actions { display:flex; gap:8px; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
+.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
+.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
+.find-work-search input, .find-work-search button { min-height:44px; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 741b0bc..6e17bdf 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -593,6 +593,14 @@
renderAvailableIssues(findWorkController.items());
},
});
+ let findWorkSearchTimer = null;
+
+ function updateFindWorkMatchStatus() {
+ const query = findWorkController.query();
+ qs('#find-work-match-status').textContent = query ?
+ availablePagination.total + ' match' + (availablePagination.total === 1 ? '' : 'es') +
+ ' for “' + query + '”.' : '';
+ }
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
@@ -4222,6 +4230,30 @@
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection());
+ qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
+ qs('#find-work-search').addEventListener('input', event => {
+ const value = event.currentTarget.value;
+ qs('#clear-find-work-search').hidden = !value;
+ clearTimeout(findWorkSearchTimer);
+ findWorkSearchTimer = setTimeout(async () => {
+ qs('#find-work-status').textContent = value.trim() ?
+ 'Searching available issues…' : 'Loading available issues…';
+ try {
+ await findWorkController.search(value);
+ updateFindWorkMatchStatus();
+ qs('#find-work-status').textContent = availablePagination.total ?
+ 'Available work loaded.' : 'No matching unassigned issues.';
+ } catch (error) {
+ qs('#find-work-status').textContent = error.message + ' Retry search.';
+ }
+ }, 250);
+ });
+ qs('#clear-find-work-search').addEventListener('click', () => {
+ const input = qs('#find-work-search');
+ input.value = '';
+ input.dispatchEvent(new Event('input', { bubbles:true }));
+ input.focus();
+ });
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
qs('#claim-selected-work').addEventListener('click', async event => {
event.currentTarget.disabled = true;
diff --git a/frontend/index.html b/frontend/index.html
index 782cc77..8393a41 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -535,6 +535,12 @@
Claim an available issue and continue it in My Work.
+
Open Find Work to load available issues.
diff --git a/frontend/pick-work.js b/frontend/pick-work.js
index 59d883a..43426a1 100644
--- a/frontend/pick-work.js
+++ b/frontend/pick-work.js
@@ -2,6 +2,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
+ let loadGeneration = 0;
+ let query = '';
let claimRequest = null;
const previewed = new Set();
let selecting = false;
@@ -41,11 +43,14 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
onPagination({ ...pagination });
}
- function loadPage(page, append) {
- if (loadRequest) return loadRequest;
- loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
+ function loadPage(page, append, requestedQuery = query) {
+ if (loadRequest && requestedQuery === query && append) return loadRequest;
+ const generation = ++loadGeneration;
+ const queryPart = requestedQuery ? '&q=' + encodeURIComponent(requestedQuery) : '';
+ const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
headers: { Accept: 'application/json' },
}).then(result => {
+ if (generation !== loadGeneration) return result;
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
@@ -53,8 +58,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
- }).finally(() => { loadRequest = null; });
- return loadRequest;
+ }).finally(() => { if (loadRequest === request) loadRequest = null; });
+ loadRequest = request;
+ return request;
}
return {
@@ -68,6 +74,13 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
if (!pagination.has_more) return Promise.resolve(false);
return loadPage(pagination.page + 1, true);
},
+ search(value) {
+ query = String(value || '').trim();
+ return loadPage(1, false, query);
+ },
+ query() {
+ return query;
+ },
items() {
return available.slice();
},
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 15db6cc..8e86097 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v97';
+const CACHE = 'stackchain-dashboard-shell-v98';
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/src/main.py b/src/main.py
index e823f49..f619255 100644
--- a/src/main.py
+++ b/src/main.py
@@ -2721,7 +2721,18 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
raise
-def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
+def _available_issue_page(
+ items: list[dict], page: int, limit: int = 50, query: str = ""
+) -> dict:
+ normalized_query = query.strip().casefold()
+ if normalized_query:
+ number_query = normalized_query.removeprefix("#")
+ items = [
+ item for item in items
+ if normalized_query in str(item.get("repository") or "").casefold()
+ or normalized_query in str(item.get("title") or "").casefold()
+ or (number_query.isdigit() and number_query == str(item.get("number") or ""))
+ ]
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
@@ -2733,7 +2744,10 @@ def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict
@app.get("/api/v1/available-issues")
-async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
+async def available_issues(
+ page: int = Query(default=1, ge=1, le=100),
+ q: str = Query(default="", max_length=100),
+) -> JSONResponse:
try:
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
@@ -2744,7 +2758,7 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
- result = _available_issue_page(items, page)
+ result = _available_issue_page(items, page, query=q)
if stale:
result["stale"] = True
if revalidating:
diff --git a/tests/test_batch_find_work.py b/tests/test_batch_find_work.py
index 2780a35..aa14829 100644
--- a/tests/test_batch_find_work.py
+++ b/tests/test_batch_find_work.py
@@ -155,3 +155,62 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
assert "env(safe-area-inset-bottom)" in css
assert "BASE + 'static/batch-find-work.js'" in worker
assert '' in html
+
+
+def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
+ script = f"""
+const createFindWork=require({json.dumps(str(PICK_WORK))});
+const pending=[];
+const rendered=[];
+const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
+const controller=createFindWork({{
+ fetchJson,
+ onItems:items=>rendered.push(items.map(item=>item.number)),
+ onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
+}});
+const first=controller.search('api');
+const second=controller.search('dashboard');
+pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
+second.then(()=>{{
+ controller.startSelection();
+ controller.toggleSelection(controller.items()[0]);
+ const third=controller.search('worker');
+ pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
+ return third;
+}}).then(()=>{{
+ controller.toggleSelection(controller.items()[0]);
+ pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
+ return first;
+}}).then(()=>process.stdout.write(JSON.stringify({{
+ calls:pending.map(entry=>entry.path),
+ rendered,
+ items:controller.items().map(item=>item.number),
+ selected:controller.selectedItems().map(item=>item.number),
+}})));
+"""
+
+ assert run_node(script) == {
+ "calls": [
+ "api/v1/available-issues?page=1&q=api",
+ "api/v1/available-issues?page=1&q=dashboard",
+ "api/v1/available-issues?page=1&q=worker",
+ ],
+ "rendered": [[673], [674]],
+ "items": [674],
+ "selected": [673, 674],
+ }
+
+
+def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
+ html = HTML.read_text()
+ dashboard = DASHBOARD.read_text()
+ css = CSS.read_text()
+
+ assert 'id="find-work-search"' in html
+ assert 'id="clear-find-work-search"' in html
+ assert 'id="find-work-match-status"' in html
+ assert 'aria-live="polite"' in html
+ assert "findWorkController.search(" in dashboard
+ assert "#find-work-search" in dashboard
+ assert ".find-work-search" in css
+ assert ".find-work-search button" in css and "min-height:44px" in css
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 0ef91ec..f7ac843 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-v97" in worker
+ assert "stackchain-dashboard-shell-v98" in worker
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index cb342b4..ab3bac1 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
- "const CACHE = 'stackchain-dashboard-shell-v97';",
+ "const CACHE = 'stackchain-dashboard-shell-v98';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py
index 2bbc00f..b0a6791 100644
--- a/tests/test_gitea_work_search.py
+++ b/tests/test_gitea_work_search.py
@@ -215,6 +215,52 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
assert calls == [True]
+@pytest.mark.anyio
+async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
+ async def available():
+ return [
+ {
+ "repository": "stackchain/api",
+ "number": number,
+ "title": "Routine API maintenance",
+ }
+ for number in range(1, 52)
+ ] + [{
+ "repository": "stackchain/dashboard",
+ "number": 673,
+ "title": "Search the full Find Work catalog",
+ }]
+
+ monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
+ by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
+ by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
+
+ expected = [{
+ "repository": "stackchain/dashboard",
+ "number": 673,
+ "title": "Search the full Find Work catalog",
+ }]
+ assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
+ assert by_number.json() == by_repo.json()
+ assert by_title.json() == by_repo.json()
+
+
+@pytest.mark.anyio
+async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
+ async def must_not_scan():
+ raise AssertionError("invalid query must be rejected before catalog scan")
+
+ monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
+
+ assert response.status_code == 422
+
+
@pytest.mark.anyio
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
monkeypatch, tmp_path
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 5e86229..4f5dafe 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -347,5 +347,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-v97" in source
+ assert "stackchain-dashboard-shell-v98" 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 c009696..dbffa7b 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -137,4 +137,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-v97" in worker
+ assert "stackchain-dashboard-shell-v98" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 1874070..29a0e9c 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-v97" in worker
+ assert "stackchain-dashboard-shell-v98" 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 4adb96d..0efe8d5 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
- assert "stackchain-dashboard-shell-v97" in worker
+ assert "stackchain-dashboard-shell-v98" 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_plan_today.py b/tests/test_plan_today.py
index 47c2204..1526e23 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v97" in source
+ assert "stackchain-dashboard-shell-v98" 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 83039ed..b0ec602 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v97" in source
+ assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v97" in source
+ assert "stackchain-dashboard-shell-v98" 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-v97" in source
+ assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -169,7 +169,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-v97" in source
+ assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v97" in source
+ assert "stackchain-dashboard-shell-v98" 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-v97" in source
+ assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -193,21 +193,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-v97" in source
+ assert "stackchain-dashboard-shell-v98" 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-v97" in source
+ assert "stackchain-dashboard-shell-v98" 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-v97" in source
+ assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -654,7 +654,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-v97" in source
+ assert "stackchain-dashboard-shell-v98" 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 363feff..687a3e9 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-v97';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v98';" 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 6692203..85debdb 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
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-v97" in source
+ assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/today-sync.js'" in source