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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.24.0",
3
+ "version": "7.25.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
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
- promptVoice.rec?.abort();
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
- const promptVoice = { rec: null, hub: null };
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 promptInserter() {
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
- renderNewSessionForm();
18868
+ field.dispatchEvent(new Event('input', { bubbles: true }));
18854
18869
  };
18855
18870
  }
18856
18871
 
18857
- function togglePromptVoice() {
18858
- if (hub.can(HUB_DICTATE)) return toggleHubDictation();
18859
- if (promptVoice.rec) return promptVoice.rec.stop();
18860
- const field = document.getElementById('ns-prompt');
18861
- const btn = document.getElementById('ns-mic');
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
- setNewSessionError('Microphone access is blocked for this page.');
18870
- else if (e.error !== 'no-speech' && e.error !== 'aborted') setNewSessionError(`Dictation failed: ${e.error}`);
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
- promptVoice.rec = rec;
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 btn = document.getElementById('ns-mic');
18885
- const op = promptVoice.hub ? 'stop' : 'start';
18886
- const insert = promptVoice.hub ?? promptInserter();
18887
- promptVoice.hub = op === 'start' ? insert : null;
18888
- btn.setAttribute('aria-pressed', String(op === 'start'));
18889
- document.getElementById('ns-prompt').focus();
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
- promptVoice.hub = null;
18905
+ if (dictation?.insert === insert) dictation = null;
18893
18906
  btn.setAttribute('aria-pressed', 'false');
18894
- return setNewSessionError(`Dictation failed: ${r.reason}`);
18907
+ return onError(`Dictation failed: ${r.reason}`);
18895
18908
  }
18896
- if (op === 'stop' && r.text) insert(r.text);
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
- #review-pop textarea,
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
- #review-pop button,
9187
- .review-panel button {
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
- .ns-prompt-field {
9811
+ .mic-field {
9811
9812
  position: relative;
9812
9813
  }
9813
9814
 
9814
- .ns-prompt-field:has(.ns-mic:not([hidden])) textarea {
9815
+ .mic-field:has(.mic-btn) textarea {
9815
9816
  padding-right: 36px;
9816
9817
  }
9817
9818
 
9818
- .ns-mic {
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
- .ns-mic[hidden] {
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
- .ns-mic:hover {
9841
+ .mic-btn:hover {
9845
9842
  color: var(--text-primary);
9846
9843
  background: var(--bg-hover);
9847
9844
  }
9848
9845
 
9849
- .ns-mic[aria-pressed='true'] {
9846
+ .mic-btn[aria-pressed='true'] {
9850
9847
  color: var(--danger);
9851
- animation: ns-mic-pulse 1.2s ease-in-out infinite;
9848
+ animation: mic-pulse 1.2s ease-in-out infinite;
9852
9849
  }
9853
9850
 
9854
- @keyframes ns-mic-pulse {
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
- .ns-mic[aria-pressed='true'] {
9858
+ .mic-btn[aria-pressed='true'] {
9862
9859
  animation: none;
9863
9860
  }
9864
9861
  }