feat: dictate active Today progress updates (Closes #1058)
CI / lint (pull_request) Successful in 2m29s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-18 03:05:53 +00:00
1 parent daf34d7cdd
commit 688eadf17a
8 files changed
+218 -31

No files matched your search

+3
View File
@@ -281,6 +281,9 @@ textarea { resize: vertical; min-height: 120px; }
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
.today-progress-actions button { width:100%; }
.today-progress-panel .voice-conversation { margin-top:10px; }
.today-progress-panel .voice-conversation-controls button,
.today-progress-panel .voice-conversation-review-actions button { min-height:44px; }
@media(max-width:359px) { .today-progress-actions { grid-template-columns:1fr; } }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
+10 -18
View File
@@ -839,24 +839,15 @@
getLogin:() => confirmedOwnerLogin,
});
const voiceTranscriptStore = createVoiceTranscriptStore();
function mountConversationVoice(kind, draftSelector) {
return createVoiceConversationCapture({
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore: voiceTranscriptStore,
getLogin: () => confirmedOwnerLogin,
elements: {
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
},
});
}
const issueVoiceReply = mountConversationVoice('issue-comment', '#issue-comment');
const pullVoiceReply = mountConversationVoice('pull-comment', '#pull-comment');
const updateVoiceReply = mountConversationVoice('update-reply', '#update-reply');
const searchVoiceReply = mountConversationVoice('search-reply', '#search-preview-reply');
const voiceOptions = {
qs, Recognition:window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore:voiceTranscriptStore, getLogin:() => confirmedOwnerLogin,
};
const [issueVoiceReply, pullVoiceReply, updateVoiceReply, searchVoiceReply, todayProgressVoice] = [
['issue-comment', '#issue-comment'], ['pull-comment', '#pull-comment'],
['update-reply', '#update-reply'], ['search-reply', '#search-preview-reply'],
['today-progress', '#today-progress-body'],
].map(([kind, draftSelector]) => mountVoiceConversation({...voiceOptions, kind, draftSelector}));
function conversationVoiceTarget(kind, item) {
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
}
@@ -1976,6 +1967,7 @@
}
const todayProgressView = createTodayProgressView({
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
voice:todayProgressVoice,
announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(),
});
+16
View File
@@ -1546,6 +1546,22 @@
<p id="today-progress-target" class="small"></p>
<label for="today-progress-body">Update</label>
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
<section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden>
<div class="voice-conversation-controls">
<button id="start-voice-today-progress" type="button">Dictate update</button>
<button id="stop-voice-today-progress" type="button" hidden>Stop listening</button>
</div>
<div id="voice-today-progress-status" class="small" aria-live="polite"></div>
<div class="voice-conversation-review" id="voice-today-progress-review" hidden>
<label for="voice-today-progress-transcript">Review transcript</label>
<textarea id="voice-today-progress-transcript" maxlength="2000"></textarea>
<div class="voice-conversation-review-actions">
<button id="append-voice-today-progress" type="button">Use transcript</button>
<button id="replace-with-voice-today-progress" type="button" hidden>Replace update</button>
<button id="discard-voice-today-progress" type="button">Discard</button>
</div>
</div>
</section>
<section class="today-progress-evidence" aria-labelledby="today-progress-evidence-label">
<strong id="today-progress-evidence-label">Photo evidence <span class="small">Optional · Up to 5</span></strong>
<div class="issue-attachment-controls conversation-photo-actions">
+7 -2
View File
@@ -132,7 +132,7 @@ maxLength = 2000, maxItems = 20 }) {
return { load, save, discard:identity => save(identity, ''), post };
}
function createTodayProgressView({ progress, currentTarget, qs, photos, announce = () => {}, onAdmitted = () => {} }) {
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, announce = () => {}, onAdmitted = () => {} }) {
const sheet = qs('#today-progress-sheet');
const body = qs('#today-progress-body');
const status = qs('#today-progress-status');
@@ -154,6 +154,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, announce
}
};
const close = () => {
voice?.cancel?.();
if (sheet.open) sheet.close();
openedTarget = null;
};
@@ -166,7 +167,11 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, announce
body.value = progress.load(target.identity);
status.textContent = 'Restoring saved photo evidence…';
sheet.showModal();
try { await photos?.open?.(target); status.textContent = ''; }
try {
await voice?.open?.(target.identity);
await photos?.open?.(target);
status.textContent = '';
}
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
body.focus();
});
+28 -7
View File
@@ -1,21 +1,26 @@
function createVoiceConversationCapture({
Recognition, elements, transcriptStore = null, getLogin = () => '',
createEvent = name => new Event(name, {bubbles:true}),
maxLength = 0, draftLabel = '',
}) {
const supported = typeof Recognition === 'function';
const label = String(draftLabel || elements.root.dataset?.draftLabel || 'reply').trim() || 'reply';
let recognition = null;
let target = '';
let generation = 0;
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the reply instead.';
elements.status.textContent = 'Voice capture is unavailable; type the ' + label + ' instead.';
}
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
const login = () => String(getLogin() || '').trim();
const composerLimit = Number(elements.draft.maxLength);
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength :
Number.isInteger(composerLimit) && composerLimit > 0 ? composerLimit : 10000;
function showReview(value, recovered = false) {
const transcript = normalize(value).slice(0, 10000);
const transcript = normalize(value).slice(0, limit);
elements.transcript.value = transcript;
elements.review.hidden = false;
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
@@ -36,7 +41,7 @@ function createVoiceConversationCapture({
elements.review.hidden = true;
elements.status.textContent = '';
if (!supported || !target || !transcriptStore || !login()) return;
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, 10000);
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, limit);
if (opening === generation && recovered) showReview(recovered, true);
}
@@ -45,13 +50,13 @@ function createVoiceConversationCapture({
}
function commit(replace) {
const transcript = normalize(elements.transcript.value).slice(0, 10000);
const transcript = normalize(elements.transcript.value).slice(0, limit);
elements.draft.value = replace ? transcript :
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, 10000);
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, limit);
elements.draft.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the reply before sending.';
elements.status.textContent = 'Transcript added. Review the ' + label + ' before sending.';
}
elements.start.addEventListener('click', () => {
@@ -121,4 +126,20 @@ function createVoiceConversationCapture({
return {supported, open, cancel};
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture};
function mountVoiceConversation({
kind, draftSelector, qs, Recognition, transcriptStore, getLogin,
createCapture = createVoiceConversationCapture,
}) {
return createCapture({
Recognition, transcriptStore, getLogin,
elements:{
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
},
});
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture, mountVoiceConversation};