Create and start captured issues #388

Merged
timmy merged 1 commits from timmy/387-create-and-start into main 2026-08-09 09:09:39 +00:00
14 changed files with 203 additions and 19 deletions

View File

@ -0,0 +1,36 @@
function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) {
const completed = new Set();
function available() {
return todayWork.read().length < todayWork.limit;
}
function complete(issue) {
const identity = todayWork.identity(issue);
if (!identity || completed.has(identity)) return 'exists';
const added = todayWork.add(issue);
if (added === 'full') {
announce('Created, but Today changed—remove an item, then add this issue.');
return 'full';
}
if (added !== 'added' && added !== 'exists') {
announce('Created, but Today could not be saved on this device.');
return 'unavailable';
}
completed.add(identity);
if (added === 'added' && !todaySync.enqueue('add', identity)) {
announce('Created and saved to Today on this device, but account sync is unavailable.');
return 'sync-unavailable';
}
refresh();
todaySync.flush();
warm();
start(issue);
announce('Created, added to Today, and ready to work.');
return 'started';
}
return { available, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;

View File

@ -110,7 +110,9 @@ textarea { resize: vertical; min-height: 120px; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.create-issue-actions button { min-height:44px; max-width:100%; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }

View File

@ -118,6 +118,7 @@
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null;
@ -688,6 +689,18 @@
},
});
const createAndStart = createCreateAndStart({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
start: item => {
qs('[data-work-filter="today"]').click();
workSession.start(item);
},
announce: message => { qs('#my-work-action-status').textContent = message; },
});
let planTodayTrigger = null;
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
@ -1948,6 +1961,7 @@
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
qs('#create-issue-title').focus();
@ -1967,7 +1981,7 @@
qs('#new-issue').focus();
}
function applyOutboxResult(result, openCreated = false) {
function applyOutboxResult(result, openCreated = false, startCreated = false) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
(result.confirmed || []).forEach(confirmed => {
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
@ -1981,7 +1995,10 @@
const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
if (startCreated && created) {
const outcome = createAndStart.complete(created);
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
} else if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
} else {
@ -2732,6 +2749,7 @@
});
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
@ -2744,9 +2762,17 @@
qs('#create-issue-anyway').focus();
return;
}
if (createAndStartRequested && !createAndStart.available()) {
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
qs('#create-and-start-issue').focus();
return;
}
const button = qs('#submit-new-issue');
const startButton = qs('#create-and-start-issue');
button.disabled = true;
qs('#create-issue-status').textContent = 'Saving for background delivery…';
startButton.disabled = true;
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) :
await issueOutbox.enqueueDurably(captureDraft);
@ -2756,6 +2782,7 @@
refreshMyWorkView();
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
button.disabled = false;
startButton.disabled = !createAndStart.available();
return;
}
editingOutboxId = null;
@ -2764,10 +2791,14 @@
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Queued for sync.';
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true);
if (navigator.onLine) applyOutboxResult(
await issueOutbox.retry(queued.id, activeFlushLogin), true, createAndStartRequested
);
createAndStartRequested = false;
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
button.disabled = false;
startButton.disabled = !createAndStart.available();
qs('#create-issue-title').focus();
}
});

View File

@ -389,6 +389,7 @@
<div class="create-issue-actions">
<button id="save-unfiled-issue" type="button">Save for filing</button>
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
<button id="create-and-start-issue" type="submit">Create &amp; start</button>
<div id="create-issue-status" class="small" aria-live="assertive"></div>
</div>
</form>
@ -593,6 +594,7 @@
<script src="static/conversation.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/create-and-start.js"></script>
<script src="static/pull-sheet.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/work-route.js"></script>

View File

@ -502,11 +502,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
return {
active: () => running,
start() {
start(item = null) {
const items = queue();
if (!items.length) return finish();
running = true;
return openAt(items, 0);
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
reconcile() {
if (!running) return false;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v57';
const CACHE = 'stackchain-dashboard-shell-v58';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@ -36,6 +36,7 @@ const SHELL = [
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',

View File

@ -0,0 +1,87 @@
import json
import subprocess
from pathlib import Path
CREATE_AND_START = Path(__file__).parents[1] / "frontend" / "create-and-start.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
script = f"""
const fs = require('fs');
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
const calls = [];
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
const flow = createCreateAndStart({{
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const result = flow.complete(issue);
flow.complete(issue);
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
}}
"""
assert run_node(script) == {
"available": True,
"result": "started",
"calls": [
"add",
"add:issue:stackchain/dashboard:387:",
"refresh",
"flush",
"warm",
"start:387",
"announce:Created, added to Today, and ready to work.",
],
}
def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_shell():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
assert '<button id="create-and-start-issue" type="submit">Create &amp; start</button>' in html
assert '<script src="static/create-and-start.js"></script>' in html
assert "const createAndStart = createCreateAndStart({" in dashboard
assert "event.submitter?.id === 'create-and-start-issue'" in dashboard
assert "createAndStart.complete(created)" in dashboard
assert ".create-issue-actions" in css and "grid-template-columns" in css
assert "BASE + 'static/create-and-start.js'" in worker
def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued():
script = f"""
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
const calls = [];
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:388}};
const flow = createCreateAndStart({{
todayWork: {{limit:5, read:()=>[], identity:()=> 'issue:stackchain/dashboard:388:', add:()=> 'added'}},
todaySync: {{enqueue:()=>false, flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:()=>calls.push('start'),
announce:message=>calls.push(message),
}});
process.stdout.write(JSON.stringify({{result:flow.complete(issue), calls}}));
"""
assert run_node(script) == {
"result": "sync-unavailable",
"calls": ["Created and saved to Today on this device, but account sync is unavailable."],
}

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v57" in worker
assert "stackchain-dashboard-shell-v58" in worker

View File

@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v57" in worker
assert "stackchain-dashboard-shell-v58" in worker

View File

@ -1376,6 +1376,29 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
assert opener in html
def test_work_session_can_start_at_a_newly_created_item():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'stackchain/dashboard', number}}));
const opened = [];
const session = buildMyWork.createWorkSession({{
getItems: () => items,
getFilter: () => 'all',
getMilestone: () => '',
onOpen: item => opened.push(item.number),
onProgress: () => {{}},
onFinish: () => {{}},
}});
const started = session.start(items[2]);
process.stdout.write(JSON.stringify({{started, opened}}));
"""
output = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
assert json.loads(output) == {"started": True, "opened": [3]}
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
items = [
{"kind": "issue", "is_review": False},

View File

@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/plan-today.js'" in source

View File

@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -132,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -352,6 +352,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "stackchain-dashboard-shell-v58" in source
assert "BASE + 'static/today-sync.js'" in source