From edbe2d76ddc33f341320153b4e4ac7917e8dda78 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 09:08:06 +0000 Subject: [PATCH] feat: create and start captured issues (#387) --- frontend/create-and-start.js | 36 ++++++++++ frontend/dashboard.css | 4 +- frontend/dashboard.js | 39 ++++++++-- frontend/index.html | 2 + frontend/my-work.js | 5 +- frontend/service-worker.js | 3 +- tests/test_create_and_start.py | 87 +++++++++++++++++++++++ tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_my_work.py | 23 ++++++ tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 13 ++-- tests/test_today_sync.py | 2 +- 14 files changed, 203 insertions(+), 19 deletions(-) create mode 100644 frontend/create-and-start.js create mode 100644 tests/test_create_and_start.py diff --git a/frontend/create-and-start.js b/frontend/create-and-start.js new file mode 100644 index 0000000..2a54f67 --- /dev/null +++ b/frontend/create-and-start.js @@ -0,0 +1,36 @@ +function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) { + const completed = new Set(); + + function available() { + return todayWork.read().length < todayWork.limit; + } + + function complete(issue) { + const identity = todayWork.identity(issue); + if (!identity || completed.has(identity)) return 'exists'; + const added = todayWork.add(issue); + if (added === 'full') { + announce('Created, but Today changed—remove an item, then add this issue.'); + return 'full'; + } + if (added !== 'added' && added !== 'exists') { + announce('Created, but Today could not be saved on this device.'); + return 'unavailable'; + } + completed.add(identity); + if (added === 'added' && !todaySync.enqueue('add', identity)) { + announce('Created and saved to Today on this device, but account sync is unavailable.'); + return 'sync-unavailable'; + } + refresh(); + todaySync.flush(); + warm(); + start(issue); + announce('Created, added to Today, and ready to work.'); + return 'started'; + } + + return { available, complete }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index df19e86..90aed09 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -110,7 +110,9 @@ textarea { resize: vertical; min-height: 120px; } .draft-preview { color:var(--muted); overflow-wrap:anywhere; } .draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .draft-actions button { min-height:44px; width:100%; } -.create-issue-actions button { min-height:44px; max-width:100%; } +.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); } +.create-issue-actions button { min-height:44px; max-width:100%; width:100%; } +.create-issue-actions #create-issue-status { grid-column:1/-1; } .my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); } .my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; } .my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ab0fec7..a6a598d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -118,6 +118,7 @@ let pullConversation = null; let pullReviewState = null; let creatingIssue = false; + let createAndStartRequested = false; let findingWork = false; let availablePagination = { page: 1, total: 0, has_more: false }; let progress = null; @@ -688,6 +689,18 @@ }, }); + const createAndStart = createCreateAndStart({ + todayWork, + todaySync, + refresh: refreshMyWorkView, + warm: warmTodayOffline, + start: item => { + qs('[data-work-filter="today"]').click(); + workSession.start(item); + }, + announce: message => { qs('#my-work-action-status').textContent = message; }, + }); + let planTodayTrigger = null; function planTodayItemMarkup(item, selected, index = -1) { const id = todayWork.identity(item); @@ -1948,6 +1961,7 @@ scheduleIssueDuplicateCheck(); qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.'; qs('#submit-new-issue').disabled = !repositories.length; + qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available(); qs('#create-issue-sheet').classList.add('open'); creatingIssue = true; qs('#create-issue-title').focus(); @@ -1967,7 +1981,7 @@ qs('#new-issue').focus(); } - function applyOutboxResult(result, openCreated = false) { + function applyOutboxResult(result, openCreated = false, startCreated = false) { if (result.lease_skipped) { refreshMyWorkView(); return; } (result.confirmed || []).forEach(confirmed => { if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); @@ -1981,7 +1995,10 @@ const created = lastMyWork.find(item => item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number ); - if (openCreated && created) openRoutedWork(created, qs('#new-issue')); + if (startCreated && created) { + const outcome = createAndStart.complete(created); + if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue')); + } else if (openCreated && created) openRoutedWork(created, qs('#new-issue')); } else if ((result.remaining || []).some(item => item.status === 'attention')) { qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.'; } else { @@ -2732,6 +2749,7 @@ }); qs('#create-issue-form').addEventListener('submit', async event => { event.preventDefault(); + if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue'; const captureDraft = currentIssueCaptureDraft(); if (!captureDraft.repository || !captureDraft.title) { qs('#create-issue-status').textContent = 'Choose a repository and add a title.'; @@ -2744,9 +2762,17 @@ qs('#create-issue-anyway').focus(); return; } + if (createAndStartRequested && !createAndStart.available()) { + qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.'; + qs('#create-and-start-issue').focus(); + return; + } const button = qs('#submit-new-issue'); + const startButton = qs('#create-and-start-issue'); button.disabled = true; - qs('#create-issue-status').textContent = 'Saving for background delivery…'; + startButton.disabled = true; + qs('#create-issue-status').textContent = createAndStartRequested ? + 'Creating issue and adding it to Today…' : 'Saving for background delivery…'; try { const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) : await issueOutbox.enqueueDurably(captureDraft); @@ -2756,6 +2782,7 @@ refreshMyWorkView(); qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.'; button.disabled = false; + startButton.disabled = !createAndStart.available(); return; } editingOutboxId = null; @@ -2764,10 +2791,14 @@ taskOverlayHistory.leave(); refreshMyWorkView(); qs('#my-work-action-status').textContent = 'Queued for sync.'; - if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true); + if (navigator.onLine) applyOutboxResult( + await issueOutbox.retry(queued.id, activeFlushLogin), true, createAndStartRequested + ); + createAndStartRequested = false; } catch (error) { qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.'; button.disabled = false; + startButton.disabled = !createAndStart.available(); qs('#create-issue-title').focus(); } }); diff --git a/frontend/index.html b/frontend/index.html index 6e4dabd..de42b17 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -389,6 +389,7 @@
+
@@ -593,6 +594,7 @@ + diff --git a/frontend/my-work.js b/frontend/my-work.js index 683683b..72b8818 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -502,11 +502,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on return { active: () => running, - start() { + start(item = null) { const items = queue(); if (!items.length) return finish(); running = true; - return openAt(items, 0); + const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0; + return openAt(items, requested >= 0 ? requested : 0); }, reconcile() { if (!running) return false; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index e48e9e2..e39e7c9 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-v57'; +const CACHE = 'stackchain-dashboard-shell-v58'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ @@ -36,6 +36,7 @@ const SHELL = [ BASE + 'static/conversation.js', BASE + 'static/issue-sheet.js', BASE + 'static/create-issue-sheet.js', + BASE + 'static/create-and-start.js', BASE + 'static/pull-sheet.js', BASE + 'static/review-sheet.js', BASE + 'static/work-route.js', diff --git a/tests/test_create_and_start.py b/tests/test_create_and_start.py new file mode 100644 index 0000000..5f75881 --- /dev/null +++ b/tests/test_create_and_start.py @@ -0,0 +1,87 @@ +import json +import subprocess +from pathlib import Path + + +CREATE_AND_START = Path(__file__).parents[1] / "frontend" / "create-and-start.js" +HTML = Path(__file__).parents[1] / "frontend" / "index.html" +DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" +CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" +WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" + + +def run_node(script): + return json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + +def test_confirmed_issue_is_planned_synced_warmed_and_started_once(): + script = f""" +const fs = require('fs'); +if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{ + process.stdout.write(JSON.stringify({{available:false}})); +}} else {{ + const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))}); + const calls = []; + const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}}; + const flow = createCreateAndStart({{ + todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}}, + todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}}, + refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number), + announce:message=>calls.push('announce:'+message), + }}); + const result = flow.complete(issue); + flow.complete(issue); + process.stdout.write(JSON.stringify({{available:true,result,calls}})); +}} +""" + + assert run_node(script) == { + "available": True, + "result": "started", + "calls": [ + "add", + "add:issue:stackchain/dashboard:387:", + "refresh", + "flush", + "warm", + "start:387", + "announce:Created, added to Today, and ready to work.", + ], + } + + +def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_shell(): + html = HTML.read_text() + dashboard = DASHBOARD.read_text() + css = CSS.read_text() + worker = WORKER.read_text() + + assert '' in html + assert '' in html + assert "const createAndStart = createCreateAndStart({" in dashboard + assert "event.submitter?.id === 'create-and-start-issue'" in dashboard + assert "createAndStart.complete(created)" in dashboard + assert ".create-issue-actions" in css and "grid-template-columns" in css + assert "BASE + 'static/create-and-start.js'" in worker + + +def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued(): + script = f""" +const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))}); +const calls = []; +const issue = {{kind:'issue', repository:'stackchain/dashboard', number:388}}; +const flow = createCreateAndStart({{ + todayWork: {{limit:5, read:()=>[], identity:()=> 'issue:stackchain/dashboard:388:', add:()=> 'added'}}, + todaySync: {{enqueue:()=>false, flush:()=>calls.push('flush')}}, + refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:()=>calls.push('start'), + announce:message=>calls.push(message), +}}); +process.stdout.write(JSON.stringify({{result:flow.complete(issue), calls}})); +""" + + assert run_node(script) == { + "result": "sync-unavailable", + "calls": ["Created and saved to Today on this device, but account sync is unavailable."], + } diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 7bb18d2..15bf74a 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-v57" in source + assert "stackchain-dashboard-shell-v58" 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 65aeacb..42809ef 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-v57" in worker + assert "stackchain-dashboard-shell-v58" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 44947e5..627dfa9 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ 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-v57" in worker + assert "stackchain-dashboard-shell-v58" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 5491d46..9f167af 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1376,6 +1376,29 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi assert opener in html +def test_work_session_can_start_at_a_newly_created_item(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'stackchain/dashboard', number}})); +const opened = []; +const session = buildMyWork.createWorkSession({{ + getItems: () => items, + getFilter: () => 'all', + getMilestone: () => '', + onOpen: item => opened.push(item.number), + onProgress: () => {{}}, + onFinish: () => {{}}, +}}); +const started = session.start(items[2]); +process.stdout.write(JSON.stringify({{started, opened}})); +""" + + output = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout + assert json.loads(output) == {"started": True, "opened": [3]} + + def test_my_work_filter_counts_distinguish_prs_from_review_requests(): items = [ {"kind": "issue", "is_review": False}, diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 9d57019..3f0608e 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v57" in source + assert "stackchain-dashboard-shell-v58" in source assert "BASE + 'static/plan-today.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index c58935c..2a30e9a 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v57" in source + assert "stackchain-dashboard-shell-v58" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -116,14 +116,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-v57" in source + assert "stackchain-dashboard-shell-v58" 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-v57" in source + assert "stackchain-dashboard-shell-v58" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -132,21 +132,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-v57" in source + assert "stackchain-dashboard-shell-v58" 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-v57" in source + assert "stackchain-dashboard-shell-v58" 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-v57" in source + assert "stackchain-dashboard-shell-v58" in source assert "BASE + 'static/update-ownership.js'" in source @@ -352,6 +352,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/conversation.js", "/dashboard/static/issue-sheet.js", "/dashboard/static/create-issue-sheet.js", + "/dashboard/static/create-and-start.js", "/dashboard/static/pull-sheet.js", "/dashboard/static/review-sheet.js", "/dashboard/static/work-route.js", diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 5b7284e..a31a70d 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); 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-v57" in source + assert "stackchain-dashboard-shell-v58" in source assert "BASE + 'static/today-sync.js'" in source