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/README.md +23 -13
- package/cli.js +20 -103
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +16 -1
- package/lib/live-sessions.js +39 -0
- package/lib/parsers.js +18 -3
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal-client.js +221 -0
- package/lib/terminal-host.js +95 -0
- package/lib/terminal.js +101 -42
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +5 -2
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -7
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +14 -1
- package/public/app.js +384 -93
- package/public/index.html +14 -3
- package/public/project-match.js +6 -1
- package/public/style.css +110 -0
- package/server.js +102 -119
- package/skill-guides/dispatch.md +16 -54
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 } =
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
3848
|
-
|
|
3849
|
-
groups.
|
|
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
|
-
|
|
3867
|
+
const key = sessionProjectKey(s);
|
|
3868
|
+
if (!key) continue;
|
|
3865
3869
|
const t = new Date(s.modifiedAt).getTime();
|
|
3866
|
-
latestByPath.set(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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}${
|
|
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
|
|
4439
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
: {
|
|
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
|
|
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 [
|
|
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
|
-
|
|
6157
|
-
|
|
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 =
|
|
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')
|
|
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
|
|
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
|
-
|
|
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
|
|
6361
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
6566
|
-
|
|
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
|
|
8636
|
-
{ match: /
|
|
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
|
-
|
|
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: '',
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
12673
|
-
|
|
12674
|
-
|
|
12675
|
-
|
|
12676
|
-
|
|
12677
|
-
|
|
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.
|
|
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
|
}
|