feat: queue authored messages for reconnect (#234)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-07 22:14:49 +00:00
1 parent 8df41e2fe7
commit d66c1b48ef
8 files changed
+381 -14

No files matched your search

+60 -7
View File
@@ -719,6 +719,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/my-work.js"></script>
<script src="static/pick-work.js"></script>
@@ -830,6 +831,7 @@ textarea { resize: vertical; min-height: 120px; }
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
@@ -985,6 +987,7 @@ textarea { resize: vertical; min-height: 120px; }
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
authoredOutbox,
onStatus: message => { qs('#update-reply-status').textContent = message; },
});
const notificationReader = createNotificationReader({
@@ -1251,13 +1254,18 @@ textarea { resize: vertical; min-height: 120px; }
function renderDrafts() {
const list = qs('#my-work-list');
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const outboxActions = item.kind === 'issue-outbox' ?
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
item.kind === 'authored-outbox' ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = item.kind === 'issue-outbox' ?
const state = isOutbox ?
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
return '<article class="my-work-card draft-card">' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
@@ -1290,7 +1298,8 @@ textarea { resize: vertical; min-height: 120px; }
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id) return;
button.disabled = true;
applyOutboxResult(await issueOutbox.retry(item.outbox_id));
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
@@ -1298,6 +1307,7 @@ textarea { resize: vertical; min-height: 120px; }
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
lastDrafts = draftInbox.list();
const count = qs('[data-work-count="draft"]');
@@ -1970,6 +1980,25 @@ textarea { resize: vertical; min-height: 120px; }
applyOutboxResult(await issueOutbox.flush());
}
function applyAuthoredOutboxResult(result) {
refreshMyWorkView();
const attention = (result.remaining || []).some(item => item.status === 'attention');
qs('#my-work-action-status').textContent = result.confirmed?.length ?
result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
'Message queued for sync when the connection returns.';
}
async function flushAuthoredOutbox() {
if (!navigator.onLine || !authoredOutbox.list().length) return;
applyAuthoredOutboxResult(await authoredOutbox.flush());
}
function canQueueMessage(error) {
const status = Number(error?.status || 0);
return !status || status >= 500;
}
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
@@ -2802,8 +2831,17 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#issue-comment').focus();
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
authoredOutbox.enqueue({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body, operationId });
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
refreshMyWorkView();
} else {
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#issue-comment').focus();
}
} finally {
button.disabled = false;
}
@@ -2894,8 +2932,17 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#pull-comment').focus();
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation');
authoredOutbox.enqueue({ kind:'pull-comment', repository:selectedPull.repository,
number:selectedPull.number, body, operationId });
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.';
refreshMyWorkView();
} else {
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#pull-comment').focus();
}
} finally {
button.disabled = false;
}
@@ -2948,7 +2995,11 @@ textarea { resize: vertical; min-height: 120px; }
qs('#send-update-reply').disabled = true;
const result = await notificationReplier.submit(selectedUpdate, body);
qs('#send-update-reply').disabled = false;
if (result) {
if (result?.queued) {
qs('#update-reply').value = '';
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
qs('#mark-update-read-next').focus();
@@ -3152,6 +3203,7 @@ textarea { resize: vertical; min-height: 120px; }
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
window.addEventListener('online', flushIssueOutbox);
window.addEventListener('online', flushAuthoredOutbox);
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
@@ -3237,6 +3289,7 @@ textarea { resize: vertical; min-height: 120px; }
}
contextPoller.start();
flushIssueOutbox();
flushAuthoredOutbox();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
});