claude-code-kanban 7.23.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/hub-app.json +20 -1
- package/lib/terminal-client.js +8 -3
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +47 -14
- package/package.json +2 -1
- package/public/app.js +491 -44
- package/public/index.html +44 -4
- package/public/style.css +148 -18
- package/public/vendor/claude-hub-sdk.js +2 -2
- package/server.js +43 -13
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
|
-
|
|
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
|
};
|
|
@@ -8377,6 +8559,7 @@ function stopReview() {
|
|
|
8377
8559
|
if (!activeReview) return;
|
|
8378
8560
|
const { ctl, panelEl, hostEl } = activeReview;
|
|
8379
8561
|
ctl.abort();
|
|
8562
|
+
cancelDictationIn(panelEl);
|
|
8380
8563
|
for (const c of reviewItems()) {
|
|
8381
8564
|
c.range = null;
|
|
8382
8565
|
c.frameId = 0;
|
|
@@ -8867,6 +9050,7 @@ function closeReviewPop() {
|
|
|
8867
9050
|
}
|
|
8868
9051
|
reviewPopMode = null;
|
|
8869
9052
|
const el = reviewPopEl();
|
|
9053
|
+
cancelDictationIn(el);
|
|
8870
9054
|
el.className = '';
|
|
8871
9055
|
el.replaceChildren();
|
|
8872
9056
|
}
|
|
@@ -8920,10 +9104,11 @@ function openReviewEditor(picked, text = '') {
|
|
|
8920
9104
|
reviewPendingHighlight?.add(picked.range);
|
|
8921
9105
|
} else if (picked.inFrame) postToReviewFrame('pend');
|
|
8922
9106
|
activeReview.picked = picked;
|
|
8923
|
-
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>
|
|
8924
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>`;
|
|
8925
9109
|
const ta = el.querySelector('textarea');
|
|
8926
9110
|
ta.value = text;
|
|
9111
|
+
addMic(ta);
|
|
8927
9112
|
const add = (comment) => addReviewComment(picked, comment);
|
|
8928
9113
|
ta.addEventListener('keydown', (e) => onReviewEditorKey(e, add, closeReviewPop));
|
|
8929
9114
|
el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
|
|
@@ -9079,6 +9264,7 @@ function renderReviewPanel() {
|
|
|
9079
9264
|
syncReviewSendClaims();
|
|
9080
9265
|
if (!activeReview) return;
|
|
9081
9266
|
const { panelEl, hostEl } = activeReview;
|
|
9267
|
+
cancelDictationIn(panelEl);
|
|
9082
9268
|
const items = reviewItems();
|
|
9083
9269
|
panelEl.hidden = !items.length;
|
|
9084
9270
|
hostEl?.classList.toggle('has-review', items.length > 0);
|
|
@@ -9092,7 +9278,7 @@ function renderReviewPanel() {
|
|
|
9092
9278
|
.map((c, i) => {
|
|
9093
9279
|
const body =
|
|
9094
9280
|
c === activeReview.editing?.c
|
|
9095
|
-
? `<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>`
|
|
9096
9282
|
: `<div class="review-card-comment" title="Double-click to edit">${escapeHtml(c.comment)}</div>`;
|
|
9097
9283
|
return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
|
|
9098
9284
|
<button type="button" class="review-edit" title="Edit" aria-label="Edit">${DETAIL_EDIT_SVG}</button>
|
|
@@ -9101,6 +9287,8 @@ function renderReviewPanel() {
|
|
|
9101
9287
|
${body}</li>`;
|
|
9102
9288
|
})
|
|
9103
9289
|
.join('')}</ol>`;
|
|
9290
|
+
const edit = panelEl.querySelector('.review-card-edit');
|
|
9291
|
+
if (edit) addMic(edit);
|
|
9104
9292
|
}
|
|
9105
9293
|
|
|
9106
9294
|
async function toastDelivery({ delivered, markdown }, noun) {
|
|
@@ -12530,6 +12718,8 @@ let spPeekToken = 0;
|
|
|
12530
12718
|
let spPeekedId = null;
|
|
12531
12719
|
let spFromTerminal = false;
|
|
12532
12720
|
let spRenderedQuery = null;
|
|
12721
|
+
// Rows ranked by the caller for the query the picker opened with; the first edit drops them.
|
|
12722
|
+
let spRanked = null;
|
|
12533
12723
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
12534
12724
|
const spPeekCache = new Map();
|
|
12535
12725
|
// 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
|
|
@@ -12583,10 +12773,11 @@ const spSearch = sessionSearch(() => {
|
|
|
12583
12773
|
if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
|
|
12584
12774
|
});
|
|
12585
12775
|
|
|
12586
|
-
function openSessionPicker() {
|
|
12776
|
+
function openSessionPicker({ query = '', ranked = null } = {}) {
|
|
12587
12777
|
spFromTerminal = terminalPaneFocused();
|
|
12588
12778
|
const input = document.getElementById('session-picker-input');
|
|
12589
|
-
input.value =
|
|
12779
|
+
input.value = query;
|
|
12780
|
+
spRanked = ranked;
|
|
12590
12781
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
12591
12782
|
spPeekCache.clear();
|
|
12592
12783
|
spRenderedQuery = null;
|
|
@@ -12716,7 +12907,7 @@ function renderSessionPicker() {
|
|
|
12716
12907
|
spRenderedQuery = renderKey;
|
|
12717
12908
|
const all = spScope === 'all';
|
|
12718
12909
|
const board = new Set(spSource.map((s) => s.id));
|
|
12719
|
-
const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
|
|
12910
|
+
const local = spRanked && !all ? spRanked : (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
|
|
12720
12911
|
const found = spSearch.rows(query, local);
|
|
12721
12912
|
const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
|
|
12722
12913
|
const extra = [...found.rows, ...tail];
|
|
@@ -12780,7 +12971,10 @@ async function spOpen(idx) {
|
|
|
12780
12971
|
|
|
12781
12972
|
function initSessionPicker() {
|
|
12782
12973
|
const input = document.getElementById('session-picker-input');
|
|
12783
|
-
input.addEventListener('input',
|
|
12974
|
+
input.addEventListener('input', () => {
|
|
12975
|
+
spRanked = null;
|
|
12976
|
+
renderSessionPicker();
|
|
12977
|
+
});
|
|
12784
12978
|
// Bound on the input: the global handler returns early on INPUT targets.
|
|
12785
12979
|
input.addEventListener('keydown', (e) => {
|
|
12786
12980
|
if (e.key === 'Escape') closeSessionPicker();
|
|
@@ -15264,6 +15458,7 @@ panePop.addEventListener('keydown', (e) => {
|
|
|
15264
15458
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
15265
15459
|
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
15266
15460
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
15461
|
+
const TERMINAL_SEEN_KEY = 'terminal-seen-sessions';
|
|
15267
15462
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
15268
15463
|
const TERMINAL_FONT_MIN = 8;
|
|
15269
15464
|
const TERMINAL_FONT_MAX = 32;
|
|
@@ -15355,6 +15550,16 @@ function setTerminalMode(sessionId, on) {
|
|
|
15355
15550
|
store.writeJson(TERMINAL_MODES_KEY, [...modes].slice(-50));
|
|
15356
15551
|
}
|
|
15357
15552
|
|
|
15553
|
+
// A session started elsewhere (dispatch, another board) runs in a terminal this board never turned on,
|
|
15554
|
+
// so its first open shows the terminal. Later opens keep what the user chose.
|
|
15555
|
+
function defaultToTerminalOnFirstOpen(sessionId) {
|
|
15556
|
+
if (!runningTerminals.has(sessionId)) return;
|
|
15557
|
+
const seen = readStoredList(TERMINAL_SEEN_KEY);
|
|
15558
|
+
if (seen.includes(sessionId)) return;
|
|
15559
|
+
store.writeJson(TERMINAL_SEEN_KEY, [...seen, sessionId].slice(-200));
|
|
15560
|
+
setTerminalMode(sessionId, true);
|
|
15561
|
+
}
|
|
15562
|
+
|
|
15358
15563
|
function wantsTerminal() {
|
|
15359
15564
|
return viewMode === 'session' && wantsTerminalFor(currentSessionId);
|
|
15360
15565
|
}
|
|
@@ -15543,6 +15748,7 @@ async function openSession(sessionId) {
|
|
|
15543
15748
|
setSessionDismissed(sessionId, false);
|
|
15544
15749
|
// Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
|
|
15545
15750
|
if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
|
|
15751
|
+
defaultToTerminalOnFirstOpen(sessionId);
|
|
15546
15752
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
15547
15753
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
15548
15754
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
@@ -16121,6 +16327,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
16121
16327
|
effort: spec.effort,
|
|
16122
16328
|
prompt: spec.prompt,
|
|
16123
16329
|
edit: spec.edit,
|
|
16330
|
+
extraArgs: spec.extraArgs,
|
|
16124
16331
|
forkOf: spec.forkOf,
|
|
16125
16332
|
}),
|
|
16126
16333
|
},
|
|
@@ -17574,8 +17781,10 @@ function dockOnScreen(active = dockActivePane()) {
|
|
|
17574
17781
|
function setDockAddon(id) {
|
|
17575
17782
|
const tid = showState.terminalId;
|
|
17576
17783
|
if (!tid || (dockAddons[tid] ?? null) === id) return;
|
|
17577
|
-
if (id)
|
|
17578
|
-
|
|
17784
|
+
if (id) {
|
|
17785
|
+
dockAddons[tid] = id;
|
|
17786
|
+
showState.closed = true;
|
|
17787
|
+
} else delete dockAddons[tid];
|
|
17579
17788
|
store.writeJson(DOCK_ADDON_KEY, dockAddons);
|
|
17580
17789
|
}
|
|
17581
17790
|
|
|
@@ -18083,7 +18292,17 @@ const newSpecs = new Map();
|
|
|
18083
18292
|
// Sessions another session started (`dispatch start`), shown where they will stay until
|
|
18084
18293
|
// their transcript appears. Kept apart from newSpecs: their prompt is already sent.
|
|
18085
18294
|
const dispatchSpecs = new Map();
|
|
18086
|
-
const ns = {
|
|
18295
|
+
const ns = {
|
|
18296
|
+
projects: [],
|
|
18297
|
+
picked: [],
|
|
18298
|
+
matches: [],
|
|
18299
|
+
idx: -1,
|
|
18300
|
+
folder: '',
|
|
18301
|
+
busy: false,
|
|
18302
|
+
resume: false,
|
|
18303
|
+
images: [],
|
|
18304
|
+
extraArgs: [],
|
|
18305
|
+
};
|
|
18087
18306
|
// Must match ATTACHMENT_TYPES and the upload limit in server.js.
|
|
18088
18307
|
const NS_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
18089
18308
|
const NS_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -18291,6 +18510,7 @@ function openNewSession(folder, resume = false) {
|
|
|
18291
18510
|
if (!terminalAvailable() || document.getElementById('new-session-modal').classList.contains('visible')) return;
|
|
18292
18511
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
18293
18512
|
ns.resume = resume;
|
|
18513
|
+
ns.extraArgs = [];
|
|
18294
18514
|
document.querySelector('.new-session-modal').classList.toggle('ns-resume', resume);
|
|
18295
18515
|
document.getElementById('ns-title').textContent = resume ? 'Resume session' : 'New session';
|
|
18296
18516
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
@@ -18308,7 +18528,7 @@ function openNewSession(folder, resume = false) {
|
|
|
18308
18528
|
const input = document.getElementById('ns-folder');
|
|
18309
18529
|
input.focus();
|
|
18310
18530
|
input.select();
|
|
18311
|
-
loadProjects().then((list) => {
|
|
18531
|
+
return loadProjects().then((list) => {
|
|
18312
18532
|
ns.projects = list
|
|
18313
18533
|
.filter((p) => !p.temp)
|
|
18314
18534
|
.sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''))
|
|
@@ -18320,10 +18540,11 @@ function openNewSession(folder, resume = false) {
|
|
|
18320
18540
|
|
|
18321
18541
|
function closeNewSession() {
|
|
18322
18542
|
hideModalOverlay('new-session-modal');
|
|
18543
|
+
setNewSessionVoice(false);
|
|
18323
18544
|
closeFolderList();
|
|
18324
18545
|
closeImagePreview();
|
|
18325
18546
|
clearNewSessionImages();
|
|
18326
|
-
|
|
18547
|
+
cancelDictationIn(document.getElementById('new-session-modal'));
|
|
18327
18548
|
}
|
|
18328
18549
|
|
|
18329
18550
|
function addNewSessionImages(files) {
|
|
@@ -18478,6 +18699,7 @@ function newSessionValues() {
|
|
|
18478
18699
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
18479
18700
|
edit,
|
|
18480
18701
|
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
18702
|
+
extraArgs: ns.resume ? [] : ns.extraArgs,
|
|
18481
18703
|
};
|
|
18482
18704
|
}
|
|
18483
18705
|
|
|
@@ -18490,7 +18712,11 @@ function newSessionProblem(v) {
|
|
|
18490
18712
|
}
|
|
18491
18713
|
|
|
18492
18714
|
function setNewSessionError(text) {
|
|
18493
|
-
|
|
18715
|
+
setFormError('ns-error', text);
|
|
18716
|
+
}
|
|
18717
|
+
|
|
18718
|
+
function setFormError(id, text) {
|
|
18719
|
+
const el = document.getElementById(id);
|
|
18494
18720
|
el.textContent = text;
|
|
18495
18721
|
el.hidden = !text;
|
|
18496
18722
|
}
|
|
@@ -18504,6 +18730,8 @@ function renderNewSessionForm() {
|
|
|
18504
18730
|
hint.hidden = !wtOn;
|
|
18505
18731
|
hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
|
|
18506
18732
|
document.getElementById('ns-stay').disabled = v.edit;
|
|
18733
|
+
document.getElementById('ns-args-row').hidden = !v.extraArgs.length;
|
|
18734
|
+
document.getElementById('ns-args').textContent = v.extraArgs.join(' ');
|
|
18507
18735
|
const problem = newSessionProblem(v);
|
|
18508
18736
|
setNewSessionError(problem);
|
|
18509
18737
|
const start = document.getElementById('ns-start');
|
|
@@ -18591,7 +18819,7 @@ function forkSession(parentId) {
|
|
|
18591
18819
|
async function startNewSessionInBackground(id, v) {
|
|
18592
18820
|
refreshPlaceholders();
|
|
18593
18821
|
setNewSessionBusy(true);
|
|
18594
|
-
const r = await newSessionPost('/api/terminal/start', { id, ...v }, 'Start failed');
|
|
18822
|
+
const r = await newSessionPost('/api/terminal/start', { id, ...v, claudeArgs: v.extraArgs }, 'Start failed');
|
|
18595
18823
|
setNewSessionBusy(false);
|
|
18596
18824
|
if (r.error) {
|
|
18597
18825
|
dropPlaceholder(id);
|
|
@@ -18609,41 +18837,93 @@ async function startNewSessionInBackground(id, v) {
|
|
|
18609
18837
|
}
|
|
18610
18838
|
|
|
18611
18839
|
const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
18612
|
-
|
|
18840
|
+
// One dictation at a time: { btn, rec } in the browser, { btn, insert } under the hub.
|
|
18841
|
+
let dictation = null;
|
|
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
|
+
}
|
|
18613
18857
|
|
|
18614
18858
|
// Replaces the selection the field had when dictation began, so interim results rewrite
|
|
18615
|
-
// themselves in place instead of piling up.
|
|
18616
|
-
function
|
|
18617
|
-
if (promptVoice.rec) return promptVoice.rec.stop();
|
|
18618
|
-
const field = document.getElementById('ns-prompt');
|
|
18619
|
-
const btn = document.getElementById('ns-mic');
|
|
18859
|
+
// themselves in place instead of piling up. Fires `input`, as typing would.
|
|
18860
|
+
function fieldInserter(field) {
|
|
18620
18861
|
const before = field.value.slice(0, field.selectionStart);
|
|
18621
18862
|
const after = field.value.slice(field.selectionEnd);
|
|
18622
18863
|
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
18623
|
-
|
|
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('');
|
|
18864
|
+
return (text) => {
|
|
18628
18865
|
field.value = before + lead + text + after;
|
|
18629
18866
|
const caret = before.length + lead.length + text.length;
|
|
18630
18867
|
field.setSelectionRange(caret, caret);
|
|
18868
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
18631
18869
|
};
|
|
18870
|
+
}
|
|
18871
|
+
|
|
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);
|
|
18877
|
+
const rec = new SpeechRecognitionApi();
|
|
18878
|
+
rec.lang = navigator.language;
|
|
18879
|
+
rec.interimResults = true;
|
|
18880
|
+
rec.onresult = (e) => insert(Array.from(e.results, (r) => r[0].transcript).join(''));
|
|
18632
18881
|
rec.onerror = (e) => {
|
|
18633
18882
|
if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
|
|
18634
|
-
|
|
18635
|
-
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}`);
|
|
18636
18885
|
};
|
|
18637
18886
|
rec.onend = () => {
|
|
18638
|
-
promptVoice.rec = null;
|
|
18639
18887
|
btn.setAttribute('aria-pressed', 'false');
|
|
18888
|
+
if (dictation?.rec === rec) dictation = null;
|
|
18640
18889
|
};
|
|
18641
|
-
|
|
18890
|
+
dictation = { btn, rec };
|
|
18642
18891
|
btn.setAttribute('aria-pressed', 'true');
|
|
18643
18892
|
field.focus();
|
|
18644
18893
|
rec.start();
|
|
18645
18894
|
}
|
|
18646
18895
|
|
|
18896
|
+
// Under a hub with voice on, the mic uses the hub's speech provider: the text arrives once, on the second click.
|
|
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' });
|
|
18904
|
+
if (!r.ok) {
|
|
18905
|
+
if (dictation?.insert === insert) dictation = null;
|
|
18906
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
18907
|
+
return onError(`Dictation failed: ${r.reason}`);
|
|
18908
|
+
}
|
|
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();
|
|
18925
|
+
}
|
|
18926
|
+
|
|
18647
18927
|
function pickFolderOption(i) {
|
|
18648
18928
|
const path = ns.matches[i];
|
|
18649
18929
|
if (!path) return;
|
|
@@ -18693,14 +18973,8 @@ function initNewSession() {
|
|
|
18693
18973
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
|
|
18694
18974
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
18695
18975
|
}
|
|
18696
|
-
if (SpeechRecognitionApi) {
|
|
18697
|
-
const mic = document.getElementById('ns-mic');
|
|
18698
|
-
mic.hidden = false;
|
|
18699
|
-
// mousedown would blur the textarea and lose the caret the dictation inserts at.
|
|
18700
|
-
mic.addEventListener('mousedown', (e) => e.preventDefault());
|
|
18701
|
-
mic.addEventListener('click', togglePromptVoice);
|
|
18702
|
-
}
|
|
18703
18976
|
const prompt = document.getElementById('ns-prompt');
|
|
18977
|
+
addMic(prompt, setNewSessionError);
|
|
18704
18978
|
const promptField = prompt.closest('.ns-prompt-field');
|
|
18705
18979
|
prompt.addEventListener('paste', (e) => {
|
|
18706
18980
|
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'));
|
|
@@ -19135,6 +19409,179 @@ hub.onActive((active) => {
|
|
|
19135
19409
|
hub.onStatus(pushTerminalClaims);
|
|
19136
19410
|
hub.onSettings(applyHubSettings);
|
|
19137
19411
|
hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session));
|
|
19412
|
+
hub.handle('session.find', (p) => findSessionFromHub(p));
|
|
19413
|
+
hub.handle('session.paste', (p) => pasteFromHub(p));
|
|
19414
|
+
hub.handle('task.add', (p) => addTaskFromHub(p));
|
|
19415
|
+
hub.handle('task.live', (p) => liveTaskFromHub(p));
|
|
19416
|
+
// After pending session.live calls, so the hub's clear of words typed into the terminal lands there, not in the dialog.
|
|
19417
|
+
hub.handle('session.new', (p) => {
|
|
19418
|
+
hubNewSession = liveChain.then(() => newSessionFromHub(p)).catch(() => {});
|
|
19419
|
+
});
|
|
19420
|
+
hub.handle('session.live', (p) => {
|
|
19421
|
+
liveChain = liveChain.then(() => liveFromHub(p)).catch(() => {});
|
|
19422
|
+
});
|
|
19423
|
+
|
|
19424
|
+
// Action params are strings, so flags come as "true" and `args` as claude args split on spaces
|
|
19425
|
+
// (the terminal refuses quotes in them anyway).
|
|
19426
|
+
function hubNewSessionValues(p) {
|
|
19427
|
+
const onScreen = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
19428
|
+
const filtered = filterProject && filterProject !== '__recent__' ? filterProject : null;
|
|
19429
|
+
const wt = p.worktree?.trim();
|
|
19430
|
+
const edit = p.edit === 'true';
|
|
19431
|
+
return {
|
|
19432
|
+
cwd: p.cwd || onScreen || filtered || '',
|
|
19433
|
+
name: p.name?.trim() || null,
|
|
19434
|
+
worktree: wt === 'true' ? true : wt && wt !== 'false' ? wt : false,
|
|
19435
|
+
model: p.model || null,
|
|
19436
|
+
effort: p.effort || null,
|
|
19437
|
+
prompt: p.prompt?.trim() || null,
|
|
19438
|
+
edit,
|
|
19439
|
+
stay: !edit && p.stay === 'true',
|
|
19440
|
+
extraArgs: p.args?.trim() ? p.args.trim().split(/\s+/) : [],
|
|
19441
|
+
};
|
|
19442
|
+
}
|
|
19443
|
+
|
|
19444
|
+
function newSessionFromHub(p) {
|
|
19445
|
+
if (!terminalAvailable()) return showToast('The embedded terminal is off', 'error');
|
|
19446
|
+
const v = hubNewSessionValues(p);
|
|
19447
|
+
const problem = newSessionProblem(v);
|
|
19448
|
+
if (problem) return showToast(problem, 'error');
|
|
19449
|
+
return newSessionDialogFromHub(v);
|
|
19450
|
+
}
|
|
19451
|
+
|
|
19452
|
+
// The dialog with the hub's values filled in, a folder picked (else the most recent project) and Start focused,
|
|
19453
|
+
// so the user sees what starts and one Enter starts it.
|
|
19454
|
+
async function newSessionDialogFromHub(v) {
|
|
19455
|
+
const opened = openNewSession(v.cwd);
|
|
19456
|
+
if (!opened) return;
|
|
19457
|
+
const set = (id, value) => {
|
|
19458
|
+
document.getElementById(id).value = value ?? '';
|
|
19459
|
+
};
|
|
19460
|
+
set('ns-name', v.name);
|
|
19461
|
+
set('ns-prompt', v.prompt);
|
|
19462
|
+
set('ns-model', v.model);
|
|
19463
|
+
set('ns-effort', v.effort);
|
|
19464
|
+
set('ns-wt-name', typeof v.worktree === 'string' ? v.worktree : '');
|
|
19465
|
+
document.getElementById('ns-wt').checked = v.worktree !== false;
|
|
19466
|
+
if (v.edit) document.getElementById('ns-edit').checked = true;
|
|
19467
|
+
if (v.stay) document.getElementById('ns-stay').checked = true;
|
|
19468
|
+
ns.extraArgs = v.extraArgs;
|
|
19469
|
+
renderNewSessionForm();
|
|
19470
|
+
await opened;
|
|
19471
|
+
if (!ns.folder && ns.projects[0]) setNewSessionFolder(ns.projects[0]);
|
|
19472
|
+
if (ns.folder) document.getElementById('ns-start').focus();
|
|
19473
|
+
}
|
|
19474
|
+
|
|
19475
|
+
// session.find from the hub: spoken words name a session in the list. Each session scores the share
|
|
19476
|
+
// of the words found in its name, project and branch; a clear best opens at once, else a toast
|
|
19477
|
+
// offers the closest.
|
|
19478
|
+
const FIND_MIN_SCORE = 0.6;
|
|
19479
|
+
const FIND_MIN_LEAD = 0.25;
|
|
19480
|
+
const FIND_SKIP = new Set(['the', 'an', 'my', 'session', 'on', 'in', 'of', 'for', 'to', 'and', 'with', 'about']);
|
|
19481
|
+
const findWords = (s) =>
|
|
19482
|
+
String(s ?? '')
|
|
19483
|
+
.toLowerCase()
|
|
19484
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
19485
|
+
.filter((w) => w.length > 1 && !FIND_SKIP.has(w));
|
|
19486
|
+
// Speech drops or adds endings ("test" for "tests"), so words of 4+ letters match on a shared start.
|
|
19487
|
+
const wordFound = (w, words) =>
|
|
19488
|
+
words.some((x) => x === w || (Math.min(x.length, w.length) >= 4 && (x.startsWith(w) || w.startsWith(x))));
|
|
19489
|
+
|
|
19490
|
+
function rankSessions(name) {
|
|
19491
|
+
const said = findWords(name);
|
|
19492
|
+
if (!said.length) return [];
|
|
19493
|
+
return sessions
|
|
19494
|
+
.filter((s) => !s.placeholder)
|
|
19495
|
+
.map((s) => {
|
|
19496
|
+
const words = findWords(
|
|
19497
|
+
`${sessionDisplayName(s)} ${s.project ? pathBasename(s.project) : ''} ${s.gitBranch ?? ''}`,
|
|
19498
|
+
);
|
|
19499
|
+
return { s, score: said.filter((w) => wordFound(w, words)).length / said.length };
|
|
19500
|
+
})
|
|
19501
|
+
.filter((r) => r.score > 0)
|
|
19502
|
+
.sort((a, b) => b.score - a.score || Date.parse(b.s.modifiedAt) - Date.parse(a.s.modifiedAt));
|
|
19503
|
+
}
|
|
19504
|
+
|
|
19505
|
+
function findSessionFromHub({ name }) {
|
|
19506
|
+
const ranked = rankSessions(name ?? '');
|
|
19507
|
+
const [best, next] = ranked;
|
|
19508
|
+
if (best && best.score >= FIND_MIN_SCORE && (!next || best.score - next.score >= FIND_MIN_LEAD)) {
|
|
19509
|
+
return revealSession(best.s.id);
|
|
19510
|
+
}
|
|
19511
|
+
if (isSessionPickerOpen()) closeSessionPicker({ refocus: false });
|
|
19512
|
+
openSessionPicker({ query: name?.trim() ?? '', ranked: ranked.length ? ranked.map((r) => r.s) : null });
|
|
19513
|
+
}
|
|
19514
|
+
|
|
19515
|
+
// Voice while the New session dialog is open is dictation into its prompt; Enter on Start sends it.
|
|
19516
|
+
function voiceIntoNewSession(text) {
|
|
19517
|
+
promptInserter()(text.trim());
|
|
19518
|
+
if (ns.folder) document.getElementById('ns-start').focus();
|
|
19519
|
+
}
|
|
19520
|
+
|
|
19521
|
+
// The hub's voice control sends dictated text here; with no session named, it goes to the one on screen.
|
|
19522
|
+
const newSessionOpen = () => document.getElementById('new-session-modal').classList.contains('visible');
|
|
19523
|
+
const hubTargetSession = (session) => session || (viewMode === 'session' && termState.shown && termState.sessionId);
|
|
19524
|
+
const noSessionToast = (text) =>
|
|
19525
|
+
showToast('No session on screen', 'info', { label: 'Copy', onClick: () => copyText(text, 'text') }, text);
|
|
19526
|
+
|
|
19527
|
+
async function pasteFromHub({ session, text, submit }) {
|
|
19528
|
+
if (!text) return;
|
|
19529
|
+
if (!session && newSessionOpen()) return voiceIntoNewSession(text);
|
|
19530
|
+
const id = hubTargetSession(session);
|
|
19531
|
+
if (!id) return noSessionToast(text);
|
|
19532
|
+
const res = await terminalFetch(`/api/terminal/paste/${encodeURIComponent(id)}`, 'POST', {
|
|
19533
|
+
text,
|
|
19534
|
+
submit: submit === 'true',
|
|
19535
|
+
});
|
|
19536
|
+
if (!res.ok) showToast((await res.json().catch(() => ({}))).error || `Paste failed (${res.status})`, 'error');
|
|
19537
|
+
}
|
|
19538
|
+
|
|
19539
|
+
// session.live: one key hold sends the words so far many times, then once with final. Calls run in
|
|
19540
|
+
// order, after a session.new sent just before them has opened its dialog.
|
|
19541
|
+
let hubNewSession = Promise.resolve();
|
|
19542
|
+
let liveChain = Promise.resolve();
|
|
19543
|
+
let liveText = { insert: null, typed: '', at: 0 };
|
|
19544
|
+
// A hold sends text about once a second, so a longer gap means the last hold ended without its
|
|
19545
|
+
// final call (the hub page reloaded) and its typed words are not this hold's to erase.
|
|
19546
|
+
const LIVE_GAP_MS = 10_000;
|
|
19547
|
+
|
|
19548
|
+
function setNewSessionVoice(on) {
|
|
19549
|
+
const modal = document.querySelector('.new-session-modal');
|
|
19550
|
+
setModalVoice(modal, [document.getElementById('ns-prompt')], on ? 'listening' : '', () => setNewSessionVoice(false));
|
|
19551
|
+
}
|
|
19552
|
+
|
|
19553
|
+
// `cancel` comes with an empty final text, which removes what the hold typed; "close" also closes
|
|
19554
|
+
// the New session dialog that the hold opened.
|
|
19555
|
+
async function liveFromHub({ session, text, final, submit, cancel }) {
|
|
19556
|
+
await hubNewSession;
|
|
19557
|
+
const done = final === 'true';
|
|
19558
|
+
if (Date.now() - liveText.at > LIVE_GAP_MS) liveText = { insert: null, typed: '', at: 0 };
|
|
19559
|
+
liveText.at = Date.now();
|
|
19560
|
+
const hold = liveText;
|
|
19561
|
+
if (done) liveText = { insert: null, typed: '', at: 0 };
|
|
19562
|
+
if (!session && newSessionOpen()) {
|
|
19563
|
+
hold.insert ??= promptInserter();
|
|
19564
|
+
hold.insert(text.trim());
|
|
19565
|
+
const prompt = document.getElementById('ns-prompt');
|
|
19566
|
+
prompt.scrollTop = prompt.scrollHeight;
|
|
19567
|
+
setNewSessionVoice(!done);
|
|
19568
|
+
if (cancel === 'close') closeNewSession();
|
|
19569
|
+
else if (done && !cancel && ns.folder) document.getElementById('ns-start').focus();
|
|
19570
|
+
return;
|
|
19571
|
+
}
|
|
19572
|
+
const id = hubTargetSession(session);
|
|
19573
|
+
if (!id) {
|
|
19574
|
+
if (done && text) noSessionToast(text);
|
|
19575
|
+
return;
|
|
19576
|
+
}
|
|
19577
|
+
const res = await terminalFetch(`/api/terminal/type/${encodeURIComponent(id)}`, 'POST', {
|
|
19578
|
+
from: hold.typed,
|
|
19579
|
+
text,
|
|
19580
|
+
submit: done && submit === 'true',
|
|
19581
|
+
});
|
|
19582
|
+
if (res.ok) hold.typed = text;
|
|
19583
|
+
else if (done) showToast((await res.json().catch(() => ({}))).error || `Typing failed (${res.status})`, 'error');
|
|
19584
|
+
}
|
|
19138
19585
|
|
|
19139
19586
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
19140
19587
|
let publishTimer = 0;
|