feat: attach screenshots to issue comments (#467)
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 08:55:13 +00:00
1 parent 3f9dd49766
commit eefb760169
13 files changed
+702 -8

No files matched your search

+8
View File
@@ -255,6 +255,14 @@ textarea { resize: vertical; min-height: 120px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
.issue-attachment-preview[hidden] { display:none; }
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
.mention-options[hidden] { display:none; }
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
+56 -8
View File
@@ -272,6 +272,37 @@
mobile: window.matchMedia('(max-width: 600px)').matches,
});
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueAttachmentController = issueAttachment.mount({
input: qs('#issue-attachment'),
preview: qs('#issue-attachment-preview'),
image: qs('#issue-attachment-image'),
meta: qs('#issue-attachment-meta'),
remove: qs('#remove-issue-attachment'),
status: qs('#issue-comment-status'),
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: payload => {
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) + '/attachments',
{
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: payload.filename,
content_type: payload.content_type,
data: payload.data,
}),
},
);
},
});
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item),
@@ -2143,6 +2174,7 @@
return;
}
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
issueAttachmentController.clear();
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueOffline = false;
@@ -3729,7 +3761,7 @@
const textarea = qs('#' + kind + '-comment');
const status = qs('#' + kind + '-comment-status');
const body = textarea.value.trim();
if (!body) {
if (!body && (kind !== 'issue' || !issueAttachmentController.state())) {
status.textContent = 'Write a comment before posting.';
textarea.focus();
return;
@@ -3741,17 +3773,21 @@
item.repository + '#' + item.number + ':operation');
postButton.disabled = true;
nextButton.disabled = true;
status.textContent = 'Posting comment and opening next…';
status.textContent = kind === 'issue' && issueAttachmentController.state() ?
'Uploading screenshot before opening next…' : 'Posting comment and opening next…';
try {
const result = await controller.submit(item, body, operationId);
const preparedBody = kind === 'issue' ?
await issueAttachmentController.prepareComment(item, body) : body;
const result = await controller.submit(item, preparedBody, operationId);
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
if (kind === 'issue') issueAttachmentController.clear();
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
else status.textContent = 'Saved for next launch; background delivery unavailable.';
} catch (error) {
status.textContent = error.message + ' Your draft and Today position are safe; retry.';
status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.';
textarea.focus();
} finally {
postButton.disabled = false;
@@ -3763,28 +3799,40 @@
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();
if (!body) {
if (!body && !issueAttachmentController.state()) {
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
qs('#issue-comment').focus();
return;
}
const button = qs('#send-issue-comment');
button.disabled = true;
qs('#issue-comment-status').textContent = 'Posting comment…';
qs('#issue-comment-status').textContent = issueAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
let preparedBody;
try {
const comment = await issueController.comment(selectedIssue, body);
preparedBody = await issueAttachmentController.prepareComment(selectedIssue, body);
} catch (error) {
qs('#issue-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#issue-comment').focus();
button.disabled = false;
return;
}
try {
const comment = await issueController.comment(selectedIssue, preparedBody);
if (issueConversation) renderIssueConversation(issueConversation.append(comment));
qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
qs('#issue-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body, operationId });
number:selectedIssue.number, body:preparedBody, operationId });
refreshMyWorkView();
if (admission.background) {
qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
} else {
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
+10
View File
@@ -297,6 +297,15 @@
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
<img id="issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="issue-attachment-meta"></span>
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
</div>
<div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
@@ -660,6 +669,7 @@
<script src="static/later-picker.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>
<script src="static/issue-attachment.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>
+107
View File
@@ -0,0 +1,107 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function create(options) {
const readDataUrl = options.readDataUrl;
const upload = options.upload;
let selected = null;
let confirmed = null;
function select(file) {
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
}
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
selected = file;
confirmed = null;
return state();
}
function clear() {
selected = null;
confirmed = null;
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const dataUrl = await readDataUrl(selected);
const marker = ';base64,';
const markerAt = String(dataUrl).indexOf(marker);
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: selected.name,
content_type: selected.type,
data: String(dataUrl).slice(markerAt + marker.length),
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
}
return { select, clear, state, prepareComment };
}
function mount(options) {
const controller = create(options);
const clearSelection = controller.clear;
let previewUrl = '';
function clearPreview() {
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
clearSelection();
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
try {
controller.select(file);
} catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = options.createObjectURL(file);
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = 'Screenshot ready to upload with this comment.';
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = 'Screenshot removed. Your comment is unchanged.';
});
return Object.assign(controller, { clear: clearPreview });
}
return { create, mount, MAX_BYTES };
});
+1
View File
@@ -42,6 +42,7 @@ const SHELL = [
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',