claude-code-kanban 5.4.0 → 6.1.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
@@ -4,7 +4,8 @@ const STORAGE_NS = window.__STORAGE_NS__ ? `${window.__STORAGE_NS__}:` : '';
4
4
  // Theme is hub-wide (echoed to every app via hub:theme), so it stays shared across config dirs.
5
5
  const GLOBAL_KEYS = new Set(['theme', 'color-theme']);
6
6
  const nsKey = (k) => (GLOBAL_KEYS.has(k) ? k : STORAGE_NS + k);
7
- const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher } = projectMatch;
7
+ const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey } =
8
+ projectMatch;
8
9
  const store = {
9
10
  keys() {
10
11
  const out = [];
@@ -3600,7 +3601,8 @@ function renderSessions() {
3600
3601
  const showCtx = !!session.contextStatus && !zenMode;
3601
3602
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
3602
3603
  const bookmarksCount = loadPins(session.id).length;
3603
- const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
3604
+ const padEmoji = store.getItem(_padEmojiKey(session.id));
3605
+ const hasScratchpad = !!padEmoji || _hasScratchpad(_sessionScratchpadKey(session.id));
3604
3606
  const tempClass =
3605
3607
  session.hasRecentLog || session.hasRecentActivity || session.inProgress || session.hasWaitingForUser
3606
3608
  ? 'warm'
@@ -3631,7 +3633,7 @@ function renderSessions() {
3631
3633
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="View session info"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
3632
3634
  ${renderWorkflowBadge(session)}
3633
3635
  ${renderLoopBadge(session)}
3634
- ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${sid}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
3636
+ ${hasScratchpad ? `<span class="scratchpad-badge${padEmoji ? ' has-emoji' : ''}" onclick="event.stopPropagation(); openSessionScratchpad('${sid}')" title="Open scratchpad">${padEmoji ? escapeHtml(padEmoji) : `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`}</span>` : ''}
3635
3637
  ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${sid}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
3636
3638
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
3637
3639
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
@@ -3781,7 +3783,7 @@ function renderSessions() {
3781
3783
  // Each session under its own project block, so a worktree session reads as its repo's.
3782
3784
  const byProject = new Map();
3783
3785
  for (const s of bucket) {
3784
- const key = s.project || '';
3786
+ const key = sessionProjectKey(s) || '';
3785
3787
  if (!byProject.has(key)) byProject.set(key, []);
3786
3788
  byProject.get(key).push(s);
3787
3789
  }
@@ -3844,9 +3846,10 @@ function renderSessions() {
3844
3846
  const groups = new Map();
3845
3847
  const ungrouped = [];
3846
3848
  for (const session of sgRest) {
3847
- if (session.project) {
3848
- if (!groups.has(session.project)) groups.set(session.project, []);
3849
- groups.get(session.project).push(session);
3849
+ const key = sessionProjectKey(session);
3850
+ if (key) {
3851
+ if (!groups.has(key)) groups.set(key, []);
3852
+ groups.get(key).push(session);
3850
3853
  } else {
3851
3854
  ungrouped.push(session);
3852
3855
  }
@@ -3861,12 +3864,13 @@ function renderSessions() {
3861
3864
  // back out restores its old slot instead of jumping to the top.
3862
3865
  const latestByPath = new Map();
3863
3866
  for (const s of filteredSessions) {
3864
- if (!s.project) continue;
3867
+ const key = sessionProjectKey(s);
3868
+ if (!key) continue;
3865
3869
  const t = new Date(s.modifiedAt).getTime();
3866
- latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
3870
+ latestByPath.set(key, Math.max(latestByPath.get(key) ?? -Infinity, t));
3867
3871
  }
3868
3872
  const byRecency = [...latestByPath.keys()].sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
3869
- const loadedPaths = new Set(sessions.map((s) => s.project));
3873
+ const loadedPaths = new Set(sessions.map(sessionProjectKey));
3870
3874
  stableGroupOrder = mergeOrder(PROJECT_ORDER_KEY, stableGroupOrder, loadedPaths, byRecency);
3871
3875
  const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
3872
3876
 
@@ -4407,7 +4411,8 @@ function sgGroupForSession(session) {
4407
4411
  }
4408
4412
 
4409
4413
  function sgUserGroupFor(session) {
4410
- return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
4414
+ const project = sessionProjectKey(session);
4415
+ return sgGroupOf('session', session.id) || (project ? sgGroupOf('project', project) : null);
4411
4416
  }
4412
4417
 
4413
4418
  // Transient groups come from the server (`dispatch start --group`) and go when their sessions
@@ -4424,20 +4429,18 @@ function sgTransientGroup(name) {
4424
4429
  return group;
4425
4430
  }
4426
4431
 
4427
- function dispatchedTitle({ parent, outcome }) {
4432
+ function dispatchedTitle({ parent }) {
4428
4433
  const starter = parent && sessions.find((s) => s.id === parent);
4429
4434
  const by = starter ? sessionDisplayName(starter) : 'another session';
4430
- return `Started by ${by}${outcome ? ` · ${outcome}` : ''}${parent ? ' — click to reveal it' : ''}`;
4435
+ return `Started by ${by}${parent ? ' — click to reveal it' : ''}`;
4431
4436
  }
4432
4437
 
4433
4438
  // A user group with the same name takes the session in, so Keep does not split a group in two.
4434
- // With no group of its own, a started session follows its starter into the starter's named group.
4435
4439
  function sgDispatchGroupFor(session) {
4436
4440
  if (sgReleased.has(session.id)) return null;
4437
4441
  const name = session.dispatchGroup;
4438
- if (name) return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
4439
- const starter = session.startedBy && sessions.find((s) => s.id === session.startedBy);
4440
- return starter ? sgUserGroupFor(starter) : null;
4442
+ if (!name) return null;
4443
+ return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
4441
4444
  }
4442
4445
 
4443
4446
  function sgKeepTransient(name) {
@@ -4467,7 +4470,8 @@ function sgHostOf(group, session) {
4467
4470
  const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
4468
4471
  const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
4469
4472
  if (m?.under && isMemberProject(m.under)) return m.under;
4470
- if (!m?.loose && isMemberProject(session.project)) return session.project;
4473
+ const own = sessionProjectKey(session);
4474
+ if (!m?.loose && isMemberProject(own)) return own;
4471
4475
  return null;
4472
4476
  }
4473
4477
  // `opts.under` places the session under another project block of the group; `opts.loose` lifts it
@@ -4478,9 +4482,9 @@ function sgAssign(groupId, type, ref, opts = {}) {
4478
4482
  sgDetach(type, ref);
4479
4483
  // Pulling a whole project in supersedes the individual placements of its sessions.
4480
4484
  if (type === 'project') {
4481
- for (const s of sessions) if (s.project === ref) sgDetach('session', s.id);
4485
+ for (const s of sessions) if (sessionProjectKey(s) === ref) sgDetach('session', s.id);
4482
4486
  }
4483
- const own = type === 'session' ? sessions.find((s) => s.id === ref)?.project : null;
4487
+ const own = type === 'session' ? sessionProjectKey(sessions.find((s) => s.id === ref)) : null;
4484
4488
  // Under its own project block is simply the natural placement - no override needed.
4485
4489
  const under = opts.under && opts.under !== own ? opts.under : null;
4486
4490
  const loose = !under && !!opts.loose;
@@ -4758,7 +4762,7 @@ function sgGroupDropZone(target) {
4758
4762
  return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
4759
4763
  }
4760
4764
  // Stacking a session onto its own project block would only fold it back where it sits today.
4761
- if (dragSession?.project === path) return releasable ? { zone } : null;
4765
+ if (dragSession && sessionProjectKey(dragSession) === path) return releasable ? { zone } : null;
4762
4766
  return { zone, pairWith: path };
4763
4767
  }
4764
4768
 
@@ -4811,7 +4815,11 @@ function sgOnDrop(e) {
4811
4815
  // Stacked onto an ungrouped item: seed a group, then let the user name it in place.
4812
4816
  const target = hit.pairWith
4813
4817
  ? { type: 'project', ref: hit.pairWith, path: hit.pairWith }
4814
- : { type: 'session', ref: hit.pairSession, path: sessions.find((s) => s.id === hit.pairSession)?.project };
4818
+ : {
4819
+ type: 'session',
4820
+ ref: hit.pairSession,
4821
+ path: sessionProjectKey(sessions.find((s) => s.id === hit.pairSession)),
4822
+ };
4815
4823
  const group = sgCreateGroup(target.path ? target.path.split(/[/\\]/).pop() : '');
4816
4824
  sgAssign(group.id, target.type, target.ref);
4817
4825
  sgAssign(group.id, drag.kind, drag.ref);
@@ -5253,7 +5261,7 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5253
5261
  function uncollapseFor(session) {
5254
5262
  const group = sgGroupForSession(session);
5255
5263
  const keys = [
5256
- session.project || '__ungrouped__',
5264
+ sessionProjectKey(session) || '__ungrouped__',
5257
5265
  ...pinnedCollapseKeys(session),
5258
5266
  group && sgKey(group.id),
5259
5267
  SECTION_GROUPS,
@@ -5271,7 +5279,11 @@ function pinnedCollapseKeys(session) {
5271
5279
  if (!isInPinnedGroup(session)) return [];
5272
5280
  const group = sgGroupForSession(session);
5273
5281
  const host = sgHostOf(group, session);
5274
- return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
5282
+ return [
5283
+ pinKey(sessionProjectKey(session) || '__ungrouped__'),
5284
+ host && pinKey(host),
5285
+ group && `__pinned_group_${group.id}__`,
5286
+ ];
5275
5287
  }
5276
5288
 
5277
5289
  // A card that moves into a collapsed Pinned sub-section would vanish from under the click.
@@ -5791,6 +5803,7 @@ const SHORTCUT_TABS = [
5791
5803
  { keys: ['Enter'], label: 'Toggle task detail panel' },
5792
5804
  { keys: ['D'], label: 'Delete selected task' },
5793
5805
  { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5806
+ { keys: ['Ctrl', 'E'], combo: true, label: 'Set the session scratchpad emoji (in the scratchpad)' },
5794
5807
  { keys: ['R'], label: 'Refresh data' },
5795
5808
  { keys: ['Esc'], label: 'Close panel / clear selection' },
5796
5809
  ],
@@ -6037,6 +6050,140 @@ function _hasScratchpad(key) {
6037
6050
  return !!(store.getItem(key) || '').trim();
6038
6051
  }
6039
6052
 
6053
+ // Not under SESSION_PAD_PREFIX: _parsePadKey would read it as a session pad.
6054
+ const PAD_EMOJI_PREFIX = 'pad-emoji-';
6055
+ const PAD_EMOJIS = [
6056
+ '🔥',
6057
+ '🚧',
6058
+ '🐛',
6059
+ '✅',
6060
+ '⭐',
6061
+ '📌',
6062
+ '💡',
6063
+ '🧪',
6064
+ '🚀',
6065
+ '⚠️',
6066
+ '🔒',
6067
+ '🧹',
6068
+ '📝',
6069
+ '🎯',
6070
+ '🧭',
6071
+ '🛠️',
6072
+ '🔍',
6073
+ '📦',
6074
+ '🎨',
6075
+ '⏳',
6076
+ '❓',
6077
+ '💬',
6078
+ '🧠',
6079
+ '🏁',
6080
+ ];
6081
+ const PAD_EMOJI_COLS = 8;
6082
+ const _scratchpadEmojiBtn = document.getElementById('scratchpad-emoji-btn');
6083
+ const _scratchpadEmojiPicker = document.getElementById('scratchpad-emoji-picker');
6084
+ const _scratchpadEmojiGrid = document.getElementById('scratchpad-emoji-grid');
6085
+ const _scratchpadEmojiInput = document.getElementById('scratchpad-emoji-input');
6086
+ _scratchpadEmojiGrid.style.gridTemplateColumns = `repeat(${PAD_EMOJI_COLS}, 1fr)`;
6087
+ let _scratchpadEmojiCursor = 0;
6088
+
6089
+ function _padEmojiKey(sessionId) {
6090
+ return PAD_EMOJI_PREFIX + sessionId;
6091
+ }
6092
+
6093
+ function _scratchpadEmojiSessionId() {
6094
+ const pad = _parsePadKey(_scratchpadKey() || '');
6095
+ return pad?.kind === 'session' ? pad.id : null;
6096
+ }
6097
+
6098
+ function _firstGrapheme(s) {
6099
+ const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s.trim());
6100
+ return seg[Symbol.iterator]().next().value?.segment || '';
6101
+ }
6102
+
6103
+ function _syncScratchpadEmojiBtn() {
6104
+ const sid = _scratchpadEmojiSessionId();
6105
+ _scratchpadEmojiBtn.hidden = !sid;
6106
+ const emoji = sid ? store.getItem(_padEmojiKey(sid)) : null;
6107
+ _scratchpadEmojiBtn.textContent = emoji || '+';
6108
+ _scratchpadEmojiBtn.classList.toggle('set', !!emoji);
6109
+ _scratchpadEmojiBtn.title = `${emoji ? 'Change' : 'Set'} scratchpad emoji (Ctrl+E)`;
6110
+ }
6111
+
6112
+ function _markScratchpadEmojiCursor() {
6113
+ _scratchpadEmojiGrid.querySelectorAll('button').forEach((b, i) => {
6114
+ b.classList.toggle('kb', i === _scratchpadEmojiCursor);
6115
+ });
6116
+ }
6117
+
6118
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
6119
+ function toggleScratchpadEmojiPicker() {
6120
+ if (_scratchpadEmojiPicker.classList.contains('open')) _closeScratchpadEmojiPicker();
6121
+ else _openScratchpadEmojiPicker();
6122
+ }
6123
+
6124
+ function _openScratchpadEmojiPicker() {
6125
+ const sid = _scratchpadEmojiSessionId();
6126
+ if (!sid) return;
6127
+ const cur = store.getItem(_padEmojiKey(sid));
6128
+ _scratchpadEmojiCursor = Math.max(0, PAD_EMOJIS.indexOf(cur));
6129
+ _scratchpadEmojiGrid.innerHTML = PAD_EMOJIS.map(
6130
+ (e, i) => `<button type="button" data-i="${i}" class="${e === cur ? 'cur' : ''}">${e}</button>`,
6131
+ ).join('');
6132
+ _markScratchpadEmojiCursor();
6133
+ _scratchpadEmojiInput.value = '';
6134
+ _scratchpadEmojiPicker.classList.add('open');
6135
+ _scratchpadEmojiPicker.focus();
6136
+ }
6137
+
6138
+ function _closeScratchpadEmojiPicker(refocus = true) {
6139
+ if (!_scratchpadEmojiPicker.classList.contains('open')) return;
6140
+ _scratchpadEmojiPicker.classList.remove('open');
6141
+ if (refocus) _scratchpadTextarea.focus();
6142
+ }
6143
+
6144
+ function setScratchpadEmoji(value) {
6145
+ const sid = _scratchpadEmojiSessionId();
6146
+ if (!sid) return;
6147
+ const emoji = _firstGrapheme(value || '');
6148
+ if (emoji) store.setItem(_padEmojiKey(sid), emoji);
6149
+ else store.removeItem(_padEmojiKey(sid));
6150
+ _syncScratchpadEmojiBtn();
6151
+ _closeScratchpadEmojiPicker();
6152
+ renderSessions();
6153
+ }
6154
+
6155
+ _scratchpadEmojiGrid.addEventListener('click', (e) => {
6156
+ const b = e.target.closest('button[data-i]');
6157
+ if (b) setScratchpadEmoji(PAD_EMOJIS[+b.dataset.i]);
6158
+ });
6159
+
6160
+ // Stops propagation so the global handler does not read Escape as "close the scratchpad".
6161
+ _scratchpadEmojiPicker.addEventListener('keydown', (e) => {
6162
+ e.stopPropagation();
6163
+ if (matchKey(e, 'Escape')) {
6164
+ e.preventDefault();
6165
+ _closeScratchpadEmojiPicker();
6166
+ return;
6167
+ }
6168
+ if (e.target === _scratchpadEmojiInput) {
6169
+ if (matchKey(e, 'Enter') && _scratchpadEmojiInput.value.trim()) {
6170
+ e.preventDefault();
6171
+ setScratchpadEmoji(_scratchpadEmojiInput.value);
6172
+ }
6173
+ return;
6174
+ }
6175
+ const moves = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: PAD_EMOJI_COLS, ArrowUp: -PAD_EMOJI_COLS };
6176
+ const move = matchKey(e, e.key) && moves[e.key];
6177
+ if (move) {
6178
+ e.preventDefault();
6179
+ _scratchpadEmojiCursor = Math.min(PAD_EMOJIS.length - 1, Math.max(0, _scratchpadEmojiCursor + move));
6180
+ _markScratchpadEmojiCursor();
6181
+ } else if (matchKey(e, 'Enter')) {
6182
+ e.preventDefault();
6183
+ setScratchpadEmoji(PAD_EMOJIS[_scratchpadEmojiCursor]);
6184
+ }
6185
+ });
6186
+
6040
6187
  function _scratchpadKey() {
6041
6188
  if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
6042
6189
  if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
@@ -6073,6 +6220,8 @@ function showScratchpad(keyOverride) {
6073
6220
  if (!key) return;
6074
6221
  _scratchpadTextarea.value = store.getItem(key) || '';
6075
6222
  _scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
6223
+ _syncScratchpadEmojiBtn();
6224
+ _closeScratchpadEmojiPicker(false);
6076
6225
  _scratchpadModal.classList.add('visible');
6077
6226
  _scratchpadTextarea.focus();
6078
6227
  }
@@ -6083,6 +6232,7 @@ function closeScratchpad() {
6083
6232
  _scratchpadSaveTimer = null;
6084
6233
  }
6085
6234
  saveScratchpad();
6235
+ _closeScratchpadEmojiPicker(false);
6086
6236
  _scratchpadKeyOverride = null;
6087
6237
  _scratchpadModal.classList.remove('visible');
6088
6238
  }
@@ -6115,6 +6265,10 @@ _scratchpadTextarea.addEventListener('input', () => {
6115
6265
  // Bound on the textarea: the global handler returns early on TEXTAREA targets.
6116
6266
  _scratchpadTextarea.addEventListener('keydown', (e) => {
6117
6267
  if (e.key === 'Escape') closeScratchpad();
6268
+ else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.code === 'KeyE' && _scratchpadEmojiSessionId()) {
6269
+ e.preventDefault();
6270
+ _openScratchpadEmojiPicker();
6271
+ }
6118
6272
  });
6119
6273
 
6120
6274
  // Vimium eats Escape inside a text field and only blurs it, so the page never sees the key.
@@ -6153,8 +6307,21 @@ function _updateStorageTotal() {
6153
6307
  if (el) el.textContent = `${(_getStorageTotalSize() / 1024).toFixed(1)} KB`;
6154
6308
  }
6155
6309
 
6156
- function _getKnownSessionIds() {
6157
- return new Set(sessions.map((s) => s.id));
6310
+ // From the server, because `sessions` is only the sidebar's loaded page (docs/session-scanning.md).
6311
+ // Loaded sessions are added for placeholders whose transcript does not exist yet.
6312
+ let _storageKnownSessions = null;
6313
+
6314
+ async function _fetchKnownSessions() {
6315
+ const res = await fetch('/api/sessions/known');
6316
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
6317
+ const known = new Map((await res.json()).map((s) => [s.id, s]));
6318
+ for (const s of sessions) known.set(s.id, s);
6319
+ _storageKnownSessions = known;
6320
+ return known;
6321
+ }
6322
+
6323
+ function _storageSessionMap() {
6324
+ return _storageKnownSessions ?? new Map(sessions.map((s) => [s.id, s]));
6158
6325
  }
6159
6326
 
6160
6327
  function _sessionLabel(session, id) {
@@ -6162,7 +6329,7 @@ function _sessionLabel(session, id) {
6162
6329
  }
6163
6330
 
6164
6331
  function _groupByProject(sessionIds) {
6165
- const sessionMap = new Map(sessions.map((s) => [s.id, s]));
6332
+ const sessionMap = _storageSessionMap();
6166
6333
  const groups = new Map();
6167
6334
  const orphans = [];
6168
6335
  for (const id of sessionIds) {
@@ -6197,14 +6364,18 @@ function _renderOrphanGroup(count, innerHtml) {
6197
6364
  return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
6198
6365
  }
6199
6366
 
6200
- function showStorageManager() {
6367
+ async function showStorageManager() {
6368
+ _storageKnownSessions = null;
6201
6369
  _updateStorageTotal();
6202
- _updateOrphanedCount();
6203
6370
  document.querySelectorAll('.storage-tab').forEach((t) => {
6204
6371
  t.classList.toggle('active', t.dataset.tab === 'sessions');
6205
6372
  });
6206
- document.getElementById('storage-modal').classList.add('visible');
6373
+ const modal = document.getElementById('storage-modal');
6374
+ modal.classList.add('visible');
6375
+ const known = await _fetchKnownSessions().catch(() => null);
6376
+ if (!modal.classList.contains('visible')) return;
6207
6377
  _renderStorageTab();
6378
+ if (known) _updateOrphanedCount(known);
6208
6379
  }
6209
6380
 
6210
6381
  function closeStorageManager() {
@@ -6349,20 +6520,31 @@ function _storageUnpinMessage(sessionId, pinId) {
6349
6520
  }
6350
6521
 
6351
6522
  function _renderStorageScratchpads() {
6352
- const allItems = [];
6523
+ const groupItems = [];
6524
+ const projectItems = [];
6525
+ const scratchBySession = new Map();
6526
+ const sessionItem = (id) => {
6527
+ if (!scratchBySession.has(id))
6528
+ scratchBySession.set(id, { key: _sessionScratchpadKey(id), kind: 'session', id, chars: 0 });
6529
+ return scratchBySession.get(id);
6530
+ };
6353
6531
  for (const key of store.keys()) {
6532
+ if (key.startsWith(PAD_EMOJI_PREFIX)) {
6533
+ sessionItem(key.slice(PAD_EMOJI_PREFIX.length)).emoji = store.getItem(key);
6534
+ continue;
6535
+ }
6354
6536
  const parsed = _parsePadKey(key);
6355
6537
  if (!parsed) continue;
6356
- allItems.push({ key, ...parsed, chars: (store.getItem(key) || '').length });
6538
+ const chars = (store.getItem(key) || '').length;
6539
+ if (parsed.kind === 'session') sessionItem(parsed.id).chars = chars;
6540
+ else (parsed.kind === 'group' ? groupItems : projectItems).push({ key, ...parsed, chars });
6541
+ }
6542
+ if (!groupItems.length && !projectItems.length && !scratchBySession.size) {
6543
+ return '<div class="storage-empty">No scratchpads</div>';
6357
6544
  }
6358
- if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
6359
6545
 
6360
- const groupItems = allItems.filter((i) => i.kind === 'group');
6361
- const projectItems = allItems.filter((i) => i.kind === 'project');
6362
- const sessionItems = allItems.filter((i) => i.kind === 'session');
6363
- const sessionIds = sessionItems.map((i) => i.id);
6364
- const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
6365
- const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
6546
+ const { groups: projectGroups, orphans } = _groupByProject(scratchBySession.keys());
6547
+ const sessionMap = _storageSessionMap();
6366
6548
 
6367
6549
  function renderScratchItem(item) {
6368
6550
  const jsKey = escAttrJs(item.key);
@@ -6375,13 +6557,10 @@ function _renderStorageScratchpads() {
6375
6557
  label = escapeHtml(_projectLabel(item.id));
6376
6558
  break;
6377
6559
  default:
6378
- label = _sessionLabel(
6379
- sessions.find((s) => s.id === item.id),
6380
- item.id,
6381
- );
6560
+ label = _sessionLabel(sessionMap.get(item.id), item.id);
6382
6561
  }
6383
6562
  return `<div class="storage-item">
6384
- <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
6563
+ <span class="storage-item-id" title="${escapeHtml(item.id)}">${item.emoji ? `${escapeHtml(item.emoji)} ` : ''}${label}</span>
6385
6564
  <span class="storage-item-badge">${item.kind}</span>
6386
6565
  <span class="storage-item-meta">${item.chars} chars</span>
6387
6566
  <div class="storage-item-actions">
@@ -6452,6 +6631,11 @@ function _storagePreviewPin(sessionId, pinId) {
6452
6631
 
6453
6632
  function _storageDeleteScratchpad(key) {
6454
6633
  store.removeItem(key);
6634
+ const pad = _parsePadKey(key);
6635
+ if (pad?.kind === 'session' && store.getItem(_padEmojiKey(pad.id))) {
6636
+ store.removeItem(_padEmojiKey(pad.id));
6637
+ renderSessions();
6638
+ }
6455
6639
  _renderStorageTab();
6456
6640
  _updateStorageTotal();
6457
6641
  }
@@ -6530,43 +6714,42 @@ function _storageClearLinkedDocs(sessionId) {
6530
6714
  afterLinkedDocsChanged(sessionId);
6531
6715
  }
6532
6716
 
6533
- function _findOrphanedKeys() {
6534
- const known = _getKnownSessionIds();
6717
+ function _findOrphanedKeys(known) {
6535
6718
  if (!known.size) return [];
6536
6719
  const orphaned = [];
6537
6720
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
6538
6721
  for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
6722
+ const sessionKeyPrefixes = ['pinned-messages-', PAD_EMOJI_PREFIX, PREVIEW_STORAGE_PREFIX, PAD_LINKED_PREFIX];
6539
6723
  for (const key of store.keys()) {
6540
6724
  const pad = _parsePadKey(key);
6541
6725
  if (pad?.kind === 'group') {
6542
6726
  if (!sgGroupById(pad.id)) orphaned.push(key);
6543
- } else if (key.startsWith('pinned-messages-')) {
6544
- if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
6545
- } else if (pad?.kind === 'session') {
6546
- if (!known.has(pad.id)) orphaned.push(key);
6547
- } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
6548
- if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
6549
- } else if (key.startsWith(PAD_LINKED_PREFIX)) {
6550
- if (!known.has(key.slice(PAD_LINKED_PREFIX.length))) orphaned.push(key);
6727
+ continue;
6551
6728
  }
6729
+ const prefix = pad ? null : sessionKeyPrefixes.find((p) => key.startsWith(p));
6730
+ const id = pad?.kind === 'session' ? pad.id : prefix && key.slice(prefix.length);
6731
+ if (id && !known.has(id)) orphaned.push(key);
6552
6732
  }
6553
6733
  return orphaned;
6554
6734
  }
6555
6735
 
6556
- function _updateOrphanedCount() {
6736
+ function _updateOrphanedCount(known) {
6557
6737
  const btn = document.getElementById('storage-cleanup-btn');
6558
6738
  if (!btn) return;
6559
- const count = _findOrphanedKeys().length;
6739
+ const count = _findOrphanedKeys(known).length;
6560
6740
  btn.textContent = count ? `Clean Orphaned (${count})` : 'Clean Orphaned';
6561
6741
  }
6562
6742
 
6563
6743
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
6564
- function cleanupOrphanedStorage() {
6565
- if (!sessions.length) {
6566
- showToast('Sessions not loaded yet — try again after they appear');
6744
+ async function cleanupOrphanedStorage() {
6745
+ let known;
6746
+ try {
6747
+ known = await _fetchKnownSessions();
6748
+ } catch {
6749
+ showToast('Could not list sessions — nothing was cleaned');
6567
6750
  return;
6568
6751
  }
6569
- const orphaned = _findOrphanedKeys();
6752
+ const orphaned = _findOrphanedKeys(known);
6570
6753
  let pinsChanged = false;
6571
6754
  for (const key of orphaned) {
6572
6755
  if (key.startsWith('__pinned__')) {
@@ -6586,7 +6769,7 @@ function cleanupOrphanedStorage() {
6586
6769
  renderSessions();
6587
6770
  _renderStorageTab();
6588
6771
  _updateStorageTotal();
6589
- _updateOrphanedCount();
6772
+ _updateOrphanedCount(known);
6590
6773
  }
6591
6774
  //#endregion
6592
6775
 
@@ -8632,8 +8815,9 @@ function setupEventSource() {
8632
8815
  const CONTEXT_COLORS = { green: '#5b9a6b', yellow: '#b8a63e', orange: '#c07840', red: '#b85555' };
8633
8816
  const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
8634
8817
  const MODEL_THRESHOLDS = [
8635
- { match: /sonnet|haiku/i, yellow: 100000, orange: 130000, red: 150000 },
8636
- { match: /opus/i, yellow: 100000, orange: 200000, red: 700000 },
8818
+ { match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
8819
+ { match: /haiku/i, yellow: 100000, orange: 130000, red: 150000 },
8820
+ { match: /opus/i, yellow: 150000, orange: 250000, red: 400000 },
8637
8821
  ];
8638
8822
  const DEFAULT_THRESHOLDS = { yellow: 100000, orange: 130000, red: 150000 };
8639
8823
 
@@ -8747,6 +8931,73 @@ function formatCost(usd) {
8747
8931
  return `$${usd.toFixed(2)}`;
8748
8932
  }
8749
8933
 
8934
+ const CACHE_TTL = { '5m': { ms: 5 * 60000, write: 1.25 }, '1h': { ms: 60 * 60000, write: 2 } };
8935
+ // List input price, $ per million tokens. Most specific prefix first.
8936
+ const INPUT_PRICE_PER_MTOK = [
8937
+ ['claude-fable-5', 10],
8938
+ ['claude-mythos-5', 10],
8939
+ ['claude-opus-5-5', 4],
8940
+ ['claude-opus-5', 5],
8941
+ ['claude-opus-4', 5],
8942
+ ['claude-sonnet-5', 2],
8943
+ ['claude-sonnet-4', 3],
8944
+ ['claude-haiku-4', 1],
8945
+ ];
8946
+
8947
+ // Same token count Claude Code prices in its own estimated_cache_write_usd.
8948
+ function cacheRewrite(usage, modelId, writeRate) {
8949
+ const price = INPUT_PRICE_PER_MTOK.find(([prefix]) => modelId?.startsWith(prefix))?.[1];
8950
+ const tokens =
8951
+ (usage.input_tokens || 0) +
8952
+ (usage.cache_read_input_tokens || 0) +
8953
+ (usage.cache_creation_input_tokens || 0) +
8954
+ (usage.output_tokens || 0);
8955
+ if (!price || !tokens) return null;
8956
+ return { tokens, usd: (tokens / 1e6) * price * writeRate };
8957
+ }
8958
+
8959
+ function formatCacheLeft(expiresAt, expiredText) {
8960
+ const left = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
8961
+ if (left === 0) return { text: expiredText, color: 'var(--text-muted)' };
8962
+ const text = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
8963
+ const color = left < 120 ? CONTEXT_COLORS.red : left < 300 ? CONTEXT_COLORS.orange : CONTEXT_COLORS.green;
8964
+ return { text, color };
8965
+ }
8966
+
8967
+ let cacheTimerInterval = null;
8968
+
8969
+ function tickCacheTimers() {
8970
+ const els = document.querySelectorAll('.cache-timer');
8971
+ if (!els.length) {
8972
+ clearInterval(cacheTimerInterval);
8973
+ cacheTimerInterval = null;
8974
+ return;
8975
+ }
8976
+ if (!isOnScreen()) return;
8977
+ for (const el of els) {
8978
+ const { text, color } = formatCacheLeft(Number(el.dataset.expires), el.dataset.expiredText);
8979
+ if (el.textContent === text) continue;
8980
+ el.textContent = text;
8981
+ el.style.color = color;
8982
+ }
8983
+ }
8984
+
8985
+ function renderCacheTimer(lastRequestAt, ttl, usage, modelId) {
8986
+ const cache = CACHE_TTL[ttl];
8987
+ if (!lastRequestAt || !cache) return '';
8988
+ const expiresAt = lastRequestAt + cache.ms;
8989
+ const rewrite = cacheRewrite(usage, modelId, cache.write);
8990
+ const expiredText = rewrite ? `expired · ~${formatCost(rewrite.usd)}` : 'expired';
8991
+ const { text, color } = formatCacheLeft(expiresAt, expiredText);
8992
+ cacheTimerInterval ??= setInterval(tickCacheTimers, 1000);
8993
+ const at = new Date(expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
8994
+ const cost = rewrite
8995
+ ? ` It writes ${formatTokens(rewrite.tokens / 1000)} tokens again, about ${formatCost(rewrite.usd)} at list price.`
8996
+ : '';
8997
+ const title = `Prompt cache, ${ttl} TTL. Expires at ${at}; the next request after that writes it again.${cost}`;
8998
+ return `<div class="stat-item" title="${escapeHtml(title)}"><span class="stat-label">Cache</span><span class="stat-value cache-timer" data-expires="${expiresAt}" data-expired-text="${escapeHtml(expiredText)}" style="color:${color}">${text}</span></div>`;
8999
+ }
9000
+
8750
9001
  function renderContextDetail(session, { tokens = true } = {}) {
8751
9002
  const ctx = getCtx(session);
8752
9003
  if (!ctx) return '';
@@ -8784,6 +9035,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
8784
9035
  <div class="detail-context-stats">
8785
9036
  ${tokenRows}
8786
9037
  <div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
9038
+ ${renderCacheTimer(raw.cache?.last_request_at, session.cacheTtl, usage, raw.model?.id)}
8787
9039
  ${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
8788
9040
  ${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
8789
9041
  ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
@@ -12271,14 +12523,16 @@ function formatTerminalProc(s) {
12271
12523
  async function pollTerminalProcStats() {
12272
12524
  if (!isOnScreen()) return;
12273
12525
  let cck;
12526
+ let host;
12274
12527
  try {
12275
12528
  const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
12276
12529
  if (!res.ok) return;
12277
- ({ cck, terminals: terminalProcStats } = await res.json());
12530
+ ({ cck, host, terminals: terminalProcStats } = await res.json());
12278
12531
  } catch (_) {
12279
12532
  return;
12280
12533
  }
12281
- document.getElementById('terminal-manager-stats').textContent = cck ? `cck ${formatTerminalProc(cck)}` : '';
12534
+ const parts = [cck && `cck ${formatTerminalProc(cck)}`, host && `terminal host ${formatTerminalProc(host)}`];
12535
+ document.getElementById('terminal-manager-stats').textContent = parts.filter(Boolean).join(' · ');
12282
12536
  for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
12283
12537
  el.textContent = formatTerminalProc(terminalProcStats[el.dataset.proc]);
12284
12538
  }
@@ -12384,7 +12638,8 @@ const newSpecs = new Map();
12384
12638
  // Sessions another session started (`dispatch start`), shown where they will stay until
12385
12639
  // their transcript appears. Kept apart from newSpecs: their prompt is already sent.
12386
12640
  const dispatchSpecs = new Map();
12387
- const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', browsing: false, resume: false };
12641
+ const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
12642
+ const NEW_SESSION_STAY_KEY = 'new-session-stay';
12388
12643
 
12389
12644
  const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
12390
12645
  const PLACEHOLDER_HINTS = {
@@ -12400,8 +12655,7 @@ function placeholderSession(id, spec) {
12400
12655
  mode: spec.mode,
12401
12656
  name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
12402
12657
  dispatchGroup: spec.group || undefined,
12403
- startedBy: spec.startedBy || undefined,
12404
- dispatched: spec.mode === 'dispatch' ? { parent: spec.startedBy || null } : undefined,
12658
+ dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
12405
12659
  project: spec.cwd,
12406
12660
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
12407
12661
  modifiedAt: new Date(spec.startedAt).toISOString(),
@@ -12435,7 +12689,7 @@ async function loadDispatches() {
12435
12689
  name: r.name,
12436
12690
  worktree: r.worktree,
12437
12691
  group: r.group,
12438
- startedBy: r.parent,
12692
+ parent: r.parent,
12439
12693
  startedAt: r.startedAt,
12440
12694
  });
12441
12695
  }
@@ -12444,9 +12698,13 @@ async function loadDispatches() {
12444
12698
 
12445
12699
  async function onDispatchUpdate() {
12446
12700
  await loadDispatches();
12701
+ refreshPlaceholders();
12702
+ fetchSessions(false).catch(() => {});
12703
+ }
12704
+
12705
+ function refreshPlaceholders() {
12447
12706
  sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
12448
12707
  renderSessions();
12449
- fetchSessions(false).catch(() => {});
12450
12708
  }
12451
12709
 
12452
12710
  // With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
@@ -12525,6 +12783,7 @@ function openNewSession(folder, resume = false) {
12525
12783
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
12526
12784
  document.getElementById('ns-wt').checked = false;
12527
12785
  document.getElementById('ns-model').value = '';
12786
+ document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
12528
12787
  setNewSessionError('');
12529
12788
  const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
12530
12789
  setNewSessionFolder(folder || current || '');
@@ -12658,36 +12917,44 @@ function renderNewSessionForm() {
12658
12917
  const problem = newSessionProblem(v);
12659
12918
  setNewSessionError(problem);
12660
12919
  const start = document.getElementById('ns-start');
12661
- start.disabled = !v.cwd || !!problem || ns.browsing;
12920
+ start.disabled = !v.cwd || !!problem || ns.busy;
12662
12921
  const verb = ns.resume ? 'Resume' : 'Start';
12663
12922
  start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
12664
12923
  }
12665
12924
 
12925
+ // Resolves to the response body, or to { error } with the message for the dialog.
12926
+ async function newSessionPost(url, body, failure) {
12927
+ try {
12928
+ const res = await terminalFetch(url, 'POST', body);
12929
+ const out = await res.json().catch(() => ({}));
12930
+ if (res.ok) return out;
12931
+ return { error: res.status === 401 ? STALE_TOKEN_MSG : out.error || `${failure} (${res.status})` };
12932
+ } catch (e) {
12933
+ return { error: e.message };
12934
+ }
12935
+ }
12936
+
12937
+ function setNewSessionBusy(on) {
12938
+ ns.busy = on;
12939
+ renderNewSessionForm();
12940
+ }
12941
+
12666
12942
  async function browseNewSessionFolder() {
12667
- if (ns.browsing) return;
12668
- ns.browsing = true;
12943
+ if (ns.busy) return;
12669
12944
  closeFolderList();
12670
12945
  const btn = document.getElementById('ns-browse');
12671
12946
  btn.textContent = 'Opening…';
12672
- renderNewSessionForm();
12673
- try {
12674
- const start = ns.folder || ns.picked[0] || ns.projects[0];
12675
- const res = await terminalFetch('/api/terminal/pick-folder', 'POST', start ? { start } : null);
12676
- const body = await res.json().catch(() => ({}));
12677
- if (res.status === 401) setNewSessionError(STALE_TOKEN_MSG);
12678
- else if (!res.ok) setNewSessionError(body.error || `Folder dialog failed (${res.status})`);
12679
- else if (body.path) {
12680
- ns.picked = [body.path, ...ns.picked.filter((p) => p !== body.path)];
12681
- setNewSessionFolder(body.path);
12682
- }
12683
- } catch (e) {
12684
- setNewSessionError(e.message);
12685
- } finally {
12686
- ns.browsing = false;
12687
- btn.textContent = 'Browse…';
12688
- renderNewSessionForm();
12689
- document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
12947
+ setNewSessionBusy(true);
12948
+ const start = ns.folder || ns.picked[0] || ns.projects[0];
12949
+ const r = await newSessionPost('/api/terminal/pick-folder', start ? { start } : null, 'Folder dialog failed');
12950
+ if (r.path) {
12951
+ ns.picked = [r.path, ...ns.picked.filter((p) => p !== r.path)];
12952
+ setNewSessionFolder(r.path);
12690
12953
  }
12954
+ btn.textContent = 'Browse…';
12955
+ setNewSessionBusy(false);
12956
+ if (r.error) setNewSessionError(r.error);
12957
+ document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
12691
12958
  }
12692
12959
 
12693
12960
  function fieldAfterFolder() {
@@ -12696,15 +12963,36 @@ function fieldAfterFolder() {
12696
12963
 
12697
12964
  function startNewSession() {
12698
12965
  const v = newSessionValues();
12699
- if (!v.cwd || newSessionProblem(v) || ns.browsing) return;
12966
+ if (!v.cwd || newSessionProblem(v) || ns.busy) return;
12700
12967
  const id = crypto.randomUUID();
12701
12968
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
12702
- sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
12703
12969
  setTerminalMode(id, true);
12970
+ if (!ns.resume && document.getElementById('ns-stay').checked) return startNewSessionInBackground(id, v);
12971
+ sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
12704
12972
  closeNewSession();
12705
12973
  fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
12706
12974
  }
12707
12975
 
12976
+ async function startNewSessionInBackground(id, v) {
12977
+ refreshPlaceholders();
12978
+ setNewSessionBusy(true);
12979
+ const r = await newSessionPost('/api/terminal/start', { id, ...v }, 'Start failed');
12980
+ setNewSessionBusy(false);
12981
+ if (r.error) {
12982
+ dropPlaceholder(id);
12983
+ return setNewSessionError(r.error);
12984
+ }
12985
+ // A server started before the page's code changed ignores the id and picks its own.
12986
+ if (r.session !== id) {
12987
+ const spec = newSpecs.get(id);
12988
+ forgetPlaceholder(id);
12989
+ newSpecs.set(r.session, spec);
12990
+ setTerminalMode(r.session, true);
12991
+ refreshPlaceholders();
12992
+ }
12993
+ closeNewSession();
12994
+ }
12995
+
12708
12996
  const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
12709
12997
  const promptVoice = { rec: null };
12710
12998
 
@@ -12780,6 +13068,9 @@ function initNewSession() {
12780
13068
  renderNewSessionForm();
12781
13069
  if (e.target.checked) document.getElementById('ns-wt-name').focus();
12782
13070
  });
13071
+ document
13072
+ .getElementById('ns-stay')
13073
+ .addEventListener('change', (e) => store.setItem(NEW_SESSION_STAY_KEY, String(e.target.checked)));
12783
13074
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
12784
13075
  document.getElementById(id).addEventListener('input', renderNewSessionForm);
12785
13076
  }