Review and reorder mobile evidence before filing #826

Merged
timmy merged 1 commits from timmy/825-review-reorder-mobile-evidence into main 2026-08-14 12:47:08 +00:00
10 changed files with 345 additions and 73 deletions

View File

@ -28,8 +28,10 @@ either stage. The mobile **New issue** capture-first stage accepts an ordered ev
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
bundle when the operator later chooses a repository. The source Draft remains available until its
evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
change the durable evidence order without changing the issue title or note. The source Draft remains
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire

View File

@ -622,6 +622,16 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-filing { display:grid; gap:12px; min-width:0; }
.create-issue-filing[hidden] { display:none; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
#create-issue-attachment-preview { grid-template-columns:1fr; }
#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; }
.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; }
.issue-evidence-tray[hidden] { display:none; }
.issue-evidence-thumbnail { position:relative; flex:0 0 64px; min-width:44px; min-height:64px; padding:3px; border:2px solid transparent; border-radius:10px; background:#10213a; }
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }

View File

@ -479,6 +479,9 @@
image: qs('#create-issue-attachment-image'),
meta: qs('#create-issue-attachment-meta'),
remove: qs('#remove-create-issue-attachment'),
tray: qs('#create-issue-evidence-tray'),
earlier: qs('#move-create-issue-attachment-earlier'),
later: qs('#move-create-issue-attachment-later'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',

View File

@ -762,7 +762,12 @@
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<button id="remove-create-issue-attachment" type="button">Remove latest</button>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
<button id="move-create-issue-attachment-later" type="button">Move later</button>
</div>
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
@ -1167,6 +1172,7 @@
<script src="static/search-batch-plan.js"></script>
<script src="static/conversation.js"></script>
<script src="static/comment-actions.js"></script>
<script src="static/issue-evidence-review.js"></script>
<script src="static/issue-attachment.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>

View File

@ -1,8 +1,9 @@
(function(root, factory) {
const api = factory();
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
const api = factory(review);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview) {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
@ -10,47 +11,7 @@
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function namedBlob(blob, name) {
if (typeof File === 'function') {
return new File([blob], name, { type: blob.type });
}
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
}
async function optimizeImage(file, environment = {}) {
if (file.size <= MAX_BYTES) return file;
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') {
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
}
let bitmap;
try {
bitmap = await decode(file);
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => {
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
});
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
const optimizeImage = issueEvidenceReview.optimizeImage;
function multipart(attachment) {
let blob = attachment?.blob;
@ -124,6 +85,16 @@
return state();
}
function move(fromIndex, toIndex) {
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 ||
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
const [item] = selected.splice(from, 1);
selected.splice(to, 0, item);
return state();
}
function restore(value) {
if (Array.isArray(value)) {
clear();
@ -198,7 +169,7 @@
return text ? text + '\n\n' + evidence : evidence;
}
return { select, restore, remove, clear, state, serialize, prepareComment };
return { select, restore, remove, move, clear, state, serialize, prepareComment };
}
function mount(options) {
@ -207,18 +178,22 @@
});
const clearSelection = controller.clear;
const restoreSelection = controller.restore;
const reviewEnabled = Boolean(options.tray && options.earlier && options.later);
let previewUrl = '';
let selectionSequence = 0;
const review = reviewEnabled ? issueEvidenceReview.create({ ...options, controller }) : null;
function setBusy(busy) {
options.input.disabled = Boolean(busy);
options.remove.disabled = Boolean(busy);
function setBusy(value) {
options.input.disabled = Boolean(value);
options.remove.disabled = Boolean(value);
review?.setBusy(value);
}
function clearPreview() {
selectionSequence += 1;
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
review?.clear();
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
@ -247,7 +222,7 @@
options.input.value = '';
return;
}
if (files.length === 1 && (!first || typeof first.then !== 'function')) {
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
showPreview(files[0], false);
return;
}
@ -269,10 +244,14 @@
if (sequence !== selectionSequence) return;
const values = Array.isArray(value) ? value : [value];
const latest = values[values.length - 1];
showPreview(latest.blob, optimized);
if (values.length > 1) {
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
options.status.textContent = values.length + ' screenshots ready to file in this order.';
if (reviewEnabled) {
review.render(values, values.length - 1, optimized);
} else {
showPreview(latest.blob, optimized);
if (values.length > 1) {
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
options.status.textContent = values.length + ' screenshots ready to file in this order.';
}
}
}).catch(error => {
if (sequence === selectionSequence) {
@ -288,25 +267,34 @@
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
if (count <= 1) {
clearPreview();
} else {
controller.remove(count - 1);
selectionSequence += 1;
controller.serialize().then(value => {
const values = Array.isArray(value) ? value : [value];
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
return Promise.resolve();
}
const removedIndex = reviewEnabled ? review.activeIndex() : count - 1;
controller.remove(removedIndex);
selectionSequence += 1;
review?.afterRemoval(removedIndex, count);
return controller.serialize().then(value => {
const values = Array.isArray(value) ? value : [value];
if (reviewEnabled) {
review.render(values, review.activeIndex());
} else {
const latest = values[values.length - 1];
showPreview(latest.blob, false);
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
});
}
options.status.textContent = options.removedMessage || (count > 1 ?
'Latest screenshot removed. Your text is unchanged.' :
'Screenshot removed. Your comment is unchanged.');
}
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
});
});
function restorePreview(value) {
clearPreview();
const restored = restoreSelection(value);
const values = Array.isArray(value) ? value : [value];
if (reviewEnabled) {
review.render(values, values.length - 1);
return restored;
}
const latest = values[values.length - 1];
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
'data:' + latest.contentType + ';base64,' + latest.data;

View File

@ -0,0 +1,153 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueEvidenceReview = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
function namedBlob(blob, name) {
if (typeof File === 'function') return new File([blob], name, { type: blob.type });
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
}
async function optimizeImage(file, environment = {}) {
if (file.size <= MAX_BYTES) return file;
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
let bitmap;
try {
bitmap = await decode(file);
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86));
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
function create(options) {
const controller = options.controller;
const documentRef = options.document || document;
let activeIndex = 0;
let busy = false;
let previewUrl = '';
let thumbnailUrls = [];
function revoke(url) {
if (url && !url.startsWith('data:')) options.revokeObjectURL(url);
}
function attachmentUrl(attachment) {
return attachment?.blob ? options.createObjectURL(attachment.blob) :
'data:' + attachment.contentType + ';base64,' + attachment.data;
}
function count() {
const value = controller.state();
return Array.isArray(value) ? value.length : (value ? 1 : 0);
}
function setBusy(value) {
busy = Boolean(value);
options.earlier.disabled = busy || activeIndex <= 0;
options.later.disabled = busy || activeIndex >= count() - 1;
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
}
function update(values, optimized = false) {
if (!values.length) return;
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
revoke(previewUrl);
previewUrl = attachmentUrl(values[activeIndex]);
options.image.src = previewUrl;
const current = values[activeIndex];
const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4));
options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' +
current.filename + ' · ' + Math.ceil(size / 1024) + ' KB';
options.preview.hidden = false;
Array.from(options.tray.children || []).forEach((button, index) =>
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
options.earlier.disabled = busy || activeIndex === 0;
options.later.disabled = busy || activeIndex === values.length - 1;
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
values.length + ' screenshots ready to file in this order.';
}
function render(value, index = activeIndex, optimized = false) {
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
activeIndex = Math.max(0, Math.min(index, values.length - 1));
thumbnailUrls.forEach(revoke);
thumbnailUrls = [];
options.tray.replaceChildren(...values.map((item, itemIndex) => {
const button = documentRef.createElement('button');
button.type = 'button';
button.className = 'issue-evidence-thumbnail';
button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename);
const thumbnail = documentRef.createElement('img');
thumbnail.alt = '';
thumbnail.src = attachmentUrl(item);
thumbnailUrls.push(thumbnail.src);
button.appendChild(thumbnail);
const position = documentRef.createElement('span');
position.textContent = String(itemIndex + 1);
button.appendChild(position);
button.addEventListener('click', () => { activeIndex = itemIndex; update(values); });
return button;
}));
options.tray.hidden = !values.length;
if (values.length) update(values, optimized);
}
function clear() {
revoke(previewUrl);
thumbnailUrls.forEach(revoke);
previewUrl = '';
thumbnailUrls = [];
activeIndex = 0;
options.tray.replaceChildren();
options.tray.hidden = true;
options.earlier.disabled = true;
options.later.disabled = true;
}
function afterRemoval(removedIndex, previousCount) {
activeIndex = Math.min(removedIndex, previousCount - 2);
}
function move(offset) {
const total = count();
const destination = activeIndex + offset;
if (destination < 0 || destination >= total) return Promise.resolve();
controller.move(activeIndex, destination);
activeIndex = destination;
return controller.serialize().then(value => {
render(value, activeIndex);
options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.';
});
}
options.earlier.addEventListener('click', () => move(-1));
options.later.addEventListener('click', () => move(1));
clear();
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
}
return { create, optimizeImage };
});

