claude-code-kanban 7.24.0 → 7.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +63 -41
- package/public/index.html +1 -4
- package/public/style.css +13 -16
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -8559,6 +8559,7 @@ function stopReview() {
|
|
|
8559
8559
|
if (!activeReview) return;
|
|
8560
8560
|
const { ctl, panelEl, hostEl } = activeReview;
|
|
8561
8561
|
ctl.abort();
|
|
8562
|
+
cancelDictationIn(panelEl);
|
|
8562
8563
|
for (const c of reviewItems()) {
|
|
8563
8564
|
c.range = null;
|
|
8564
8565
|
c.frameId = 0;
|
|
@@ -9049,6 +9050,7 @@ function closeReviewPop() {
|
|
|
9049
9050
|
}
|
|
9050
9051
|
reviewPopMode = null;
|
|
9051
9052
|
const el = reviewPopEl();
|
|
9053
|
+
cancelDictationIn(el);
|
|
9052
9054
|
el.className = '';
|
|
9053
9055
|
el.replaceChildren();
|
|
9054
9056
|
}
|
|
@@ -9102,10 +9104,11 @@ function openReviewEditor(picked, text = '') {
|
|
|
9102
9104
|
reviewPendingHighlight?.add(picked.range);
|
|
9103
9105
|
} else if (picked.inFrame) postToReviewFrame('pend');
|
|
9104
9106
|
activeReview.picked = picked;
|
|
9105
|
-
el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
|
|
9107
|
+
el.innerHTML = `<div class="mic-field"><textarea class="review-pop-input" rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea></div>
|
|
9106
9108
|
<div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
|
|
9107
9109
|
const ta = el.querySelector('textarea');
|
|
9108
9110
|
ta.value = text;
|
|
9111
|
+
addMic(ta);
|
|
9109
9112
|
const add = (comment) => addReviewComment(picked, comment);
|
|
9110
9113
|
ta.addEventListener('keydown', (e) => onReviewEditorKey(e, add, closeReviewPop));
|
|
9111
9114
|
el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
|
|
@@ -9261,6 +9264,7 @@ function renderReviewPanel() {
|
|
|
9261
9264
|
syncReviewSendClaims();
|
|
9262
9265
|
if (!activeReview) return;
|
|
9263
9266
|
const { panelEl, hostEl } = activeReview;
|
|
9267
|
+
cancelDictationIn(panelEl);
|
|
9264
9268
|
const items = reviewItems();
|
|
9265
9269
|
panelEl.hidden = !items.length;
|
|
9266
9270
|
hostEl?.classList.toggle('has-review', items.length > 0);
|
|
@@ -9274,7 +9278,7 @@ function renderReviewPanel() {
|
|
|
9274
9278
|
.map((c, i) => {
|
|
9275
9279
|
const body =
|
|
9276
9280
|
c === activeReview.editing?.c
|
|
9277
|
-
? `<textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea>`
|
|
9281
|
+
? `<div class="mic-field"><textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea></div>`
|
|
9278
9282
|
: `<div class="review-card-comment" title="Double-click to edit">${escapeHtml(c.comment)}</div>`;
|
|
9279
9283
|
return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
|
|
9280
9284
|
<button type="button" class="review-edit" title="Edit" aria-label="Edit">${DETAIL_EDIT_SVG}</button>
|
|
@@ -9283,6 +9287,8 @@ function renderReviewPanel() {
|
|
|
9283
9287
|
${body}</li>`;
|
|
9284
9288
|
})
|
|
9285
9289
|
.join('')}</ol>`;
|
|
9290
|
+
const edit = panelEl.querySelector('.review-card-edit');
|
|
9291
|
+
if (edit) addMic(edit);
|
|
9286
9292
|
}
|
|
9287
9293
|
|
|
9288
9294
|
async function toastDelivery({ delivered, markdown }, noun) {
|
|
@@ -18538,12 +18544,7 @@ function closeNewSession() {
|
|
|
18538
18544
|
closeFolderList();
|
|
18539
18545
|
closeImagePreview();
|
|
18540
18546
|
clearNewSessionImages();
|
|
18541
|
-
|
|
18542
|
-
if (promptVoice.hub) {
|
|
18543
|
-
promptVoice.hub = null;
|
|
18544
|
-
document.getElementById('ns-mic').setAttribute('aria-pressed', 'false');
|
|
18545
|
-
hub.invoke(HUB_DICTATE, { op: 'cancel' });
|
|
18546
|
-
}
|
|
18547
|
+
cancelDictationIn(document.getElementById('new-session-modal'));
|
|
18547
18548
|
}
|
|
18548
18549
|
|
|
18549
18550
|
function addNewSessionImages(files) {
|
|
@@ -18836,13 +18837,27 @@ async function startNewSessionInBackground(id, v) {
|
|
|
18836
18837
|
}
|
|
18837
18838
|
|
|
18838
18839
|
const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
18839
|
-
|
|
18840
|
+
// One dictation at a time: { btn, rec } in the browser, { btn, insert } under the hub.
|
|
18841
|
+
let dictation = null;
|
|
18840
18842
|
const HUB_DICTATE = 'voice.dictate';
|
|
18843
|
+
const MIC_HTML = `<button type="button" class="mic-btn" title="Dictate" aria-label="Dictate" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg></button>`;
|
|
18844
|
+
|
|
18845
|
+
// Puts a mic after a textarea that sits in a `.mic-field`. No browser speech input, no mic.
|
|
18846
|
+
function addMic(field, onError = (msg) => showToast(msg, 'error')) {
|
|
18847
|
+
if (!SpeechRecognitionApi) return;
|
|
18848
|
+
field.insertAdjacentHTML('afterend', MIC_HTML);
|
|
18849
|
+
const btn = field.nextElementSibling;
|
|
18850
|
+
// mousedown would blur the textarea and lose the caret the dictation inserts at.
|
|
18851
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
18852
|
+
btn.addEventListener('click', (e) => {
|
|
18853
|
+
e.stopPropagation();
|
|
18854
|
+
toggleDictation(field, btn, onError);
|
|
18855
|
+
});
|
|
18856
|
+
}
|
|
18841
18857
|
|
|
18842
18858
|
// Replaces the selection the field had when dictation began, so interim results rewrite
|
|
18843
|
-
// themselves in place instead of piling up.
|
|
18844
|
-
function
|
|
18845
|
-
const field = document.getElementById('ns-prompt');
|
|
18859
|
+
// themselves in place instead of piling up. Fires `input`, as typing would.
|
|
18860
|
+
function fieldInserter(field) {
|
|
18846
18861
|
const before = field.value.slice(0, field.selectionStart);
|
|
18847
18862
|
const after = field.value.slice(field.selectionEnd);
|
|
18848
18863
|
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
@@ -18850,50 +18865,63 @@ function promptInserter() {
|
|
|
18850
18865
|
field.value = before + lead + text + after;
|
|
18851
18866
|
const caret = before.length + lead.length + text.length;
|
|
18852
18867
|
field.setSelectionRange(caret, caret);
|
|
18853
|
-
|
|
18868
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
18854
18869
|
};
|
|
18855
18870
|
}
|
|
18856
18871
|
|
|
18857
|
-
function
|
|
18858
|
-
if (
|
|
18859
|
-
if (
|
|
18860
|
-
|
|
18861
|
-
const
|
|
18862
|
-
const insert = promptInserter();
|
|
18872
|
+
function toggleDictation(field, btn, onError) {
|
|
18873
|
+
if (dictation?.btn !== btn) cancelDictation();
|
|
18874
|
+
if (hub.can(HUB_DICTATE)) return toggleHubDictation(field, btn, onError);
|
|
18875
|
+
if (dictation) return dictation.rec.stop();
|
|
18876
|
+
const insert = fieldInserter(field);
|
|
18863
18877
|
const rec = new SpeechRecognitionApi();
|
|
18864
18878
|
rec.lang = navigator.language;
|
|
18865
18879
|
rec.interimResults = true;
|
|
18866
18880
|
rec.onresult = (e) => insert(Array.from(e.results, (r) => r[0].transcript).join(''));
|
|
18867
18881
|
rec.onerror = (e) => {
|
|
18868
18882
|
if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
|
|
18869
|
-
|
|
18870
|
-
else if (e.error !== 'no-speech' && e.error !== 'aborted')
|
|
18883
|
+
onError('Microphone access is blocked for this page.');
|
|
18884
|
+
else if (e.error !== 'no-speech' && e.error !== 'aborted') onError(`Dictation failed: ${e.error}`);
|
|
18871
18885
|
};
|
|
18872
18886
|
rec.onend = () => {
|
|
18873
|
-
promptVoice.rec = null;
|
|
18874
18887
|
btn.setAttribute('aria-pressed', 'false');
|
|
18888
|
+
if (dictation?.rec === rec) dictation = null;
|
|
18875
18889
|
};
|
|
18876
|
-
|
|
18890
|
+
dictation = { btn, rec };
|
|
18877
18891
|
btn.setAttribute('aria-pressed', 'true');
|
|
18878
18892
|
field.focus();
|
|
18879
18893
|
rec.start();
|
|
18880
18894
|
}
|
|
18881
18895
|
|
|
18882
18896
|
// Under a hub with voice on, the mic uses the hub's speech provider: the text arrives once, on the second click.
|
|
18883
|
-
async function toggleHubDictation() {
|
|
18884
|
-
const
|
|
18885
|
-
const
|
|
18886
|
-
|
|
18887
|
-
|
|
18888
|
-
|
|
18889
|
-
|
|
18890
|
-
const r = await hub.invoke(HUB_DICTATE, { op });
|
|
18897
|
+
async function toggleHubDictation(field, btn, onError) {
|
|
18898
|
+
const running = dictation?.insert;
|
|
18899
|
+
const insert = running ?? fieldInserter(field);
|
|
18900
|
+
dictation = running ? null : { btn, insert };
|
|
18901
|
+
btn.setAttribute('aria-pressed', String(!running));
|
|
18902
|
+
field.focus();
|
|
18903
|
+
const r = await hub.invoke(HUB_DICTATE, { op: running ? 'stop' : 'start' });
|
|
18891
18904
|
if (!r.ok) {
|
|
18892
|
-
|
|
18905
|
+
if (dictation?.insert === insert) dictation = null;
|
|
18893
18906
|
btn.setAttribute('aria-pressed', 'false');
|
|
18894
|
-
return
|
|
18907
|
+
return onError(`Dictation failed: ${r.reason}`);
|
|
18895
18908
|
}
|
|
18896
|
-
if (
|
|
18909
|
+
if (running && r.text && field.isConnected) insert(r.text);
|
|
18910
|
+
}
|
|
18911
|
+
|
|
18912
|
+
// Drops the running dictation without inserting text.
|
|
18913
|
+
function cancelDictation() {
|
|
18914
|
+
if (!dictation) return;
|
|
18915
|
+
const { btn, rec } = dictation;
|
|
18916
|
+
dictation = null;
|
|
18917
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
18918
|
+
if (rec) rec.abort();
|
|
18919
|
+
else hub.invoke(HUB_DICTATE, { op: 'cancel' });
|
|
18920
|
+
}
|
|
18921
|
+
|
|
18922
|
+
// For a field about to close or render again.
|
|
18923
|
+
function cancelDictationIn(root) {
|
|
18924
|
+
if (dictation && root.contains(dictation.btn)) cancelDictation();
|
|
18897
18925
|
}
|
|
18898
18926
|
|
|
18899
18927
|
function pickFolderOption(i) {
|
|
@@ -18945,14 +18973,8 @@ function initNewSession() {
|
|
|
18945
18973
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
|
|
18946
18974
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
18947
18975
|
}
|
|
18948
|
-
if (SpeechRecognitionApi) {
|
|
18949
|
-
const mic = document.getElementById('ns-mic');
|
|
18950
|
-
mic.hidden = false;
|
|
18951
|
-
// mousedown would blur the textarea and lose the caret the dictation inserts at.
|
|
18952
|
-
mic.addEventListener('mousedown', (e) => e.preventDefault());
|
|
18953
|
-
mic.addEventListener('click', togglePromptVoice);
|
|
18954
|
-
}
|
|
18955
18976
|
const prompt = document.getElementById('ns-prompt');
|
|
18977
|
+
addMic(prompt, setNewSessionError);
|
|
18956
18978
|
const promptField = prompt.closest('.ns-prompt-field');
|
|
18957
18979
|
prompt.addEventListener('paste', (e) => {
|
|
18958
18980
|
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'));
|
package/public/index.html
CHANGED
|
@@ -841,11 +841,8 @@
|
|
|
841
841
|
</div>
|
|
842
842
|
<div class="ns-row ns-extra">
|
|
843
843
|
<label class="ns-label" for="ns-prompt">Prompt</label>
|
|
844
|
-
<div class="ns-field ns-prompt-field">
|
|
844
|
+
<div class="ns-field ns-prompt-field mic-field">
|
|
845
845
|
<textarea id="ns-prompt" class="form-input" rows="4" placeholder="Optional. Sent as the first message. Paste or drop images to attach."></textarea>
|
|
846
|
-
<button type="button" id="ns-mic" class="ns-mic" title="Dictate" aria-label="Dictate" aria-pressed="false" hidden>
|
|
847
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>
|
|
848
|
-
</button>
|
|
849
846
|
<div id="ns-images" class="ns-images" hidden></div>
|
|
850
847
|
</div>
|
|
851
848
|
</div>
|
package/public/style.css
CHANGED
|
@@ -9108,7 +9108,7 @@ select.form-input option:checked {
|
|
|
9108
9108
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
|
9109
9109
|
}
|
|
9110
9110
|
|
|
9111
|
-
|
|
9111
|
+
.review-pop-input,
|
|
9112
9112
|
.review-card-edit {
|
|
9113
9113
|
width: 100%;
|
|
9114
9114
|
box-sizing: border-box;
|
|
@@ -9183,8 +9183,9 @@ select.form-input option:checked {
|
|
|
9183
9183
|
gap: 6px;
|
|
9184
9184
|
}
|
|
9185
9185
|
|
|
9186
|
-
|
|
9187
|
-
|
|
9186
|
+
/* :where() keeps the specificity as it was, so the button.review-* rules above still win. */
|
|
9187
|
+
#review-pop button:not(:where(.mic-btn)),
|
|
9188
|
+
.review-panel button:not(:where(.mic-btn)) {
|
|
9188
9189
|
padding: 3px 10px;
|
|
9189
9190
|
font: inherit;
|
|
9190
9191
|
color: var(--text-primary);
|
|
@@ -9807,15 +9808,15 @@ select.form-input option:checked {
|
|
|
9807
9808
|
max-height: 50vh;
|
|
9808
9809
|
}
|
|
9809
9810
|
|
|
9810
|
-
.
|
|
9811
|
+
.mic-field {
|
|
9811
9812
|
position: relative;
|
|
9812
9813
|
}
|
|
9813
9814
|
|
|
9814
|
-
.
|
|
9815
|
+
.mic-field:has(.mic-btn) textarea {
|
|
9815
9816
|
padding-right: 36px;
|
|
9816
9817
|
}
|
|
9817
9818
|
|
|
9818
|
-
.
|
|
9819
|
+
.mic-btn {
|
|
9819
9820
|
position: absolute;
|
|
9820
9821
|
top: 6px;
|
|
9821
9822
|
right: 6px;
|
|
@@ -9832,33 +9833,29 @@ select.form-input option:checked {
|
|
|
9832
9833
|
cursor: pointer;
|
|
9833
9834
|
}
|
|
9834
9835
|
|
|
9835
|
-
.
|
|
9836
|
-
display: none;
|
|
9837
|
-
}
|
|
9838
|
-
|
|
9839
|
-
.ns-mic svg {
|
|
9836
|
+
.mic-btn svg {
|
|
9840
9837
|
width: 15px;
|
|
9841
9838
|
height: 15px;
|
|
9842
9839
|
}
|
|
9843
9840
|
|
|
9844
|
-
.
|
|
9841
|
+
.mic-btn:hover {
|
|
9845
9842
|
color: var(--text-primary);
|
|
9846
9843
|
background: var(--bg-hover);
|
|
9847
9844
|
}
|
|
9848
9845
|
|
|
9849
|
-
.
|
|
9846
|
+
.mic-btn[aria-pressed='true'] {
|
|
9850
9847
|
color: var(--danger);
|
|
9851
|
-
animation:
|
|
9848
|
+
animation: mic-pulse 1.2s ease-in-out infinite;
|
|
9852
9849
|
}
|
|
9853
9850
|
|
|
9854
|
-
@keyframes
|
|
9851
|
+
@keyframes mic-pulse {
|
|
9855
9852
|
50% {
|
|
9856
9853
|
opacity: 0.45;
|
|
9857
9854
|
}
|
|
9858
9855
|
}
|
|
9859
9856
|
|
|
9860
9857
|
@media (prefers-reduced-motion: reduce) {
|
|
9861
|
-
.
|
|
9858
|
+
.mic-btn[aria-pressed='true'] {
|
|
9862
9859
|
animation: none;
|
|
9863
9860
|
}
|
|
9864
9861
|
}
|