Merge pull request 'Assign and start work from global search' (#414) from timmy/413-search-assign-and-start into main
All checks were successful
CI / lint (push) Successful in 45s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
rockachopa 2026-08-09 17:08:02 +00:00
commit 4b8d0c4504
6 changed files with 87 additions and 5 deletions

View File

@ -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';
})

View File

@ -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; }

View File

@ -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);

View File

@ -223,7 +223,10 @@
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<div class="search-preview-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>

View File

@ -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()

View File

@ -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))});