feat: batch mobile review suggestions (Closes #1384)
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m20s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-25 07:14:54 +00:00
1 parent c8ffde7d96
commit 869543e992
8 files changed
+392 -2

No files matched your search

+6
View File
@@ -774,6 +774,12 @@ textarea { resize: vertical; min-height: 120px; }
.pull-feedback-suggestion-change > div { min-width:0; }
.pull-feedback-suggestion-change pre { box-sizing:border-box; max-width:100%; max-height:160px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; }
.pull-feedback-suggestion-preview input { box-sizing:border-box; width:100%; min-height:44px; }
.pull-feedback-batch-review { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #3b82f6; border-radius:10px; background:#07101e; }
.pull-feedback-batch-review[hidden] { display:none; }
.pull-feedback-batch-review h5 { margin:0; }
.pull-feedback-batch-review ul { min-width:0; margin:0; padding-left:20px; overflow-wrap:anywhere; }
.pull-feedback-batch-review input { box-sizing:border-box; width:100%; min-width:0; min-height:44px; }
#review-pull-feedback-batch { position:sticky; bottom:calc(8px + env(safe-area-inset-bottom)); z-index:4; width:100%; min-height:44px; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
+12
View File
@@ -1755,9 +1755,21 @@
<input id="pull-feedback-suggestion-message" maxlength="120" value="fix: apply review suggestion">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-suggestion" type="button" hidden>Cancel suggestion</button>
<button id="stage-pull-feedback-suggestion" type="button" hidden>Add to fix batch</button>
<button id="apply-pull-feedback-suggestion" type="button" hidden>Commit suggested change</button>
</div>
</section>
<section class="pull-feedback-batch-review" id="pull-feedback-batch-review" aria-labelledby="pull-feedback-batch-heading" hidden>
<h5 id="pull-feedback-batch-heading">Review fixes</h5>
<ul id="pull-feedback-batch-items"></ul>
<label for="pull-feedback-batch-message">Commit message</label>
<input id="pull-feedback-batch-message" maxlength="120" value="fix: apply review suggestions">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-batch" type="button" hidden>Keep reviewing</button>
<button id="commit-pull-feedback-batch" type="button" hidden>Commit all fixes</button>
</div>
</section>
<button id="review-pull-feedback-batch" type="button" hidden>Review fixes (0)</button>
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
<label for="pull-feedback-file-content">File content</label>
+130 -1
View File
@@ -416,6 +416,7 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const clearSuggestion = () => {
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#cancel-pull-feedback-suggestion').hidden = true;
qs('#stage-pull-feedback-suggestion').hidden = true;
qs('#apply-pull-feedback-suggestion').hidden = true;
if (active) active.suggestion = null;
};
@@ -430,6 +431,25 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
lines.splice(line - 1, 1, ...suggestion.content.split('\n'));
return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') };
};
const renderBatchButton = () => {
const count = active?.batch ? Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0) : 0;
const button = qs('#review-pull-feedback-batch');
button.hidden = count < 2;
button.textContent = 'Review fixes (' + count + ')';
};
const rebuildBatchFile = entry => {
const trailingNewline = entry.file.content.endsWith('\n');
const lines = entry.file.content.split('\n');
if (trailingNewline) lines.pop();
const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line);
if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null;
for (const suggestion of ordered) {
if (suggestion.line < 1 || suggestion.line > lines.length) return null;
lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n'));
}
return lines.join('\n') + (trailingNewline ? '\n' : '');
};
const render = () => {
if (!active) return;
const items = comments();
@@ -500,15 +520,17 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
active = { item, detail, reviewer, login,
active = { item, detail, reviewer, login, batch:{},
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
active.suggestion = null;
renderBatchButton();
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
@@ -534,6 +556,7 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = false;
qs('#cancel-pull-feedback-suggestion').hidden = false;
qs('#stage-pull-feedback-suggestion').hidden = false;
qs('#apply-pull-feedback-suggestion').hidden = false;
qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.';
qs('#apply-pull-feedback-suggestion').focus();
@@ -548,6 +571,90 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
clearSuggestion();
qs('#review-pull-feedback-suggestion').focus();
});
qs('#stage-pull-feedback-suggestion').addEventListener('click', () => {
if (!active?.suggestion) return;
const pending = active.suggestion;
const path = pending.file.path;
const stagedPath = Object.keys(active.batch)[0];
if (stagedPath && stagedPath !== path) {
qs('#pull-feedback-status').textContent = 'Commit the staged file before batching suggestions from another file.';
return;
}
const entry = active.batch[path] || {file:pending.file, suggestions:[]};
if (entry.file.blob_sha !== pending.file.blob_sha ||
entry.suggestions.some(item => item.commentId === pending.commentId)) return;
const comment = current();
entry.suggestions.push({
line:Number(comment.suggestion.line), content:comment.suggestion.content,
commentId:pending.commentId,
});
entry.content = rebuildBatchFile(entry);
if (entry.content === null) {
entry.suggestions.pop();
qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.';
return;
}
active.batch[path] = entry;
clearSuggestion();
renderBatchButton();
qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.';
qs('#next-pull-feedback').focus();
});
qs('#review-pull-feedback-batch').addEventListener('click', () => {
if (!active?.batch) return;
const list = qs('#pull-feedback-batch-items');
list.replaceChildren(...Object.values(active.batch).map(entry => {
const item = doc.createElement('li');
item.textContent = entry.file.path + ' · ' + entry.suggestions.length +
(entry.suggestions.length === 1 ? ' suggestion' : ' suggestions');
return item;
}));
qs('#pull-feedback-batch-review').hidden = false;
qs('#cancel-pull-feedback-batch').hidden = false;
qs('#commit-pull-feedback-batch').hidden = false;
qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'});
qs('#commit-pull-feedback-batch').focus();
});
qs('#cancel-pull-feedback-batch').addEventListener('click', () => {
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
qs('#review-pull-feedback-batch').focus();
});
qs('#commit-pull-feedback-batch').addEventListener('click', async () => {
if (!active?.batch) return;
const entries = Object.values(active.batch);
const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0);
const message = qs('#pull-feedback-batch-message').value.trim();
if (count < 2 || !message) return;
if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-batch');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…';
try {
const result = await controller.commitFeedbackBatch(active.item, {
file:{
path:entries[0].file.path, content:entries[0].content,
expectedBlobSha:entries[0].file.blob_sha,
},
suggestionCount:count, message, expectedHeadSha:active.detail.head_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.batch = {};
save();
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
renderBatchButton();
qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => {
if (!active?.suggestion) return;
const message = qs('#pull-feedback-suggestion-message').value.trim();
@@ -951,6 +1058,28 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
commitFeedbackBatch(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
file:{
path:draft.file.path, content:draft.file.content,
expected_blob_sha:draft.file.expectedBlobSha,
},
suggestion_count:draft.suggestionCount,
message:draft.message, expected_head_sha:draft.expectedHeadSha,
}),
}).then(result => {
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha ||
result.path !== draft.file.path || result.suggestion_count !== draft.suggestionCount) {
throw new Error('The suggestion batch was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {