diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 4b28b87..5ecb4ad 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1056,8 +1056,10 @@ textarea { resize: vertical; min-height: 120px; } #keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; } .search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; } .search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } +.search-preview-primary-actions.following-disposition-mode { grid-template-columns:repeat(3,minmax(0,1fr)); } @media (max-width:420px) { .search-preview-primary-actions { grid-template-columns:1fr; } + .search-preview-primary-actions.following-disposition-mode { grid-template-columns:1fr; } } @media (max-width:600px) { .search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6af5946..404f65d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -5716,6 +5716,7 @@ const detail = state.detail; if (!detail) return; searchPreviewDetail = detail; + shareButton.disabled = state.status === 'sharing'; qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number; qs('#search-preview-title').textContent = detail.title || 'Untitled work item'; @@ -5747,6 +5748,7 @@ (detail.assigned_to_me ? 'Start in Today' : 'Assign & start'); startButton.disabled = state.status === 'claiming' || state.status === 'reopening'; renderSearchPreviewWatch(detail, state, watchButton); + followingQueue.preview(state); const shareStatus = { sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.', 'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.', @@ -5817,7 +5819,7 @@ createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr, getDetail:()=>searchPreviewDetail, announce:message=>{qs('#search-preview-status').textContent=message;}, - onPlanned:item=>{searchPreviewDetail={...item,assigned_to_me:true,claimable:false};}, + onPlanned:item=>(searchPreviewDetail=item,item.following&&next()), }); searchBatchPlanning = mountSearchBatchPlanning( document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson, diff --git a/frontend/following.js b/frontend/following.js index f3f87c4..fbeb959 100644 --- a/frontend/following.js +++ b/frontend/following.js @@ -3,11 +3,33 @@ if (typeof module === 'object' && module.exports) { module.exports = exports.createFollowing; module.exports.attachFollowing = exports.attachFollowing; + module.exports.planningDisposition = exports.planningDisposition; } else { root.createFollowing = exports.createFollowing; root.attachFollowing = exports.attachFollowing; + root.followingPlanningDisposition = exports.planningDisposition; } })(typeof self !== 'undefined' ? self : this, function () { + function planningDisposition(detail, controls) { + const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' && + Boolean(detail.claimable || detail.assigned_to_me); + const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false}; + if (!controls && globalThis.document) { + const query = selector => globalThis.document.querySelector(selector); + controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'), + today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'), + start:query('#start-search-result'),watch:query('#watch-search-result')}; + } + controls?.root?.classList.toggle('following-disposition-mode', active); + if (active && controls) { + controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true; + controls.today.textContent = result.today; + controls.later.textContent = result.later; + controls.week.textContent = result.week; + } + return result; + } + function createFollowing(options) { let generation = 0; let snapshot = {revision:0, items:[]}; @@ -248,7 +270,8 @@ keepForLater:feature.keepForLater, retire:feature.retire, preview(state) { - keepButton.hidden = state?.item?.following !== true; + const disposition = planningDisposition(state?.detail || state?.item); + keepButton.hidden = state?.item?.following !== true || disposition.active; keepButton.disabled = false; if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = ''; }, @@ -265,5 +288,5 @@ }; } - return {createFollowing, attachFollowing}; + return {createFollowing, attachFollowing, planningDisposition}; }); diff --git a/frontend/search-week-plan.js b/frontend/search-week-plan.js index f1f8bb7..df63b5e 100644 --- a/frontend/search-week-plan.js +++ b/frontend/search-week-plan.js @@ -42,7 +42,7 @@ function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5} await week.flush(); return {status:'planned',date:options.date,estimate,item}; } catch(_error) { - return {status:'planned-pending',date:options.date,estimate}; + return {status:'planned-pending',date:options.date,estimate,item}; } }; running=perform().finally(()=>{running=null;}); @@ -99,8 +99,8 @@ function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()= else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.'; else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.'; else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.'; - else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);} - else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);} + else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');await onPlanned(outcome.item||detail,true,outcome);close(true);} + else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);close(true);} else status.textContent='Could not plan this issue. Retry.'; button.disabled=false; } diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 49db033..73de126 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -487,6 +487,40 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow(): assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard assert "followingQueue.returnToFollowing()" in dashboard assert "'Back to Following'" in dashboard + + +def test_following_open_issue_exposes_only_action_and_continue_planning_dispositions(): + script = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const disposition = createFollowing.planningDisposition; +process.stdout.write(JSON.stringify({{ + active:disposition({{following:true,kind:'issue',state:'open',claimable:true}}), + generic:disposition({{following:false,kind:'issue',state:'open',claimable:true}}), + pull:disposition({{following:true,kind:'pull',state:'open',assigned_to_me:true}}), +}})); +""" + result = json.loads(subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ).stdout) + + assert result == { + "active": { + "active": True, + "today": "Add to Today & next", + "later": "Later & next", + "week": "Week Ahead & next", + }, + "generic": {"active": False}, + "pull": {"active": False}, + } + + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + css = (ROOT / "frontend" / "dashboard.css").read_text() + service_worker = (ROOT / "frontend" / "service-worker.js").read_text() + assert "followingQueue.preview(state);" in dashboard + assert "following-disposition-mode" in MODULE.read_text() + assert "controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true" in MODULE.read_text() + assert ".following-disposition-mode" in css assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard assert "stackchain-dashboard-shell-v137" in service_worker diff --git a/tests/test_search_week_plan.py b/tests/test_search_week_plan.py index 9945d5b..6c3747a 100644 --- a/tests/test_search_week_plan.py +++ b/tests/test_search_week_plan.py @@ -98,7 +98,12 @@ const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true} console.log(JSON.stringify(outcome)); """) - assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45} + assert result == { + "status": "planned-pending", + "date": "2026-08-22", + "estimate": 45, + "item": {"kind": "issue", "state": "open", "assigned_to_me": True}, + } def test_search_week_plan_stages_canonical_details_before_flushing(): @@ -122,3 +127,37 @@ console.log(JSON.stringify({outcome,calls})); ["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42], ["flush"], ] + + +def test_search_week_plan_waits_for_success_callback_before_closing(): + result = run_planner(""" +const events=[]; +const nodes={}; +function node(extra={}) { return Object.assign({ + hidden:true,disabled:false,textContent:'',value:'',dataset:{}, + addEventListener(){},focus(){events.push('focus');}, + setAttribute(){},querySelectorAll(){return[];} +},extra); } +nodes['#search-week-plan-sheet']=node(); +nodes['#search-week-plan-status']=node(); +nodes['#confirm-search-week-plan']=node(); +nodes['#search-week-plan-copy']=node(); +nodes['#search-week-plan-days']=node({dataset:{selected:'2026-08-22'}}); +nodes['#search-week-plan-estimate']=node({value:'45'}); +nodes['#plan-search-result']=node(); nodes['#cancel-search-week-plan']=node(); +const document={querySelector:selector=>nodes[selector],addEventListener(){}}; +const window={history:{state:{},pushState(value){this.state=value;},back(){events.push('back');nodes['#search-week-plan-sheet'].hidden=true;}},location:{href:'/'},addEventListener(){}}; +const planner={pending:()=>false,preview:async()=>({days:[{date:'2026-08-22',label:'Sat',planned_minutes:0,capacity_minutes:90,ids:[]}]}), + plan:async()=>({status:'planned',date:'2026-08-22',estimate:45,item:{number:42}})}; +const ui=createSearchWeekPlan.UI({planner,document,window,getDetail:()=>({title:'Issue',number:42}), + escapeHtml:value=>value,escapeAttribute:value=>value,announce:message=>events.push(message), + onPlanned:async()=>{events.push('callback-start');await Promise.resolve();events.push('callback-end');}}); +await ui.open(node()); +await ui.confirm(); +events.push(nodes['#search-week-plan-sheet'].hidden?'closed':'open'); +console.log(JSON.stringify({events})); +""") + + assert result["events"][-4:] == [ + "callback-start", "callback-end", "back", "closed" + ]