Recover progressive mobile captures after reload (#1410)
All checks were successful
CI / lint (push) Successful in 4m18s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 8m13s
CI / release-candidate (push) Successful in 11s

Closes #1409
This commit is contained in:
rockachopa 2026-08-25 21:41:01 +00:00
commit e7e8c24d05
3 changed files with 200 additions and 10 deletions

View File

@ -6430,6 +6430,7 @@
if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) {
issueCapture.saveDraft({repository:'', labelIds:[],
title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body});
progressiveCaptureHandoff.complete?.();
await openCreateIssueSheet(false);
}
qs('#create-issue-repository').addEventListener('change', event => {

View File

@ -31,6 +31,7 @@ function createProgressiveCapture({
let saving = null;
let saved = false;
let handedOff = false;
const checkpointKey = 'stackchain.progressive-capture.v1';
function listen(element, name, callback) {
element?.addEventListener?.(name, callback);
@ -41,8 +42,58 @@ function createProgressiveCapture({
root?.classList.remove('open');
}
function readCheckpoints() {
try {
const record = JSON.parse(storage?.getItem(checkpointKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && typeof item.body === 'string');
} catch (_error) { return []; }
}
function readCheckpoint() {
const login = String(getLogin() || '').trim();
if (!login) return null;
return readCheckpoints().find(item => item.ownerLogin === login) || null;
}
function checkpoint() {
const ownerLogin = String(getLogin() || '').trim();
if (!ownerLogin) return false;
try {
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
items.push({
ownerLogin,
title:String(title?.value || '').slice(0, 255),
body:String(body?.value || '').slice(0, 10000),
});
storage?.setItem(checkpointKey, JSON.stringify({
version:1, items,
}));
return true;
} catch (_error) { return false; }
}
function clearCheckpoint(expected) {
const current = readCheckpoint();
if (!current || current.title !== expected.title || current.body !== expected.body) return false;
try {
const ownerLogin = String(getLogin() || '').trim();
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
if (items.length) storage?.setItem(checkpointKey, JSON.stringify({version:1, items}));
else storage?.removeItem(checkpointKey);
return true;
} catch (_error) { return false; }
}
function open(event) {
saved = false;
const recovered = readCheckpoint();
if (recovered) {
if (title) title.value = recovered.title;
if (body) body.value = recovered.body;
if (status) status.textContent = 'Unfinished capture restored from this phone.';
}
if (heading) heading.textContent = 'Capture work';
root?.classList.add('open');
title?.focus?.();
@ -51,10 +102,10 @@ function createProgressiveCapture({
async function saveDraft() {
if (saved || saving) return saving;
save.disabled = true;
saving = Promise.resolve().then(() => captures.save({
title:title?.value || '', body:body?.value || '',
})).then(() => {
const draft = {title:title?.value || '', body:body?.value || ''};
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
saved = true;
clearCheckpoint(draft);
if (title) title.value = '';
if (body) body.value = '';
close();
@ -94,6 +145,8 @@ function createProgressiveCapture({
listen(save, 'click', saveDraft);
listen(file, 'click', fileNow);
listen(cancel, 'click', close);
listen(title, 'input', checkpoint);
listen(body, 'input', checkpoint);
return true;
}
@ -104,6 +157,7 @@ function createProgressiveCapture({
open:Boolean(root?.classList.contains('open')),
title:title?.value || '', body:body?.value || '',
};
state.complete = () => clearCheckpoint(state);
root?.classList.remove('progressive-capture');
file.disabled = false;
listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback));

View File

@ -28,8 +28,12 @@ const button = {dataset:{mobileTask:'new'}, addEventListener(name, callback){lis
const save = {disabled:false, addEventListener(name, callback){listeners.set('save:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('save:'+name)===callback) listeners.delete('save:'+name);}};
const file = {disabled:false, addEventListener(){}, removeEventListener(){}};
const cancel = {addEventListener(){}, removeEventListener(){}};
const title = {value:'', focused:0, focus(){this.focused++;}};
const body = {value:''};
const field = name => ({value:'', focused:0, focus(){this.focused++;},
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);},
});
const title = field('title');
const body = field('body');
const heading = {textContent:''};
const status = {textContent:''};
const classes = new Set();
@ -43,15 +47,16 @@ const document = {
querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);},
};
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const flow = createProgressiveCapture({document, storage, getLogin:()=> 'timmy', createCaptures:createUnfiledCaptures, createId:()=> 'draft-1', now:()=> 42});
flow.start();
listeners.get('new:click')({currentTarget:button});
title.value='Production outage'; body.value='Investigate mobile reports';
title.value='Production outage'; listeners.get('title:input')();
body.value='Investigate mobile reports'; listeners.get('body:input')();
await listeners.get('save:click')();
await listeners.get('save:click')();
const stored=JSON.parse(values.get('stackchain.unfiled-issues.v1'));
console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()]}));
console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()],checkpoint:values.get('stackchain.progressive-capture.v1')||null}));
""")
assert result == {
@ -66,7 +71,136 @@ console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,headi
"body": "Investigate mobile reports",
"savedAt": 42,
}],
"listeners": ["new:click", "save:click"],
"listeners": ["new:click", "save:click", "title:input", "body:input"],
"checkpoint": None,
}
def test_failed_progressive_save_keeps_rendered_fields_and_checkpoint_for_retry():
result = run_capture(r"""
const listeners={};
const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){this.focused=true;}});
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
const title=control('title'); const body=control('body'); const status={textContent:''}; const classes=new Set();
const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':status};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:()=>({save(){throw new Error('Phone storage is full.');}})});
flow.start(); listeners['new:click']();
title.value='Keep title'; listeners['title:input'](); body.value='Keep note'; listeners['body:input']();
const saved=await listeners['save:click']();
console.log(JSON.stringify({saved,open:classes.has('open'),title:title.value,body:body.value,status:status.textContent,
checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1'))}));
""")
assert result == {
"saved": False,
"open": True,
"title": "Keep title",
"body": "Keep note",
"status": "Phone storage is full.",
"checkpoint": {
"version": 1,
"items": [{
"ownerLogin": "timmy",
"title": "Keep title",
"body": "Keep note",
}],
},
}
def test_progressive_capture_restores_latest_checkpoint_only_for_its_account():
result = run_capture(r"""
const values = new Map();
const storage = {
getItem:key=>values.get(key)||null,
setItem:(key,value)=>values.set(key,value),
removeItem:key=>values.delete(key),
};
function mount(login) {
const listeners = new Map();
const control = name => ({disabled:false,value:'',
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
removeEventListener(){},focus(){},
});
const button=control('new'); const save=control('save'); const file=control('file');
const cancel=control('cancel'); const title=control('title'); const body=control('body');
const status={textContent:''}; const classes=new Set();
const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':status};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
const flow=createProgressiveCapture({document,storage,getLogin:()=>login,createCaptures:createUnfiledCaptures});
flow.start();
return {flow,listeners,title,body,status,button};
}
const first=mount('timmy'); first.listeners.get('new:click')();
first.title.value='Production outage'; first.listeners.get('title:input')?.();
first.body.value='Latest phone note'; first.listeners.get('body:input')?.();
const restored=mount('timmy'); restored.listeners.get('new:click')();
const other=mount('alexander'); other.listeners.get('new:click')();
other.title.value='Alexander note'; other.listeners.get('title:input')?.();
const timmyAgain=mount('timmy'); timmyAgain.listeners.get('new:click')();
console.log(JSON.stringify({
restored:{title:restored.title.value,body:restored.body.value,status:restored.status.textContent},
other:{title:other.title.value,body:other.body.value,status:other.status.textContent},
timmyAgain:{title:timmyAgain.title.value,body:timmyAgain.body.value},
checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1') || 'null'),
}));
""")
assert result == {
"restored": {
"title": "Production outage",
"body": "Latest phone note",
"status": "Unfinished capture restored from this phone.",
},
"other": {"title": "Alexander note", "body": "", "status": ""},
"timmyAgain": {"title": "Production outage", "body": "Latest phone note"},
"checkpoint": {
"version": 1,
"items": [
{"ownerLogin": "timmy", "title": "Production outage", "body": "Latest phone note"},
{"ownerLogin": "alexander", "title": "Alexander note", "body": ""},
],
},
}
def test_hydration_handoff_clears_checkpoint_only_after_explicit_completion():
result = run_capture(r"""
const listeners={};
const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){}});
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
const title=control('title'); const body=control('body'); const root={classList:{add(){},remove(){},contains:()=>true}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':{textContent:''}};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:createUnfiledCaptures});
flow.start(); title.value='Hand off'; listeners['title:input'](); body.value='Preserve me'; listeners['body:input']();
const handoff=flow.handoff();
const before=JSON.parse(values.get('stackchain.progressive-capture.v1'));
if (typeof handoff.complete === 'function') handoff.complete();
console.log(JSON.stringify({before,after:values.get('stackchain.progressive-capture.v1')||null}));
""")
assert result == {
"before": {
"version": 1,
"items": [{
"ownerLogin": "timmy",
"title": "Hand off",
"body": "Preserve me",
}],
},
"after": None,
}
@ -109,7 +243,7 @@ console.log(JSON.stringify({filed,requests,open:classes.has('open'),status:statu
"focused": 2,
"title": "Keep this",
"body": "Do not lose this note",
"before": ["cancel:click", "file:click", "new:click", "save:click"],
"before": ["cancel:click", "file:click", "new:click", "save:click", "title:input"],
"after": [],
"handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"},
"second": None,
@ -122,6 +256,7 @@ def test_hydrated_dashboard_adopts_open_progressive_capture_without_losing_field
assert "const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();" in source
assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source
assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source
assert "progressiveCaptureHandoff.complete?.();" in source
assert "await openCreateIssueSheet(false);" in source