Compare commits
No commits in common. "af9699e362bb6ef29b7cdeefcc8176ec695e59bf" and "0a8f33dc836486513a603bce752f765b989d4f6d" have entirely different histories.
af9699e362
...
0a8f33dc83
|
|
@ -63,12 +63,6 @@ the private content. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
|
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
|
||||||
let operators assign an eligible issue directly into Later at an exact local return time without filling
|
|
||||||
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
|
|
||||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
|
||||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
|
||||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
|
||||||
Search previews also
|
|
||||||
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
||||||
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
||||||
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
|
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
|
||||||
|
|
|
||||||
|
|
@ -2231,7 +2231,6 @@
|
||||||
});
|
});
|
||||||
const laterPickerElement = qs('#later-picker');
|
const laterPickerElement = qs('#later-picker');
|
||||||
const laterPickerInput = qs('#later-picker-time');
|
const laterPickerInput = qs('#later-picker-time');
|
||||||
let searchDefer = null;
|
|
||||||
const laterPicker = createLaterPicker({
|
const laterPicker = createLaterPicker({
|
||||||
history: window.history,
|
history: window.history,
|
||||||
eventTarget: window,
|
eventTarget: window,
|
||||||
|
|
@ -2250,13 +2249,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConfirm: (item, until, context) => {
|
onConfirm: (item, until, context) => {
|
||||||
if (context === 'search') {
|
|
||||||
return () => searchDefer.run(item, until).then(outcome => {
|
|
||||||
if (outcome === 'deferred') closeSearchPreview();
|
|
||||||
}).catch(error => {
|
|
||||||
qs('#search-preview-status').textContent = error.message + ' Retry.';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (context === 'detail') {
|
if (context === 'detail') {
|
||||||
const inSession = workSession.active();
|
const inSession = workSession.active();
|
||||||
const deferred = detailDefer.deferUntil(item, until, {
|
const deferred = detailDefer.deferUntil(item, until, {
|
||||||
|
|
@ -4338,7 +4330,6 @@
|
||||||
const sheet = qs('#search-preview');
|
const sheet = qs('#search-preview');
|
||||||
const status = qs('#search-preview-status');
|
const status = qs('#search-preview-status');
|
||||||
const claimButton = qs('#claim-search-result');
|
const claimButton = qs('#claim-search-result');
|
||||||
const deferButton = qs('#defer-search-result');
|
|
||||||
const queueButton = qs('#queue-search-result');
|
const queueButton = qs('#queue-search-result');
|
||||||
const startButton = qs('#start-search-result');
|
const startButton = qs('#start-search-result');
|
||||||
const shareButton = qs('#share-search-result');
|
const shareButton = qs('#share-search-result');
|
||||||
|
|
@ -4352,8 +4343,6 @@
|
||||||
sheet.classList.add('open');
|
sheet.classList.add('open');
|
||||||
claimButton.hidden = true;
|
claimButton.hidden = true;
|
||||||
claimButton.disabled = false;
|
claimButton.disabled = false;
|
||||||
deferButton.hidden = true;
|
|
||||||
deferButton.disabled = false;
|
|
||||||
queueButton.hidden = true;
|
queueButton.hidden = true;
|
||||||
startButton.hidden = true;
|
startButton.hidden = true;
|
||||||
startButton.disabled = false;
|
startButton.disabled = false;
|
||||||
|
|
@ -4389,10 +4378,6 @@
|
||||||
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
|
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
|
||||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||||
claimButton.disabled = state.status === 'claiming';
|
claimButton.disabled = state.status === 'claiming';
|
||||||
deferButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
|
||||||
(detail.claimable || detail.assigned_to_me));
|
|
||||||
deferButton.textContent = detail.assigned_to_me ? 'Defer' : 'Assign & defer';
|
|
||||||
deferButton.disabled = state.status === 'claiming' || searchDefer?.pending();
|
|
||||||
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||||||
(detail.claimable || detail.assigned_to_me));
|
(detail.claimable || detail.assigned_to_me));
|
||||||
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
|
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
|
||||||
|
|
@ -4424,18 +4409,6 @@
|
||||||
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
||||||
onState: renderSearchPreview,
|
onState: renderSearchPreview,
|
||||||
});
|
});
|
||||||
searchDefer = createSearchDefer({
|
|
||||||
claim: detail => searchPreview.claim(detail),
|
|
||||||
accept: confirmed => acceptClaimedIssue(confirmed),
|
|
||||||
defer: (item, until) => laterWork.defer(item, until),
|
|
||||||
refresh: refreshMyWorkView,
|
|
||||||
announce: message => {
|
|
||||||
qs('#search-preview-status').textContent = message;
|
|
||||||
qs('#cmd-search-action-status').textContent = message;
|
|
||||||
qs('#my-work-action-status').textContent = message;
|
|
||||||
},
|
|
||||||
formatTime: fmt,
|
|
||||||
});
|
|
||||||
function canonicalSearchPreviewUrl() {
|
function canonicalSearchPreviewUrl() {
|
||||||
const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
|
const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|
@ -4763,12 +4736,6 @@
|
||||||
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#defer-search-result').addEventListener('click', event => {
|
|
||||||
const detail = searchPreviewDetail;
|
|
||||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
|
||||||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
|
|
||||||
laterPicker.open(detail, event.currentTarget, 'search');
|
|
||||||
});
|
|
||||||
qs('#queue-search-result').addEventListener('click', async () => {
|
qs('#queue-search-result').addEventListener('click', async () => {
|
||||||
const detail = searchPreviewDetail;
|
const detail = searchPreviewDetail;
|
||||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||||||
|
|
|
||||||
|
|
@ -468,7 +468,6 @@
|
||||||
<div class="search-preview-actions">
|
<div class="search-preview-actions">
|
||||||
<div class="search-preview-primary-actions">
|
<div class="search-preview-primary-actions">
|
||||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||||
<button id="defer-search-result" type="button" hidden>Assign & defer</button>
|
|
||||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||||
|
|
||||||
|
|
@ -1053,7 +1052,6 @@
|
||||||
<script src="static/markdown.js"></script>
|
<script src="static/markdown.js"></script>
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
<script src="static/search-defer.js"></script>
|
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/drafts.js"></script>
|
<script src="static/drafts.js"></script>
|
||||||
<script src="static/unfiled-captures.js"></script>
|
<script src="static/unfiled-captures.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
|
|
||||||
let request = null;
|
|
||||||
|
|
||||||
function run(detail, until) {
|
|
||||||
if (request) return request;
|
|
||||||
const wasClaimable = Boolean(detail?.claimable);
|
|
||||||
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
|
|
||||||
.then(confirmed => {
|
|
||||||
const item = accept(confirmed);
|
|
||||||
const outcome = defer(item, until);
|
|
||||||
refresh();
|
|
||||||
if (outcome === 'deferred') {
|
|
||||||
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
|
|
||||||
} else {
|
|
||||||
announce(wasClaimable
|
|
||||||
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
|
|
||||||
: 'Could not save Later on this device. Retry scheduling.');
|
|
||||||
}
|
|
||||||
return outcome;
|
|
||||||
})
|
|
||||||
.finally(() => { request = null; });
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { run, pending:() => Boolean(request) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
|
|
||||||
|
|
@ -18,7 +18,6 @@ const SHELL = [
|
||||||
BASE + 'static/markdown.js',
|
BASE + 'static/markdown.js',
|
||||||
BASE + 'static/commands.js',
|
BASE + 'static/commands.js',
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
BASE + 'static/search-defer.js',
|
|
||||||
BASE + 'static/widgets.js',
|
BASE + 'static/widgets.js',
|
||||||
BASE + 'static/drafts.js',
|
BASE + 'static/drafts.js',
|
||||||
BASE + 'static/unfiled-captures.js',
|
BASE + 'static/unfiled-captures.js',
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.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-recap.js",
|
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.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-recap.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/assign-and-start.js", "static/queue-today.js",
|
"static/assign-and-start.js", "static/queue-today.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||||
COMMANDS = FRONTEND / "commands.js"
|
COMMANDS = FRONTEND / "commands.js"
|
||||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||||
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||||
SEARCH_DEFER = FRONTEND / "search-defer.js"
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptSourceParser(HTMLParser):
|
class ScriptSourceParser(HTMLParser):
|
||||||
|
|
@ -553,100 +552,6 @@ def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_
|
||||||
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
|
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
|
||||||
|
|
||||||
|
|
||||||
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
|
|
||||||
script = f"""
|
|
||||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
|
||||||
(async () => {{
|
|
||||||
const calls = [];
|
|
||||||
let releaseClaim;
|
|
||||||
const flow = createSearchDefer({{
|
|
||||||
claim: detail => {{
|
|
||||||
calls.push(['claim', detail.number]);
|
|
||||||
return new Promise(resolve => releaseClaim = resolve);
|
|
||||||
}},
|
|
||||||
accept: confirmed => {{
|
|
||||||
calls.push(['accept', confirmed.key]);
|
|
||||||
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
|
|
||||||
}},
|
|
||||||
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
|
|
||||||
refresh: () => calls.push(['refresh']),
|
|
||||||
announce: message => calls.push(['announce', message]),
|
|
||||||
formatTime: value => value.toISOString(),
|
|
||||||
}});
|
|
||||||
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
|
|
||||||
const until = new Date('2026-08-15T09:00:00.000Z');
|
|
||||||
const first = flow.run(detail, until);
|
|
||||||
const second = flow.run(detail, until);
|
|
||||||
if (first !== second) throw new Error('confirmation was not single-flight');
|
|
||||||
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
|
|
||||||
const outcome = await first;
|
|
||||||
process.stdout.write(JSON.stringify({{outcome, calls}}));
|
|
||||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
||||||
"""
|
|
||||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
assert json.loads(result.stdout) == {
|
|
||||||
"outcome": "deferred",
|
|
||||||
"calls": [
|
|
||||||
["claim", 42],
|
|
||||||
["accept", "stackchain/api#42"],
|
|
||||||
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
|
|
||||||
["refresh"],
|
|
||||||
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
|
|
||||||
html = dashboard_bundle_text()
|
|
||||||
css = (FRONTEND / "dashboard.css").read_text()
|
|
||||||
|
|
||||||
assert 'id="defer-search-result"' in html
|
|
||||||
assert "const deferButton = qs('#defer-search-result')" in html
|
|
||||||
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
|
|
||||||
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
|
|
||||||
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
|
|
||||||
"qs('#queue-search-result')", 1
|
|
||||||
)[0]
|
|
||||||
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
|
|
||||||
assert "searchPreview.claim" not in handler
|
|
||||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
|
||||||
assert "@media (max-width:420px)" in css
|
|
||||||
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
|
|
||||||
script = f"""
|
|
||||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
|
||||||
(async () => {{
|
|
||||||
const messages = [];
|
|
||||||
let refreshes = 0;
|
|
||||||
const flow = createSearchDefer({{
|
|
||||||
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
|
|
||||||
accept: issue => issue,
|
|
||||||
defer: () => 'unavailable',
|
|
||||||
refresh: () => refreshes += 1,
|
|
||||||
announce: message => messages.push(message),
|
|
||||||
formatTime: value => value.toISOString(),
|
|
||||||
}});
|
|
||||||
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
|
|
||||||
new Date('2026-08-15T09:00:00.000Z'));
|
|
||||||
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
|
|
||||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
||||||
"""
|
|
||||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
assert json.loads(result.stdout) == {
|
|
||||||
"outcome": "unavailable",
|
|
||||||
"refreshes": 1,
|
|
||||||
"messages": [
|
|
||||||
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||||
html = dashboard_bundle_text()
|
html = dashboard_bundle_text()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -788,7 +788,6 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/markdown.js",
|
"/dashboard/static/markdown.js",
|
||||||
"/dashboard/static/commands.js",
|
"/dashboard/static/commands.js",
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
"/dashboard/static/search-defer.js",
|
|
||||||
"/dashboard/static/widgets.js",
|
"/dashboard/static/widgets.js",
|
||||||
"/dashboard/static/drafts.js",
|
"/dashboard/static/drafts.js",
|
||||||
"/dashboard/static/unfiled-captures.js",
|
"/dashboard/static/unfiled-captures.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user