From 8bb8fb0cd9d83414347fc3977ce106768d08c70d Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 20:11:13 +0000 Subject: [PATCH] feat: enable progressive mobile capture Closes #1407 --- frontend/dashboard.css | 6 ++ frontend/dashboard.js | 6 ++ frontend/index.html | 1 + frontend/progressive-capture.js | 128 +++++++++++++++++++++++ frontend/progressive-my-work.js | 3 + frontend/service-worker.js | 1 + src/frontend_bundle.py | 7 +- tests/test_frontend_bundle.py | 4 + tests/test_progressive_capture.py | 164 ++++++++++++++++++++++++++++++ tests/test_progressive_my_work.py | 26 ++++- tests/test_service_worker.py | 1 + 11 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 frontend/progressive-capture.js create mode 100644 tests/test_progressive_capture.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b59d10c..9785be9 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1203,6 +1203,12 @@ textarea { resize: vertical; min-height: 120px; } .voice-conversation-review textarea { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; } .create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .create-issue-capture-actions[hidden] { display:none; } +.create-issue-sheet.progressive-capture .mobile-create-issue-nav, +.create-issue-sheet.progressive-capture .create-issue-attachment, +.create-issue-sheet.progressive-capture .create-issue-filing, +.create-issue-sheet.progressive-capture #switch-to-create-pull, +.create-issue-sheet.progressive-capture .today-capture-interruption, +.create-issue-sheet.progressive-capture .shared-content-conflict { display:none; } .create-issue-filing { display:grid; gap:12px; min-width:0; } .create-issue-filing[hidden] { display:none; } .create-issue-attachment { display:grid; gap:8px; min-width:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 98334d8..f2d0cb2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,6 +1,7 @@ (async function(){ const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window })); await workspaceLifecycle.optionalReady; + const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.(); const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.(); window.stackchainProgressiveMyWork?.stop(); const qs = (s, el=document) => el.querySelector(s); @@ -6426,6 +6427,11 @@ if (selector === '#create-issue-title') scheduleIssueDuplicateCheck(); }) ); + if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) { + issueCapture.saveDraft({repository:'', labelIds:[], + title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body}); + await openCreateIssueSheet(false); + } qs('#create-issue-repository').addEventListener('change', event => { issueTemplatePicker.changeRepository(currentIssueCaptureDraft()); issueOwnerPicker.reset(event.target.value); diff --git a/frontend/index.html b/frontend/index.html index 84cfd90..d015189 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2285,6 +2285,7 @@ + diff --git a/frontend/progressive-capture.js b/frontend/progressive-capture.js new file mode 100644 index 0000000..6200f31 --- /dev/null +++ b/frontend/progressive-capture.js @@ -0,0 +1,128 @@ +function createProgressiveCapture({ + document, + storage, + getLogin = () => '', + createCaptures = globalThis.createUnfiledCaptures, + requestFullWorkspace = async () => { + const lifecycle = await globalThis.stackchainWorkspaceLifecycle; + return lifecycle?.optionalReady; + }, + createId, + now, +}) { + const button = document.querySelector('[data-mobile-task="new"]'); + const root = document.querySelector('#create-issue-sheet'); + const title = document.querySelector('#create-issue-title'); + const body = document.querySelector('#create-issue-body'); + const heading = document.querySelector('#create-issue-heading'); + const save = document.querySelector('#save-unfiled-issue'); + const file = document.querySelector('#file-new-issue'); + const cancel = document.querySelector('#cancel-new-issue'); + const status = document.querySelector('#my-work-action-status'); + const captures = createCaptures({ + storage, + getCaptureLogin:getLogin, + getCurrentLogin:getLogin, + ...(createId ? {createId} : {}), + ...(now ? {now} : {}), + }); + const listeners = []; + let started = false; + let saving = null; + let saved = false; + let handedOff = false; + + function listen(element, name, callback) { + element?.addEventListener?.(name, callback); + listeners.push([element, name, callback]); + } + + function close() { + root?.classList.remove('open'); + } + + function open(event) { + saved = false; + if (heading) heading.textContent = 'Capture work'; + root?.classList.add('open'); + title?.focus?.(); + } + + async function saveDraft() { + if (saved || saving) return saving; + save.disabled = true; + saving = Promise.resolve().then(() => captures.save({ + title:title?.value || '', body:body?.value || '', + })).then(() => { + saved = true; + if (title) title.value = ''; + if (body) body.value = ''; + close(); + if (status) status.textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.'; + return true; + }).catch(error => { + if (status) status.textContent = error.message; + title?.focus?.(); + return false; + }).finally(() => { + save.disabled = false; + saving = null; + }); + return saving; + } + + async function fileNow() { + if (file.disabled) return false; + file.disabled = true; + if (status) status.textContent = 'Loading filing tools…'; + try { + await requestFullWorkspace(); + return true; + } catch (_error) { + file.disabled = false; + if (status) status.textContent = 'Filing tools unavailable. Your capture is still editable; retry when connected.'; + title?.focus?.(); + return false; + } + } + + function start() { + if (started) return false; + started = true; + root?.classList.add('progressive-capture'); + listen(button, 'click', open); + listen(save, 'click', saveDraft); + listen(file, 'click', fileNow); + listen(cancel, 'click', close); + return true; + } + + function handoff() { + if (handedOff) return null; + handedOff = true; + const state = { + open:Boolean(root?.classList.contains('open')), + title:title?.value || '', body:body?.value || '', + }; + root?.classList.remove('progressive-capture'); + file.disabled = false; + listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback)); + listeners.length = 0; + return state; + } + + return {start, handoff}; +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined' && + typeof createUnfiledCaptures === 'function' && + window.matchMedia?.('(max-width: 600px)').matches === true) { + window.stackchainProgressiveCapture = createProgressiveCapture({ + document, + storage:window.localStorage, + getLogin:() => window.stackchainProgressiveMyWork?.login?.() || '', + }); + window.stackchainProgressiveCapture.start(); +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveCapture; diff --git a/frontend/progressive-my-work.js b/frontend/progressive-my-work.js index eb815a7..6674456 100644 --- a/frontend/progressive-my-work.js +++ b/frontend/progressive-my-work.js @@ -9,6 +9,7 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { let selectedByUser = false; let liveSnapshot = null; let liveSnapshotPromise = null; + let confirmedLogin = ''; const deferredQueues = { today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts', }; @@ -60,6 +61,7 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { }); return { + login() { return confirmedLogin; }, handoff() { const state = { selectedFilter:selectedByUser ? active : null }; if (liveSnapshot) { @@ -87,6 +89,7 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { if (transferable) liveSnapshot = snapshot; else liveSnapshotPromise = null; const context = snapshot?.context || snapshot || {}; + confirmedLogin = String(context.user?.login || '').trim(); items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); updateCounts(); render(); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index fcd7961..154450e 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -187,6 +187,7 @@ const SHELL = [ BASE + 'static/offline-today.js', BASE + 'static/my-work.js', BASE + 'static/progressive-my-work.js', + BASE + 'static/progressive-capture.js', BASE + 'static/agenda-replan.js', BASE + 'static/agenda-calendar.js', BASE + 'static/protect-today.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 51e8730..7485aa1 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -21,7 +21,10 @@ COMMONJS_BROWSER_BRANCH = re.compile( ) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { - "work-core": ("static/my-work.js", "static/progressive-my-work.js"), + "work-core": ( + "static/my-work.js", "static/progressive-my-work.js", + "static/unfiled-captures.js", "static/progressive-capture.js", + ), "comment-actions": ("static/comment-actions.js",), "issue-capture": ( "static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js", @@ -42,7 +45,7 @@ FEATURE_SOURCES = { "today-timer": ( "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js", "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", - "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", + "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js", diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 505dcd5..65e336c 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -133,7 +133,11 @@ def test_my_work_has_a_small_blocking_bundle_before_optional_workspace_hydration assert b"function buildMyWork" in work_core.runtime_bytes assert b"function createProgressiveMyWork" in work_core.runtime_bytes + assert b"function createProgressiveCapture" in work_core.runtime_bytes + assert b"function createUnfiledCaptures" in work_core.runtime_bytes assert b"function buildMyWork" not in today.runtime_bytes + assert b"function createProgressiveCapture" not in today.runtime_bytes + assert b"function createUnfiledCaptures" not in today.runtime_bytes assert b"loadWorkspace({document,window})" in build.runtime_bytes assert b"const workspaceLifecycle" not in build.runtime_bytes assert b"const workspaceLifecycle" in today.runtime_bytes diff --git a/tests/test_progressive_capture.py b/tests/test_progressive_capture.py new file mode 100644 index 0000000..85313ba --- /dev/null +++ b/tests/test_progressive_capture.py @@ -0,0 +1,164 @@ +import json +import subprocess +from pathlib import Path + + +MODULE = Path(__file__).parents[1] / "frontend" / "progressive-capture.js" +UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js" +DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" +CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" + + +def run_capture(scenario: str) -> dict: + harness = f""" +const createProgressiveCapture = require({json.dumps(str(MODULE))}); +const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); +(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], check=True, capture_output=True, text=True + ) + return json.loads(completed.stdout) + + +def test_progressive_mobile_new_opens_and_saves_one_account_bound_draft(): + result = run_capture(r""" +const listeners = new Map(); +const button = {dataset:{mobileTask:'new'}, addEventListener(name, callback){listeners.set('new:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('new:'+name)===callback) listeners.delete('new:'+name);}, setAttribute(){}, removeAttribute(){}}; +const save = {disabled:false, addEventListener(name, callback){listeners.set('save:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('save:'+name)===callback) listeners.delete('save:'+name);}}; +const file = {disabled:false, addEventListener(){}, removeEventListener(){}}; +const cancel = {addEventListener(){}, removeEventListener(){}}; +const title = {value:'', focused:0, focus(){this.focused++;}}; +const body = {value:''}; +const heading = {textContent:''}; +const status = {textContent:''}; +const classes = new Set(); +const root = {classList:{add:name=>classes.add(name), remove:name=>classes.delete(name), contains:name=>classes.has(name)}}; +const nodes = { + '#create-issue-sheet':root, '#create-issue-title':title, '#create-issue-body':body, + '#create-issue-heading':heading, '#save-unfiled-issue':save, '#file-new-issue':file, + '#cancel-new-issue':cancel, '#my-work-action-status':status, +}; +const document = { + querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);}, +}; +const values = new Map(); +const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; +const flow = createProgressiveCapture({document, storage, getLogin:()=> 'timmy', createCaptures:createUnfiledCaptures, createId:()=> 'draft-1', now:()=> 42}); +flow.start(); +listeners.get('new:click')({currentTarget:button}); +title.value='Production outage'; body.value='Investigate mobile reports'; +await listeners.get('save:click')(); +await listeners.get('save:click')(); +const stored=JSON.parse(values.get('stackchain.unfiled-issues.v1')); +console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()]})); +""") + + assert result == { + "open": False, + "focused": 1, + "heading": "Capture work", + "status": "Saved to Drafts. Choose a repository when you’re ready to file it.", + "items": [{ + "id": "draft-1", + "ownerLogin": "timmy", + "title": "Production outage", + "body": "Investigate mobile reports", + "savedAt": 42, + }], + "listeners": ["new:click", "save:click"], + } + + +def test_progressive_file_now_preserves_capture_on_failure_and_hands_it_off_once(): + result = run_capture(r""" +const listeners = new Map(); +const control = name => ({disabled:false, + addEventListener(event, callback){listeners.set(name+':'+event, callback);}, + removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);}, + focus(){this.focused=(this.focused||0)+1;}, +}); +const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); +const title=control('title'); title.value='Keep this'; const body={value:'Do not lose this note'}; +const status={textContent:''}; const heading={textContent:''}; const classes=new Set(); +const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; +const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, + '#create-issue-heading':heading,'#save-unfiled-issue':save,'#file-new-issue':file, + '#cancel-new-issue':cancel,'#my-work-action-status':status}; +const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null)}; +let requests=0; +const flow=createProgressiveCapture({ + document,storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',createCaptures:createUnfiledCaptures, + requestFullWorkspace:async()=>{requests++;throw new Error('offline');}, +}); +flow.start(); listeners.get('new:click')({currentTarget:button}); +const filed=await listeners.get('file:click')(); +const before=Array.from(listeners.keys()).sort(); +const handoff=flow.handoff(); const second=flow.handoff(); +console.log(JSON.stringify({filed,requests,open:classes.has('open'),status:status.textContent, + fileDisabled:file.disabled,focused:title.focused,title:title.value,body:body.value,before, + after:Array.from(listeners.keys()),handoff,second})); +""") + + assert result == { + "filed": False, + "requests": 1, + "open": True, + "status": "Filing tools unavailable. Your capture is still editable; retry when connected.", + "fileDisabled": False, + "focused": 2, + "title": "Keep this", + "body": "Do not lose this note", + "before": ["cancel:click", "file:click", "new:click", "save:click"], + "after": [], + "handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"}, + "second": None, + } + + +def test_hydrated_dashboard_adopts_open_progressive_capture_without_losing_fields(): + source = DASHBOARD.read_text() + + assert "const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();" in source + assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source + assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source + assert "await openCreateIssueSheet(false);" in source + + +def test_progressive_capture_is_mobile_only_and_hides_unhydrated_filing_controls(): + source = MODULE.read_text() + css = CSS.read_text() + + assert "window.matchMedia?.('(max-width: 600px)').matches === true" in source + assert "root?.classList.add('progressive-capture')" in source + assert "root?.classList.remove('progressive-capture')" in source + assert ".create-issue-sheet.progressive-capture .mobile-create-issue-nav" in css + assert ".create-issue-sheet.progressive-capture .create-issue-attachment" in css + assert ".create-issue-sheet.progressive-capture .create-issue-filing" in css + assert ".create-issue-sheet.progressive-capture #switch-to-create-pull" in css + + +def test_successful_filing_handoff_reenables_the_hydrated_file_control(): + result = run_capture(r""" +const listeners={}; +const control=name=>({disabled:false,addEventListener(event,callback){listeners[name+event]=callback;},removeEventListener(){},focus(){}}); +const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); +const title={value:'Ready to file',focus(){}}; const body={value:'Context'}; const status={textContent:''}; +const classes=new Set(); const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; +const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, + '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, + '#cancel-new-issue':cancel,'#my-work-action-status':status}; +const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; +const flow=createProgressiveCapture({document,storage:{getItem:()=>null,setItem(){}},getLogin:()=>'timmy', + createCaptures:createUnfiledCaptures,requestFullWorkspace:async()=>true}); +flow.start(); listeners.newclick({currentTarget:button}); +const filed=await listeners.fileclick(); const before=file.disabled; const handoff=flow.handoff(); +console.log(JSON.stringify({filed,before,after:file.disabled,handoff})); +""") + + assert result == { + "filed": True, + "before": True, + "after": False, + "handoff": {"open": True, "title": "Ready to file", "body": "Context"}, + } diff --git a/tests/test_progressive_my_work.py b/tests/test_progressive_my_work.py index ae8f8fd..369b48c 100644 --- a/tests/test_progressive_my_work.py +++ b/tests/test_progressive_my_work.py @@ -219,4 +219,28 @@ const flow=context.module.exports({{document,fetchSnapshot:()=>new Promise(resol "context": {"user": {"login": "timmy"}, "issues": [], "pull_requests": []}, "events": [], "notifications": [], - }} \ No newline at end of file + }} + + +def test_progressive_capture_can_read_only_the_confirmed_snapshot_login(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const document={{ + querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null, + querySelectorAll:()=>[], +}}; +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context); +context.buildMyWork=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context); +const flow=context.module.exports({{document,fetchSnapshot:async()=>({{ + context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}}, events:[], notifications:[], +}})}}); +(async()=>{{ + const before=flow.login(); await flow.start(); const after=flow.login(); flow.stop(); + console.log(JSON.stringify({{before,after,stopped:flow.login()}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + + assert json.loads(result.stdout) == {"before": "", "after": "timmy", "stopped": "timmy"} \ No newline at end of file diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2b2049e..bffa217 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1409,6 +1409,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-today.js", "/dashboard/static/my-work.js", "/dashboard/static/progressive-my-work.js", + "/dashboard/static/progressive-capture.js", "/dashboard/static/agenda-replan.js", "/dashboard/static/agenda-calendar.js", "/dashboard/static/protect-today.js", -- 2.43.0