claude-code-kanban 5.4.0 → 6.0.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 +2 -2
- 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/retention.js +5 -17
- package/lib/session-events.js +3 -2
- package/lib/terminal.js +28 -4
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- 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/public/app.js +249 -66
- package/public/index.html +12 -2
- package/public/project-match.js +6 -1
- package/public/style.css +99 -0
- package/server.js +43 -55
- 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
|
|
|
@@ -12400,8 +12584,7 @@ function placeholderSession(id, spec) {
|
|
|
12400
12584
|
mode: spec.mode,
|
|
12401
12585
|
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
12402
12586
|
dispatchGroup: spec.group || undefined,
|
|
12403
|
-
|
|
12404
|
-
dispatched: spec.mode === 'dispatch' ? { parent: spec.startedBy || null } : undefined,
|
|
12587
|
+
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
12405
12588
|
project: spec.cwd,
|
|
12406
12589
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
12407
12590
|
modifiedAt: new Date(spec.startedAt).toISOString(),
|
|
@@ -12435,7 +12618,7 @@ async function loadDispatches() {
|
|
|
12435
12618
|
name: r.name,
|
|
12436
12619
|
worktree: r.worktree,
|
|
12437
12620
|
group: r.group,
|
|
12438
|
-
|
|
12621
|
+
parent: r.parent,
|
|
12439
12622
|
startedAt: r.startedAt,
|
|
12440
12623
|
});
|
|
12441
12624
|
}
|
package/public/index.html
CHANGED
|
@@ -691,8 +691,18 @@
|
|
|
691
691
|
</div>
|
|
692
692
|
<div id="scratchpad-modal" class="modal-overlay" onclick="closeScratchpad()">
|
|
693
693
|
<div class="modal scratchpad-modal" onclick="event.stopPropagation()">
|
|
694
|
-
<div class="modal-header">
|
|
695
|
-
<
|
|
694
|
+
<div class="modal-header scratchpad-header">
|
|
695
|
+
<div class="scratchpad-title-row">
|
|
696
|
+
<button id="scratchpad-emoji-btn" class="scratchpad-emoji-btn" type="button" onclick="toggleScratchpadEmojiPicker()"></button>
|
|
697
|
+
<h3 class="modal-title">Scratchpad</h3>
|
|
698
|
+
</div>
|
|
699
|
+
<div id="scratchpad-emoji-picker" class="scratchpad-emoji-picker" tabindex="-1">
|
|
700
|
+
<div id="scratchpad-emoji-grid" class="scratchpad-emoji-grid"></div>
|
|
701
|
+
<div class="scratchpad-emoji-row">
|
|
702
|
+
<input id="scratchpad-emoji-input" class="form-input" maxlength="16" placeholder="Paste" aria-label="Custom emoji">
|
|
703
|
+
<button type="button" class="btn btn-secondary" onclick="setScratchpadEmoji('')">Clear</button>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
696
706
|
<button class="modal-close" aria-label="Close dialog" onclick="closeScratchpad()">
|
|
697
707
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
698
708
|
<path d="M18 6L6 18M6 6l12 12"/>
|
package/public/project-match.js
CHANGED
|
@@ -23,7 +23,12 @@ const projectMatch = (() => {
|
|
|
23
23
|
return p === q || (!!repo && normalizeProjectPath(repo) === q);
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// The project a session is listed under. A worktree transcript is filed under the worktree
|
|
27
|
+
// path, so `session.project` alone would show each worktree as a project of its own.
|
|
28
|
+
function sessionProjectKey(session) {
|
|
29
|
+
return session?.worktree?.repo || session?.project || null;
|
|
30
|
+
}
|
|
31
|
+
return { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey };
|
|
27
32
|
})();
|
|
28
33
|
|
|
29
34
|
if (typeof module === 'object' && module.exports) module.exports = projectMatch;
|