claude-code-kanban 7.22.0 → 7.24.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/public/app.js CHANGED
@@ -4672,7 +4672,7 @@ function renderKanban() {
4672
4672
  // away mid-typing -- leave the column alone until the input is gone.
4673
4673
  if (!addingTask) {
4674
4674
  const addTile = canAddTask()
4675
- ? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)" title="You can also ask the agent to add tasks with its TaskCreate tool. Make sure CLAUDE_CODE_ENABLE_TASKS is set in its environment.">${plusIcon}<span>Add task</span></button>`
4675
+ ? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this, event)" title="Ctrl+Click opens the dialog with a description. You can also ask the agent to add tasks with its TaskCreate tool. Make sure CLAUDE_CODE_ENABLE_TASKS is set in its environment.">${plusIcon}<span>Add task</span></button>`
4676
4676
  : '';
4677
4677
  writes.push([
4678
4678
  pendingTasks,
@@ -4732,9 +4732,196 @@ function canAddTask() {
4732
4732
  return viewMode === 'session' && !!currentSessionId;
4733
4733
  }
4734
4734
 
4735
+ async function postTask(subject, description = '', sessionId = currentSessionId) {
4736
+ const res = await api(apiPath`/api/tasks/${sessionId}`, { method: 'POST', body: { subject, description } });
4737
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
4738
+ // The response carries the finished task, and the watcher will resend it within the
4739
+ // SSE debounce anyway -- so show it now rather than paying a session refetch for it.
4740
+ const { task } = await res.json();
4741
+ if (sessionId === currentSessionId) currentTasks.push({ ...task, sessionId });
4742
+ }
4743
+
4744
+ // task.add from the hub: the subject goes on the board on screen; with none, the Add task dialog opens.
4745
+ async function addTaskFromHub({ subject, description }) {
4746
+ if (!canAddTask()) return showToast('Open a session to add a task', 'info');
4747
+ const text = subject?.trim();
4748
+ if (!text) return openTaskDialog(true);
4749
+ try {
4750
+ await postTask(text, description?.trim());
4751
+ if (!addingTask) renderKanban();
4752
+ } catch (error) {
4753
+ console.error('Failed to create task:', error);
4754
+ showToast('Failed to create task', 'error');
4755
+ }
4756
+ }
4757
+
4758
+ // The Add task dialog: the hub opens it by voice and fills it as the user speaks. The task goes to
4759
+ // the session that was on screen when it opened.
4760
+ let taskDialogSession = null;
4761
+ const taskDialogOpen = () => document.getElementById('task-modal').classList.contains('visible');
4762
+
4763
+ function openTaskDialog(focus = false) {
4764
+ if (terminalPaneFocused()) leaveTerminalPane();
4765
+ taskDialogSession = currentSessionId;
4766
+ const session = sessions.find((s) => s.id === currentSessionId);
4767
+ document.getElementById('task-modal-session').textContent = session ? sessionDisplayName(session) : '';
4768
+ document.getElementById('task-subject').value = '';
4769
+ document.getElementById('task-description').value = '';
4770
+ setFormError('task-error', '');
4771
+ renderTaskDialog();
4772
+ document.getElementById('task-modal').classList.add('visible');
4773
+ if (focus) document.getElementById('task-subject').focus();
4774
+ }
4775
+
4776
+ function closeTaskDialog() {
4777
+ hideModalOverlay('task-modal');
4778
+ taskDialogSession = null;
4779
+ taskDialogVoice = false;
4780
+ setTaskVoiceState('');
4781
+ }
4782
+
4783
+ function renderTaskDialog() {
4784
+ const subject = document.getElementById('task-subject');
4785
+ document.getElementById('task-save').disabled = subject.readOnly || !subject.value.trim();
4786
+ }
4787
+
4788
+ function fillTaskDialog(subject, description) {
4789
+ fillTaskField('task-subject', subject);
4790
+ fillTaskField('task-description', description);
4791
+ renderTaskDialog();
4792
+ }
4793
+
4794
+ // Voice fills a field: it flashes, and a long text shows its newest words.
4795
+ function fillTaskField(id, value) {
4796
+ const field = document.getElementById(id);
4797
+ if (field.value === value) return;
4798
+ field.value = value;
4799
+ field.scrollTop = field.scrollHeight;
4800
+ if (value) flashTaskField(field, 'task-filled');
4801
+ }
4802
+
4803
+ function flashTaskField(field, cls) {
4804
+ field.classList.remove('task-filled', 'task-moved');
4805
+ void field.offsetWidth;
4806
+ field.classList.add(cls);
4807
+ }
4808
+
4809
+ async function saveTaskDialog() {
4810
+ const subject = document.getElementById('task-subject').value.trim();
4811
+ if (!subject || !taskDialogSession) return;
4812
+ const save = document.getElementById('task-save');
4813
+ save.disabled = true;
4814
+ try {
4815
+ await postTask(subject, document.getElementById('task-description').value.trim(), taskDialogSession);
4816
+ closeTaskDialog();
4817
+ if (!addingTask) renderKanban();
4818
+ } catch (error) {
4819
+ console.error('Failed to create task:', error);
4820
+ setFormError('task-error', 'Failed to create task');
4821
+ renderTaskDialog();
4822
+ }
4823
+ }
4824
+
4825
+ // The title can take a model call after the release; a quick answer shows no loading at all.
4826
+ const TASK_TITLING_DELAY_MS = 300;
4827
+ let taskTitlingTimer = null;
4828
+
4829
+ // A hold fills a dialog: it glows, shows the badge, and its fields are read-only, so typing cannot
4830
+ // race the voice. A hold whose last call never comes (the hub page reloaded) runs `onStale` after
4831
+ // VOICE_STALE_MS, longer than a task title's model call.
4832
+ const VOICE_STALE_MS = 20_000;
4833
+ const voiceStale = new Map();
4834
+
4835
+ function setModalVoice(modal, fields, state, onStale) {
4836
+ clearTimeout(voiceStale.get(modal));
4837
+ if (state) voiceStale.set(modal, setTimeout(onStale, VOICE_STALE_MS));
4838
+ if ((modal.dataset.voice ?? '') === state) return;
4839
+ if (state) modal.dataset.voice = state;
4840
+ else delete modal.dataset.voice;
4841
+ for (const field of fields) field.readOnly = !!state;
4842
+ modal.querySelector('.voice-mic').hidden = !state;
4843
+ }
4844
+
4845
+ // 'listening' while the user speaks, 'titling' while the title is on its way, '' when the dialog is
4846
+ // the user's.
4847
+ function setTaskVoiceState(state) {
4848
+ clearTimeout(taskTitlingTimer);
4849
+ if (state === 'titling') {
4850
+ taskTitlingTimer = setTimeout(() => applyTaskVoiceState(state), TASK_TITLING_DELAY_MS);
4851
+ return;
4852
+ }
4853
+ applyTaskVoiceState(state);
4854
+ }
4855
+
4856
+ function applyTaskVoiceState(state) {
4857
+ const subject = document.getElementById('task-subject');
4858
+ const fields = [subject, document.getElementById('task-description')];
4859
+ setModalVoice(document.querySelector('.task-modal'), fields, state, () => {
4860
+ taskDialogVoice = false;
4861
+ setTaskVoiceState('');
4862
+ });
4863
+ subject.classList.toggle('task-titling', state === 'titling');
4864
+ subject.placeholder = state === 'titling' ? 'Writing a title…' : 'What to do';
4865
+ document.getElementById('task-modal-mic-label').textContent = state === 'titling' ? 'Writing a title' : 'Listening';
4866
+ renderTaskDialog();
4867
+ }
4868
+
4869
+ // Set from a hold's first task.live until its last call or a close, so a final call that lands
4870
+ // after the user closed the dialog does not open it again.
4871
+ let taskDialogVoice = false;
4872
+
4873
+ // task.live from the hub: while the user speaks, the words so far as the description; `pending` on
4874
+ // release; then `final` with the title and the description. A short task moves up into the title.
4875
+ function liveTaskFromHub(p) {
4876
+ if (p.cancel === 'true') return taskDialogVoice && closeTaskDialog();
4877
+ const last = p.final === 'true';
4878
+ if (!taskDialogVoice && !last && p.pending !== 'true' && canAddTask()) {
4879
+ if (!taskDialogOpen()) openTaskDialog();
4880
+ taskDialogVoice = true;
4881
+ setTaskVoiceState('listening');
4882
+ }
4883
+ if (!taskDialogVoice) return last && !canAddTask() && showToast('Open a session to add a task', 'info');
4884
+ if (p.pending === 'true') return setTaskVoiceState('titling');
4885
+ if (!last) {
4886
+ setTaskVoiceState('listening');
4887
+ return fillTaskField('task-description', p.description ?? '');
4888
+ }
4889
+ const subject = document.getElementById('task-subject');
4890
+ taskDialogVoice = false;
4891
+ setTaskVoiceState('');
4892
+ if (p.subject !== undefined) {
4893
+ const moved = !p.description && p.subject === document.getElementById('task-description').value.trim();
4894
+ fillTaskDialog(p.subject, p.description ?? '');
4895
+ if (moved) flashTaskField(subject, 'task-moved');
4896
+ }
4897
+ if (p.submit === 'true') return saveTaskDialog();
4898
+ const save = document.getElementById('task-save');
4899
+ (save.disabled ? subject : save).focus();
4900
+ }
4901
+
4902
+ function initTaskDialog() {
4903
+ const subject = document.getElementById('task-subject');
4904
+ const description = document.getElementById('task-description');
4905
+ subject.addEventListener('input', renderTaskDialog);
4906
+ document.querySelector('.task-modal').addEventListener('keydown', (e) => {
4907
+ if (e.key === 'Escape') {
4908
+ e.preventDefault();
4909
+ e.stopPropagation();
4910
+ return closeTaskDialog();
4911
+ }
4912
+ if (e.key !== 'Enter' || e.shiftKey || e.isComposing) return;
4913
+ if (e.target !== subject && e.target !== description) return;
4914
+ e.preventDefault();
4915
+ e.stopPropagation();
4916
+ saveTaskDialog();
4917
+ });
4918
+ }
4919
+ initTaskDialog();
4920
+
4735
4921
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
4736
- function startAddTask(tile) {
4922
+ function startAddTask(tile, e) {
4737
4923
  if (addingTask) return;
4924
+ if (e?.ctrlKey || e?.metaKey) return openTaskDialog(true);
4738
4925
  addingTask = true;
4739
4926
 
4740
4927
  const input = document.createElement('input');
@@ -4761,14 +4948,8 @@ function startAddTask(tile) {
4761
4948
  const subject = input.value.trim();
4762
4949
  if (!subject) return reset();
4763
4950
  input.disabled = true;
4764
- const sessionId = currentSessionId;
4765
4951
  try {
4766
- const res = await api(apiPath`/api/tasks/${sessionId}`, { method: 'POST', body: { subject } });
4767
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
4768
- // The response carries the finished task, and the watcher will resend it within the
4769
- // SSE debounce anyway -- so show it now rather than paying a session refetch for it.
4770
- const { task } = await res.json();
4771
- currentTasks.push({ ...task, sessionId });
4952
+ await postTask(subject);
4772
4953
  addingTask = false;
4773
4954
  renderKanban();
4774
4955
  } catch (error) {
@@ -7831,6 +8012,7 @@ const MODAL_CLOSERS = {
7831
8012
  'project-picker-modal': () => closeProjectPicker(),
7832
8013
  'terminal-manager-modal': () => closeTerminalManager(),
7833
8014
  'new-session-modal': () => closeNewSession(),
8015
+ 'task-modal': () => closeTaskDialog(),
7834
8016
  'confirm-modal': () => closeConfirmModal?.(false),
7835
8017
  'pane-pop': () => closePanePop(),
7836
8018
  };
@@ -12530,6 +12712,8 @@ let spPeekToken = 0;
12530
12712
  let spPeekedId = null;
12531
12713
  let spFromTerminal = false;
12532
12714
  let spRenderedQuery = null;
12715
+ // Rows ranked by the caller for the query the picker opened with; the first edit drops them.
12716
+ let spRanked = null;
12533
12717
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
12534
12718
  const spPeekCache = new Map();
12535
12719
  // 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
@@ -12583,10 +12767,11 @@ const spSearch = sessionSearch(() => {
12583
12767
  if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
12584
12768
  });
12585
12769
 
12586
- function openSessionPicker() {
12770
+ function openSessionPicker({ query = '', ranked = null } = {}) {
12587
12771
  spFromTerminal = terminalPaneFocused();
12588
12772
  const input = document.getElementById('session-picker-input');
12589
- input.value = '';
12773
+ input.value = query;
12774
+ spRanked = ranked;
12590
12775
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
12591
12776
  spPeekCache.clear();
12592
12777
  spRenderedQuery = null;
@@ -12716,7 +12901,7 @@ function renderSessionPicker() {
12716
12901
  spRenderedQuery = renderKey;
12717
12902
  const all = spScope === 'all';
12718
12903
  const board = new Set(spSource.map((s) => s.id));
12719
- const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
12904
+ const local = spRanked && !all ? spRanked : (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
12720
12905
  const found = spSearch.rows(query, local);
12721
12906
  const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
12722
12907
  const extra = [...found.rows, ...tail];
@@ -12780,7 +12965,10 @@ async function spOpen(idx) {
12780
12965
 
12781
12966
  function initSessionPicker() {
12782
12967
  const input = document.getElementById('session-picker-input');
12783
- input.addEventListener('input', renderSessionPicker);
12968
+ input.addEventListener('input', () => {
12969
+ spRanked = null;
12970
+ renderSessionPicker();
12971
+ });
12784
12972
  // Bound on the input: the global handler returns early on INPUT targets.
12785
12973
  input.addEventListener('keydown', (e) => {
12786
12974
  if (e.key === 'Escape') closeSessionPicker();
@@ -15264,6 +15452,7 @@ panePop.addEventListener('keydown', (e) => {
15264
15452
  const TERMINAL_TOKEN_KEY = 'terminal-token';
15265
15453
  const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
15266
15454
  const TERMINAL_MODES_KEY = 'terminal-sessions';
15455
+ const TERMINAL_SEEN_KEY = 'terminal-seen-sessions';
15267
15456
  const TERMINAL_FONT_KEY = 'terminal-font-size';
15268
15457
  const TERMINAL_FONT_MIN = 8;
15269
15458
  const TERMINAL_FONT_MAX = 32;
@@ -15355,6 +15544,16 @@ function setTerminalMode(sessionId, on) {
15355
15544
  store.writeJson(TERMINAL_MODES_KEY, [...modes].slice(-50));
15356
15545
  }
15357
15546
 
15547
+ // A session started elsewhere (dispatch, another board) runs in a terminal this board never turned on,
15548
+ // so its first open shows the terminal. Later opens keep what the user chose.
15549
+ function defaultToTerminalOnFirstOpen(sessionId) {
15550
+ if (!runningTerminals.has(sessionId)) return;
15551
+ const seen = readStoredList(TERMINAL_SEEN_KEY);
15552
+ if (seen.includes(sessionId)) return;
15553
+ store.writeJson(TERMINAL_SEEN_KEY, [...seen, sessionId].slice(-200));
15554
+ setTerminalMode(sessionId, true);
15555
+ }
15556
+
15358
15557
  function wantsTerminal() {
15359
15558
  return viewMode === 'session' && wantsTerminalFor(currentSessionId);
15360
15559
  }
@@ -15543,6 +15742,7 @@ async function openSession(sessionId) {
15543
15742
  setSessionDismissed(sessionId, false);
15544
15743
  // Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
15545
15744
  if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
15745
+ defaultToTerminalOnFirstOpen(sessionId);
15546
15746
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
15547
15747
  if (!alreadyOpen) termState.openedByUser = sessionId;
15548
15748
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
@@ -16121,6 +16321,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
16121
16321
  effort: spec.effort,
16122
16322
  prompt: spec.prompt,
16123
16323
  edit: spec.edit,
16324
+ extraArgs: spec.extraArgs,
16124
16325
  forkOf: spec.forkOf,
16125
16326
  }),
16126
16327
  },
@@ -16951,6 +17152,11 @@ function focusShowBody() {
16951
17152
 
16952
17153
  const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+shift+Enter': 'dock', 'alt+w': 'close' };
16953
17154
 
17155
+ // Docked, the card is a dock item, so it shares the dock's expand with the add-ons.
17156
+ const showExpanded = () => (showState.docked ? dockExpanded : showState.expanded);
17157
+
17158
+ const expandTitle = (on, home) => (on ? `Back to the ${home} · Alt+Enter or Esc` : 'Fill the terminal · Alt+Enter');
17159
+
16954
17160
  // The body keeps focus, because a render replaces an HTML card's frame.
16955
17161
  function showCardKey(e) {
16956
17162
  if (matchKey(e, '[', ']')) {
@@ -16960,8 +17166,7 @@ function showCardKey(e) {
16960
17166
  }
16961
17167
  const combo = ClaudeHub.comboOf(e);
16962
17168
  let cmd = SHOW_KEY_COMMANDS[combo];
16963
- if (combo === 'Escape') cmd = showState.expanded && !showState.docked ? 'expand' : 'collapse';
16964
- else if (cmd === 'expand' && showState.docked) cmd = null;
17169
+ if (combo === 'Escape') cmd = showExpanded() ? 'expand' : 'collapse';
16965
17170
  if (cmd) {
16966
17171
  showCommand(cmd);
16967
17172
  if (cmd === 'expand' || cmd === 'dock') focusShowBody();
@@ -17126,16 +17331,11 @@ function showHeaderHtml(p) {
17126
17331
  const i = showState.idx;
17127
17332
  const key = p.key ? `<span class="show-sep">·</span><span class="show-key">${escapeHtml(p.key)}</span>` : '';
17128
17333
  const btn = (...args) => showBtnHtml('show', ...args);
17129
- const { collapsed, expanded, docked } = showState;
17334
+ const { collapsed, docked } = showState;
17335
+ const expanded = showExpanded();
17130
17336
  const dockTitle = docked ? 'Back to the overlay · Alt+Shift+Enter' : 'Dock beside the terminal · Alt+Shift+Enter';
17131
17337
  const dock = btn('dock', 'dock', dockTitle, false, docked);
17132
- const expand = docked
17133
- ? ''
17134
- : btn(
17135
- 'expand',
17136
- expanded ? 'restore' : 'expand',
17137
- expanded ? 'Back to the card · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter',
17138
- );
17338
+ const expand = btn('expand', expanded ? 'restore' : 'expand', expandTitle(expanded, docked ? 'dock' : 'card'));
17139
17339
  return `<span class="show-nav"><span class="show-title" title="${escapeHtml(p.title)}">${escapeHtml(p.title)}</span>${key}</span>
17140
17340
  <span class="show-pager">${btn('prev', 'prev', 'Previous post', i === 0)}<span class="show-pos">${i + 1}/${n}</span>${btn('next', 'next', 'Next post', i === n - 1)}</span>
17141
17341
  <span class="show-actions">${btn('pane', 'pane', 'Open in a pane of the session')}${dock}${btn('collapse', collapsed ? 'uncollapse' : 'collapse', collapsed ? 'Show the post' : 'Collapse to the header · Esc')}${expand}${btn('remove', 'clear', 'Remove this post · Ctrl+D · Shift+click clears every post')}${btn('close', 'close', 'Close until the next post · Alt+W')}</span>`;
@@ -17176,7 +17376,7 @@ function renderShow({ flash = false } = {}) {
17176
17376
  }
17177
17377
  const p = showState.posts[showState.idx];
17178
17378
  showState.marked.delete(p.id);
17179
- card.classList.toggle('expanded', showState.expanded && !showState.collapsed && !showState.docked);
17379
+ card.classList.toggle('expanded', showExpanded() && !showState.collapsed);
17180
17380
  card.classList.toggle('collapsed', showState.collapsed);
17181
17381
  if (!card.firstChild) {
17182
17382
  card.innerHTML = `<header class="show-head"></header><div class="show-ticks"></div><div class="show-body" tabindex="-1"></div>
@@ -17575,8 +17775,10 @@ function dockOnScreen(active = dockActivePane()) {
17575
17775
  function setDockAddon(id) {
17576
17776
  const tid = showState.terminalId;
17577
17777
  if (!tid || (dockAddons[tid] ?? null) === id) return;
17578
- if (id) dockAddons[tid] = id;
17579
- else delete dockAddons[tid];
17778
+ if (id) {
17779
+ dockAddons[tid] = id;
17780
+ showState.closed = true;
17781
+ } else delete dockAddons[tid];
17580
17782
  store.writeJson(DOCK_ADDON_KEY, dockAddons);
17581
17783
  }
17582
17784
 
@@ -17670,11 +17872,11 @@ function renderDockAddon(pane) {
17670
17872
  panel.classList.toggle('expanded', dockExpanded);
17671
17873
  panel.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
17672
17874
  const expand = panel.querySelector('[data-dock="expand"]');
17673
- const expandTitle = dockExpanded ? 'Back to the dock · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter';
17674
- if (expand.title !== expandTitle) {
17875
+ const label = expandTitle(dockExpanded, 'dock');
17876
+ if (expand.title !== label) {
17675
17877
  expand.innerHTML = showIcon(dockExpanded ? 'restore' : 'expand');
17676
- expand.title = expandTitle;
17677
- expand.setAttribute('aria-label', expandTitle);
17878
+ expand.title = label;
17879
+ expand.setAttribute('aria-label', label);
17678
17880
  }
17679
17881
  if (!dockFrames.has(pane.id)) {
17680
17882
  const view = createDockView(pane);
@@ -17769,7 +17971,7 @@ async function removeDockItem(item) {
17769
17971
  function setDockExpanded(on) {
17770
17972
  dockExpanded = on;
17771
17973
  store.setItem(DOCK_EXPANDED_KEY, on ? '1' : '');
17772
- renderDockAddon(dockActivePane());
17974
+ renderShow();
17773
17975
  }
17774
17976
 
17775
17977
  // Alt+Enter toggles the full width and Esc goes back; Esc in the dock focuses the terminal.
@@ -17966,8 +18168,9 @@ function showCommand(cmd) {
17966
18168
  showState.docked = !showState.docked;
17967
18169
  showState.collapsed = false;
17968
18170
  } else if (cmd === 'expand') {
17969
- showState.expanded = !showState.expanded;
17970
18171
  showState.collapsed = false;
18172
+ if (showState.docked) return setDockExpanded(!dockExpanded);
18173
+ showState.expanded = !showState.expanded;
17971
18174
  } else if (cmd === 'close') showState.closed = true;
17972
18175
  else if (cmd === 'remove') {
17973
18176
  const id = showState.posts[showState.idx]?.id;
@@ -18083,7 +18286,17 @@ const newSpecs = new Map();
18083
18286
  // Sessions another session started (`dispatch start`), shown where they will stay until
18084
18287
  // their transcript appears. Kept apart from newSpecs: their prompt is already sent.
18085
18288
  const dispatchSpecs = new Map();
18086
- const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false, images: [] };
18289
+ const ns = {
18290
+ projects: [],
18291
+ picked: [],
18292
+ matches: [],
18293
+ idx: -1,
18294
+ folder: '',
18295
+ busy: false,
18296
+ resume: false,
18297
+ images: [],
18298
+ extraArgs: [],
18299
+ };
18087
18300
  // Must match ATTACHMENT_TYPES and the upload limit in server.js.
18088
18301
  const NS_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
18089
18302
  const NS_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
@@ -18291,6 +18504,7 @@ function openNewSession(folder, resume = false) {
18291
18504
  if (!terminalAvailable() || document.getElementById('new-session-modal').classList.contains('visible')) return;
18292
18505
  if (terminalPaneFocused()) leaveTerminalPane();
18293
18506
  ns.resume = resume;
18507
+ ns.extraArgs = [];
18294
18508
  document.querySelector('.new-session-modal').classList.toggle('ns-resume', resume);
18295
18509
  document.getElementById('ns-title').textContent = resume ? 'Resume session' : 'New session';
18296
18510
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
@@ -18308,7 +18522,7 @@ function openNewSession(folder, resume = false) {
18308
18522
  const input = document.getElementById('ns-folder');
18309
18523
  input.focus();
18310
18524
  input.select();
18311
- loadProjects().then((list) => {
18525
+ return loadProjects().then((list) => {
18312
18526
  ns.projects = list
18313
18527
  .filter((p) => !p.temp)
18314
18528
  .sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''))
@@ -18320,10 +18534,16 @@ function openNewSession(folder, resume = false) {
18320
18534
 
18321
18535
  function closeNewSession() {
18322
18536
  hideModalOverlay('new-session-modal');
18537
+ setNewSessionVoice(false);
18323
18538
  closeFolderList();
18324
18539
  closeImagePreview();
18325
18540
  clearNewSessionImages();
18326
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
+ }
18327
18547
  }
18328
18548
 
18329
18549
  function addNewSessionImages(files) {
@@ -18478,6 +18698,7 @@ function newSessionValues() {
18478
18698
  prompt: document.getElementById('ns-prompt').value.trim() || null,
18479
18699
  edit,
18480
18700
  stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
18701
+ extraArgs: ns.resume ? [] : ns.extraArgs,
18481
18702
  };
18482
18703
  }
18483
18704
 
@@ -18490,7 +18711,11 @@ function newSessionProblem(v) {
18490
18711
  }
18491
18712
 
18492
18713
  function setNewSessionError(text) {
18493
- const el = document.getElementById('ns-error');
18714
+ setFormError('ns-error', text);
18715
+ }
18716
+
18717
+ function setFormError(id, text) {
18718
+ const el = document.getElementById(id);
18494
18719
  el.textContent = text;
18495
18720
  el.hidden = !text;
18496
18721
  }
@@ -18504,6 +18729,8 @@ function renderNewSessionForm() {
18504
18729
  hint.hidden = !wtOn;
18505
18730
  hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
18506
18731
  document.getElementById('ns-stay').disabled = v.edit;
18732
+ document.getElementById('ns-args-row').hidden = !v.extraArgs.length;
18733
+ document.getElementById('ns-args').textContent = v.extraArgs.join(' ');
18507
18734
  const problem = newSessionProblem(v);
18508
18735
  setNewSessionError(problem);
18509
18736
  const start = document.getElementById('ns-start');
@@ -18591,7 +18818,7 @@ function forkSession(parentId) {
18591
18818
  async function startNewSessionInBackground(id, v) {
18592
18819
  refreshPlaceholders();
18593
18820
  setNewSessionBusy(true);
18594
- const r = await newSessionPost('/api/terminal/start', { id, ...v }, 'Start failed');
18821
+ const r = await newSessionPost('/api/terminal/start', { id, ...v, claudeArgs: v.extraArgs }, 'Start failed');
18595
18822
  setNewSessionBusy(false);
18596
18823
  if (r.error) {
18597
18824
  dropPlaceholder(id);
@@ -18609,26 +18836,34 @@ async function startNewSessionInBackground(id, v) {
18609
18836
  }
18610
18837
 
18611
18838
  const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
18612
- const promptVoice = { rec: null };
18839
+ const promptVoice = { rec: null, hub: null };
18840
+ const HUB_DICTATE = 'voice.dictate';
18613
18841
 
18614
18842
  // Replaces the selection the field had when dictation began, so interim results rewrite
18615
18843
  // themselves in place instead of piling up.
18616
- function togglePromptVoice() {
18617
- if (promptVoice.rec) return promptVoice.rec.stop();
18844
+ function promptInserter() {
18618
18845
  const field = document.getElementById('ns-prompt');
18619
- const btn = document.getElementById('ns-mic');
18620
18846
  const before = field.value.slice(0, field.selectionStart);
18621
18847
  const after = field.value.slice(field.selectionEnd);
18622
18848
  const lead = before && !/\s$/.test(before) ? ' ' : '';
18623
- const rec = new SpeechRecognitionApi();
18624
- rec.lang = navigator.language;
18625
- rec.interimResults = true;
18626
- rec.onresult = (e) => {
18627
- const text = Array.from(e.results, (r) => r[0].transcript).join('');
18849
+ return (text) => {
18628
18850
  field.value = before + lead + text + after;
18629
18851
  const caret = before.length + lead.length + text.length;
18630
18852
  field.setSelectionRange(caret, caret);
18853
+ renderNewSessionForm();
18631
18854
  };
18855
+ }
18856
+
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();
18863
+ const rec = new SpeechRecognitionApi();
18864
+ rec.lang = navigator.language;
18865
+ rec.interimResults = true;
18866
+ rec.onresult = (e) => insert(Array.from(e.results, (r) => r[0].transcript).join(''));
18632
18867
  rec.onerror = (e) => {
18633
18868
  if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
18634
18869
  setNewSessionError('Microphone access is blocked for this page.');
@@ -18644,6 +18879,23 @@ function togglePromptVoice() {
18644
18879
  rec.start();
18645
18880
  }
18646
18881
 
18882
+ // 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 });
18891
+ if (!r.ok) {
18892
+ promptVoice.hub = null;
18893
+ btn.setAttribute('aria-pressed', 'false');
18894
+ return setNewSessionError(`Dictation failed: ${r.reason}`);
18895
+ }
18896
+ if (op === 'stop' && r.text) insert(r.text);
18897
+ }
18898
+
18647
18899
  function pickFolderOption(i) {
18648
18900
  const path = ns.matches[i];
18649
18901
  if (!path) return;
@@ -19135,6 +19387,179 @@ hub.onActive((active) => {
19135
19387
  hub.onStatus(pushTerminalClaims);
19136
19388
  hub.onSettings(applyHubSettings);
19137
19389
  hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session));
19390
+ hub.handle('session.find', (p) => findSessionFromHub(p));
19391
+ hub.handle('session.paste', (p) => pasteFromHub(p));
19392
+ hub.handle('task.add', (p) => addTaskFromHub(p));
19393
+ hub.handle('task.live', (p) => liveTaskFromHub(p));
19394
+ // After pending session.live calls, so the hub's clear of words typed into the terminal lands there, not in the dialog.
19395
+ hub.handle('session.new', (p) => {
19396
+ hubNewSession = liveChain.then(() => newSessionFromHub(p)).catch(() => {});
19397
+ });
19398
+ hub.handle('session.live', (p) => {
19399
+ liveChain = liveChain.then(() => liveFromHub(p)).catch(() => {});
19400
+ });
19401
+
19402
+ // Action params are strings, so flags come as "true" and `args` as claude args split on spaces
19403
+ // (the terminal refuses quotes in them anyway).
19404
+ function hubNewSessionValues(p) {
19405
+ const onScreen = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
19406
+ const filtered = filterProject && filterProject !== '__recent__' ? filterProject : null;
19407
+ const wt = p.worktree?.trim();
19408
+ const edit = p.edit === 'true';
19409
+ return {
19410
+ cwd: p.cwd || onScreen || filtered || '',
19411
+ name: p.name?.trim() || null,
19412
+ worktree: wt === 'true' ? true : wt && wt !== 'false' ? wt : false,
19413
+ model: p.model || null,
19414
+ effort: p.effort || null,
19415
+ prompt: p.prompt?.trim() || null,
19416
+ edit,
19417
+ stay: !edit && p.stay === 'true',
19418
+ extraArgs: p.args?.trim() ? p.args.trim().split(/\s+/) : [],
19419
+ };
19420
+ }
19421
+
19422
+ function newSessionFromHub(p) {
19423
+ if (!terminalAvailable()) return showToast('The embedded terminal is off', 'error');
19424
+ const v = hubNewSessionValues(p);
19425
+ const problem = newSessionProblem(v);
19426
+ if (problem) return showToast(problem, 'error');
19427
+ return newSessionDialogFromHub(v);
19428
+ }
19429
+
19430
+ // The dialog with the hub's values filled in, a folder picked (else the most recent project) and Start focused,
19431
+ // so the user sees what starts and one Enter starts it.
19432
+ async function newSessionDialogFromHub(v) {
19433
+ const opened = openNewSession(v.cwd);
19434
+ if (!opened) return;
19435
+ const set = (id, value) => {
19436
+ document.getElementById(id).value = value ?? '';
19437
+ };
19438
+ set('ns-name', v.name);
19439
+ set('ns-prompt', v.prompt);
19440
+ set('ns-model', v.model);
19441
+ set('ns-effort', v.effort);
19442
+ set('ns-wt-name', typeof v.worktree === 'string' ? v.worktree : '');
19443
+ document.getElementById('ns-wt').checked = v.worktree !== false;
19444
+ if (v.edit) document.getElementById('ns-edit').checked = true;
19445
+ if (v.stay) document.getElementById('ns-stay').checked = true;
19446
+ ns.extraArgs = v.extraArgs;
19447
+ renderNewSessionForm();
19448
+ await opened;
19449
+ if (!ns.folder && ns.projects[0]) setNewSessionFolder(ns.projects[0]);
19450
+ if (ns.folder) document.getElementById('ns-start').focus();
19451
+ }
19452
+
19453
+ // session.find from the hub: spoken words name a session in the list. Each session scores the share
19454
+ // of the words found in its name, project and branch; a clear best opens at once, else a toast
19455
+ // offers the closest.
19456
+ const FIND_MIN_SCORE = 0.6;
19457
+ const FIND_MIN_LEAD = 0.25;
19458
+ const FIND_SKIP = new Set(['the', 'an', 'my', 'session', 'on', 'in', 'of', 'for', 'to', 'and', 'with', 'about']);
19459
+ const findWords = (s) =>
19460
+ String(s ?? '')
19461
+ .toLowerCase()
19462
+ .split(/[^\p{L}\p{N}]+/u)
19463
+ .filter((w) => w.length > 1 && !FIND_SKIP.has(w));
19464
+ // Speech drops or adds endings ("test" for "tests"), so words of 4+ letters match on a shared start.
19465
+ const wordFound = (w, words) =>
19466
+ words.some((x) => x === w || (Math.min(x.length, w.length) >= 4 && (x.startsWith(w) || w.startsWith(x))));
19467
+
19468
+ function rankSessions(name) {
19469
+ const said = findWords(name);
19470
+ if (!said.length) return [];
19471
+ return sessions
19472
+ .filter((s) => !s.placeholder)
19473
+ .map((s) => {
19474
+ const words = findWords(
19475
+ `${sessionDisplayName(s)} ${s.project ? pathBasename(s.project) : ''} ${s.gitBranch ?? ''}`,
19476
+ );
19477
+ return { s, score: said.filter((w) => wordFound(w, words)).length / said.length };
19478
+ })
19479
+ .filter((r) => r.score > 0)
19480
+ .sort((a, b) => b.score - a.score || Date.parse(b.s.modifiedAt) - Date.parse(a.s.modifiedAt));
19481
+ }
19482
+
19483
+ function findSessionFromHub({ name }) {
19484
+ const ranked = rankSessions(name ?? '');
19485
+ const [best, next] = ranked;
19486
+ if (best && best.score >= FIND_MIN_SCORE && (!next || best.score - next.score >= FIND_MIN_LEAD)) {
19487
+ return revealSession(best.s.id);
19488
+ }
19489
+ if (isSessionPickerOpen()) closeSessionPicker({ refocus: false });
19490
+ openSessionPicker({ query: name?.trim() ?? '', ranked: ranked.length ? ranked.map((r) => r.s) : null });
19491
+ }
19492
+
19493
+ // Voice while the New session dialog is open is dictation into its prompt; Enter on Start sends it.
19494
+ function voiceIntoNewSession(text) {
19495
+ promptInserter()(text.trim());
19496
+ if (ns.folder) document.getElementById('ns-start').focus();
19497
+ }
19498
+
19499
+ // The hub's voice control sends dictated text here; with no session named, it goes to the one on screen.
19500
+ const newSessionOpen = () => document.getElementById('new-session-modal').classList.contains('visible');
19501
+ const hubTargetSession = (session) => session || (viewMode === 'session' && termState.shown && termState.sessionId);
19502
+ const noSessionToast = (text) =>
19503
+ showToast('No session on screen', 'info', { label: 'Copy', onClick: () => copyText(text, 'text') }, text);
19504
+
19505
+ async function pasteFromHub({ session, text, submit }) {
19506
+ if (!text) return;
19507
+ if (!session && newSessionOpen()) return voiceIntoNewSession(text);
19508
+ const id = hubTargetSession(session);
19509
+ if (!id) return noSessionToast(text);
19510
+ const res = await terminalFetch(`/api/terminal/paste/${encodeURIComponent(id)}`, 'POST', {
19511
+ text,
19512
+ submit: submit === 'true',
19513
+ });
19514
+ if (!res.ok) showToast((await res.json().catch(() => ({}))).error || `Paste failed (${res.status})`, 'error');
19515
+ }
19516
+
19517
+ // session.live: one key hold sends the words so far many times, then once with final. Calls run in
19518
+ // order, after a session.new sent just before them has opened its dialog.
19519
+ let hubNewSession = Promise.resolve();
19520
+ let liveChain = Promise.resolve();
19521
+ let liveText = { insert: null, typed: '', at: 0 };
19522
+ // A hold sends text about once a second, so a longer gap means the last hold ended without its
19523
+ // final call (the hub page reloaded) and its typed words are not this hold's to erase.
19524
+ const LIVE_GAP_MS = 10_000;
19525
+
19526
+ function setNewSessionVoice(on) {
19527
+ const modal = document.querySelector('.new-session-modal');
19528
+ setModalVoice(modal, [document.getElementById('ns-prompt')], on ? 'listening' : '', () => setNewSessionVoice(false));
19529
+ }
19530
+
19531
+ // `cancel` comes with an empty final text, which removes what the hold typed; "close" also closes
19532
+ // the New session dialog that the hold opened.
19533
+ async function liveFromHub({ session, text, final, submit, cancel }) {
19534
+ await hubNewSession;
19535
+ const done = final === 'true';
19536
+ if (Date.now() - liveText.at > LIVE_GAP_MS) liveText = { insert: null, typed: '', at: 0 };
19537
+ liveText.at = Date.now();
19538
+ const hold = liveText;
19539
+ if (done) liveText = { insert: null, typed: '', at: 0 };
19540
+ if (!session && newSessionOpen()) {
19541
+ hold.insert ??= promptInserter();
19542
+ hold.insert(text.trim());
19543
+ const prompt = document.getElementById('ns-prompt');
19544
+ prompt.scrollTop = prompt.scrollHeight;
19545
+ setNewSessionVoice(!done);
19546
+ if (cancel === 'close') closeNewSession();
19547
+ else if (done && !cancel && ns.folder) document.getElementById('ns-start').focus();
19548
+ return;
19549
+ }
19550
+ const id = hubTargetSession(session);
19551
+ if (!id) {
19552
+ if (done && text) noSessionToast(text);
19553
+ return;
19554
+ }
19555
+ const res = await terminalFetch(`/api/terminal/type/${encodeURIComponent(id)}`, 'POST', {
19556
+ from: hold.typed,
19557
+ text,
19558
+ submit: done && submit === 'true',
19559
+ });
19560
+ if (res.ok) hold.typed = text;
19561
+ else if (done) showToast((await res.json().catch(() => ({}))).error || `Typing failed (${res.status})`, 'error');
19562
+ }
19138
19563
 
19139
19564
  // session.changed goes out once the switch settles, so arrowing through the list sends one event.
19140
19565
  let publishTimer = 0;