diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 12a2f7d..111174c 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -208,6 +208,13 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
+.queue-finder { display:grid; gap:6px; margin:10px 0; }
+.queue-finder > label { font-weight:700; }
+.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
+.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
+.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
+.queue-finder button { padding-inline:14px; }
+#search-older-work { width:100%; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
@@ -569,6 +576,7 @@ textarea { resize: vertical; min-height: 120px; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
+ .queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
.my-work-list { grid-template-columns:1fr; }
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
.my-work-card { min-width:0; overflow-x:hidden; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 88502e4..1243408 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -117,6 +117,7 @@
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all';
let selectedWorkMilestone = 'all';
+ let queueFindQuery = '';
let savedWorkFilter = null;
let launchFilterResolved = false;
try {
@@ -2123,7 +2124,8 @@
function renderDrafts() {
const list = qs('#my-work-list');
- const deliveryCenter = draftInbox.partition(lastDrafts);
+ const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery);
+ const deliveryCenter = draftInbox.partition(displayedDrafts);
const renderDraftCard = item => {
const index = lastDrafts.indexOf(item);
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
@@ -2194,6 +2196,7 @@
list.innerHTML = deliverySummary + '' +
'Queued deliveries
' + deliveryCards + '' +
'Unfinished drafts
' + draftCards + '';
+ updateQueueFinder(displayedDrafts.length, lastDrafts.length, false);
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
const button = event.currentTarget;
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
@@ -2327,11 +2330,13 @@
qs('#load-more-notifications').hidden = true;
return;
}
- const visible = selectedWorkFilter === 'today' ?
+ const queueItems = selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
+ const visible = findQueueItems(queueItems, queueFindQuery);
+ updateQueueFinder(visible.length, queueItems.length, incomplete);
const selection = notificationSelection.snapshot();
const selectedIds = new Set(selection.ids);
const workSelectionState = workSelection.snapshot();
@@ -5672,6 +5677,36 @@
selectWorkQueue(button.dataset.workFilter);
});
});
+ function updateQueueFinder(matches, loaded, incomplete) {
+ const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
+ const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work';
+ qs('#queue-find-label').textContent = queueLabel;
+ qs('#clear-queue-find').hidden = !queueFindQuery;
+ qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete;
+ qs('#queue-find-status').textContent = queueFindQuery ?
+ (matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel +
+ (incomplete ? ' among loaded work.' : '.')) : '';
+ }
+ qs('#queue-finder').addEventListener('submit', event => event.preventDefault());
+ qs('#queue-find-input').addEventListener('input', event => {
+ queueFindQuery = event.target.value;
+ renderMyWork();
+ });
+ qs('#clear-queue-find').addEventListener('click', () => {
+ queueFindQuery = '';
+ qs('#queue-find-input').value = '';
+ renderMyWork();
+ qs('#queue-find-input').focus();
+ });
+ qs('#search-older-work').addEventListener('click', () => {
+ const notificationButton = qs('#load-more-notifications');
+ const workButton = qs('#load-more-work');
+ const target = selectedWorkFilter === 'update' ? notificationButton : workButton;
+ if (!target || target.hidden || target.disabled) return;
+ qs('#queue-find-status').textContent = 'Searching older ' +
+ (selectedWorkFilter === 'update' ? 'updates…' : 'work…');
+ target.click();
+ });
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
const button = qs('[data-work-filter="' + filter + '"]');
if (!button) return false;
diff --git a/frontend/index.html b/frontend/index.html
index ce42380..0391cff 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -169,6 +169,15 @@
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index f2cf02b..2c19551 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -814,6 +814,16 @@ function summarizeMyWork(items) {
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
}
+function findQueueItems(items, query) {
+ const needle = String(query || '').trim().toLowerCase();
+ if (!needle) return (items || []).slice();
+ return (items || []).filter(item => {
+ const number = Number.isInteger(item?.number) ? '#' + item.number : '';
+ return [item?.repository, item?.key, number, item?.title]
+ .some(value => String(value || '').toLowerCase().includes(needle));
+ });
+}
+
function countMyWork(items) {
return {
all: items.length,
@@ -837,6 +847,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
+ buildMyWork.findQueueItems = findQueueItems;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index b7f2842..2cb644a 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-v96';
+const CACHE = 'stackchain-dashboard-shell-v97';
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/test_comment_next.py b/tests/test_comment_next.py
index c9cb61c..0ef91ec 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-v96" in worker
+ assert "stackchain-dashboard-shell-v97" in worker
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
index 85beef0..ca3f66e 100644
--- a/tests/test_drafts.py
+++ b/tests/test_drafts.py
@@ -313,7 +313,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert 'Sending ' in html
assert 'Needs attention ' in html
assert 'Authorize ' in html
- assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
+ assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
assert "deliveryCenter.retryable.length" in html
assert "item.status === 'sending' ? 'Sending'" in html
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index 54cd2aa..cb342b4 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -69,7 +69,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
- assert len(first.runtime_gzip_bytes) <= 95 * 1024
+ assert len(first.runtime_gzip_bytes) <= 96 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
@@ -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-v96';",
+ "const CACHE = 'stackchain-dashboard-shell-v97';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index f0717b9..5e86229 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-v96" in source
+ assert "stackchain-dashboard-shell-v97" 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 944d2e3..c009696 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-v96" in worker
+ assert "stackchain-dashboard-shell-v97" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index da902f7..1874070 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-v96" in worker
+ assert "stackchain-dashboard-shell-v97" 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 5e10eb8..4adb96d 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-v96" in worker
+ assert "stackchain-dashboard-shell-v97" 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_my_work.py b/tests/test_my_work.py
index 17279a2..17503fc 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -29,6 +29,47 @@ TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
+def test_queue_finder_matches_repository_number_and_title_without_reordering():
+ script = f"""
+const work = require({json.dumps(str(MY_WORK))});
+const items = [
+ {{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}},
+ {{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}},
+ {{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}},
+];
+process.stdout.write(JSON.stringify({{
+ repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key),
+ number:work.findQueueItems(items, '#42').map(item => item.key),
+ title:work.findQueueItems(items, 'mobile queue').map(item => item.key),
+ clear:work.findQueueItems(items, ' ').map(item => item.key),
+}}));
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "repo": ["stackchain/api#42"],
+ "number": ["stackchain/api#42", "other/repo#42"],
+ "title": ["stackchain/web#7"],
+ "clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"],
+ }
+
+
+@pytest.mark.anyio
+async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
+ html = await dashboard()
+
+ assert '