diff --git a/frontend/agenda-session-launcher.js b/frontend/agenda-session-launcher.js
new file mode 100644
index 0000000..76c17e3
--- /dev/null
+++ b/frontend/agenda-session-launcher.js
@@ -0,0 +1,33 @@
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory;
+ else root.createAgendaSessionLauncher = factory;
+})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
+ let pending = null;
+
+ function open() {
+ if (pending) return pending;
+ options.selectAgenda();
+ pending = Promise.resolve()
+ .then(() => options.discover())
+ .then(complete => {
+ if (!options.isAgendaSelected()) return 'cancelled';
+ if (complete === false || options.hasMore()) {
+ options.announce('Agenda check paused. Retry to check older assigned deadlines.');
+ return 'incomplete';
+ }
+ const opened = options.hasCheckpoint() ? options.resume() : options.start();
+ if (!opened) options.announce('No deadlines are ready in Agenda.');
+ return opened ? 'opened' : 'empty';
+ })
+ .catch(() => {
+ if (options.isAgendaSelected()) {
+ options.announce('Agenda check paused. Retry to check older assigned deadlines.');
+ }
+ return 'incomplete';
+ })
+ .finally(() => { pending = null; });
+ return pending;
+ }
+
+ return {open};
+});
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index cb2b311..167b4af 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1210,11 +1210,18 @@
qs('[data-work-filter="today"]').click();
}
+ const agendaSessionLauncher = createAgendaSessionLauncher({
+ selectAgenda: () => selectMobileQueue('agenda'),
+ discover: completeAgendaIssues,
+ isAgendaSelected: () => selectedWorkFilter === 'agenda',
+ hasMore: () => Boolean(workPagination.issue?.has_more),
+ hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()),
+ resume: () => workSession.resume(),
+ start: () => workSession.start(),
+ announce: message => { qs('#my-work-action-status').textContent = message; },
+ });
function openAgendaSession() {
- selectMobileQueue('agenda');
- const opened = agendaSessionCheckpoint.read() ? workSession.resume() : workSession.start();
- if (!opened) qs('#my-work-action-status').textContent = 'No deadlines are ready in Agenda.';
- return opened ? 'opened' : 'empty';
+ return agendaSessionLauncher.open();
}
let todayReadinessTrigger = null;
@@ -2340,17 +2347,20 @@
}
async function completeAgendaIssues() {
- if (selectedWorkFilter !== 'agenda' || !workPagination.issue?.has_more || !lastContextSnapshot) return;
+ if (selectedWorkFilter !== 'agenda') return false;
+ if (!workPagination.issue?.has_more) return true;
+ if (!lastContextSnapshot) return false;
agendaChecking = true;
qs('#my-work-action-status').textContent = 'Checking all assigned deadlines…';
renderMyWork();
const complete = await workPager.loadAll('issue', () => lastContextSnapshot?.issues || []);
agendaChecking = false;
- if (selectedWorkFilter !== 'agenda') return;
+ if (selectedWorkFilter !== 'agenda') return false;
qs('#my-work-action-status').textContent = complete ?
'All assigned deadlines checked.' :
'Agenda check paused. Retry to check older assigned deadlines.';
renderMyWork();
+ return complete && !workPagination.issue?.has_more;
}
function renderDrafts() {
diff --git a/frontend/index.html b/frontend/index.html
index e663395..1d9f5cf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1055,6 +1055,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 2061ec2..b37330a 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -74,6 +74,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
+ BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 8fdd5dc..a73c3b0 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -40,6 +40,89 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
+AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js"
+
+
+def test_agenda_session_launch_waits_for_complete_single_flight_discovery():
+ script = f"""
+const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
+let finishDiscovery;
+let discoveries = 0;
+let opens = 0;
+let queue = 'agenda';
+const launcher = createLauncher({{
+ selectAgenda: () => {{ queue = 'agenda'; }},
+ discover: () => {{
+ discoveries += 1;
+ return new Promise(resolve => {{ finishDiscovery = resolve; }});
+ }},
+ isAgendaSelected: () => queue === 'agenda',
+ hasMore: () => false,
+ hasCheckpoint: () => true,
+ resume: () => {{ opens += 1; return true; }},
+ start: () => {{ throw new Error('must resume'); }},
+ announce: () => {{}},
+}});
+const first = launcher.open();
+const second = launcher.open();
+Promise.resolve().then(() => {{
+ const before = {{discoveries, opens, same:first === second}};
+ finishDiscovery(true);
+ return Promise.all([first, second]).then(results => {{
+ process.stdout.write(JSON.stringify({{before, results, discoveries, opens}}));
+ }});
+}});
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "before": {"discoveries": 1, "opens": 0, "same": True},
+ "results": ["opened", "opened"],
+ "discoveries": 1,
+ "opens": 1,
+ }
+
+
+def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes():
+ script = f"""
+const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
+async function scenario(mode) {{
+ let queue = 'agenda';
+ let opens = 0;
+ const messages = [];
+ const launcher = createLauncher({{
+ selectAgenda: () => {{ queue = 'agenda'; }},
+ discover: async () => {{
+ if (mode === 'leave') queue = 'today';
+ if (mode === 'throw') throw new Error('offline');
+ return mode !== 'incomplete';
+ }},
+ isAgendaSelected: () => queue === 'agenda',
+ hasMore: () => mode === 'incomplete',
+ hasCheckpoint: () => true,
+ resume: () => {{ opens += 1; return true; }},
+ start: () => {{ opens += 1; return true; }},
+ announce: message => messages.push(message),
+ }});
+ return {{result:await launcher.open(), opens, messages}};
+}}
+Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results =>
+ process.stdout.write(JSON.stringify(results))
+);
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == [
+ {"result": "incomplete", "opens": 0, "messages": [
+ "Agenda check paused. Retry to check older assigned deadlines."
+ ]},
+ {"result": "incomplete", "opens": 0, "messages": [
+ "Agenda check paused. Retry to check older assigned deadlines."
+ ]},
+ {"result": "cancelled", "opens": 0, "messages": []},
+ ]
def test_protect_today_route_is_an_explicit_agenda_action():
@@ -305,7 +388,11 @@ process.stdout.write(JSON.stringify({{
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
html = await dashboard()
+ assert '' in html
assert "openAgenda: openAgendaSession" in html
+ assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html
+ assert "discover: completeAgendaIssues" in html
+ assert "hasMore: () => Boolean(workPagination.issue?.has_more)" in html
assert "key: 'stackchain.agenda-session.v1'" in html
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 6309b0c..a284c39 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -786,6 +786,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
+ "/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",