From 6e3b6804c13057e9b745cfe11b96e8e07e400101 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 17:06:16 +0000 Subject: [PATCH] feat: assign and start work from search (#413) --- frontend/assign-and-start.js | 11 ++++++---- frontend/dashboard.css | 1 + frontend/dashboard.js | 37 ++++++++++++++++++++++++++++++++++ frontend/index.html | 5 ++++- tests/test_assign_and_start.py | 26 ++++++++++++++++++++++++ tests/test_command_palette.py | 12 +++++++++++ 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/frontend/assign-and-start.js b/frontend/assign-and-start.js index 6c02cfc..b220e95 100644 --- a/frontend/assign-and-start.js +++ b/frontend/assign-and-start.js @@ -1,21 +1,24 @@ function createAssignAndStart({ available, claim, start, recover, announce }) { let request = null; - function run(item) { + function run(item, { alreadyOwned = false } = {}) { if (request) return request; if (!available()) { announce('Today is full—remove an item before assigning this issue.'); return Promise.resolve('full'); } request = Promise.resolve() - .then(() => claim(item)) + .then(() => alreadyOwned ? item : claim(item)) .then(confirmed => { const outcome = start(confirmed); if (outcome === 'started') { - announce('Assigned, added to Today, and ready to work.'); + announce(alreadyOwned ? 'Added to Today and ready to work.' : + 'Assigned, added to Today, and ready to work.'); return outcome; } - announce('Assigned to you, but Today could not start. The issue is open so you can recover.'); + announce(alreadyOwned ? + 'Today could not start. The issue is open so you can recover.' : + 'Assigned to you, but Today could not start. The issue is open so you can recover.'); recover(confirmed); return 'recovery'; }) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 89878ca..12013ea 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -274,6 +274,7 @@ textarea { resize: vertical; min-height: 120px; } .search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; } .search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } .search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } +.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; } .markdown-content > :first-child { margin-top:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 83dfe83..9d27128 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -2644,6 +2644,7 @@ const sheet = qs('#search-preview'); const status = qs('#search-preview-status'); const claimButton = qs('#claim-search-result'); + const startButton = qs('#start-search-result'); if (state.status === 'closed') { sheet.classList.remove('open'); return; @@ -2651,6 +2652,8 @@ sheet.classList.add('open'); claimButton.hidden = true; claimButton.disabled = false; + startButton.hidden = true; + startButton.disabled = false; if (state.status === 'loading') { searchPreviewDetail = null; qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number; @@ -2680,6 +2683,10 @@ claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')); claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; claimButton.disabled = state.status === 'claiming'; + startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' && + (detail.claimable || detail.assigned_to_me)); + startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'; + startButton.disabled = state.status === 'claiming'; status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' : (state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' : (detail.claimable ? 'This issue is open and unassigned.' : @@ -2694,6 +2701,26 @@ ), onState: renderSearchPreview, }); + const searchAssignAndStart = createAssignAndStart({ + available: createAndStart.available, + claim: detail => searchPreview.claim(detail), + start: confirmed => { + const claimed = acceptClaimedIssue(confirmed); + taskOverlayHistory.leave(); + refreshMyWorkView(); + return createAndStart.complete(claimed); + }, + recover: confirmed => { + const claimed = acceptClaimedIssue(confirmed); + taskOverlayHistory.leave(); + refreshMyWorkView(); + openRoutedWork(claimed, qs('#open-palette')); + }, + announce: message => { + qs('#search-preview-status').textContent = message; + qs('#my-work-action-status').textContent = message; + }, + }); const mobileSearchViewport = createMobileSearchViewport({ palette: qs('#cmd-palette'), results: qs('#cmd-results'), @@ -2902,6 +2929,16 @@ qs('#search-preview-status').textContent = error.message + ' Retry assignment.'; } }); + qs('#start-search-result').addEventListener('click', async () => { + const detail = searchPreviewDetail; + if (!detail || detail.kind !== 'issue' || detail.state !== 'open' || + (!detail.claimable && !detail.assigned_to_me)) return; + try { + await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me }); + } catch (error) { + qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.'; + } + }); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); qs('#find-work').addEventListener('click', openFindWorkSheet); qs('#close-find-work').addEventListener('click', closeFindWorkSheet); diff --git a/frontend/index.html b/frontend/index.html index ada1710..0f6412c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -223,7 +223,10 @@
- +
+ + +
Open in Gitea
diff --git a/tests/test_assign_and_start.py b/tests/test_assign_and_start.py index e8f9a2b..2f39bc4 100644 --- a/tests/test_assign_and_start.py +++ b/tests/test_assign_and_start.py @@ -128,6 +128,32 @@ flow.run(issue).catch(error=>{{ } +def test_already_owned_search_result_starts_without_claiming(): + script = f""" +const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))}); +const calls=[]; +const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}}; +const flow=createAssignAndStart({{ + available:()=>true, + claim:()=>{{calls.push('claim');return Promise.resolve(issue);}}, + start:item=>{{calls.push('start:'+item.number);return 'started';}}, + recover:item=>calls.push('recover:'+item.number), + announce:message=>calls.push('announce:'+message), +}}); +flow.run(issue, {{alreadyOwned:true}}).then(result=> + process.stdout.write(JSON.stringify({{result,calls}})) +); +""" + + assert run_node(script) == { + "result": "started", + "calls": [ + "start:413", + "announce:Added to Today and ready to work.", + ], + } + + def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell(): dashboard = DASHBOARD.read_text() css = CSS.read_text() diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index ce0dafd..0f496eb 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -243,6 +243,18 @@ def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet(): assert "openPreviewIssueInMyWork" in html +def test_search_preview_offers_assign_and_start_for_eligible_issues(): + html = dashboard_bundle_text() + css = (FRONTEND / "dashboard.css").read_text() + + assert 'id="start-search-result"' in html + assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html + assert "const searchAssignAndStart = createAssignAndStart({" in html + assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html + assert ".search-preview-primary-actions" in css + assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css + + def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners(): script = f""" const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});