View File

@ -64,6 +64,7 @@ const SHELL = [
BASE + 'static/search-batch-plan.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-evidence-review.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',

View File

@ -35,7 +35,7 @@ FEATURE_SOURCES = {
"static/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js",
),
}
CACHE_DECLARATION = re.compile(

View File

@ -89,6 +89,33 @@ controller.select(image('replacement.png'));
assert len({key for _, key in output["calls"]}) == 5
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const uploaded=[];
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
maxFiles:5,
upload:async payload=>{{uploaded.push(payload.filename);return {{markdown:'!['+payload.filename+'](url)'}};}},
}});
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
controller.move(2, 0);
controller.move(0, 99);
controller.move(0, 1);
(async()=>{{
const serialized=await controller.serialize();
const comment=await controller.prepareComment({{repository:'o/r',number:825}},'Reviewed');
process.stdout.write(JSON.stringify({{names:serialized.map(x=>x.filename),uploaded,comment}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["names"] == ["one.png", "three.png", "two.png"]
assert output["uploaded"] == output["names"]
assert output["comment"].endswith(
"![one.png](url)\n\n![three.png](url)\n\n![two.png](url)"
)
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@ -273,6 +300,53 @@ process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textConte
def test_evidence_review_tray_selects_reorders_and_removes_any_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};this.dataset={{}};}}
addEventListener(type,fn){{this.listeners[type]=fn;}}
dispatch(type){{return this.listeners[type]({{target:this}});}}
appendChild(child){{this.children.push(child);return child;}}
replaceChildren(...children){{this.children=children;}}
setAttribute(name,value){{this.attributes[name]=String(value);}}
}}
const document={{createElement:tag=>new Element(tag)}};
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button');
input.multiple=true;
const urls=[];
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
removedMessage:'Screenshot removed. Your issue draft is unchanged.',
createObjectURL:blob=>{{const url='blob:'+blob.name;urls.push(url);return url;}},revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
input.files=['one.png','two.png','three.png'].map(file);
(async()=>{{
await input.dispatch('change');
await tray.children[1].dispatch('click');
await earlier.dispatch('click');
const reordered=(await controller.serialize()).map(item=>item.filename);
await remove.dispatch('click');
const remaining=(await controller.serialize()).map(item=>item.filename);
process.stdout.write(JSON.stringify({{reordered,remaining,preview:image.src,meta:meta.textContent,
selected:tray.children.map(button=>button.attributes['aria-pressed']),
labels:tray.children.map(button=>button.attributes['aria-label']),
earlierDisabled:earlier.disabled,laterDisabled:later.disabled,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["reordered"] == ["two.png", "one.png", "three.png"]
assert output["remaining"] == ["one.png", "three.png"]
assert output["preview"] == "blob:one.png"
assert output["meta"] == "Screenshot 1 of 2 · one.png · 1 KB"
assert output["selected"] == ["true", "false"]
assert output["labels"] == [
"Review screenshot 1 of 2: one.png", "Review screenshot 2 of 2: three.png"
]
assert output["earlierDisabled"] is True
assert output["laterDisabled"] is False
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
@ -317,11 +391,25 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'Up to 5' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert 'id="create-issue-evidence-tray"' in html
assert 'role="toolbar"' in html
assert 'aria-label="Evidence screenshots"' in html
assert 'id="move-create-issue-attachment-earlier"' in html
assert 'id="move-create-issue-attachment-later"' in html
assert "tray: qs('#create-issue-evidence-tray')" in source
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
assert "later: qs('#move-create-issue-attachment-later')" in source
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachments:evidence" in source
assert "hydrated.attachments || hydrated.attachment" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert ".issue-evidence-tray" in css
assert ".issue-evidence-review-actions" in css
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
assert "overflow-x:auto" in css
assert "min-width:44px" in css
assert "overflow-x:hidden" in css
@ -461,21 +549,38 @@ def test_pending_comment_admission_locks_attachment_replacement_and_remove_contr
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
constructor() {{ this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}}; }}
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
appendChild(child) {{ this.children.push(child); }}
replaceChildren(...children) {{ this.children=children; }}
setAttribute(name,value) {{ this.attributes[name]=String(value); }}
}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const controller=attachment.mount({{input,preview,image,meta,remove,status,
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(),tray=new Element(),earlier=new Element(),later=new Element();
input.multiple=true;
const document={{createElement:()=>new Element()}};
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
controller.restore([
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
{{filename:'two.png',contentType:'image/png',blob:new Blob(['two'],{{type:'image/png'}})}},
]);
controller.setBusy(true);
const pending={{input:input.disabled,remove:remove.disabled}};
const pending={{input:input.disabled,remove:remove.disabled,earlier:earlier.disabled,later:later.disabled,
thumbnails:tray.children.map(button=>button.disabled)}};
controller.setBusy(false);
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}}));
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled,
earlier:earlier.disabled,later:later.disabled,thumbnails:tray.children.map(button=>button.disabled)}}}}));
"""
assert json.loads(run_node(script)) == {
"pending": {"input": True, "remove": True},
"released": {"input": False, "remove": False},
"pending": {
"input": True, "remove": True, "earlier": True, "later": True,
"thumbnails": [True, True],
},
"released": {
"input": False, "remove": False, "earlier": False, "later": True,
"thumbnails": [False, False],
},
}
@ -513,6 +618,9 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme
assert "thumbnail tray" in readme
assert "Move earlier" in readme
assert "Remove selected" in readme
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():

View File

@ -837,6 +837,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-batch-plan.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-evidence-review.js",
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",