claude-code-kanban 7.6.0 → 7.7.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 -1
- package/cli.js +412 -62
- package/hub-app.json +1 -1
- package/kanbot/.claude-plugin/plugin.json +5 -0
- package/kanbot/agents/kanbot.md +34 -0
- package/lib/http-error.js +2 -1
- package/lib/kanbot.js +86 -0
- package/lib/owner-routing.js +53 -0
- package/lib/preview-action.js +55 -0
- package/lib/session-events.js +5 -0
- package/lib/session-pins.js +99 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/terminal.js +27 -13
- package/lib/user-groups.js +364 -0
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +4 -2
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +5 -1
- package/public/app.js +1211 -294
- package/public/index.html +19 -15
- package/public/style.css +727 -125
- package/public/terminal-frame.js +12 -0
- package/server.js +273 -117
package/public/app.js
CHANGED
|
@@ -4,7 +4,9 @@ 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 THEME_KEY = 'theme';
|
|
6
6
|
const COLOR_THEME_KEY = 'color-theme';
|
|
7
|
-
|
|
7
|
+
// Shared too, so after a config-dir switch the cursor goes where it was on the board just left.
|
|
8
|
+
const FOCUS_ZONE_KEY = 'focus-zone';
|
|
9
|
+
const GLOBAL_KEYS = new Set([THEME_KEY, COLOR_THEME_KEY, FOCUS_ZONE_KEY]);
|
|
8
10
|
// localStorage names are kept as they are: renaming one drops what users saved under it.
|
|
9
11
|
const MESSAGE_PANEL_OPEN_KEY = 'message-panel-open';
|
|
10
12
|
const MESSAGE_PANEL_FULL_KEY = 'message-panel-full';
|
|
@@ -323,18 +325,16 @@ async function getJson(path, fallback = null, opts) {
|
|
|
323
325
|
|
|
324
326
|
async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
325
327
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
// The focused session must come back whatever the filters say: renderSession and the
|
|
329
|
+
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
330
|
+
// opened from outside the current filter would leave the view on the previous one.
|
|
331
|
+
// The server adds the pinned sessions itself; the ones `session open` showed are this page's.
|
|
332
|
+
const include = [focusId, revealedPlanSessionId, revealedStorageSessionId, ...openedStickyIds].filter(Boolean);
|
|
329
333
|
const recent = filterProject === '__recent__';
|
|
330
334
|
const res = await api('/api/sessions', {
|
|
331
335
|
query: {
|
|
332
336
|
limit: sessionLimit,
|
|
333
|
-
|
|
334
|
-
// The focused session must come back whatever the filters say: renderSession and the
|
|
335
|
-
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
336
|
-
// opened from outside the current filter would leave the view on the previous one.
|
|
337
|
-
include: focusId || null,
|
|
337
|
+
include: include.length ? include.join(',') : null,
|
|
338
338
|
recentHours: recent ? RECENT_PROJECT_HOURS : null,
|
|
339
339
|
project: !recent && filterProject ? filterProject : null,
|
|
340
340
|
filter: sessionFilter === 'active' ? 'active' : null,
|
|
@@ -1655,8 +1655,12 @@ function toggleToolGroup(id) {
|
|
|
1655
1655
|
|
|
1656
1656
|
// Legacy markers (written before the gate minted kind:"plan") carry no kind
|
|
1657
1657
|
// for plans — the tool name is the durable signal there.
|
|
1658
|
+
function isPlanAsk(kind, tool) {
|
|
1659
|
+
return kind === 'plan' || tool === 'ExitPlanMode';
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1658
1662
|
function getWaitingPill(kind, tool) {
|
|
1659
|
-
if (kind
|
|
1663
|
+
if (isPlanAsk(kind, tool)) return 'Plan awaiting approval';
|
|
1660
1664
|
if (kind === 'question') return 'Question pending';
|
|
1661
1665
|
return 'Awaiting permission';
|
|
1662
1666
|
}
|
|
@@ -1735,27 +1739,34 @@ function waitingDecisionButtons(cls) {
|
|
|
1735
1739
|
const plan = currentWaiting.kind === 'plan';
|
|
1736
1740
|
// Code-authored call argument, not data — hence the constant-style name the
|
|
1737
1741
|
// escaping check reads as safe.
|
|
1738
|
-
const DENY_ARG = plan ? "
|
|
1739
|
-
|
|
1742
|
+
const DENY_ARG = plan ? "rejectWaitingPlan('inline-plan-feedback')" : "respondWaiting({behavior:'deny'})";
|
|
1743
|
+
const feedback = plan
|
|
1744
|
+
? `<input id="inline-plan-feedback" class="msg-waiting-feedback" type="text" placeholder="feedback for reject (optional)…" value="${escapeHtml(inlinePlanFeedback)}" oninput="inlinePlanFeedback=this.value" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">`
|
|
1745
|
+
: '';
|
|
1746
|
+
return `${feedback}<button class="${escapeHtml(cls)}-allow" onclick="event.stopPropagation();respondWaiting({behavior:'allow'})">${plan ? 'Approve' : 'Allow'}</button><button class="${escapeHtml(cls)}-deny" onclick="event.stopPropagation();${DENY_ARG}">${plan ? 'Reject' : 'Deny'}</button>`;
|
|
1740
1747
|
}
|
|
1741
1748
|
|
|
1749
|
+
let inlinePlanFeedback = '';
|
|
1750
|
+
|
|
1742
1751
|
function renderWaitingEntry() {
|
|
1743
1752
|
if (!isWaitingFresh()) return '';
|
|
1744
1753
|
const tool = currentWaiting.toolName || 'unknown';
|
|
1745
1754
|
const params = parseWaitingInput();
|
|
1746
|
-
const
|
|
1755
|
+
const plan = isPlanAsk(currentWaiting.kind, tool);
|
|
1747
1756
|
const detail = deriveWaitingDetail(tool, params);
|
|
1748
1757
|
const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
|
|
1749
1758
|
const bodyHtml = renderWaitingBody(tool, params);
|
|
1750
1759
|
const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
|
|
1751
|
-
const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
|
|
1752
1760
|
const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
|
1753
1761
|
// Questions are answered in the detail modal; plans render there too.
|
|
1754
1762
|
const buttons = waitingDecisionButtons('msg-waiting');
|
|
1755
1763
|
const actions = buttons ? `<span class="msg-waiting-actions">${buttons}</span>` : '';
|
|
1756
1764
|
// Lapsed = gate stopped polling — the ask is only answerable in the terminal
|
|
1757
1765
|
const lapsed = !!currentWaiting.lapsed;
|
|
1758
|
-
|
|
1766
|
+
const keyHint = lapsed ? 'answer in the terminal' : buttons ? `${sendKbd()} ${plan ? 'approve' : 'allow'}` : '';
|
|
1767
|
+
const hintHtml = keyHint ? `<div class="msg-time waiting-key-hint-line">${keyHint}</div>` : '';
|
|
1768
|
+
const footer = hintHtml || actions ? `<div class="msg-waiting-footer">${hintHtml}${actions}</div>` : '';
|
|
1769
|
+
return `<div class="msg-item msg-waiting${plan ? ' msg-waiting-plan' : ''}" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text"><span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}${footer}</div>${discardBtn}</div>`;
|
|
1759
1770
|
}
|
|
1760
1771
|
|
|
1761
1772
|
// Shared approval controls for a fresh plan ask — the waiting-detail footer and
|
|
@@ -1804,6 +1815,7 @@ async function respondWaiting(payload) {
|
|
|
1804
1815
|
// Drop the answered/discarded ask from every surface it renders on
|
|
1805
1816
|
function clearWaitingUi() {
|
|
1806
1817
|
currentWaiting = null;
|
|
1818
|
+
inlinePlanFeedback = '';
|
|
1807
1819
|
waitingAnswerDraft = {};
|
|
1808
1820
|
waitingCustomDraft = {};
|
|
1809
1821
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
@@ -2123,35 +2135,89 @@ let stickySessionIds = new Set();
|
|
|
2123
2135
|
// Pinning the currently-selected session keeps it in place until deselected (less UI movement).
|
|
2124
2136
|
const deferredPinPlacement = new Set();
|
|
2125
2137
|
|
|
2126
|
-
|
|
2127
|
-
|
|
2138
|
+
// Sessions that `session open` keeps on screen for this page only; the server never has them.
|
|
2139
|
+
const openedStickyIds = new Set();
|
|
2140
|
+
|
|
2141
|
+
// The server keeps the pins (`/api/session/pins`, lib/session-pins.js); the two sets are only the
|
|
2142
|
+
// page's copy. The localStorage lists are from before that. Each origin has its own (the hub's
|
|
2143
|
+
// proxied port, the standalone board), so each one merges its lists into the server's once, then
|
|
2144
|
+
// deletes them.
|
|
2145
|
+
async function syncSessionPins() {
|
|
2146
|
+
try {
|
|
2147
|
+
const body = { pinned: readStoredList(PINNED_SESSIONS_KEY), sticky: readStoredList(STICKY_SESSIONS_KEY) };
|
|
2148
|
+
const local = body.pinned.length || body.sticky.length;
|
|
2149
|
+
const res = await (local ? api('/api/session/pins/import', { method: 'POST', body }) : api('/api/session/pins'));
|
|
2150
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2151
|
+
const data = await res.json();
|
|
2152
|
+
store.removeItem(PINNED_SESSIONS_KEY);
|
|
2153
|
+
store.removeItem(STICKY_SESSIONS_KEY);
|
|
2154
|
+
applyServerPins(data.pins);
|
|
2155
|
+
} catch (e) {
|
|
2156
|
+
console.warn('[pins] server sync failed:', e.message);
|
|
2157
|
+
}
|
|
2128
2158
|
}
|
|
2129
2159
|
|
|
2130
|
-
|
|
2131
|
-
|
|
2160
|
+
// The first apply needs no fetch: `/api/sessions` adds the pinned sessions itself.
|
|
2161
|
+
let pinsApplied = false;
|
|
2162
|
+
|
|
2163
|
+
function applyServerPins(pins) {
|
|
2164
|
+
const before = new Map([...pinnedSessionIds].map((id) => [id, getSessionPinState(id)]));
|
|
2165
|
+
pinnedSessionIds = new Set();
|
|
2166
|
+
stickySessionIds = new Set(openedStickyIds);
|
|
2167
|
+
for (const [id, state] of Object.entries(pins || {})) {
|
|
2168
|
+
pinnedSessionIds.add(id);
|
|
2169
|
+
if (state === 'sticky') {
|
|
2170
|
+
stickySessionIds.add(id);
|
|
2171
|
+
openedStickyIds.delete(id);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
for (const id of deferredPinPlacement) if (!isAnyPinned(id)) deferredPinPlacement.delete(id);
|
|
2175
|
+
const first = !pinsApplied;
|
|
2176
|
+
pinsApplied = true;
|
|
2177
|
+
const unchanged =
|
|
2178
|
+
before.size === pinnedSessionIds.size && [...before].every(([id, s]) => getSessionPinState(id) === s);
|
|
2179
|
+
if (unchanged && !first) return;
|
|
2180
|
+
const added = first ? [] : [...pinnedSessionIds].filter((id) => !before.has(id));
|
|
2181
|
+
for (const id of added) expandPinnedFor(id);
|
|
2182
|
+
showPinnedSessions(added);
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// A pinned session outside the current filter is only in the list once it is fetched with the pins.
|
|
2186
|
+
function showPinnedSessions(added) {
|
|
2187
|
+
const known = new Set(sessions.map((s) => s.id));
|
|
2188
|
+
if (added.some((id) => !known.has(id))) fetchSessions(false);
|
|
2189
|
+
else renderSessions();
|
|
2132
2190
|
}
|
|
2133
2191
|
|
|
2134
|
-
function
|
|
2135
|
-
|
|
2136
|
-
store.writeJson(STICKY_SESSIONS_KEY, [...stickySessionIds]);
|
|
2192
|
+
function sendPinChange(sessionId) {
|
|
2193
|
+
return postPins({ ids: [sessionId], state: getSessionPinState(sessionId, { saved: true }) });
|
|
2137
2194
|
}
|
|
2138
2195
|
|
|
2139
|
-
//
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2196
|
+
// A write names only its sessions, so the last write for a session wins. A failed save re-reads the
|
|
2197
|
+
// server so the page shows what is saved.
|
|
2198
|
+
async function postPins(body) {
|
|
2199
|
+
let error;
|
|
2200
|
+
try {
|
|
2201
|
+
const res = await api('/api/session/pin', { method: 'POST', body });
|
|
2202
|
+
if (res.ok) return;
|
|
2203
|
+
error = (await res.json().catch(() => null))?.error || `HTTP ${res.status}`;
|
|
2204
|
+
} catch (e) {
|
|
2205
|
+
error = e.message;
|
|
2206
|
+
}
|
|
2207
|
+
showToast(`Pin not saved (${error}); showing the saved pins`, 'error');
|
|
2208
|
+
await syncSessionPins();
|
|
2143
2209
|
}
|
|
2144
2210
|
|
|
2145
2211
|
function clearSessionPin(sessionId) {
|
|
2146
2212
|
pinnedSessionIds.delete(sessionId);
|
|
2147
2213
|
stickySessionIds.delete(sessionId);
|
|
2214
|
+
openedStickyIds.delete(sessionId);
|
|
2148
2215
|
deferredPinPlacement.delete(sessionId);
|
|
2149
2216
|
}
|
|
2150
2217
|
|
|
2151
2218
|
function unpinSession(sessionId) {
|
|
2152
2219
|
clearSessionPin(sessionId);
|
|
2153
|
-
|
|
2154
|
-
offloadSessionPin(sessionId);
|
|
2220
|
+
sendPinChange(sessionId);
|
|
2155
2221
|
renderSessions();
|
|
2156
2222
|
}
|
|
2157
2223
|
|
|
@@ -2160,8 +2226,7 @@ function toggleSessionPin(sessionId) {
|
|
|
2160
2226
|
pinnedSessionIds.add(sessionId);
|
|
2161
2227
|
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
2162
2228
|
expandPinnedFor(sessionId);
|
|
2163
|
-
|
|
2164
|
-
offloadSessionPin(sessionId);
|
|
2229
|
+
sendPinChange(sessionId);
|
|
2165
2230
|
renderSessions();
|
|
2166
2231
|
}
|
|
2167
2232
|
|
|
@@ -2173,20 +2238,13 @@ function toggleSessionSticky(sessionId) {
|
|
|
2173
2238
|
stickySessionIds.add(sessionId);
|
|
2174
2239
|
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
2175
2240
|
}
|
|
2176
|
-
|
|
2177
|
-
offloadSessionPin(sessionId);
|
|
2241
|
+
sendPinChange(sessionId);
|
|
2178
2242
|
renderSessions();
|
|
2179
2243
|
}
|
|
2180
2244
|
|
|
2181
|
-
|
|
2182
|
-
return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2183
|
-
}
|
|
2184
|
-
function isPlacedSticky(id) {
|
|
2185
|
-
return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2245
|
+
// The echo of this page's own change is a no-op, so the selected session keeps its deferred place.
|
|
2188
2246
|
function handleSessionPinEvent({ id, state }) {
|
|
2189
|
-
if (!id) return;
|
|
2247
|
+
if (!id || getSessionPinState(id, { saved: true }) === state) return;
|
|
2190
2248
|
clearSessionPin(id);
|
|
2191
2249
|
if (state === 'pinned') pinnedSessionIds.add(id);
|
|
2192
2250
|
if (state === 'sticky') {
|
|
@@ -2194,12 +2252,19 @@ function handleSessionPinEvent({ id, state }) {
|
|
|
2194
2252
|
stickySessionIds.add(id);
|
|
2195
2253
|
}
|
|
2196
2254
|
expandPinnedFor(id);
|
|
2197
|
-
|
|
2198
|
-
renderSessions();
|
|
2255
|
+
showPinnedSessions(isAnyPinned(id) ? [id] : []);
|
|
2199
2256
|
}
|
|
2200
2257
|
|
|
2201
|
-
function
|
|
2202
|
-
|
|
2258
|
+
function isPlacedPinned(id) {
|
|
2259
|
+
return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2260
|
+
}
|
|
2261
|
+
function isPlacedSticky(id) {
|
|
2262
|
+
return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// `saved` gives the pin the user set: a sticky that `session open` added is only this page's.
|
|
2266
|
+
function getSessionPinState(sessionId, { saved = false } = {}) {
|
|
2267
|
+
if (stickySessionIds.has(sessionId) && !(saved && openedStickyIds.has(sessionId))) return 'sticky';
|
|
2203
2268
|
if (pinnedSessionIds.has(sessionId)) return 'pinned';
|
|
2204
2269
|
return 'none';
|
|
2205
2270
|
}
|
|
@@ -3790,8 +3855,38 @@ function renderSessions() {
|
|
|
3790
3855
|
|
|
3791
3856
|
const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
|
|
3792
3857
|
|
|
3858
|
+
const childDepth = new Map();
|
|
3859
|
+
const parentOf = new Map();
|
|
3860
|
+
for (const g of sessionGroups) for (const m of g.members) if (m.parent) parentOf.set(m.ref, m.parent);
|
|
3861
|
+
const nestChildren = (list) => {
|
|
3862
|
+
if (parentOf.size === 0) return list;
|
|
3863
|
+
const ids = new Set(list.map((s) => s.id));
|
|
3864
|
+
const kids = new Map();
|
|
3865
|
+
for (const s of list) {
|
|
3866
|
+
const p = parentOf.get(s.id);
|
|
3867
|
+
if (p && ids.has(p)) {
|
|
3868
|
+
if (!kids.has(p)) kids.set(p, []);
|
|
3869
|
+
kids.get(p).push(s);
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
if (kids.size === 0) return list;
|
|
3873
|
+
const out = [];
|
|
3874
|
+
const seen = new Set();
|
|
3875
|
+
const add = (s, depth) => {
|
|
3876
|
+
if (seen.has(s.id)) return;
|
|
3877
|
+
seen.add(s.id);
|
|
3878
|
+
out.push(s);
|
|
3879
|
+
if (depth) childDepth.set(s.id, Math.min(depth, 3));
|
|
3880
|
+
for (const k of kids.get(s.id) || []) add(k, depth + 1);
|
|
3881
|
+
};
|
|
3882
|
+
for (const s of list) if (!ids.has(parentOf.get(s.id))) add(s, 0);
|
|
3883
|
+
// Sessions that are each other's parents have no top; show them as they came.
|
|
3884
|
+
for (const s of list) add(s, 0);
|
|
3885
|
+
return out;
|
|
3886
|
+
};
|
|
3887
|
+
|
|
3793
3888
|
const renderSessionCard = (session) => {
|
|
3794
|
-
if (session.placeholder) return renderPlaceholderCard(session);
|
|
3889
|
+
if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
|
|
3795
3890
|
const total = session.taskCount;
|
|
3796
3891
|
const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
|
|
3797
3892
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
@@ -3830,7 +3925,7 @@ function renderSessions() {
|
|
|
3830
3925
|
: '';
|
|
3831
3926
|
const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
|
|
3832
3927
|
return `
|
|
3833
|
-
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}">
|
|
3928
|
+
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}${childDepthClass(childDepth.get(session.id))}">
|
|
3834
3929
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3835
3930
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3836
3931
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
@@ -3939,11 +4034,11 @@ function renderSessions() {
|
|
|
3939
4034
|
const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
|
|
3940
4035
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3941
4036
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3942
|
-
const renderGroupSessions = (
|
|
3943
|
-
if (!groupPinned || pinnedSessionIds.size === 0) return
|
|
3944
|
-
const gIdlePinned =
|
|
3945
|
-
if (gIdlePinned.length === 0) return
|
|
3946
|
-
const gUnpinned =
|
|
4037
|
+
const renderGroupSessions = (list, pinKey) => {
|
|
4038
|
+
if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(renderSessionCard).join('');
|
|
4039
|
+
const gIdlePinned = nestChildren(list.filter(isInPinnedGroup));
|
|
4040
|
+
if (gIdlePinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
|
|
4041
|
+
const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
|
|
3947
4042
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3948
4043
|
return (
|
|
3949
4044
|
'<div class="pinned-sub-section">' +
|
|
@@ -4020,67 +4115,94 @@ function renderSessions() {
|
|
|
4020
4115
|
// shows its drop-here state instead of vanishing.
|
|
4021
4116
|
const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
|
|
4022
4117
|
|
|
4118
|
+
// A group whose parent is gone renders at the top.
|
|
4119
|
+
const sgIds = new Set(sessionGroups.map((g) => g.id));
|
|
4120
|
+
const sgChildren = new Map();
|
|
4121
|
+
for (const g of sessionGroups) {
|
|
4122
|
+
const p = g.parent && sgIds.has(g.parent) ? g.parent : null;
|
|
4123
|
+
if (!sgChildren.has(p)) sgChildren.set(p, []);
|
|
4124
|
+
sgChildren.get(p).push(g);
|
|
4125
|
+
}
|
|
4126
|
+
const sgSubtree = new Map();
|
|
4127
|
+
const sgSubtreeOf = (group) => {
|
|
4128
|
+
let all = sgSubtree.get(group.id);
|
|
4129
|
+
if (!all) {
|
|
4130
|
+
all = [...(sgBuckets.get(group.id) || []), ...(sgChildren.get(group.id) || []).flatMap(sgSubtreeOf)];
|
|
4131
|
+
sgSubtree.set(group.id, all);
|
|
4132
|
+
}
|
|
4133
|
+
return all;
|
|
4134
|
+
};
|
|
4135
|
+
|
|
4023
4136
|
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
4024
4137
|
const sgSectionHtml = (flat) => {
|
|
4025
4138
|
const transient = [...transientGroups.values()];
|
|
4026
4139
|
if (sessionGroups.length === 0 && transient.length === 0) return '';
|
|
4027
4140
|
let out = '';
|
|
4028
|
-
for (const group of [...
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
}
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
if (
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
|
|
4071
|
-
loose.sort((a, b) => (slot.get(`session:${a.id}`) ?? 0) - (slot.get(`session:${b.id}`) ?? 0));
|
|
4072
|
-
// Label them, else a card sitting under the project blocks reads as stranded.
|
|
4073
|
-
if (body) body += '<div class="sg-sub-label">Sessions</div>';
|
|
4074
|
-
body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
|
|
4141
|
+
for (const group of [...(sgChildren.get(null) || []), ...transient]) out += sgGroupHtml(group, flat);
|
|
4142
|
+
if (!out) return '';
|
|
4143
|
+
return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
|
|
4144
|
+
};
|
|
4145
|
+
|
|
4146
|
+
const sgGroupHtml = (group, flat) => {
|
|
4147
|
+
const bucket = sgBuckets.get(group.id) || [];
|
|
4148
|
+
const subtree = group.transient ? bucket : sgSubtreeOf(group);
|
|
4149
|
+
if (subtree.length === 0 && sgFiltering) return '';
|
|
4150
|
+
if (!groupPinned) bucket.sort(pinSort);
|
|
4151
|
+
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
4152
|
+
// Placed sessions follow the order the user dragged them into; a session that is here through
|
|
4153
|
+
// its project takes the project's slot.
|
|
4154
|
+
const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
|
|
4155
|
+
const slotOf = (s) => slot.get(`session:${s.id}`) ?? slot.get(`project:${sessionProjectKey(s)}`) ?? 0;
|
|
4156
|
+
const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
|
|
4157
|
+
let body = '';
|
|
4158
|
+
if (flat) {
|
|
4159
|
+
body = nestChildren([...bucket].sort(byMemberOrder))
|
|
4160
|
+
.map(renderSessionCard)
|
|
4161
|
+
.join('');
|
|
4162
|
+
} else if (group.transient) {
|
|
4163
|
+
// Each session under its own project block, so a worktree session reads as its repo's.
|
|
4164
|
+
const byProject = new Map();
|
|
4165
|
+
for (const s of bucket) {
|
|
4166
|
+
const key = sessionProjectKey(s) || '';
|
|
4167
|
+
if (!byProject.has(key)) byProject.set(key, []);
|
|
4168
|
+
byProject.get(key).push(s);
|
|
4169
|
+
}
|
|
4170
|
+
for (const [p, arr] of byProject) {
|
|
4171
|
+
body += p ? projectBlock(p, arr, true) : renderGroupSessions(arr, `__pinned_group_${group.id}__`);
|
|
4172
|
+
}
|
|
4173
|
+
} else {
|
|
4174
|
+
const loose = [];
|
|
4175
|
+
const byProject = new Map();
|
|
4176
|
+
for (const s of bucket) {
|
|
4177
|
+
const host = sgHostOf(group, s);
|
|
4178
|
+
if (host) {
|
|
4179
|
+
if (!byProject.has(host)) byProject.set(host, []);
|
|
4180
|
+
byProject.get(host).push(s);
|
|
4181
|
+
} else {
|
|
4182
|
+
loose.push(s);
|
|
4075
4183
|
}
|
|
4076
4184
|
}
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4185
|
+
// Project members first (in the user's member order), then the individually placed
|
|
4186
|
+
// sessions — one Pinned sub-section per group instead of one per interleaved run.
|
|
4187
|
+
for (const m of group.members) {
|
|
4188
|
+
if (m.type !== 'project') continue;
|
|
4189
|
+
const arr = byProject.get(m.ref);
|
|
4190
|
+
// A member project with nothing left in it still renders: it shows the membership, and
|
|
4191
|
+
// it is the drop target for putting a session back under it.
|
|
4192
|
+
if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
|
|
4193
|
+
}
|
|
4194
|
+
if (loose.length) {
|
|
4195
|
+
loose.sort(byMemberOrder);
|
|
4196
|
+
// Label them, else a card sitting under the project blocks reads as stranded.
|
|
4197
|
+
if (body) body += '<div class="sg-sub-label">Sessions</div>';
|
|
4198
|
+
body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
|
|
4199
|
+
}
|
|
4081
4200
|
}
|
|
4082
|
-
|
|
4083
|
-
|
|
4201
|
+
let childHtml = '';
|
|
4202
|
+
if (!group.transient) for (const child of sgChildren.get(group.id) || []) childHtml += sgGroupHtml(child, flat);
|
|
4203
|
+
if (!body && !childHtml) body = '<div class="sg-empty">Drop a session or a project here</div>';
|
|
4204
|
+
const idAttr = group.transient ? '' : ` data-group-id="${escapeHtml(group.id)}"`;
|
|
4205
|
+
return `${sgHeaderHtml(group, countHtml(subtree))}<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"${idAttr}>${body}${childHtml}</div>`;
|
|
4084
4206
|
};
|
|
4085
4207
|
|
|
4086
4208
|
let html = '';
|
|
@@ -4588,8 +4710,8 @@ async function onColumnDrop(e) {
|
|
|
4588
4710
|
|
|
4589
4711
|
//#region SESSION_GROUPS
|
|
4590
4712
|
// User-named groups that hold whole projects and individual sessions, dragged in from the
|
|
4591
|
-
// sidebar. They render above the project groups. Membership is a workspace preference kept
|
|
4592
|
-
//
|
|
4713
|
+
// sidebar. They render above the project groups. Membership is a workspace preference kept on
|
|
4714
|
+
// the server; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
|
|
4593
4715
|
// windows the list), so membership is never garbage-collected on load.
|
|
4594
4716
|
const SESSION_GROUPS_KEY = 'sessionGroups';
|
|
4595
4717
|
const SG_ACTION_SELECTOR =
|
|
@@ -4611,20 +4733,71 @@ function pinKey(projectPath) {
|
|
|
4611
4733
|
return projectPath === '__ungrouped__' ? '__pinned___ungrouped__' : `__pinned_${projectPath}__`;
|
|
4612
4734
|
}
|
|
4613
4735
|
|
|
4614
|
-
|
|
4615
|
-
|
|
4736
|
+
// The server keeps the groups (`/api/groups`, lib/user-groups.js). `sessionGroups` in localStorage
|
|
4737
|
+
// is the copy from before that: the board imports it once and never writes it, so it stays as a
|
|
4738
|
+
// backup.
|
|
4739
|
+
const GROUPS_MIGRATED_AT = 'groupsMigratedAt';
|
|
4740
|
+
let sgRev = null;
|
|
4741
|
+
let sgSaving = null;
|
|
4742
|
+
let sgDirty = false;
|
|
4743
|
+
let sgUnsent = false;
|
|
4744
|
+
let sgSeenRev = 0;
|
|
4745
|
+
|
|
4746
|
+
// Until it succeeds the board shows no groups, and it tries again on the next load or SSE reconnect.
|
|
4747
|
+
async function syncSessionGroups() {
|
|
4748
|
+
const migrated = !!store.getItem(GROUPS_MIGRATED_AT);
|
|
4749
|
+
const local = migrated ? null : store.readJson(SESSION_GROUPS_KEY);
|
|
4750
|
+
const hasLocal = local?.groups?.length > 0 || local?.released?.length > 0;
|
|
4751
|
+
try {
|
|
4752
|
+
const res = hasLocal
|
|
4753
|
+
? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups, released: local.released } })
|
|
4754
|
+
: await api('/api/groups');
|
|
4755
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4756
|
+
sgTakeServer(await res.json());
|
|
4757
|
+
if (!migrated) store.setItem(GROUPS_MIGRATED_AT, new Date().toISOString());
|
|
4758
|
+
if (sgSeenRev > sgRev) sgRefresh(sgSeenRev);
|
|
4759
|
+
} catch (e) {
|
|
4760
|
+
console.warn('[groups] server sync failed:', e.message);
|
|
4761
|
+
}
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
// The SSE event for this board's own write often arrives before the write's response.
|
|
4765
|
+
async function sgRefresh(rev = Infinity) {
|
|
4766
|
+
await sgSaving;
|
|
4767
|
+
if (sgRev !== null && sgRev >= rev) return;
|
|
4768
|
+
const data = await getJson('/api/groups');
|
|
4769
|
+
if (data) sgTakeServer(data);
|
|
4770
|
+
}
|
|
4771
|
+
|
|
4772
|
+
function sgTakeServer(data, force = false) {
|
|
4773
|
+
if (!Number.isInteger(data?.rev) || (!force && sgRev !== null && data.rev <= sgRev)) return;
|
|
4774
|
+
sgRev = data.rev;
|
|
4775
|
+
sgApplyState(data);
|
|
4776
|
+
renderSessions();
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4779
|
+
// An event during the first sync is kept, because the sync's read may be older than it.
|
|
4780
|
+
function onGroupChanged(data) {
|
|
4781
|
+
if (!Number.isInteger(data?.rev)) return;
|
|
4782
|
+
sgSeenRev = Math.max(sgSeenRev, data.rev);
|
|
4783
|
+
if (sgRev !== null && data.rev > sgRev) sgRefresh(data.rev);
|
|
4784
|
+
}
|
|
4785
|
+
|
|
4786
|
+
function sgApplyState(saved) {
|
|
4616
4787
|
const list = saved?.groups;
|
|
4617
4788
|
sessionGroups = (Array.isArray(list) ? list : [])
|
|
4618
4789
|
.filter((g) => g && typeof g.id === 'string')
|
|
4619
4790
|
.map((g) => ({
|
|
4620
4791
|
id: g.id,
|
|
4621
4792
|
name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
|
|
4793
|
+
parent: typeof g.parent === 'string' ? g.parent : null,
|
|
4622
4794
|
members: (Array.isArray(g.members) ? g.members : [])
|
|
4623
4795
|
.filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
|
|
4624
4796
|
.map((m) => {
|
|
4625
4797
|
const out = { type: m.type, ref: m.ref };
|
|
4626
4798
|
if (typeof m.under === 'string' && m.under) out.under = m.under;
|
|
4627
4799
|
if (m.loose === true) out.loose = true;
|
|
4800
|
+
if (typeof m.parent === 'string' && m.parent) out.parent = m.parent;
|
|
4628
4801
|
return out;
|
|
4629
4802
|
}),
|
|
4630
4803
|
}));
|
|
@@ -4633,17 +4806,85 @@ function loadSessionGroups() {
|
|
|
4633
4806
|
}
|
|
4634
4807
|
|
|
4635
4808
|
function persistSessionGroups() {
|
|
4636
|
-
|
|
4809
|
+
if (sgRev === null) {
|
|
4810
|
+
showToast('Groups are not loaded from the server; this change is not saved', 'error');
|
|
4811
|
+
return;
|
|
4812
|
+
}
|
|
4813
|
+
sgDirty = true;
|
|
4814
|
+
sgSaving ??= sgFlush().finally(() => {
|
|
4815
|
+
sgSaving = null;
|
|
4816
|
+
});
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
// Writes run one at a time; edits made meanwhile go out in the next write. A 409 means another
|
|
4820
|
+
// board wrote first: its state wins and this board's unsent edit is dropped. A write that does not
|
|
4821
|
+
// reach the server is sent again on SSE reconnect.
|
|
4822
|
+
async function sgFlush() {
|
|
4823
|
+
while (sgDirty) {
|
|
4824
|
+
sgDirty = false;
|
|
4825
|
+
let res;
|
|
4826
|
+
try {
|
|
4827
|
+
res = await api('/api/groups', {
|
|
4828
|
+
method: 'PUT',
|
|
4829
|
+
body: { rev: sgRev, groups: sessionGroups, released: [...sgReleased] },
|
|
4830
|
+
});
|
|
4831
|
+
} catch (e) {
|
|
4832
|
+
console.warn('[groups] save failed:', e.message);
|
|
4833
|
+
sgUnsent = true;
|
|
4834
|
+
showToast('Groups not saved: the server is unreachable. Retrying when it is back', 'error');
|
|
4835
|
+
return;
|
|
4836
|
+
}
|
|
4837
|
+
sgUnsent = false;
|
|
4838
|
+
const data = await res.json().catch(() => null);
|
|
4839
|
+
if (res.ok && Number.isInteger(data?.rev)) {
|
|
4840
|
+
sgRev = data.rev;
|
|
4841
|
+
if (!sgDirty) sgTakeCleaned(data);
|
|
4842
|
+
continue;
|
|
4843
|
+
}
|
|
4844
|
+
showToast(
|
|
4845
|
+
res.status === 409
|
|
4846
|
+
? 'Groups changed in another window; showing the latest'
|
|
4847
|
+
: `Groups not saved (${data?.error || res.status})`,
|
|
4848
|
+
res.status === 409 ? undefined : 'error',
|
|
4849
|
+
);
|
|
4850
|
+
sgDirty = false;
|
|
4851
|
+
const fresh = res.status === 409 ? data : await getJson('/api/groups');
|
|
4852
|
+
if (fresh) sgTakeServer(fresh, true);
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
// The server drops what breaks its rules (a member in two groups, a group past the limit) and
|
|
4857
|
+
// trims long names; the board shows what was saved.
|
|
4858
|
+
function sgTakeCleaned(data) {
|
|
4859
|
+
const saved = JSON.stringify({ groups: data.groups, released: data.released });
|
|
4860
|
+
if (saved === JSON.stringify({ groups: sessionGroups, released: [...sgReleased] })) return;
|
|
4861
|
+
sgApplyState(data);
|
|
4862
|
+
renderSessions();
|
|
4637
4863
|
}
|
|
4638
4864
|
|
|
4639
4865
|
function sgGroupById(id) {
|
|
4640
4866
|
return sessionGroups.find((g) => g.id === id) || null;
|
|
4641
4867
|
}
|
|
4642
4868
|
|
|
4869
|
+
// Matches MAX_DEPTH in lib/user-groups.js.
|
|
4870
|
+
const SG_MAX_DEPTH = 8;
|
|
4871
|
+
|
|
4872
|
+
function sgPathOf(group) {
|
|
4873
|
+
const names = [];
|
|
4874
|
+
for (let g = group; g && names.length < SG_MAX_DEPTH; g = sgGroupById(g.parent)) names.unshift(g.name);
|
|
4875
|
+
return names.join('/');
|
|
4876
|
+
}
|
|
4877
|
+
|
|
4643
4878
|
function sgGroupOf(type, ref) {
|
|
4644
4879
|
return sessionGroups.find((g) => g.members.some((m) => m.type === type && m.ref === ref)) || null;
|
|
4645
4880
|
}
|
|
4646
4881
|
|
|
4882
|
+
const sgMemberOf = (group, type, ref) => group?.members.find((m) => m.type === type && m.ref === ref);
|
|
4883
|
+
|
|
4884
|
+
function sgSessionParent(id) {
|
|
4885
|
+
return sgMemberOf(sgGroupOf('session', id), 'session', id)?.parent || null;
|
|
4886
|
+
}
|
|
4887
|
+
|
|
4647
4888
|
// An individually placed session wins over the placement of its project.
|
|
4648
4889
|
function sgGroupForSession(session) {
|
|
4649
4890
|
return sgUserGroupFor(session) || sgDispatchGroupFor(session);
|
|
@@ -4668,6 +4909,8 @@ function sgTransientGroup(name) {
|
|
|
4668
4909
|
return group;
|
|
4669
4910
|
}
|
|
4670
4911
|
|
|
4912
|
+
const childDepthClass = (depth) => (depth ? ` sg-child sg-depth-${depth}` : '');
|
|
4913
|
+
|
|
4671
4914
|
function dispatchedTitle({ parent }) {
|
|
4672
4915
|
const starter = parent && sessions.find((s) => s.id === parent);
|
|
4673
4916
|
const by = starter ? sessionDisplayName(starter) : 'another session';
|
|
@@ -4714,10 +4957,12 @@ function sgHostOf(group, session) {
|
|
|
4714
4957
|
return null;
|
|
4715
4958
|
}
|
|
4716
4959
|
// `opts.under` places the session under another project block of the group; `opts.loose` lifts it
|
|
4717
|
-
// out of its project block onto the group's own level.
|
|
4960
|
+
// out of its project block onto the group's own level. `opts.parent` follows the server's place():
|
|
4961
|
+
// left out, a move inside the group keeps it; null clears it.
|
|
4718
4962
|
function sgAssign(groupId, type, ref, opts = {}) {
|
|
4719
4963
|
const group = sgGroupById(groupId);
|
|
4720
4964
|
if (!group) return;
|
|
4965
|
+
const parent = opts.parent !== undefined ? opts.parent : sgMemberOf(group, type, ref)?.parent;
|
|
4721
4966
|
sgDetach(type, ref);
|
|
4722
4967
|
// Pulling a whole project in supersedes the individual placements of its sessions.
|
|
4723
4968
|
if (type === 'project') {
|
|
@@ -4731,10 +4976,11 @@ function sgAssign(groupId, type, ref, opts = {}) {
|
|
|
4731
4976
|
type === 'session' && !under && !loose && group.members.some((m) => m.type === 'project' && m.ref === own);
|
|
4732
4977
|
// The group already holds the session's project — a separate placement would only strand
|
|
4733
4978
|
// the card at the bottom of the group, away from its project block.
|
|
4734
|
-
if (!covered) {
|
|
4979
|
+
if (!covered || parent) {
|
|
4735
4980
|
const member = { type, ref };
|
|
4736
4981
|
if (under) member.under = under;
|
|
4737
4982
|
if (loose) member.loose = true;
|
|
4983
|
+
if (parent) member.parent = parent;
|
|
4738
4984
|
group.members.push(member);
|
|
4739
4985
|
}
|
|
4740
4986
|
persistSessionGroups();
|
|
@@ -4744,6 +4990,7 @@ function sgCreateGroup(name) {
|
|
|
4744
4990
|
const group = {
|
|
4745
4991
|
id: `g_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
4746
4992
|
name: (name || '').trim() || `Group ${sessionGroups.length + 1}`,
|
|
4993
|
+
parent: null,
|
|
4747
4994
|
members: [],
|
|
4748
4995
|
};
|
|
4749
4996
|
sessionGroups.push(group);
|
|
@@ -4752,7 +4999,9 @@ function sgCreateGroup(name) {
|
|
|
4752
4999
|
}
|
|
4753
5000
|
|
|
4754
5001
|
function sgDeleteGroup(id) {
|
|
5002
|
+
const parent = sgGroupById(id)?.parent ?? null;
|
|
4755
5003
|
sessionGroups = sessionGroups.filter((g) => g.id !== id);
|
|
5004
|
+
for (const g of sessionGroups) if (g.parent === id) g.parent = parent;
|
|
4756
5005
|
if (collapsedProjectGroups.delete(sgKey(id))) persistCollapsedGroups();
|
|
4757
5006
|
persistSessionGroups();
|
|
4758
5007
|
}
|
|
@@ -4778,13 +5027,17 @@ function sgReorderGroup(dragId, targetId) {
|
|
|
4778
5027
|
persistSessionGroups();
|
|
4779
5028
|
}
|
|
4780
5029
|
|
|
4781
|
-
// Typing a group name should surface the sessions inside it.
|
|
5030
|
+
// Typing a group name should surface the sessions inside it and inside its child groups.
|
|
4782
5031
|
function sgSearchMatchIds(query) {
|
|
4783
5032
|
const ids = new Set();
|
|
4784
5033
|
if (!query) return ids;
|
|
4785
|
-
const hits = sessionGroups.filter((g) => fuzzyMatch(g.name, query));
|
|
4786
|
-
if (hits.
|
|
4787
|
-
const
|
|
5034
|
+
const hits = new Set(sessionGroups.filter((g) => fuzzyMatch(g.name, query)).map((g) => g.id));
|
|
5035
|
+
if (hits.size === 0) return ids;
|
|
5036
|
+
const underHit = (g) => {
|
|
5037
|
+
for (let p = g, n = 0; p && n < SG_MAX_DEPTH; p = sgGroupById(p.parent), n++) if (hits.has(p.id)) return true;
|
|
5038
|
+
return false;
|
|
5039
|
+
};
|
|
5040
|
+
const hitIds = new Set(sessionGroups.filter(underHit).map((g) => g.id));
|
|
4788
5041
|
for (const s of sessions) {
|
|
4789
5042
|
const group = sgGroupForSession(s);
|
|
4790
5043
|
if (group && hitIds.has(group.id)) ids.add(s.id);
|
|
@@ -4857,7 +5110,7 @@ function sgCommitRename(input, save) {
|
|
|
4857
5110
|
// At most one zone is lit, so the reference is enough - dragover fires many times a second.
|
|
4858
5111
|
let sgLitZone = null;
|
|
4859
5112
|
let sgDragEl = null;
|
|
4860
|
-
const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after'];
|
|
5113
|
+
const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after', 'sg-insert-parent'];
|
|
4861
5114
|
const SG_LIT_SELECTOR = SG_LIT_CLASSES.map((c) => `.${c}`).join(', ');
|
|
4862
5115
|
|
|
4863
5116
|
function sgClearDropTargets() {
|
|
@@ -4882,15 +5135,43 @@ function sgInsertHit(card, e, share) {
|
|
|
4882
5135
|
return { zone: card, cls: after ? 'sg-insert-after' : 'sg-insert-before', insert: { card, after } };
|
|
4883
5136
|
}
|
|
4884
5137
|
|
|
5138
|
+
// Outside the Active filter the sidebar sorts ungrouped cards itself, so only a group's members,
|
|
5139
|
+
// which keep their own order, take an insert there.
|
|
4885
5140
|
function sgSiblingCard(target) {
|
|
4886
|
-
if (sgDrag?.kind !== 'session' ||
|
|
5141
|
+
if (sgDrag?.kind !== 'session' || !sgDragEl) return null;
|
|
4887
5142
|
const card = target.closest('.session-item');
|
|
4888
|
-
|
|
5143
|
+
if (!card || card === sgDragEl || card.parentElement !== sgDragEl.parentElement) return null;
|
|
5144
|
+
if (sessionFilter === 'active') return card;
|
|
5145
|
+
const group = sgGroupOf('session', sgDrag.ref);
|
|
5146
|
+
return group && sgGroupOf('session', card.dataset.sessionId) === group ? card : null;
|
|
5147
|
+
}
|
|
5148
|
+
|
|
5149
|
+
function sgDescendsFrom(id, ancestor) {
|
|
5150
|
+
for (let p = sgSessionParent(id), n = 0; p && n < SG_MAX_DEPTH; p = sgSessionParent(p), n++) {
|
|
5151
|
+
if (p === ancestor) return true;
|
|
5152
|
+
}
|
|
5153
|
+
return false;
|
|
5154
|
+
}
|
|
5155
|
+
|
|
5156
|
+
// A child group's header drops into its parent group on its top edge, and wherever its own group
|
|
5157
|
+
// would refuse the drop. Without it a session in the child group has no way up when the parent's
|
|
5158
|
+
// header is off screen: the gap above is a few pixels.
|
|
5159
|
+
// A child group renders inside its parent's body, so that body is the parent's drop zone.
|
|
5160
|
+
function sgParentEdgeHit(target, e, hit) {
|
|
5161
|
+
const header = sgDrag?.kind === 'group' ? null : target.closest('.session-group-header[data-group-id]');
|
|
5162
|
+
const parentBody = header?.parentElement.closest('.session-group-sessions[data-group-id]');
|
|
5163
|
+
if (!parentBody) return null;
|
|
5164
|
+
const rect = header.getBoundingClientRect();
|
|
5165
|
+
if (e.clientY - rect.top > rect.height * 0.4 && hit) return null;
|
|
5166
|
+
const up = sgGroupDropZone(parentBody);
|
|
5167
|
+
return up ? { ...up, zone: header, cls: 'sg-insert-parent' } : null;
|
|
4889
5168
|
}
|
|
4890
5169
|
|
|
4891
5170
|
function sgDropZone(target, e) {
|
|
4892
|
-
const sibling = sgSiblingCard(target);
|
|
4893
5171
|
const hit = sgGroupDropZone(target);
|
|
5172
|
+
const edge = sgParentEdgeHit(target, e, hit);
|
|
5173
|
+
if (edge) return edge;
|
|
5174
|
+
const sibling = sgSiblingCard(target);
|
|
4894
5175
|
// Where the middle of a card does nothing, the whole card is an insert target.
|
|
4895
5176
|
return (sibling && sgInsertHit(sibling, e, hit ? 0.25 : 0.5)) || hit;
|
|
4896
5177
|
}
|
|
@@ -4900,10 +5181,14 @@ function sgInsertSession(dragId, { card, after }) {
|
|
|
4900
5181
|
const group = card.closest('.project-group-sessions') ? null : sgGroupOf('session', ref);
|
|
4901
5182
|
if (group && sgGroupOf('session', dragId)?.id === group.id) {
|
|
4902
5183
|
// Loose sessions in a named group follow the group's member order, not the sidebar order.
|
|
5184
|
+
// A card set next to another becomes its sibling: it takes that card's parent.
|
|
5185
|
+
const { parent: _old, ...moved } = sgMemberOf(group, 'session', dragId);
|
|
5186
|
+
const parent = sgMemberOf(group, 'session', ref)?.parent;
|
|
5187
|
+
if (parent && parent !== dragId && !sgDescendsFrom(parent, dragId)) moved.parent = parent;
|
|
4903
5188
|
const members = insertNear(
|
|
4904
5189
|
group.members.filter((m) => !(m.type === 'session' && m.ref === dragId)),
|
|
4905
5190
|
(m) => m.type === 'session' && m.ref === ref,
|
|
4906
|
-
|
|
5191
|
+
moved,
|
|
4907
5192
|
after,
|
|
4908
5193
|
);
|
|
4909
5194
|
if (!members) return;
|
|
@@ -4960,8 +5245,9 @@ function sgGroupDropZone(target) {
|
|
|
4960
5245
|
if (groupId) {
|
|
4961
5246
|
if (!dragSession) return dragGroup?.id === groupId ? null : { zone, groupId };
|
|
4962
5247
|
if (fromGroupId !== groupId) return { zone, groupId };
|
|
4963
|
-
// Already in this group: its own level is a move only out of a project block
|
|
4964
|
-
|
|
5248
|
+
// Already in this group: its own level is a move only out of a project block or from under
|
|
5249
|
+
// a parent session.
|
|
5250
|
+
return fromHost || sgSessionParent(dragSession.id) ? { zone, groupId, loose: true, parent: null } : null;
|
|
4965
5251
|
}
|
|
4966
5252
|
const targetSessionId = zone.dataset.sessionId;
|
|
4967
5253
|
if (targetSessionId) {
|
|
@@ -4971,20 +5257,17 @@ function sgGroupDropZone(target) {
|
|
|
4971
5257
|
const targetGroupId = targetGroup?.id || null;
|
|
4972
5258
|
// A transient group holds no members to reorder or place under; a drop there pairs instead.
|
|
4973
5259
|
if (targetGroup && !targetGroup.transient) {
|
|
4974
|
-
// Stacking onto a card
|
|
5260
|
+
// Stacking onto a card nests the dragged session under it, in that card's placement, project
|
|
5261
|
+
// block included.
|
|
5262
|
+
if (sgSessionParent(sgDrag.ref) === targetSessionId && fromGroupId === targetGroupId) return null;
|
|
5263
|
+
if (sgDescendsFrom(targetSessionId, sgDrag.ref)) return null;
|
|
4975
5264
|
const targetHost = sgHostOf(targetGroup, targetSession);
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
if (fromGroupId === targetGroupId && !fromHost) {
|
|
4983
|
-
return sgGroupOf('session', targetSessionId)?.id === targetGroupId
|
|
4984
|
-
? { zone, groupId: targetGroupId, reorder: { type: 'session', ref: targetSessionId } }
|
|
4985
|
-
: null;
|
|
4986
|
-
}
|
|
4987
|
-
return { zone, groupId: targetGroupId, loose: true };
|
|
5265
|
+
return {
|
|
5266
|
+
zone,
|
|
5267
|
+
groupId: targetGroupId,
|
|
5268
|
+
...(targetHost ? { under: targetHost } : { loose: true }),
|
|
5269
|
+
parent: targetSessionId,
|
|
5270
|
+
};
|
|
4988
5271
|
}
|
|
4989
5272
|
return { zone, pairSession: targetSessionId };
|
|
4990
5273
|
}
|
|
@@ -5070,7 +5353,11 @@ function sgOnDrop(e) {
|
|
|
5070
5353
|
if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
|
|
5071
5354
|
else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
|
|
5072
5355
|
else {
|
|
5073
|
-
sgAssign(hit.groupId, drag.kind, drag.ref, {
|
|
5356
|
+
sgAssign(hit.groupId, drag.kind, drag.ref, {
|
|
5357
|
+
under: hit.under,
|
|
5358
|
+
loose: hit.loose,
|
|
5359
|
+
parent: hit.parent,
|
|
5360
|
+
});
|
|
5074
5361
|
// A drop shows where the card landed; a move from the context menu leaves the group as it was.
|
|
5075
5362
|
if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
|
|
5076
5363
|
}
|
|
@@ -5136,11 +5423,6 @@ function initSessionGroupsDnd() {
|
|
|
5136
5423
|
sessionsList.addEventListener('touchstart', sgOnTouchStart, { passive: true });
|
|
5137
5424
|
sessionsList.addEventListener('touchend', sgCancelLongPress);
|
|
5138
5425
|
sessionsList.addEventListener('touchmove', sgCancelLongPress, { passive: true });
|
|
5139
|
-
window.addEventListener('storage', (e) => {
|
|
5140
|
-
if (e.key !== SESSION_GROUPS_KEY) return;
|
|
5141
|
-
loadSessionGroups();
|
|
5142
|
-
renderSessions();
|
|
5143
|
-
});
|
|
5144
5426
|
}
|
|
5145
5427
|
//#endregion
|
|
5146
5428
|
|
|
@@ -5218,7 +5500,7 @@ function sgOpenMenu(x, y, kind, ref) {
|
|
|
5218
5500
|
.filter((g) => g.id !== current?.id)
|
|
5219
5501
|
.map(
|
|
5220
5502
|
(g) =>
|
|
5221
|
-
`<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g
|
|
5503
|
+
`<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(sgPathOf(g))}</button>`,
|
|
5222
5504
|
)
|
|
5223
5505
|
.join('');
|
|
5224
5506
|
const menu = document.createElement('div');
|
|
@@ -5414,7 +5696,7 @@ function getNavigableItems() {
|
|
|
5414
5696
|
}
|
|
5415
5697
|
} else if (child.classList.contains('session-item')) {
|
|
5416
5698
|
items.push(child);
|
|
5417
|
-
} else if (child.classList.contains('project-group-header')) {
|
|
5699
|
+
} else if (child.classList.contains('project-group-header') || child.classList.contains('session-group-header')) {
|
|
5418
5700
|
items.push(child);
|
|
5419
5701
|
if (!collapsedProjectGroups.has(child.dataset.groupPath)) {
|
|
5420
5702
|
walkGroupContainer(getGroupSessionsContainer(child));
|
|
@@ -5636,6 +5918,7 @@ function activateSelectedSession(items) {
|
|
|
5636
5918
|
}
|
|
5637
5919
|
|
|
5638
5920
|
function setFocusZone(zone, target) {
|
|
5921
|
+
passTerminalZoneOn = false;
|
|
5639
5922
|
clearKbSelection();
|
|
5640
5923
|
clearTaskSelection();
|
|
5641
5924
|
|
|
@@ -5911,10 +6194,30 @@ function closeDetailPanel() {
|
|
|
5911
6194
|
// Set while #confirm-modal is open; settles the open confirmModal() promise.
|
|
5912
6195
|
let closeConfirmModal = null;
|
|
5913
6196
|
|
|
5914
|
-
|
|
6197
|
+
// chip: the object named in one line. items: [{state, label, meta}] rows, state in busy|wait|idle (a dot) or
|
|
6198
|
+
// omitted (no dot); steps: plain strings shown as what will happen; warn: a line under the list.
|
|
6199
|
+
function confirmModal({ title, message, okLabel, chip, items, steps, warn }) {
|
|
5915
6200
|
const modal = document.getElementById('confirm-modal');
|
|
5916
6201
|
document.getElementById('confirm-modal-title').textContent = title;
|
|
5917
6202
|
document.getElementById('confirm-modal-message').textContent = message;
|
|
6203
|
+
const chipEl = document.getElementById('confirm-modal-chip');
|
|
6204
|
+
chipEl.hidden = !chip;
|
|
6205
|
+
chipEl.textContent = chip || '';
|
|
6206
|
+
const listEl = document.getElementById('confirm-modal-list');
|
|
6207
|
+
const rows = [
|
|
6208
|
+
...(items || []).map(
|
|
6209
|
+
(it) =>
|
|
6210
|
+
`<div class="confirm-row">${it.state ? `<span class="confirm-dot ${escapeHtml(it.state)}"></span>` : ''}<span class="confirm-row-label">${escapeHtml(it.label)}</span><span class="confirm-row-meta">${escapeHtml(it.meta || '')}</span></div>`,
|
|
6211
|
+
),
|
|
6212
|
+
...(steps || []).map(
|
|
6213
|
+
(s) => `<div class="confirm-row confirm-step"><span class="confirm-row-label">${escapeHtml(s)}</span></div>`,
|
|
6214
|
+
),
|
|
6215
|
+
];
|
|
6216
|
+
listEl.hidden = !rows.length;
|
|
6217
|
+
listEl.innerHTML = rows.join('');
|
|
6218
|
+
const warnEl = document.getElementById('confirm-modal-warn');
|
|
6219
|
+
warnEl.hidden = !warn;
|
|
6220
|
+
warnEl.textContent = warn || '';
|
|
5918
6221
|
const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
|
|
5919
6222
|
buttons[1].textContent = okLabel;
|
|
5920
6223
|
const returnFocus = document.activeElement;
|
|
@@ -5946,9 +6249,10 @@ async function deleteTask(taskId, sessionId) {
|
|
|
5946
6249
|
const task = currentTasks.find((t) => t.id === taskId);
|
|
5947
6250
|
if (!task) return;
|
|
5948
6251
|
const ok = await confirmModal({
|
|
5949
|
-
title: 'Delete
|
|
5950
|
-
message:
|
|
5951
|
-
|
|
6252
|
+
title: 'Delete this task?',
|
|
6253
|
+
message: 'It is removed from the board. This cannot be undone.',
|
|
6254
|
+
chip: task.subject,
|
|
6255
|
+
okLabel: 'Delete task',
|
|
5952
6256
|
});
|
|
5953
6257
|
if (!ok) return;
|
|
5954
6258
|
|
|
@@ -6018,6 +6322,7 @@ const SHORTCUT_TABS = [
|
|
|
6018
6322
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
6019
6323
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
6020
6324
|
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
6325
|
+
{ keys: ['Ctrl', 'Shift', 'K'], combo: true, label: 'Show / hide Kanbot' },
|
|
6021
6326
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
6022
6327
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
6023
6328
|
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
@@ -6135,6 +6440,14 @@ const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platf
|
|
|
6135
6440
|
const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
|
|
6136
6441
|
if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
|
|
6137
6442
|
|
|
6443
|
+
// Ctrl+Enter sends on macOS as Cmd+Enter too (the handlers accept metaKey), so the hints show ⌘ and ⌥ there.
|
|
6444
|
+
function sendKbd() {
|
|
6445
|
+
return `<kbd>${IS_MAC ? '⌘' : 'Ctrl'}</kbd>+<kbd>Enter</kbd>`;
|
|
6446
|
+
}
|
|
6447
|
+
function termKbd() {
|
|
6448
|
+
return `<kbd>${IS_MAC ? '⌥' : 'Alt'}</kbd>+<kbd>\`</kbd>`;
|
|
6449
|
+
}
|
|
6450
|
+
|
|
6138
6451
|
// The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
|
|
6139
6452
|
// macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
|
|
6140
6453
|
// row shows the keys the hub reports, which the user can change; row.keys until it answers.
|
|
@@ -6741,7 +7054,7 @@ async function _storageViewSession(id) {
|
|
|
6741
7054
|
|
|
6742
7055
|
function _storageUnpinSession(id) {
|
|
6743
7056
|
clearSessionPin(id);
|
|
6744
|
-
|
|
7057
|
+
sendPinChange(id);
|
|
6745
7058
|
renderSessions();
|
|
6746
7059
|
_renderStorageTab();
|
|
6747
7060
|
_updateStorageTotal();
|
|
@@ -6972,7 +7285,7 @@ function _findOrphanedKeys(known) {
|
|
|
6972
7285
|
for (const key of store.keys()) {
|
|
6973
7286
|
const pad = _parsePadKey(key);
|
|
6974
7287
|
if (pad?.kind === 'group') {
|
|
6975
|
-
if (!sgGroupById(pad.id)) orphaned.push(key);
|
|
7288
|
+
if (sgRev !== null && !sgGroupById(pad.id)) orphaned.push(key);
|
|
6976
7289
|
continue;
|
|
6977
7290
|
}
|
|
6978
7291
|
const prefix = pad ? null : sessionKeyPrefixes.find((p) => key.startsWith(p));
|
|
@@ -7001,13 +7314,10 @@ async function cleanupOrphanedStorage() {
|
|
|
7001
7314
|
const orphaned = _findOrphanedKeys(known);
|
|
7002
7315
|
const unpinned = new Set();
|
|
7003
7316
|
for (const key of orphaned) {
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
} else if (key.startsWith('__sticky__')) {
|
|
7009
|
-
const id = key.slice('__sticky__'.length);
|
|
7010
|
-
stickySessionIds.delete(id);
|
|
7317
|
+
const pin = /^__(pinned|sticky)__/.exec(key);
|
|
7318
|
+
if (pin) {
|
|
7319
|
+
const id = key.slice(pin[0].length);
|
|
7320
|
+
clearSessionPin(id);
|
|
7011
7321
|
unpinned.add(id);
|
|
7012
7322
|
} else {
|
|
7013
7323
|
store.removeItem(key);
|
|
@@ -7015,8 +7325,7 @@ async function cleanupOrphanedStorage() {
|
|
|
7015
7325
|
if (key.startsWith(PREVIEW_STORAGE_PREFIX)) forgetServerLinkedDoc(key.slice(PREVIEW_STORAGE_PREFIX.length));
|
|
7016
7326
|
}
|
|
7017
7327
|
}
|
|
7018
|
-
if (unpinned.size)
|
|
7019
|
-
for (const id of unpinned) offloadSessionPin(id);
|
|
7328
|
+
if (unpinned.size) postPins({ ids: [...unpinned], state: 'none' });
|
|
7020
7329
|
const removed = orphaned.length;
|
|
7021
7330
|
|
|
7022
7331
|
showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
|
|
@@ -7083,13 +7392,18 @@ async function confirmDismissBlockers(sid) {
|
|
|
7083
7392
|
const session = sessions.find((s) => s.id === sid);
|
|
7084
7393
|
const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
|
|
7085
7394
|
const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
|
|
7086
|
-
const
|
|
7087
|
-
|
|
7088
|
-
|
|
7089
|
-
|
|
7090
|
-
|
|
7091
|
-
|
|
7092
|
-
const ok = await confirmModal({
|
|
7395
|
+
const okLabel = sticky && terminal ? 'Unpin, end and dismiss' : sticky ? 'Unpin and dismiss' : 'End and dismiss';
|
|
7396
|
+
const steps = [
|
|
7397
|
+
sticky && 'Remove the sticky pin',
|
|
7398
|
+
terminal && 'End the terminal',
|
|
7399
|
+
'Hide the session from the sidebar',
|
|
7400
|
+
].filter(Boolean);
|
|
7401
|
+
const ok = await confirmModal({
|
|
7402
|
+
title: `Dismiss “${name}”?`,
|
|
7403
|
+
message: `The session has ${has}.`,
|
|
7404
|
+
steps,
|
|
7405
|
+
okLabel,
|
|
7406
|
+
});
|
|
7093
7407
|
if (!ok) return false;
|
|
7094
7408
|
if (sticky) unpinSession(sid);
|
|
7095
7409
|
if (terminal) closeTerminalSession(sid);
|
|
@@ -7194,15 +7508,26 @@ document.addEventListener('keydown', (e) => {
|
|
|
7194
7508
|
e.preventDefault();
|
|
7195
7509
|
closeMsgDetailModal();
|
|
7196
7510
|
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
(e.ctrlKey || e.metaKey) &&
|
|
7200
|
-
currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX &&
|
|
7201
|
-
isWaitingAnswerable() &&
|
|
7202
|
-
currentWaiting.kind !== 'question'
|
|
7203
|
-
) {
|
|
7511
|
+
const askOpen = currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX && isWaitingAnswerable();
|
|
7512
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen && currentWaiting.kind !== 'question') {
|
|
7204
7513
|
e.preventDefault();
|
|
7205
7514
|
respondWaiting({ behavior: 'allow' });
|
|
7515
|
+
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen) {
|
|
7516
|
+
e.preventDefault();
|
|
7517
|
+
submitWaitingAnswers();
|
|
7518
|
+
} else if (
|
|
7519
|
+
askOpen &&
|
|
7520
|
+
currentWaiting.kind === 'question' &&
|
|
7521
|
+
!e.ctrlKey &&
|
|
7522
|
+
!e.metaKey &&
|
|
7523
|
+
!e.altKey &&
|
|
7524
|
+
/^[1-9]$/.test(e.key)
|
|
7525
|
+
) {
|
|
7526
|
+
e.preventDefault();
|
|
7527
|
+
pickWaitingOptionByNumber(Number(e.key) - 1);
|
|
7528
|
+
} else if (askOpen && currentWaiting.kind === 'question' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
7529
|
+
e.preventDefault();
|
|
7530
|
+
setWaitingTab(waitingTab + (e.key === 'ArrowRight' ? 1 : -1));
|
|
7206
7531
|
} else if (matchKey(e, 'ArrowDown')) {
|
|
7207
7532
|
e.preventDefault();
|
|
7208
7533
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
@@ -7234,6 +7559,20 @@ document.addEventListener('keydown', (e) => {
|
|
|
7234
7559
|
return;
|
|
7235
7560
|
}
|
|
7236
7561
|
|
|
7562
|
+
if (
|
|
7563
|
+
e.key === 'Enter' &&
|
|
7564
|
+
(e.ctrlKey || e.metaKey) &&
|
|
7565
|
+
!e.altKey &&
|
|
7566
|
+
!e.shiftKey &&
|
|
7567
|
+
isWaitingFresh() &&
|
|
7568
|
+
isWaitingAnswerable() &&
|
|
7569
|
+
currentWaiting.kind !== 'question'
|
|
7570
|
+
) {
|
|
7571
|
+
e.preventDefault();
|
|
7572
|
+
respondWaiting({ behavior: 'allow' });
|
|
7573
|
+
return;
|
|
7574
|
+
}
|
|
7575
|
+
|
|
7237
7576
|
// Global shortcuts
|
|
7238
7577
|
if (matchKey(e, '\\')) {
|
|
7239
7578
|
e.preventDefault();
|
|
@@ -7739,6 +8078,31 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
7739
8078
|
(document.head || document.documentElement).append(style);
|
|
7740
8079
|
}
|
|
7741
8080
|
const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
|
|
8081
|
+
const fields = (form, submitter) => {
|
|
8082
|
+
const out = {};
|
|
8083
|
+
for (const [k, v] of new FormData(form, submitter)) {
|
|
8084
|
+
if (typeof v !== 'string') continue;
|
|
8085
|
+
out[k] = k in out ? [].concat(out[k], v) : v;
|
|
8086
|
+
}
|
|
8087
|
+
return out;
|
|
8088
|
+
};
|
|
8089
|
+
const labelOf = (el, fallback) => (el?.textContent || el?.value || '').trim().slice(0, 200) || fallback;
|
|
8090
|
+
addEventListener('click', (e) => {
|
|
8091
|
+
const b = e.target.closest?.('[data-cck-action]');
|
|
8092
|
+
if (!b || (b.form?.matches('[data-cck-submit]') && b.type === 'submit')) return;
|
|
8093
|
+
e.preventDefault();
|
|
8094
|
+
const action = b.dataset.cckAction;
|
|
8095
|
+
send({ type: 'action', action, label: labelOf(b, action), data: b.form ? fields(b.form) : {} });
|
|
8096
|
+
});
|
|
8097
|
+
// The sandbox allows forms so validation runs. Every submit stops here, so a page still cannot
|
|
8098
|
+
// navigate its frame with a form: a show card's form-action 'none' does not stop a GET to about:srcdoc.
|
|
8099
|
+
addEventListener('submit', (e) => {
|
|
8100
|
+
e.preventDefault();
|
|
8101
|
+
const f = e.target.closest?.('form[data-cck-submit]');
|
|
8102
|
+
if (!f) return;
|
|
8103
|
+
const action = f.dataset.cckSubmit;
|
|
8104
|
+
send({ type: 'action', action, label: labelOf(e.submitter, action), data: fields(f, e.submitter) });
|
|
8105
|
+
});
|
|
7742
8106
|
const describe = (node) => {
|
|
7743
8107
|
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
7744
8108
|
if (!el) return {};
|
|
@@ -7922,10 +8286,11 @@ function sendBridgeClaims(frames, claims) {
|
|
|
7922
8286
|
window.addEventListener('message', (e) => {
|
|
7923
8287
|
const type =
|
|
7924
8288
|
typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
|
|
7925
|
-
if (type !== 'link' && type !== 'key' && type !== 'scroll') return;
|
|
8289
|
+
if (type !== 'link' && type !== 'key' && type !== 'scroll' && type !== 'action') return;
|
|
7926
8290
|
const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
|
|
7927
8291
|
if (!frame) return;
|
|
7928
|
-
if (type === '
|
|
8292
|
+
if (type === 'action') sendPreviewAction(frame, e.data);
|
|
8293
|
+
else if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
|
|
7929
8294
|
else if (type === 'key') replayKey(e.data, frame);
|
|
7930
8295
|
else {
|
|
7931
8296
|
const url = linkUrl(e.data.href);
|
|
@@ -8138,6 +8503,24 @@ function renderReviewPanel() {
|
|
|
8138
8503
|
.join('')}</ol>`;
|
|
8139
8504
|
}
|
|
8140
8505
|
|
|
8506
|
+
async function toastDelivery({ delivered, markdown }, noun) {
|
|
8507
|
+
if (delivered === 'doorbell') return showToast('Queued for the session', 'success');
|
|
8508
|
+
if (delivered === 'terminal') return showToast('Pasted into the terminal: press Enter there to send', 'info');
|
|
8509
|
+
const copy = async () => {
|
|
8510
|
+
const copied = await navigator.clipboard.writeText(markdown).then(
|
|
8511
|
+
() => true,
|
|
8512
|
+
() => false,
|
|
8513
|
+
);
|
|
8514
|
+
showToast(
|
|
8515
|
+
copied ? `Copied the ${noun}. Paste it into the session.` : `Failed to copy the ${noun}`,
|
|
8516
|
+
'info',
|
|
8517
|
+
{ label: 'Copy again', onClick: copy },
|
|
8518
|
+
`Sessions get ${noun}s directly when the cck plugin runs there and board events are on.`,
|
|
8519
|
+
);
|
|
8520
|
+
};
|
|
8521
|
+
await copy();
|
|
8522
|
+
}
|
|
8523
|
+
|
|
8141
8524
|
async function sendReview() {
|
|
8142
8525
|
if (!activeReview) return;
|
|
8143
8526
|
const { sessionId, source, key, onSent } = activeReview;
|
|
@@ -8150,30 +8533,49 @@ async function sendReview() {
|
|
|
8150
8533
|
});
|
|
8151
8534
|
const data = await res.json();
|
|
8152
8535
|
if (!res.ok) return showToast(data.error || 'Review failed', 'error');
|
|
8153
|
-
|
|
8154
|
-
else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
|
|
8155
|
-
else {
|
|
8156
|
-
const { markdown } = data;
|
|
8157
|
-
const copy = async () => {
|
|
8158
|
-
const copied = await navigator.clipboard.writeText(markdown).then(
|
|
8159
|
-
() => true,
|
|
8160
|
-
() => false,
|
|
8161
|
-
);
|
|
8162
|
-
showToast(
|
|
8163
|
-
copied ? 'Copied the review. Paste it into the session.' : 'Failed to copy the review',
|
|
8164
|
-
'info',
|
|
8165
|
-
{ label: 'Copy again', onClick: copy },
|
|
8166
|
-
'Sessions get reviews directly when the cck plugin runs there and board events are on.',
|
|
8167
|
-
);
|
|
8168
|
-
};
|
|
8169
|
-
await copy();
|
|
8170
|
-
}
|
|
8536
|
+
await toastDelivery(data, 'review');
|
|
8171
8537
|
clearReviewDrafts(key);
|
|
8172
8538
|
if (activeReview?.key === key) onSent?.();
|
|
8173
8539
|
} catch {
|
|
8174
8540
|
showToast('Failed to send the review', 'error');
|
|
8175
8541
|
}
|
|
8176
8542
|
}
|
|
8543
|
+
|
|
8544
|
+
// A page script can post an action without a click, so the board sends it only with transient
|
|
8545
|
+
// user activation, which a click in the frame gives to the board too.
|
|
8546
|
+
const ACTION_REPEAT_MS = 1000;
|
|
8547
|
+
let lastAction = null;
|
|
8548
|
+
|
|
8549
|
+
function reviewOptsOfFrame(frame) {
|
|
8550
|
+
return (
|
|
8551
|
+
[...reviewStack, reviewBase, showState.review].find((o) => o?.frame === frame) ||
|
|
8552
|
+
paneReviews.get(frame.parentElement) ||
|
|
8553
|
+
null
|
|
8554
|
+
);
|
|
8555
|
+
}
|
|
8556
|
+
|
|
8557
|
+
async function sendPreviewAction(frame, msg) {
|
|
8558
|
+
if (!navigator.userActivation?.isActive) return;
|
|
8559
|
+
const opts = reviewOptsOfFrame(frame);
|
|
8560
|
+
if (!opts?.sessionId) return;
|
|
8561
|
+
const key = `${msg.action}\n${JSON.stringify(msg.data)}`;
|
|
8562
|
+
const now = Date.now();
|
|
8563
|
+
if (lastAction?.frame === frame && lastAction.key === key && now - lastAction.at < ACTION_REPEAT_MS) return;
|
|
8564
|
+
lastAction = { frame, key, at: now };
|
|
8565
|
+
try {
|
|
8566
|
+
const res = await terminalFetch(apiPath`/api/sessions/${opts.sessionId}/action`, 'POST', {
|
|
8567
|
+
source: opts.source,
|
|
8568
|
+
action: msg.action,
|
|
8569
|
+
label: msg.label,
|
|
8570
|
+
data: msg.data,
|
|
8571
|
+
});
|
|
8572
|
+
const out = await res.json();
|
|
8573
|
+
if (!res.ok) return showToast(out.error || 'Send failed', 'error');
|
|
8574
|
+
await toastDelivery(out, 'answer');
|
|
8575
|
+
} catch {
|
|
8576
|
+
showToast('Failed to send the answer', 'error');
|
|
8577
|
+
}
|
|
8578
|
+
}
|
|
8177
8579
|
//#endregion
|
|
8178
8580
|
|
|
8179
8581
|
//#region MARKDOWN_PREVIEW
|
|
@@ -8284,7 +8686,7 @@ function renderFrontmatterBlock(fm) {
|
|
|
8284
8686
|
// allow-same-origin puts it on an opaque origin, so it cannot touch this app's
|
|
8285
8687
|
// storage, DOM or API. srcdoc has no base URL, so the server has already embedded
|
|
8286
8688
|
// the document's local assets (lib/inline-assets.js); remote refs load normally.
|
|
8287
|
-
function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups') {
|
|
8689
|
+
function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups allow-forms') {
|
|
8288
8690
|
const frame = document.createElement('iframe');
|
|
8289
8691
|
frame.className = className;
|
|
8290
8692
|
frame.setAttribute('sandbox', sandbox);
|
|
@@ -8306,6 +8708,10 @@ function isAbsolutePath(p) {
|
|
|
8306
8708
|
return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
|
|
8307
8709
|
}
|
|
8308
8710
|
|
|
8711
|
+
function isUrlRef(s) {
|
|
8712
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(s) || s.startsWith('//');
|
|
8713
|
+
}
|
|
8714
|
+
|
|
8309
8715
|
function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
8310
8716
|
if (bodyEl.dataset.relLinkBound) return;
|
|
8311
8717
|
bodyEl.addEventListener('click', (e) => {
|
|
@@ -8313,8 +8719,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
|
8313
8719
|
if (!a) return;
|
|
8314
8720
|
const href = a.getAttribute('href');
|
|
8315
8721
|
if (!href || href.startsWith('#')) return;
|
|
8316
|
-
|
|
8317
|
-
if (isAbsoluteUrl) return;
|
|
8722
|
+
if (isUrlRef(href)) return;
|
|
8318
8723
|
const cleanHref = href.replace(/#.*$/, '');
|
|
8319
8724
|
e.preventDefault();
|
|
8320
8725
|
openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
|
|
@@ -8366,10 +8771,37 @@ function renderPreviewContent(bodyEl, filePath, content, kind) {
|
|
|
8366
8771
|
bodyEl.replaceChildren(img);
|
|
8367
8772
|
} else {
|
|
8368
8773
|
const { fm, body } = splitFrontmatter(content);
|
|
8369
|
-
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') +
|
|
8774
|
+
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdownFile(body, filePath);
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
|
|
8778
|
+
// A relative src would resolve against the board's URL, and DOMPurify drops a drive path (`C:/x`
|
|
8779
|
+
// reads as a scheme), so the hook moves a local image to the image route before that check.
|
|
8780
|
+
function renderMarkdownFile(text, filePath) {
|
|
8781
|
+
if (!filePath || typeof DOMPurify === 'undefined') return renderMarkdown(text);
|
|
8782
|
+
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
|
8783
|
+
if (node.nodeName === 'IMG' && data.attrName === 'src') data.attrValue = localImageUrl(data.attrValue, filePath);
|
|
8784
|
+
});
|
|
8785
|
+
try {
|
|
8786
|
+
return renderMarkdown(text);
|
|
8787
|
+
} finally {
|
|
8788
|
+
DOMPurify.removeHook('uponSanitizeAttribute');
|
|
8370
8789
|
}
|
|
8371
8790
|
}
|
|
8372
8791
|
|
|
8792
|
+
function localImageUrl(src, base) {
|
|
8793
|
+
const fileUrl = /^file:/i.test(src);
|
|
8794
|
+
if (src.startsWith('//') || (!fileUrl && !isAbsolutePath(src) && isUrlRef(src))) return src;
|
|
8795
|
+
let p = src;
|
|
8796
|
+
if (!fileUrl) {
|
|
8797
|
+
p = src.replace(/[?#].*$/, '');
|
|
8798
|
+
try {
|
|
8799
|
+
p = decodeURI(p);
|
|
8800
|
+
} catch {}
|
|
8801
|
+
}
|
|
8802
|
+
return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
|
|
8803
|
+
}
|
|
8804
|
+
|
|
8373
8805
|
function openPreviewModal(filePath, content, kind) {
|
|
8374
8806
|
currentPreviewPath = filePath;
|
|
8375
8807
|
const fileName = filePath.split(/[\\/]/).pop();
|
|
@@ -8521,6 +8953,7 @@ function handleSessionOpenEvent(data) {
|
|
|
8521
8953
|
}
|
|
8522
8954
|
if (!isSessionActive(target)) {
|
|
8523
8955
|
stickySessionIds.add(id);
|
|
8956
|
+
openedStickyIds.add(id);
|
|
8524
8957
|
}
|
|
8525
8958
|
setSessionDismissed(id, false);
|
|
8526
8959
|
cliOpenedId = id;
|
|
@@ -9184,6 +9617,10 @@ function setupEventSource() {
|
|
|
9184
9617
|
fetchSessions().catch(() => {});
|
|
9185
9618
|
if (currentSessionId) fetchTasks(currentSessionId);
|
|
9186
9619
|
refetchPaneLayout();
|
|
9620
|
+
if (sgRev === null) syncSessionGroups();
|
|
9621
|
+
else if (sgUnsent) persistSessionGroups();
|
|
9622
|
+
else sgRefresh();
|
|
9623
|
+
syncSessionPins();
|
|
9187
9624
|
if (terminalAvailable())
|
|
9188
9625
|
loadTerminals()
|
|
9189
9626
|
.then(renderSessions)
|
|
@@ -9249,6 +9686,8 @@ function setupEventSource() {
|
|
|
9249
9686
|
|
|
9250
9687
|
if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
|
|
9251
9688
|
|
|
9689
|
+
if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
|
|
9690
|
+
|
|
9252
9691
|
if (data.type === 'dispatch-update') onDispatchUpdate();
|
|
9253
9692
|
|
|
9254
9693
|
if (data.type === 'show:posted') onShowPosted(data);
|
|
@@ -9296,6 +9735,10 @@ function setupEventSource() {
|
|
|
9296
9735
|
handleSessionPinEvent(data);
|
|
9297
9736
|
}
|
|
9298
9737
|
|
|
9738
|
+
if (data.type === 'group:changed') {
|
|
9739
|
+
onGroupChanged(data);
|
|
9740
|
+
}
|
|
9741
|
+
|
|
9299
9742
|
if (data.type === 'pane:changed') {
|
|
9300
9743
|
applyPaneLayout(data.sessionId, data.layout);
|
|
9301
9744
|
}
|
|
@@ -9663,45 +10106,93 @@ function renderWaitingFooter(tool, params) {
|
|
|
9663
10106
|
if (currentWaiting?.kind === 'plan') {
|
|
9664
10107
|
return isWaitingAnswerable() ? planApprovalControlsHtml('plan-reject-feedback') : '';
|
|
9665
10108
|
}
|
|
9666
|
-
if (currentWaiting?.kind !== 'question')
|
|
10109
|
+
if (currentWaiting?.kind !== 'question') {
|
|
10110
|
+
const buttons = waitingDecisionButtons('waiting-btn');
|
|
10111
|
+
const hint = `<span class="waiting-footer-hint">${sendKbd()} allow · ${termKbd()} terminal</span>`;
|
|
10112
|
+
return buttons ? hint + buttons : '';
|
|
10113
|
+
}
|
|
9667
10114
|
if (!isWaitingAnswerable()) return '';
|
|
9668
10115
|
if (tool !== 'AskUserQuestion') return '';
|
|
9669
10116
|
const questions = Array.isArray(params?.questions) ? params.questions : [];
|
|
9670
10117
|
if (!questions.length) return '';
|
|
9671
10118
|
const picked = questions.filter((q) => waitingAnswerFor(q)).length;
|
|
9672
10119
|
const partial = picked > 0 && picked < questions.length ? ` (${picked}/${questions.length})` : '';
|
|
9673
|
-
return `<button id="waiting-answer-btn" class="waiting-btn-allow" ${picked > 0 ? '' : 'disabled'} onclick="submitWaitingAnswers()">
|
|
10120
|
+
return `<span class="waiting-footer-hint"><kbd>1</kbd>–<kbd>9</kbd> pick · ${sendKbd()} send</span><button id="waiting-answer-btn" class="waiting-btn-allow" ${picked > 0 ? '' : 'disabled'} onclick="submitWaitingAnswers()">Send answers${partial}</button>`;
|
|
9674
10121
|
}
|
|
9675
10122
|
|
|
9676
10123
|
function renderWaitingActions(tool, params) {
|
|
9677
10124
|
if (!isWaitingAnswerable() || currentWaiting.kind !== 'question' || tool !== 'AskUserQuestion') return '';
|
|
9678
10125
|
waitingQuestions = Array.isArray(params?.questions) ? params.questions : [];
|
|
9679
10126
|
if (!waitingQuestions.length) return '';
|
|
10127
|
+
const multiQ = waitingQuestions.length > 1;
|
|
10128
|
+
waitingTab = Math.max(0, Math.min(waitingTab, multiQ ? waitingQuestions.length : 0));
|
|
10129
|
+
const onReview = multiQ && waitingTab === waitingQuestions.length;
|
|
10130
|
+
const tabs = multiQ
|
|
10131
|
+
? `<div class="waiting-qtabs" role="tablist">${waitingQuestions
|
|
10132
|
+
.map((q, idx) => {
|
|
10133
|
+
const done = !!waitingAnswerFor(q);
|
|
10134
|
+
return `<button class="waiting-qtab${idx === waitingTab ? ' active' : ''}${done ? ' done' : ''}" role="tab" onclick="setWaitingTab(${idx})"><span class="waiting-qtab-n">${idx + 1}</span>${escapeHtml(q.header || `Question ${idx + 1}`)}${done ? '<span class="waiting-qtab-check">✓</span>' : ''}</button>`;
|
|
10135
|
+
})
|
|
10136
|
+
.join(
|
|
10137
|
+
'',
|
|
10138
|
+
)}<button class="waiting-qtab${onReview ? ' active' : ''}" role="tab" onclick="setWaitingTab(${escapeHtml(String(waitingQuestions.length))})">Review</button><span class="waiting-qtabs-hint"><kbd>←</kbd><kbd>→</kbd> switch</span></div>`
|
|
10139
|
+
: '';
|
|
10140
|
+
if (onReview) {
|
|
10141
|
+
const rows = waitingQuestions
|
|
10142
|
+
.map((q) => {
|
|
10143
|
+
const a = waitingAnswerFor(q);
|
|
10144
|
+
const text = Array.isArray(a) ? a.join(', ') : a;
|
|
10145
|
+
return `<div class="waiting-review-row"><div class="waiting-review-q">${escapeHtml(q.header || q.question || '')}</div><div class="waiting-review-a${text ? '' : ' empty'}">${text ? escapeHtml(text) : 'Not answered'}</div></div>`;
|
|
10146
|
+
})
|
|
10147
|
+
.join('');
|
|
10148
|
+
return `<div class="waiting-actions">${tabs}<div class="waiting-review">${rows}</div></div>`;
|
|
10149
|
+
}
|
|
9680
10150
|
const qs = waitingQuestions
|
|
9681
10151
|
.map((q, idx) => {
|
|
10152
|
+
if (idx !== waitingTab && multiQ) return '';
|
|
9682
10153
|
const picks = waitingAnswerDraft[q.question] || [];
|
|
9683
10154
|
const opts = (q.options || [])
|
|
9684
|
-
.map((o) => {
|
|
9685
|
-
const label =
|
|
10155
|
+
.map((o, oi) => {
|
|
10156
|
+
const label = waitingOptionLabel(o);
|
|
9686
10157
|
const desc = typeof o === 'object' ? o?.description || '' : '';
|
|
9687
10158
|
const descHtml = desc ? `<span class="waiting-option-desc">${renderMarkdown(desc)}</span>` : '';
|
|
9688
10159
|
// The option `preview` field is the TUI's side-by-side visualization
|
|
9689
10160
|
// pane (mockups, code snippets, diagrams) — rendered as markdown
|
|
9690
10161
|
const preview = typeof o === 'object' ? o?.preview || '' : '';
|
|
9691
10162
|
const previewHtml = preview ? `<span class="waiting-option-preview">${renderMarkdown(preview)}</span>` : '';
|
|
9692
|
-
return `<button class="waiting-option${picks.includes(label) ? ' selected' : ''}" data-label="${escapeHtml(label)}" onclick="selectWaitingAnswer(${idx}, this.dataset.label)"><span class="waiting-option-label">${escapeHtml(label)}</span>${descHtml}${previewHtml}</button>`;
|
|
10163
|
+
return `<button class="waiting-option${picks.includes(label) ? ' selected' : ''}" data-label="${escapeHtml(label)}" onclick="selectWaitingAnswer(${idx}, this.dataset.label)"><kbd class="waiting-option-key">${oi + 1}</kbd><span class="waiting-option-text"><span class="waiting-option-label">${escapeHtml(label)}</span>${descHtml}${previewHtml}</span></button>`;
|
|
9693
10164
|
})
|
|
9694
10165
|
.join('');
|
|
9695
10166
|
const header = q.header ? `<span class="waiting-question-header">${escapeHtml(q.header)}</span>` : '';
|
|
9696
10167
|
const multi = q.multiSelect ? '<span class="waiting-question-multi">multi-select</span>' : '';
|
|
9697
|
-
const input = `<input type="text" class="waiting-option-input" placeholder="Or type your own answer" value="${escapeHtml(waitingCustomDraft[q.question] || '')}" oninput="setWaitingCustomAnswer(${idx}, this)">`;
|
|
9698
|
-
return `<div class="waiting-question">${header}${multi}<div class="waiting-question-text">${renderMarkdown(q.question || '')}</div><div class="waiting-options">${opts}</div>${input}</div>`;
|
|
10168
|
+
const input = `<input type="text" class="waiting-option-input" placeholder="Or type your own answer" value="${escapeHtml(waitingCustomDraft[q.question] || '')}" oninput="setWaitingCustomAnswer(${idx}, this)" onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();submitWaitingAnswers()}">`;
|
|
10169
|
+
return `<div class="waiting-question">${multiQ ? '' : header}${multi}<div class="waiting-question-text">${renderMarkdown(q.question || '')}</div><div class="waiting-options">${opts}</div>${input}</div>`;
|
|
9699
10170
|
})
|
|
9700
10171
|
.join('');
|
|
9701
|
-
return `<div class="waiting-actions">${qs}</div>`;
|
|
10172
|
+
return `<div class="waiting-actions">${tabs}${qs}</div>`;
|
|
10173
|
+
}
|
|
10174
|
+
|
|
10175
|
+
let waitingTab = 0;
|
|
10176
|
+
|
|
10177
|
+
function waitingOptionLabel(o) {
|
|
10178
|
+
return typeof o === 'string' ? o : o?.label || '';
|
|
10179
|
+
}
|
|
10180
|
+
|
|
10181
|
+
function setWaitingTab(i) {
|
|
10182
|
+
const last = waitingQuestions.length > 1 ? waitingQuestions.length : 0;
|
|
10183
|
+
const next = Math.max(0, Math.min(i, last));
|
|
10184
|
+
if (next === waitingTab) return;
|
|
10185
|
+
waitingTab = next;
|
|
10186
|
+
showWaitingDetail();
|
|
10187
|
+
}
|
|
10188
|
+
|
|
10189
|
+
function pickWaitingOptionByNumber(n) {
|
|
10190
|
+
const qi = waitingQuestions.length > 1 ? waitingTab : 0;
|
|
10191
|
+
const o = waitingQuestions[qi]?.options?.[n];
|
|
10192
|
+
if (!o) return;
|
|
10193
|
+
selectWaitingAnswer(qi, waitingOptionLabel(o));
|
|
9702
10194
|
}
|
|
9703
10195
|
|
|
9704
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
9705
10196
|
function selectWaitingAnswer(qi, label) {
|
|
9706
10197
|
const q = waitingQuestions[qi];
|
|
9707
10198
|
if (!q) return;
|
|
@@ -9714,6 +10205,7 @@ function selectWaitingAnswer(qi, label) {
|
|
|
9714
10205
|
} else {
|
|
9715
10206
|
waitingAnswerDraft[q.question] = [label];
|
|
9716
10207
|
delete waitingCustomDraft[q.question];
|
|
10208
|
+
if (waitingQuestions.length > 1 && waitingTab < waitingQuestions.length) waitingTab += 1;
|
|
9717
10209
|
}
|
|
9718
10210
|
showWaitingDetail();
|
|
9719
10211
|
}
|
|
@@ -9739,11 +10231,12 @@ function setWaitingCustomAnswer(qi, el) {
|
|
|
9739
10231
|
const picked = waitingQuestions.filter((qq) => waitingAnswerFor(qq)).length;
|
|
9740
10232
|
btn.disabled = picked === 0;
|
|
9741
10233
|
btn.textContent =
|
|
9742
|
-
picked > 0 && picked < waitingQuestions.length
|
|
10234
|
+
picked > 0 && picked < waitingQuestions.length
|
|
10235
|
+
? `Send answers (${picked}/${waitingQuestions.length})`
|
|
10236
|
+
: 'Send answers';
|
|
9743
10237
|
}
|
|
9744
10238
|
}
|
|
9745
10239
|
|
|
9746
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
9747
10240
|
function submitWaitingAnswers() {
|
|
9748
10241
|
// Partial submits are fine — send only the questions actually answered.
|
|
9749
10242
|
// multiSelect answers go as arrays of labels (the shape the TUI validates).
|
|
@@ -9756,6 +10249,38 @@ function submitWaitingAnswers() {
|
|
|
9756
10249
|
respondWaiting({ answers });
|
|
9757
10250
|
}
|
|
9758
10251
|
|
|
10252
|
+
function renderPermissionDiff(oldText, newText) {
|
|
10253
|
+
const rows = (text, sign, cls) =>
|
|
10254
|
+
String(text)
|
|
10255
|
+
.split('\n')
|
|
10256
|
+
.map(
|
|
10257
|
+
(l) =>
|
|
10258
|
+
`<div class="perm-diff-row ${escapeHtml(cls)}"><span class="perm-diff-sign">${sign}</span>${escapeHtml(l)}</div>`,
|
|
10259
|
+
)
|
|
10260
|
+
.join('');
|
|
10261
|
+
return `<div class="perm-diff">${rows(oldText, '−', 'del')}${rows(newText, '+', 'add')}</div>`;
|
|
10262
|
+
}
|
|
10263
|
+
|
|
10264
|
+
// The permission card shows what Claude wants to run in full, not a one-line gist:
|
|
10265
|
+
// the command or the diff, where it runs, and how long the ask has been waiting.
|
|
10266
|
+
function renderPermissionSummary(tool, params) {
|
|
10267
|
+
const waited = formatDuration(Math.max(0, Date.now() - new Date(currentWaiting.timestamp).getTime()));
|
|
10268
|
+
const head = `<div class="perm-head"><span class="perm-tool">${escapeHtml(tool)}</span><span class="perm-wait" title="How long Claude has been waiting">waiting ${escapeHtml(waited)}</span></div>`;
|
|
10269
|
+
let body = '';
|
|
10270
|
+
if (typeof params.command === 'string') {
|
|
10271
|
+
body = `<div class="perm-label">Command</div><pre class="perm-code">${escapeHtml(params.command)}</pre>`;
|
|
10272
|
+
if (params.description) body += `<div class="perm-note">${escapeHtml(params.description)}</div>`;
|
|
10273
|
+
} else if (typeof params.old_string === 'string' && typeof params.new_string === 'string') {
|
|
10274
|
+
body = `<div class="perm-label">${escapeHtml(params.file_path || 'File')}</div>${renderPermissionDiff(params.old_string, params.new_string)}`;
|
|
10275
|
+
} else if (typeof params.file_path === 'string') {
|
|
10276
|
+
body = `<div class="perm-label">File</div><pre class="perm-code">${escapeHtml(params.file_path)}</pre>`;
|
|
10277
|
+
} else if (typeof params.description === 'string') {
|
|
10278
|
+
body = `<pre class="perm-code">${escapeHtml(params.description)}</pre>`;
|
|
10279
|
+
}
|
|
10280
|
+
const cwd = currentWaiting.cwd ? `<div class="perm-cwd">in ${escapeHtml(currentWaiting.cwd)}</div>` : '';
|
|
10281
|
+
return `<div class="perm-card">${head}${body}${cwd}</div>`;
|
|
10282
|
+
}
|
|
10283
|
+
|
|
9759
10284
|
function showWaitingDetail() {
|
|
9760
10285
|
if (!isWaitingFresh()) return;
|
|
9761
10286
|
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
@@ -9770,6 +10295,7 @@ function showWaitingDetail() {
|
|
|
9770
10295
|
waitingDraftId = currentWaiting.id || null;
|
|
9771
10296
|
waitingAnswerDraft = {};
|
|
9772
10297
|
waitingCustomDraft = {};
|
|
10298
|
+
waitingTab = 0;
|
|
9773
10299
|
}
|
|
9774
10300
|
const tool = currentWaiting.toolName || 'unknown';
|
|
9775
10301
|
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
@@ -9791,17 +10317,9 @@ function showWaitingDetail() {
|
|
|
9791
10317
|
// A plan ask IS the plan — render it in full so it can be reviewed and
|
|
9792
10318
|
// approved right here (follow mode included) instead of a JSON dump. The
|
|
9793
10319
|
// raw tool input is dropped entirely to match the saved-plan modal.
|
|
9794
|
-
inputHtml = `<div class="detail-desc rendered-md">${renderMarkdown(params.plan)}</div>`;
|
|
10320
|
+
inputHtml = `<div class="detail-desc rendered-md waiting-plan">${renderMarkdown(params.plan)}</div>`;
|
|
9795
10321
|
} else {
|
|
9796
|
-
if (currentWaiting.kind !== 'question' && params)
|
|
9797
|
-
const gist =
|
|
9798
|
-
typeof params.command === 'string'
|
|
9799
|
-
? params.command
|
|
9800
|
-
: typeof params.description === 'string'
|
|
9801
|
-
? params.description
|
|
9802
|
-
: '';
|
|
9803
|
-
if (gist) summaryHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(gist)}</pre>`;
|
|
9804
|
-
}
|
|
10322
|
+
if (currentWaiting.kind !== 'question' && params) summaryHtml = renderPermissionSummary(tool, params);
|
|
9805
10323
|
inputHtml = `${summaryHtml}<details class="waiting-raw-input"><summary>Raw tool input</summary>${inputHtml}</details>`;
|
|
9806
10324
|
}
|
|
9807
10325
|
}
|
|
@@ -10102,7 +10620,6 @@ const MERMAID_VARS = {
|
|
|
10102
10620
|
pieOuterStrokeColor: '--border',
|
|
10103
10621
|
pieTitleTextColor: '--text-primary',
|
|
10104
10622
|
pieLegendTextColor: '--text-primary',
|
|
10105
|
-
fontFamily: '--mono',
|
|
10106
10623
|
};
|
|
10107
10624
|
// --bg-elevated sits too close to the dark page for a node, and the base theme derives near-black ER
|
|
10108
10625
|
// rows from a dark primaryColor.
|
|
@@ -10111,15 +10628,39 @@ const MERMAID_MODE_VARS = {
|
|
|
10111
10628
|
light: { primaryColor: '--bg-elevated', rowOdd: '--bg-elevated', rowEven: '--bg-elevated' },
|
|
10112
10629
|
};
|
|
10113
10630
|
let mermaidThemeKey = '';
|
|
10631
|
+
let colorProbe = null;
|
|
10632
|
+
const probedColors = new Map();
|
|
10633
|
+
|
|
10634
|
+
// Mermaid's color parser knows only plain hex, rgb() and hsl(), and a hub theme may set oklch() or
|
|
10635
|
+
// relative color syntax; its initialize throws on one, and no diagram renders.
|
|
10636
|
+
function mermaidColor(value) {
|
|
10637
|
+
if (!value || value.startsWith('#')) return value;
|
|
10638
|
+
let rgb = probedColors.get(value);
|
|
10639
|
+
if (rgb) return rgb;
|
|
10640
|
+
colorProbe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
10641
|
+
colorProbe.clearRect(0, 0, 1, 1);
|
|
10642
|
+
colorProbe.fillStyle = 'transparent';
|
|
10643
|
+
colorProbe.fillStyle = value;
|
|
10644
|
+
colorProbe.fillRect(0, 0, 1, 1);
|
|
10645
|
+
const [r, g, b, a] = colorProbe.getImageData(0, 0, 1, 1).data;
|
|
10646
|
+
rgb = a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${+(a / 255).toFixed(3)})`;
|
|
10647
|
+
probedColors.set(value, rgb);
|
|
10648
|
+
return rgb;
|
|
10649
|
+
}
|
|
10114
10650
|
|
|
10115
10651
|
// The base theme derives its other colors from these, and it parses colors itself, so it takes
|
|
10116
10652
|
// resolved values rather than var() references.
|
|
10117
10653
|
function mermaidConfig() {
|
|
10118
10654
|
const css = getComputedStyle(document.body);
|
|
10119
10655
|
const mode = isLightTheme() ? 'light' : 'dark';
|
|
10120
|
-
const themeVariables = {
|
|
10656
|
+
const themeVariables = {
|
|
10657
|
+
darkMode: mode === 'dark',
|
|
10658
|
+
pieOpacity: '1',
|
|
10659
|
+
pieSectionTextColor: '#fff',
|
|
10660
|
+
fontFamily: css.getPropertyValue('--mono').trim(),
|
|
10661
|
+
};
|
|
10121
10662
|
for (const [k, name] of Object.entries({ ...MERMAID_VARS, ...MERMAID_MODE_VARS[mode] })) {
|
|
10122
|
-
themeVariables[k] = css.getPropertyValue(name).trim();
|
|
10663
|
+
themeVariables[k] = mermaidColor(css.getPropertyValue(name).trim());
|
|
10123
10664
|
}
|
|
10124
10665
|
SHOW_PALETTE[mode].series.forEach((c, i) => {
|
|
10125
10666
|
themeVariables[`pie${i + 1}`] = c;
|
|
@@ -10145,10 +10686,14 @@ function loadMermaid() {
|
|
|
10145
10686
|
script.integrity = MERMAID_SCRIPT.integrity;
|
|
10146
10687
|
script.crossOrigin = 'anonymous';
|
|
10147
10688
|
script.onload = () => {
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10689
|
+
try {
|
|
10690
|
+
const config = mermaidConfig();
|
|
10691
|
+
mermaidThemeKey = JSON.stringify(config);
|
|
10692
|
+
mermaid.initialize(config);
|
|
10693
|
+
resolve();
|
|
10694
|
+
} catch (err) {
|
|
10695
|
+
reject(err);
|
|
10696
|
+
}
|
|
10152
10697
|
};
|
|
10153
10698
|
script.onerror = () => {
|
|
10154
10699
|
script.remove();
|
|
@@ -10167,24 +10712,45 @@ function queueMermaid(work) {
|
|
|
10167
10712
|
mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
|
|
10168
10713
|
}
|
|
10169
10714
|
|
|
10715
|
+
const MERMAID_PENDING = 'pre.mermaid:not([data-processed])';
|
|
10716
|
+
|
|
10170
10717
|
function initMermaidBlocks(container) {
|
|
10171
10718
|
const root = container || document;
|
|
10172
|
-
if (!root.querySelector(
|
|
10719
|
+
if (!root.querySelector(MERMAID_PENDING)) return;
|
|
10720
|
+
// Stamped here, after DOMPurify: it drops an attribute whose value holds `-->`, as most diagrams do.
|
|
10721
|
+
const stampPending = () =>
|
|
10722
|
+
[...root.querySelectorAll(MERMAID_PENDING)].map((b) => {
|
|
10723
|
+
if (!b.hasAttribute('data-original')) b.setAttribute('data-original', b.textContent);
|
|
10724
|
+
return b;
|
|
10725
|
+
});
|
|
10173
10726
|
if (typeof mermaid === 'undefined') {
|
|
10174
10727
|
loadMermaid().then(
|
|
10175
10728
|
() => initMermaidBlocks(container),
|
|
10176
|
-
(err) =>
|
|
10729
|
+
(err) => {
|
|
10730
|
+
console.warn(err.message);
|
|
10731
|
+
for (const b of stampPending()) showMermaidError(b, err);
|
|
10732
|
+
},
|
|
10177
10733
|
);
|
|
10178
10734
|
return;
|
|
10179
10735
|
}
|
|
10180
|
-
queueMermaid(() => {
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
|
|
10736
|
+
queueMermaid(async () => {
|
|
10737
|
+
// One block per run, so a bad diagram shows its own error and the others still render.
|
|
10738
|
+
for (const b of stampPending()) {
|
|
10739
|
+
try {
|
|
10740
|
+
await mermaid.run({ nodes: [b] });
|
|
10741
|
+
} catch (err) {
|
|
10742
|
+
showMermaidError(b, err);
|
|
10743
|
+
}
|
|
10744
|
+
}
|
|
10185
10745
|
});
|
|
10186
10746
|
}
|
|
10187
10747
|
|
|
10748
|
+
function showMermaidError(block, err) {
|
|
10749
|
+
block.setAttribute('data-processed', 'true');
|
|
10750
|
+
block.setAttribute('data-mermaid-error', '');
|
|
10751
|
+
block.textContent = err?.message || String(err);
|
|
10752
|
+
}
|
|
10753
|
+
|
|
10188
10754
|
function reinitMermaidTheme() {
|
|
10189
10755
|
if (typeof mermaid === 'undefined') return;
|
|
10190
10756
|
queueMermaid(() => {
|
|
@@ -10195,6 +10761,7 @@ function reinitMermaidTheme() {
|
|
|
10195
10761
|
mermaid.initialize(config);
|
|
10196
10762
|
document.querySelectorAll('pre.mermaid[data-processed]').forEach((el) => {
|
|
10197
10763
|
el.removeAttribute('data-processed');
|
|
10764
|
+
el.removeAttribute('data-mermaid-error');
|
|
10198
10765
|
el.innerHTML = escapeHtml(el.getAttribute('data-original') || '');
|
|
10199
10766
|
});
|
|
10200
10767
|
initMermaidBlocks();
|
|
@@ -10619,9 +11186,10 @@ document.addEventListener('click', (e) => {
|
|
|
10619
11186
|
if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
|
|
10620
11187
|
const name = sgHeader.dataset.transientGroup;
|
|
10621
11188
|
confirmModal({
|
|
10622
|
-
title: 'Delete
|
|
10623
|
-
message:
|
|
10624
|
-
|
|
11189
|
+
title: 'Delete this group?',
|
|
11190
|
+
message: 'Its sessions go back to Projects.',
|
|
11191
|
+
chip: name,
|
|
11192
|
+
okLabel: 'Delete group',
|
|
10625
11193
|
}).then((ok) => {
|
|
10626
11194
|
if (!ok) return;
|
|
10627
11195
|
sgDeleteTransient(name);
|
|
@@ -10638,9 +11206,10 @@ document.addEventListener('click', (e) => {
|
|
|
10638
11206
|
}
|
|
10639
11207
|
if (e.target.closest('.sg-delete')) {
|
|
10640
11208
|
confirmModal({
|
|
10641
|
-
title: 'Delete
|
|
10642
|
-
message:
|
|
10643
|
-
|
|
11209
|
+
title: 'Delete this group?',
|
|
11210
|
+
message: 'Its sessions and projects go back to Projects.',
|
|
11211
|
+
chip: group.name,
|
|
11212
|
+
okLabel: 'Delete group',
|
|
10644
11213
|
}).then((ok) => {
|
|
10645
11214
|
if (!ok) return;
|
|
10646
11215
|
sgDeleteGroup(groupId);
|
|
@@ -10914,22 +11483,22 @@ function initSidebarResize() {
|
|
|
10914
11483
|
}
|
|
10915
11484
|
|
|
10916
11485
|
function initPanelResize(panelId, handleId, cssVar) {
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
let startWidth;
|
|
11486
|
+
initResize(document.getElementById(panelId), document.getElementById(handleId), cssVar, panelWidthKey(panelId), 200);
|
|
11487
|
+
}
|
|
10920
11488
|
|
|
11489
|
+
function initResize(panel, handle, cssVar, key, min) {
|
|
11490
|
+
let startWidth;
|
|
10921
11491
|
_initDragResize(handle, {
|
|
10922
11492
|
onStart() {
|
|
10923
11493
|
startWidth = panel.offsetWidth;
|
|
10924
11494
|
panel.classList.add('resizing');
|
|
10925
11495
|
},
|
|
10926
11496
|
onMove(dx) {
|
|
10927
|
-
|
|
10928
|
-
panel.style.setProperty(cssVar, `${w}px`);
|
|
11497
|
+
panel.style.setProperty(cssVar, `${Math.max(min, startWidth - dx)}px`);
|
|
10929
11498
|
},
|
|
10930
11499
|
onEnd() {
|
|
10931
11500
|
panel.classList.remove('resizing');
|
|
10932
|
-
store.setItem(
|
|
11501
|
+
store.setItem(key, panel.style.getPropertyValue(cssVar));
|
|
10933
11502
|
},
|
|
10934
11503
|
});
|
|
10935
11504
|
}
|
|
@@ -12504,6 +13073,15 @@ function markPaneSide(el, side, focused) {
|
|
|
12504
13073
|
|
|
12505
13074
|
const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
|
|
12506
13075
|
|
|
13076
|
+
function paneMenuLabel(pane, label) {
|
|
13077
|
+
const cut =
|
|
13078
|
+
pane.kind !== 'url' && pane.kind !== 'message' ? Math.max(label.lastIndexOf('/'), label.lastIndexOf('\\')) + 1 : 0;
|
|
13079
|
+
if (!cut || cut === label.length) {
|
|
13080
|
+
return `<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>`;
|
|
13081
|
+
}
|
|
13082
|
+
return `<div class="pane-menu-label pane-menu-path" title="${escapeHtml(label)}"><span class="pane-menu-dir">‎${escapeHtml(label.slice(0, cut))}‎</span><span class="pane-menu-name">${escapeHtml(label.slice(cut))}</span></div>`;
|
|
13083
|
+
}
|
|
13084
|
+
|
|
12507
13085
|
function renderPaneTabs(sid, layout, split) {
|
|
12508
13086
|
// A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
|
|
12509
13087
|
if (paneDragId || paneRenameId) return;
|
|
@@ -12994,16 +13572,22 @@ function paneMenuKeydown(e) {
|
|
|
12994
13572
|
async function deleteBoardTasks(onlyCompleted) {
|
|
12995
13573
|
const sid = paneSessionId();
|
|
12996
13574
|
if (!sid) return;
|
|
12997
|
-
const
|
|
13575
|
+
const doomed = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed');
|
|
13576
|
+
const count = doomed.length;
|
|
12998
13577
|
if (!count) {
|
|
12999
13578
|
showToast(onlyCompleted ? 'No completed tasks' : 'No tasks', 'info');
|
|
13000
13579
|
return;
|
|
13001
13580
|
}
|
|
13002
13581
|
const noun = `${count} ${onlyCompleted ? 'completed ' : ''}task${count === 1 ? '' : 's'}`;
|
|
13582
|
+
const shown = doomed.slice(0, 3).map((t) => ({ label: t.subject, meta: t.status.replace('_', ' ') }));
|
|
13583
|
+
if (doomed.length > shown.length) shown.push({ label: `+ ${doomed.length - shown.length} more`, meta: '' });
|
|
13003
13584
|
const ok = await confirmModal({
|
|
13004
|
-
title:
|
|
13005
|
-
message:
|
|
13006
|
-
|
|
13585
|
+
title: `Delete ${noun}?`,
|
|
13586
|
+
message: onlyCompleted
|
|
13587
|
+
? 'Every completed task in this session. This cannot be undone.'
|
|
13588
|
+
: 'Every task in this session. This cannot be undone.',
|
|
13589
|
+
items: shown,
|
|
13590
|
+
okLabel: `Delete ${noun}`,
|
|
13007
13591
|
});
|
|
13008
13592
|
if (!ok) return;
|
|
13009
13593
|
const q = onlyCompleted ? '?status=completed' : '';
|
|
@@ -13079,9 +13663,12 @@ function openPaneMenu(x, y, id) {
|
|
|
13079
13663
|
y,
|
|
13080
13664
|
pane.id,
|
|
13081
13665
|
'Pane actions',
|
|
13082
|
-
|
|
13666
|
+
paneMenuLabel(pane, label) +
|
|
13083
13667
|
(message ? '' : paneMenuItem('copy', url ? 'Copy URL' : 'Copy path')) +
|
|
13084
13668
|
paneMenuItem('open', url ? 'Open in new tab' : message ? 'Open in message dialog' : 'Open in preview') +
|
|
13669
|
+
(message
|
|
13670
|
+
? ''
|
|
13671
|
+
: paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
|
|
13085
13672
|
paneMenuItem('rename', 'Rename') +
|
|
13086
13673
|
paneMenuItem('reload', 'Reload') +
|
|
13087
13674
|
paneMenuItem('close', 'Close pane'),
|
|
@@ -13089,6 +13676,21 @@ function openPaneMenu(x, y, id) {
|
|
|
13089
13676
|
return true;
|
|
13090
13677
|
}
|
|
13091
13678
|
|
|
13679
|
+
function paneIsLinked(pane) {
|
|
13680
|
+
const key = canonicalPath(pane.target);
|
|
13681
|
+
return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
|
|
13682
|
+
}
|
|
13683
|
+
|
|
13684
|
+
function togglePaneLink(id) {
|
|
13685
|
+
const pane = paneById(id);
|
|
13686
|
+
const sid = paneSessionId();
|
|
13687
|
+
if (!pane || !sid) return;
|
|
13688
|
+
const unlink = paneIsLinked(pane);
|
|
13689
|
+
setSessionDocLink(sid, pane.target, unlink);
|
|
13690
|
+
if (unlink) forgetServerLinkedDoc(sid, pane.target);
|
|
13691
|
+
showToast(unlink ? 'Unlinked from session' : 'Linked to session', 'success');
|
|
13692
|
+
}
|
|
13693
|
+
|
|
13092
13694
|
function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
13093
13695
|
closePaneMenu();
|
|
13094
13696
|
sgCloseMenu();
|
|
@@ -13107,6 +13709,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
|
13107
13709
|
const PANE_MENU_ACTIONS = {
|
|
13108
13710
|
copy: copyPaneTarget,
|
|
13109
13711
|
open: openPaneExternally,
|
|
13712
|
+
link: togglePaneLink,
|
|
13110
13713
|
rename: beginPaneRename,
|
|
13111
13714
|
reload: reloadPane,
|
|
13112
13715
|
close: closePane,
|
|
@@ -13579,7 +14182,6 @@ panePop.addEventListener('keydown', (e) => {
|
|
|
13579
14182
|
// leaving the session view or switching sessions drops only the socket; coming back
|
|
13580
14183
|
// reattaches and the server replays the screen.
|
|
13581
14184
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
13582
|
-
const TERMINAL_FOCUS_KEY = 'terminal-focus';
|
|
13583
14185
|
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
13584
14186
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
13585
14187
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
@@ -13720,6 +14322,7 @@ function terminalShortcut(e, probe = false) {
|
|
|
13720
14322
|
if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
|
|
13721
14323
|
// Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
|
|
13722
14324
|
if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
|
|
14325
|
+
if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
|
|
13723
14326
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
13724
14327
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
13725
14328
|
}
|
|
@@ -13867,6 +14470,8 @@ function focusTerminalPane() {
|
|
|
13867
14470
|
termFrame.el.focus();
|
|
13868
14471
|
terminalFrameSend('focus');
|
|
13869
14472
|
}
|
|
14473
|
+
// After a page load the frame may still be loading; its socket's ready takes the focus then.
|
|
14474
|
+
else termState.focusNext = true;
|
|
13870
14475
|
}
|
|
13871
14476
|
|
|
13872
14477
|
function leaveTerminalPane() {
|
|
@@ -13927,12 +14532,16 @@ function loadTerminalFrame(origin, timeoutMs) {
|
|
|
13927
14532
|
}
|
|
13928
14533
|
|
|
13929
14534
|
function sendTerminalTheme() {
|
|
13930
|
-
if (!termFrame.inited) return;
|
|
13931
14535
|
const options = terminalThemeOptions();
|
|
13932
14536
|
const key = JSON.stringify(options);
|
|
13933
|
-
|
|
13934
|
-
|
|
13935
|
-
|
|
14537
|
+
for (const [frame, post] of [
|
|
14538
|
+
[termFrame, postToTerminalFrame],
|
|
14539
|
+
[kanbotState, postToKanbotFrame],
|
|
14540
|
+
]) {
|
|
14541
|
+
if (!frame.inited || key === frame.theme) continue;
|
|
14542
|
+
frame.theme = key;
|
|
14543
|
+
post('theme', { options });
|
|
14544
|
+
}
|
|
13936
14545
|
}
|
|
13937
14546
|
|
|
13938
14547
|
// Keyed on the colors, and style is watched, because under the hub a theme change lands in two
|
|
@@ -13957,6 +14566,18 @@ window.addEventListener('message', (e) => {
|
|
|
13957
14566
|
onTerminalFrameMessage(m.type.slice(TERMINAL_MSG.length), m);
|
|
13958
14567
|
});
|
|
13959
14568
|
|
|
14569
|
+
function terminalInitOptions(themeOptions) {
|
|
14570
|
+
return {
|
|
14571
|
+
fontFamily:
|
|
14572
|
+
appConfig.terminal.fontFamily ||
|
|
14573
|
+
getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
|
|
14574
|
+
'monospace',
|
|
14575
|
+
fontSize: currentTerminalFontSize(),
|
|
14576
|
+
scrollback: appConfig.terminal.scrollback,
|
|
14577
|
+
themeOptions,
|
|
14578
|
+
};
|
|
14579
|
+
}
|
|
14580
|
+
|
|
13960
14581
|
function onTerminalFrameMessage(t, m) {
|
|
13961
14582
|
const socket = termState.socket;
|
|
13962
14583
|
const current = socket && m.socketId === socket.id;
|
|
@@ -13964,15 +14585,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
13964
14585
|
// A second load is the frame reloaded (its context menu), with no terminal and no socket.
|
|
13965
14586
|
const themeOptions = terminalThemeOptions();
|
|
13966
14587
|
resetTerminalFrame({ reload: termFrame.reload || termFrame.inited, theme: JSON.stringify(themeOptions) });
|
|
13967
|
-
postToTerminalFrame('init',
|
|
13968
|
-
fontFamily:
|
|
13969
|
-
appConfig.terminal.fontFamily ||
|
|
13970
|
-
getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
|
|
13971
|
-
'monospace',
|
|
13972
|
-
fontSize: currentTerminalFontSize(),
|
|
13973
|
-
scrollback: appConfig.terminal.scrollback,
|
|
13974
|
-
themeOptions,
|
|
13975
|
-
});
|
|
14588
|
+
postToTerminalFrame('init', terminalInitOptions(themeOptions));
|
|
13976
14589
|
} else if (t === 'term') {
|
|
13977
14590
|
termFrame.inited = true;
|
|
13978
14591
|
pushTerminalClaims();
|
|
@@ -14077,17 +14690,21 @@ function pushTerminalClaims() {
|
|
|
14077
14690
|
queueMicrotask(() => {
|
|
14078
14691
|
terminalClaimsQueued = false;
|
|
14079
14692
|
const bridged = bridgedFrameEls();
|
|
14080
|
-
if (!termFrame.inited && !bridged.length) return;
|
|
14693
|
+
if (!termFrame.inited && !kanbotState.inited && !bridged.length) return;
|
|
14081
14694
|
const claims = keyClaims();
|
|
14082
14695
|
sendBridgeClaimsAll(bridged, claims);
|
|
14083
|
-
if (!termFrame.inited) return;
|
|
14084
14696
|
const sig = JSON.stringify(claims);
|
|
14085
|
-
|
|
14086
|
-
termFrame
|
|
14087
|
-
terminalFrameSend('claims', claims);
|
|
14697
|
+
sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
|
|
14698
|
+
sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
|
|
14088
14699
|
});
|
|
14089
14700
|
}
|
|
14090
14701
|
|
|
14702
|
+
function sendFrameClaims(frameState, claims, sig, send) {
|
|
14703
|
+
if (!frameState.inited || sig === frameState.claims) return;
|
|
14704
|
+
frameState.claims = sig;
|
|
14705
|
+
send('claims', claims);
|
|
14706
|
+
}
|
|
14707
|
+
|
|
14091
14708
|
function terminalTheme() {
|
|
14092
14709
|
const css = getComputedStyle(document.body);
|
|
14093
14710
|
const v = (name) => css.getPropertyValue(name).trim();
|
|
@@ -14217,7 +14834,7 @@ function setTerminalAttached(on) {
|
|
|
14217
14834
|
|
|
14218
14835
|
// A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
|
|
14219
14836
|
function syncCloseGuard() {
|
|
14220
|
-
const on = termState.attached && termState.shown;
|
|
14837
|
+
const on = (termState.attached && termState.shown) || kanbotOnScreen();
|
|
14221
14838
|
if (termState.closeGuard === on) return;
|
|
14222
14839
|
termState.closeGuard = on;
|
|
14223
14840
|
hub.closeGuard(on);
|
|
@@ -14231,6 +14848,7 @@ window.addEventListener('beforeunload', (e) => {
|
|
|
14231
14848
|
// The hub's palette takes focus first and clears activeElement here, so the last focus inside this
|
|
14232
14849
|
// page is tracked instead; focus leaving the frame keeps it.
|
|
14233
14850
|
let terminalHadFocus = false;
|
|
14851
|
+
let passTerminalZoneOn = false;
|
|
14234
14852
|
document.addEventListener('focusin', () => {
|
|
14235
14853
|
terminalHadFocus = terminalPaneFocused();
|
|
14236
14854
|
});
|
|
@@ -14247,7 +14865,14 @@ document.addEventListener('focusout', (e) => {
|
|
|
14247
14865
|
// Chrome fires this before focusin when a click or a focus() call in this page takes focus from the
|
|
14248
14866
|
// frame, so it waits a task and gives way to whatever took focus.
|
|
14249
14867
|
let boardPointerDown = false;
|
|
14250
|
-
document.addEventListener(
|
|
14868
|
+
document.addEventListener(
|
|
14869
|
+
'pointerdown',
|
|
14870
|
+
() => {
|
|
14871
|
+
boardPointerDown = true;
|
|
14872
|
+
passTerminalZoneOn = false;
|
|
14873
|
+
},
|
|
14874
|
+
true,
|
|
14875
|
+
);
|
|
14251
14876
|
for (const type of ['pointerup', 'pointercancel']) {
|
|
14252
14877
|
document.addEventListener(type, () => (boardPointerDown = false), true);
|
|
14253
14878
|
}
|
|
@@ -14260,14 +14885,24 @@ window.addEventListener('focus', () => {
|
|
|
14260
14885
|
}
|
|
14261
14886
|
});
|
|
14262
14887
|
});
|
|
14888
|
+
// A board with no session open passes the zone on, so a stop at an empty config dir keeps it. So does a
|
|
14889
|
+
// board whose session has no terminal for an inherited terminal zone, until the user picks a zone here.
|
|
14263
14890
|
window.addEventListener('pagehide', () => {
|
|
14264
|
-
if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(
|
|
14265
|
-
else
|
|
14891
|
+
if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(FOCUS_ZONE_KEY, 'terminal');
|
|
14892
|
+
else if (passTerminalZoneOn) return;
|
|
14893
|
+
else if (currentSessionId || focusZone === 'sidebar') tabStore.setItem(FOCUS_ZONE_KEY, focusZone);
|
|
14266
14894
|
});
|
|
14267
14895
|
|
|
14268
|
-
|
|
14269
|
-
|
|
14270
|
-
|
|
14896
|
+
// While the hub shows another app this frame cannot take focus, so terminalHadFocus lets the
|
|
14897
|
+
// window focus handler finish the restore when the hub focuses this frame.
|
|
14898
|
+
function restoreTerminalFocus(zone, sessionId) {
|
|
14899
|
+
if (zone !== 'terminal') return;
|
|
14900
|
+
if (!wantsTerminalFor(sessionId)) {
|
|
14901
|
+
passTerminalZoneOn = true;
|
|
14902
|
+
return;
|
|
14903
|
+
}
|
|
14904
|
+
termState.focusNext = true;
|
|
14905
|
+
terminalHadFocus = true;
|
|
14271
14906
|
}
|
|
14272
14907
|
|
|
14273
14908
|
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
@@ -14459,13 +15094,25 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
14459
15094
|
// Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
|
|
14460
15095
|
// Terminal mode must go too: left on, opening the session again reconnects in 'auto' and resumes claude.
|
|
14461
15096
|
async function closeTerminalSession(id = termState.sessionId) {
|
|
15097
|
+
exitTerminalMode(id);
|
|
15098
|
+
await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
|
|
15099
|
+
dropPlaceholder(id);
|
|
15100
|
+
}
|
|
15101
|
+
|
|
15102
|
+
function exitTerminalMode(id) {
|
|
14462
15103
|
if (id === termState.sessionId) {
|
|
14463
15104
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
14464
15105
|
detachTerminal();
|
|
14465
15106
|
}
|
|
14466
15107
|
setTerminalMode(id, false);
|
|
14467
15108
|
syncTerminal();
|
|
14468
|
-
|
|
15109
|
+
}
|
|
15110
|
+
|
|
15111
|
+
// An end from another tab or `dispatch end` must clear terminal mode here too, or focusing the
|
|
15112
|
+
// session reconnects in 'auto' and resumes claude. The mode set is shared by same-origin tabs, so the
|
|
15113
|
+
// tab that ended it may have cleared it already while this tab still shows its pane.
|
|
15114
|
+
function onTerminalEnded(id) {
|
|
15115
|
+
if (id === termState.sessionId || terminalModes().has(id)) exitTerminalMode(id);
|
|
14469
15116
|
dropPlaceholder(id);
|
|
14470
15117
|
}
|
|
14471
15118
|
|
|
@@ -14531,7 +15178,7 @@ async function renderTerminalManager() {
|
|
|
14531
15178
|
body.innerHTML = list
|
|
14532
15179
|
.map((t) => {
|
|
14533
15180
|
const session = sessions.find((s) => s.id === t.id);
|
|
14534
|
-
const name = session ? sessionDisplayName(session) : t.id.slice(0, 8);
|
|
15181
|
+
const name = t.kanbot ? 'Kanbot' : session ? sessionDisplayName(session) : t.id.slice(0, 8);
|
|
14535
15182
|
const here = t.id === termState.sessionId ? '<span class="terminal-manager-here">this tab</span>' : '';
|
|
14536
15183
|
const attached = t.clients ? `${t.clients} attached` : 'detached';
|
|
14537
15184
|
return `<div class="terminal-manager-row${t.clients ? ' attached' : ''}">
|
|
@@ -14547,8 +15194,8 @@ async function renderTerminalManager() {
|
|
|
14547
15194
|
</div>
|
|
14548
15195
|
</div>
|
|
14549
15196
|
<div class="terminal-manager-actions">
|
|
14550
|
-
<button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}">Open</button>
|
|
14551
|
-
<button type="button" class="btn btn-secondary terminal-manager-end" data-end="${escapeHtml(t.id)}">End</button>
|
|
15197
|
+
<button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}"${t.kanbot ? ' data-kanbot' : ''}>Open</button>
|
|
15198
|
+
<button type="button" class="btn btn-secondary terminal-manager-end" data-end="${escapeHtml(t.id)}" data-started="${escapeHtml(String(Number(t.startedAt) || 0))}">End</button>
|
|
14552
15199
|
</div>
|
|
14553
15200
|
</div>`;
|
|
14554
15201
|
})
|
|
@@ -14556,7 +15203,8 @@ async function renderTerminalManager() {
|
|
|
14556
15203
|
body.querySelectorAll('[data-open]').forEach((b) => {
|
|
14557
15204
|
b.onclick = () => {
|
|
14558
15205
|
closeTerminalManager();
|
|
14559
|
-
|
|
15206
|
+
if ('kanbot' in b.dataset) toggleKanbot(true);
|
|
15207
|
+
else showSessionTerminal(b.dataset.open);
|
|
14560
15208
|
};
|
|
14561
15209
|
});
|
|
14562
15210
|
body.querySelectorAll('[data-end]').forEach((b) => {
|
|
@@ -14574,15 +15222,254 @@ async function terminalFetch(url, method, body) {
|
|
|
14574
15222
|
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
14575
15223
|
}
|
|
14576
15224
|
|
|
15225
|
+
const TERMINAL_STATE_LABEL = { busy: 'working', wait: 'waiting', idle: 'idle' };
|
|
15226
|
+
|
|
14577
15227
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
14578
15228
|
async function endAllTerminals() {
|
|
14579
|
-
const
|
|
14580
|
-
|
|
15229
|
+
const items = [...document.querySelectorAll('#terminal-manager-body [data-end]')].map((b) => {
|
|
15230
|
+
const id = b.dataset.end;
|
|
15231
|
+
const session = sessions.find((s) => s.id === id);
|
|
15232
|
+
const state = !session ? 'idle' : isWaitingSession(session) ? 'wait' : isActiveSession(session) ? 'busy' : 'idle';
|
|
15233
|
+
const started = Number(b.dataset.started);
|
|
15234
|
+
const meta = [
|
|
15235
|
+
TERMINAL_STATE_LABEL[state],
|
|
15236
|
+
started && `up ${formatDuration(Date.now() - started)}`,
|
|
15237
|
+
formatTerminalProc(terminalProcStats[id]),
|
|
15238
|
+
]
|
|
15239
|
+
.filter(Boolean)
|
|
15240
|
+
.join(' · ');
|
|
15241
|
+
return { id, state, label: session ? sessionDisplayName(session) : id.slice(0, 8), meta };
|
|
15242
|
+
});
|
|
15243
|
+
const ids = items.map((i) => i.id);
|
|
15244
|
+
const notIdle = items.filter((i) => i.state !== 'idle').length;
|
|
15245
|
+
if (notIdle) {
|
|
15246
|
+
const ok = await confirmModal({
|
|
15247
|
+
title: `End ${ids.length} terminals?`,
|
|
15248
|
+
message: 'The Claude Code processes stop. Sessions stay on the board and can be resumed.',
|
|
15249
|
+
items,
|
|
15250
|
+
warn: `${notIdle} ${notIdle === 1 ? 'is' : 'are'} not idle. Work in progress is cut off.`,
|
|
15251
|
+
okLabel: `End ${ids.length} terminals`,
|
|
15252
|
+
});
|
|
15253
|
+
if (!ok) return;
|
|
15254
|
+
}
|
|
15255
|
+
await Promise.all(ids.map((id) => closeTerminalSession(id)));
|
|
14581
15256
|
renderTerminalManager();
|
|
14582
15257
|
}
|
|
14583
15258
|
|
|
14584
15259
|
//#endregion
|
|
14585
15260
|
|
|
15261
|
+
//#region KANBOT
|
|
15262
|
+
// The board's own assistant (docs/kanbot.md): a claude session the server keeps hidden from the list,
|
|
15263
|
+
// shown in a popover through a second terminal frame on the board terminal's frame origin.
|
|
15264
|
+
const KANBOT_SVG =
|
|
15265
|
+
'<svg viewBox="0 0 16 13" width="16" height="13" shape-rendering="crispEdges" aria-hidden="true">' +
|
|
15266
|
+
'<path fill="#d97757" d="M3 0h10v4h3v3h-3v3H3V7H0V4h3z"/>' +
|
|
15267
|
+
'<path fill="#d97757" d="M4 10h1v3H4zM6 10h1v3H6zM9 10h1v3H9zM11 10h1v3h-1z"/>' +
|
|
15268
|
+
'<path fill="#1f1e1d" d="M5 2h1v2H5zM10 2h1v2h-1z"/></svg>';
|
|
15269
|
+
const KANBOT_LAYOUT_KEY = 'cck-kanbot-layout';
|
|
15270
|
+
const KANBOT_WIDTH_KEY = 'cck-kanbot-width';
|
|
15271
|
+
const kanbotState = {
|
|
15272
|
+
el: null,
|
|
15273
|
+
frame: null,
|
|
15274
|
+
origin: null,
|
|
15275
|
+
ptyId: null,
|
|
15276
|
+
inited: false,
|
|
15277
|
+
attached: false,
|
|
15278
|
+
ended: false,
|
|
15279
|
+
socketId: 0,
|
|
15280
|
+
theme: null,
|
|
15281
|
+
claims: null,
|
|
15282
|
+
returnFocus: null,
|
|
15283
|
+
};
|
|
15284
|
+
|
|
15285
|
+
function kanbotFocused() {
|
|
15286
|
+
return !!kanbotState.el?.contains(document.activeElement);
|
|
15287
|
+
}
|
|
15288
|
+
|
|
15289
|
+
function kanbotAvailable() {
|
|
15290
|
+
return !!appConfig.kanbot && terminalAvailable();
|
|
15291
|
+
}
|
|
15292
|
+
|
|
15293
|
+
function kanbotButton() {
|
|
15294
|
+
const b = document.createElement('button');
|
|
15295
|
+
b.type = 'button';
|
|
15296
|
+
b.className = 'footer-kanbot';
|
|
15297
|
+
b.title = 'Kanbot, the board assistant';
|
|
15298
|
+
b.setAttribute('aria-label', b.title);
|
|
15299
|
+
b.innerHTML = KANBOT_SVG;
|
|
15300
|
+
b.onclick = toggleKanbot;
|
|
15301
|
+
return b;
|
|
15302
|
+
}
|
|
15303
|
+
|
|
15304
|
+
function ensureKanbotPopover() {
|
|
15305
|
+
if (kanbotState.el) return kanbotState.el;
|
|
15306
|
+
const el = document.createElement('div');
|
|
15307
|
+
el.className = 'kanbot-popover';
|
|
15308
|
+
el.innerHTML = `<div class="kanbot-head">${KANBOT_SVG}<strong>Kanbot</strong><span class="kanbot-status"></span><button type="button" class="kanbot-collapse" aria-label="Hide" title="Hide, Kanbot keeps running">–</button><button type="button" class="kanbot-dock">${showIcon('dock')}</button><button type="button" class="kanbot-expand"></button><button type="button" class="kanbot-close" aria-label="End" title="End the session, the next open resumes the chat">×</button></div><div class="kanbot-body"></div><div class="panel-resize-handle kanbot-resize"></div>`;
|
|
15309
|
+
el.querySelector('.kanbot-dock').onclick = () => setKanbotLayout(el.classList.contains('docked') ? '' : 'docked');
|
|
15310
|
+
el.querySelector('.kanbot-expand').onclick = () =>
|
|
15311
|
+
setKanbotLayout(el.classList.contains('expanded') ? '' : 'expanded');
|
|
15312
|
+
el.querySelector('.kanbot-collapse').onclick = () => toggleKanbot(false);
|
|
15313
|
+
el.querySelector('.kanbot-close').onclick = endKanbot;
|
|
15314
|
+
const width = store.getItem(KANBOT_WIDTH_KEY);
|
|
15315
|
+
if (width) el.style.setProperty('--kanbot-width', width);
|
|
15316
|
+
initResize(el, el.querySelector('.kanbot-resize'), '--kanbot-width', KANBOT_WIDTH_KEY, 320);
|
|
15317
|
+
document.body.appendChild(el);
|
|
15318
|
+
kanbotState.el = el;
|
|
15319
|
+
setKanbotLayout(store.getItem(KANBOT_LAYOUT_KEY) || '');
|
|
15320
|
+
return el;
|
|
15321
|
+
}
|
|
15322
|
+
|
|
15323
|
+
function setKanbotLayout(layout) {
|
|
15324
|
+
const el = kanbotState.el;
|
|
15325
|
+
const docked = layout === 'docked';
|
|
15326
|
+
const expanded = layout === 'expanded';
|
|
15327
|
+
el.classList.toggle('docked', docked);
|
|
15328
|
+
el.classList.toggle('expanded', expanded);
|
|
15329
|
+
const dock = el.querySelector('.kanbot-dock');
|
|
15330
|
+
dock.classList.toggle('active', docked);
|
|
15331
|
+
dock.title = docked ? 'Undock' : 'Dock to the right';
|
|
15332
|
+
dock.setAttribute('aria-label', dock.title);
|
|
15333
|
+
dock.setAttribute('aria-pressed', String(docked));
|
|
15334
|
+
const expand = el.querySelector('.kanbot-expand');
|
|
15335
|
+
expand.innerHTML = showIcon(expanded ? 'restore' : 'expand');
|
|
15336
|
+
expand.title = expanded ? 'Restore' : 'Expand';
|
|
15337
|
+
expand.setAttribute('aria-label', expand.title);
|
|
15338
|
+
store.setItem(KANBOT_LAYOUT_KEY, layout);
|
|
15339
|
+
postToKanbotFrame('focus');
|
|
15340
|
+
}
|
|
15341
|
+
|
|
15342
|
+
function setKanbotStatus(text) {
|
|
15343
|
+
kanbotState.el.querySelector('.kanbot-status').textContent = text;
|
|
15344
|
+
}
|
|
15345
|
+
|
|
15346
|
+
function postToKanbotFrame(t, data) {
|
|
15347
|
+
kanbotState.frame?.contentWindow?.postMessage({ ...data, type: TERMINAL_MSG + t }, kanbotState.origin);
|
|
15348
|
+
}
|
|
15349
|
+
|
|
15350
|
+
function kanbotOnScreen() {
|
|
15351
|
+
return kanbotState.attached && !!kanbotState.el?.classList.contains('visible');
|
|
15352
|
+
}
|
|
15353
|
+
|
|
15354
|
+
function endKanbot() {
|
|
15355
|
+
const id = kanbotState.ptyId;
|
|
15356
|
+
toggleKanbot(false);
|
|
15357
|
+
kanbotState.ptyId = null;
|
|
15358
|
+
if (id) terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
|
|
15359
|
+
}
|
|
15360
|
+
|
|
15361
|
+
function setKanbotAttached(on) {
|
|
15362
|
+
kanbotState.attached = on;
|
|
15363
|
+
syncCloseGuard();
|
|
15364
|
+
}
|
|
15365
|
+
|
|
15366
|
+
function toggleKanbot(force) {
|
|
15367
|
+
const el = ensureKanbotPopover();
|
|
15368
|
+
const show = typeof force === 'boolean' ? force : !el.classList.contains('visible');
|
|
15369
|
+
const wasShown = el.classList.contains('visible');
|
|
15370
|
+
el.classList.toggle('visible', show);
|
|
15371
|
+
if (!show) {
|
|
15372
|
+
const hadFocus = kanbotFocused();
|
|
15373
|
+
postToKanbotFrame('detach');
|
|
15374
|
+
setKanbotAttached(false);
|
|
15375
|
+
const back = kanbotState.returnFocus;
|
|
15376
|
+
kanbotState.returnFocus = null;
|
|
15377
|
+
if (hadFocus) {
|
|
15378
|
+
document.activeElement.blur();
|
|
15379
|
+
if (back === 'terminal') focusTerminalPane();
|
|
15380
|
+
else if (back?.isConnected) back.focus();
|
|
15381
|
+
}
|
|
15382
|
+
return;
|
|
15383
|
+
}
|
|
15384
|
+
if (!wasShown && !kanbotFocused()) {
|
|
15385
|
+
kanbotState.returnFocus = terminalPaneFocused() ? 'terminal' : document.activeElement;
|
|
15386
|
+
}
|
|
15387
|
+
kanbotState.frame?.focus();
|
|
15388
|
+
if (!kanbotState.frame) {
|
|
15389
|
+
const frame = document.createElement('iframe');
|
|
15390
|
+
frame.className = 'kanbot-frame';
|
|
15391
|
+
frame.title = 'Kanbot';
|
|
15392
|
+
frame.allow = 'clipboard-read; clipboard-write';
|
|
15393
|
+
kanbotState.origin = termFrame.sameOrigin ? location.origin : terminalFrameOrigin();
|
|
15394
|
+
frame.src = `${kanbotState.origin}/terminal.html#p=${encodeURIComponent(location.origin)}`;
|
|
15395
|
+
kanbotState.frame = frame;
|
|
15396
|
+
el.querySelector('.kanbot-body').appendChild(frame);
|
|
15397
|
+
return;
|
|
15398
|
+
}
|
|
15399
|
+
if (kanbotState.inited) {
|
|
15400
|
+
postToKanbotFrame('refresh');
|
|
15401
|
+
postToKanbotFrame('focus');
|
|
15402
|
+
if (!kanbotState.attached) openKanbot();
|
|
15403
|
+
}
|
|
15404
|
+
}
|
|
15405
|
+
|
|
15406
|
+
async function openKanbot() {
|
|
15407
|
+
setKanbotStatus('starting…');
|
|
15408
|
+
let res;
|
|
15409
|
+
try {
|
|
15410
|
+
res = await terminalFetch('/api/kanbot/start', 'POST', {});
|
|
15411
|
+
} catch {
|
|
15412
|
+
setKanbotStatus('board unreachable, retrying…');
|
|
15413
|
+
setTimeout(() => {
|
|
15414
|
+
if (kanbotState.el.classList.contains('visible') && !kanbotState.attached) openKanbot();
|
|
15415
|
+
}, 3000);
|
|
15416
|
+
return;
|
|
15417
|
+
}
|
|
15418
|
+
const data = await res.json().catch(() => ({}));
|
|
15419
|
+
if (!res.ok) return setKanbotStatus(data.error || `error ${res.status}`);
|
|
15420
|
+
kanbotState.ended = false;
|
|
15421
|
+
kanbotState.ptyId = data.ptyId;
|
|
15422
|
+
setKanbotStatus(data.sessionId ? data.sessionId.slice(0, 8) : '');
|
|
15423
|
+
postToKanbotFrame('open', {
|
|
15424
|
+
socketId: ++kanbotState.socketId,
|
|
15425
|
+
reset: true,
|
|
15426
|
+
hello: { token: terminalToken, id: data.ptyId, mode: 'auto' },
|
|
15427
|
+
});
|
|
15428
|
+
postToKanbotFrame('focus');
|
|
15429
|
+
}
|
|
15430
|
+
|
|
15431
|
+
window.addEventListener('message', (e) => {
|
|
15432
|
+
if (!kanbotState.frame || e.source !== kanbotState.frame.contentWindow || e.origin !== kanbotState.origin) return;
|
|
15433
|
+
const m = e.data;
|
|
15434
|
+
if (typeof m?.type !== 'string' || !m.type.startsWith(TERMINAL_MSG)) return;
|
|
15435
|
+
const t = m.type.slice(TERMINAL_MSG.length);
|
|
15436
|
+
if (t === 'loaded') {
|
|
15437
|
+
const themeOptions = terminalThemeOptions();
|
|
15438
|
+
kanbotState.inited = false;
|
|
15439
|
+
kanbotState.claims = null;
|
|
15440
|
+
kanbotState.theme = JSON.stringify(themeOptions);
|
|
15441
|
+
postToKanbotFrame('init', terminalInitOptions(themeOptions));
|
|
15442
|
+
return;
|
|
15443
|
+
}
|
|
15444
|
+
if (t === 'term') {
|
|
15445
|
+
kanbotState.inited = true;
|
|
15446
|
+
const claims = keyClaims();
|
|
15447
|
+
sendFrameClaims(kanbotState, claims, JSON.stringify(claims), postToKanbotFrame);
|
|
15448
|
+
// The first show created the frame after its focus call, so the frame takes focus once it is up.
|
|
15449
|
+
if (kanbotState.el.classList.contains('visible')) kanbotState.frame.focus();
|
|
15450
|
+
openKanbot();
|
|
15451
|
+
return;
|
|
15452
|
+
}
|
|
15453
|
+
if (t === 'key') {
|
|
15454
|
+
replayKey(m, document.getElementById('terminal-key-proxy'));
|
|
15455
|
+
return;
|
|
15456
|
+
}
|
|
15457
|
+
if (m.socketId !== kanbotState.socketId) return;
|
|
15458
|
+
if (t === 'ws') {
|
|
15459
|
+
if (m.msg?.t === 'ready') setKanbotAttached(true);
|
|
15460
|
+
else if (m.msg?.t === 'exit') {
|
|
15461
|
+
setKanbotAttached(false);
|
|
15462
|
+
kanbotState.ended = true;
|
|
15463
|
+
setKanbotStatus('ended, reopen to resume');
|
|
15464
|
+
} else if (m.msg?.t === 'error') setKanbotStatus(m.msg.msg || 'error');
|
|
15465
|
+
} else if (t === 'close') {
|
|
15466
|
+
setKanbotAttached(false);
|
|
15467
|
+
if (!kanbotState.ended && kanbotState.el.classList.contains('visible')) setTimeout(openKanbot, 1000);
|
|
15468
|
+
}
|
|
15469
|
+
});
|
|
15470
|
+
|
|
15471
|
+
//#endregion
|
|
15472
|
+
|
|
14586
15473
|
//#region SHOW
|
|
14587
15474
|
// The card the session in the embedded terminal posts to with the show tool. It belongs to the
|
|
14588
15475
|
// terminal, not the session: /clear and the --resume picker change the session id, the terminal id
|
|
@@ -14593,6 +15480,7 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
|
|
|
14593
15480
|
const SHOW_MIN_W = 280;
|
|
14594
15481
|
const SHOW_MIN_H = 120;
|
|
14595
15482
|
const SHOW_INSET = 8;
|
|
15483
|
+
const SHOW_SANDBOX = 'allow-scripts allow-forms';
|
|
14596
15484
|
const SHOW_CSP =
|
|
14597
15485
|
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; frame-src 'none'; connect-src 'none'; form-action 'none'";
|
|
14598
15486
|
// The card's token contract (the show skill lists these names) mapped to the board's theme vars.
|
|
@@ -14641,6 +15529,7 @@ const SHOW_ICONS = {
|
|
|
14641
15529
|
uncollapse: '<path d="M6 9l6 6 6-6"/>',
|
|
14642
15530
|
expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
|
|
14643
15531
|
restore: '<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/>',
|
|
15532
|
+
dock: '<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM15 3v18"/>',
|
|
14644
15533
|
clear: '<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
|
|
14645
15534
|
close: '<path d="M18 6L6 18M6 6l12 12"/>',
|
|
14646
15535
|
pane: '<use href="#icon-pane"/>',
|
|
@@ -14695,8 +15584,11 @@ function syncShowFocus() {
|
|
|
14695
15584
|
setTimeout(() => showCardEl()?.classList.toggle('focused', isShowCardFocused()));
|
|
14696
15585
|
}
|
|
14697
15586
|
|
|
15587
|
+
// An HTML card's own keys and scroll keys reach it only inside its frame.
|
|
14698
15588
|
function focusShowBody() {
|
|
14699
|
-
|
|
15589
|
+
const frame = showState.frame;
|
|
15590
|
+
if (frame?.isConnected && frame.loaded) frame.focus();
|
|
15591
|
+
else showCardEl()?.querySelector('.show-body')?.focus();
|
|
14700
15592
|
}
|
|
14701
15593
|
|
|
14702
15594
|
const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+w': 'close' };
|
|
@@ -14718,8 +15610,12 @@ function showCardKey(e) {
|
|
|
14718
15610
|
}
|
|
14719
15611
|
const p = showState.posts[showState.idx];
|
|
14720
15612
|
if (combo !== 'ctrl+d' || !p) return false;
|
|
14721
|
-
|
|
14722
|
-
|
|
15613
|
+
confirmModal({
|
|
15614
|
+
title: 'Remove this post?',
|
|
15615
|
+
message: 'It is taken off the card.',
|
|
15616
|
+
chip: p.title,
|
|
15617
|
+
okLabel: 'Remove post',
|
|
15618
|
+
}).then((ok) => {
|
|
14723
15619
|
if (!ok || showState.posts[showState.idx]?.id !== p.id) return;
|
|
14724
15620
|
showCommand('remove');
|
|
14725
15621
|
focusAfterShowRemove();
|
|
@@ -14749,6 +15645,13 @@ function rememberTerminalIds(list) {
|
|
|
14749
15645
|
store.writeJson(SHOW_VIEW_KEY, showViews);
|
|
14750
15646
|
}
|
|
14751
15647
|
|
|
15648
|
+
// A session resumed after its terminal ended runs under the same PTY id in a new terminal, with a
|
|
15649
|
+
// new terminal id, so the next view change looks it up again.
|
|
15650
|
+
function forgetEndedTerminals() {
|
|
15651
|
+
for (const pty of terminalIds.keys()) if (!runningTerminals.has(pty)) terminalIds.delete(pty);
|
|
15652
|
+
if (!runningTerminals.has(showState.lookup)) showState.lookup = null;
|
|
15653
|
+
}
|
|
15654
|
+
|
|
14752
15655
|
function setShowView(tid, view) {
|
|
14753
15656
|
const set = SHOW_VIEW_FLAGS.filter((f) => view[f]);
|
|
14754
15657
|
const old = showViews[tid] || {};
|
|
@@ -14923,7 +15826,7 @@ async function renderShowBody(p, samePost) {
|
|
|
14923
15826
|
showState.path = data && !data.waiting ? data.path : null;
|
|
14924
15827
|
const html = data?.kind === 'html' && !data.waiting;
|
|
14925
15828
|
// A new frame or document drops focus to the page, and Ctrl +/- then zooms the terminal.
|
|
14926
|
-
const refocus = isShowCardFocused() ?
|
|
15829
|
+
const refocus = isShowCardFocused() ? focusShowBody : () => {};
|
|
14927
15830
|
card.classList.toggle('html', html);
|
|
14928
15831
|
if (html) {
|
|
14929
15832
|
const restoreY = samePost && showState.frame ? showState.frameY : 0;
|
|
@@ -14985,8 +15888,16 @@ function setShowReview(opts) {
|
|
|
14985
15888
|
// No allow-popups: it opens window.open as a way out. The CSP blocks fetch and remote sub-resources,
|
|
14986
15889
|
// but not navigation or WebRTC; the real guard is that only the terminal's own agent can post.
|
|
14987
15890
|
function createShowFrame(title, srcdoc) {
|
|
14988
|
-
const frame = createPreviewFrame('show-frame', srcdoc,
|
|
15891
|
+
const frame = createPreviewFrame('show-frame', srcdoc, SHOW_SANDBOX);
|
|
14989
15892
|
frame.title = title;
|
|
15893
|
+
// Focus given before the first document loads stays on the frame element, out of the document's
|
|
15894
|
+
// keys, and the document never reports focus or blur, so the body holds focus until then.
|
|
15895
|
+
frame.addEventListener('load', () => {
|
|
15896
|
+
const waited = !frame.loaded && document.activeElement === frame.parentElement;
|
|
15897
|
+
frame.loaded = true;
|
|
15898
|
+
if (waited) frame.focus();
|
|
15899
|
+
else if (frame === document.activeElement) frame.contentWindow?.focus();
|
|
15900
|
+
});
|
|
14990
15901
|
showState.frame = frame;
|
|
14991
15902
|
showState.frameFocused = false;
|
|
14992
15903
|
return frame;
|
|
@@ -15368,11 +16279,11 @@ function forgetPlaceholder(id) {
|
|
|
15368
16279
|
return true;
|
|
15369
16280
|
}
|
|
15370
16281
|
|
|
15371
|
-
function renderPlaceholderCard(session) {
|
|
16282
|
+
function renderPlaceholderCard(session, depth) {
|
|
15372
16283
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
15373
16284
|
const projectHtml = renderProjectIdentity(session);
|
|
15374
16285
|
return `
|
|
15375
|
-
<button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
16286
|
+
<button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
15376
16287
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
15377
16288
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
15378
16289
|
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
@@ -15417,6 +16328,8 @@ async function loadTerminals() {
|
|
|
15417
16328
|
|
|
15418
16329
|
function setRunningTerminals(ids) {
|
|
15419
16330
|
runningTerminals = new Set(ids);
|
|
16331
|
+
forgetEndedTerminals();
|
|
16332
|
+
syncShowCard();
|
|
15420
16333
|
pushTerminalClaims();
|
|
15421
16334
|
if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
|
|
15422
16335
|
else renderSessionViews();
|
|
@@ -15868,7 +16781,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
15868
16781
|
|
|
15869
16782
|
loadSidebarState();
|
|
15870
16783
|
for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
|
|
15871
|
-
|
|
16784
|
+
syncSessionGroups();
|
|
15872
16785
|
initSessionGroupsDnd();
|
|
15873
16786
|
initSessionPicker();
|
|
15874
16787
|
initProjectPicker();
|
|
@@ -15959,13 +16872,15 @@ function makeLimitSpan(rl) {
|
|
|
15959
16872
|
function renderSidebarFooter(rateLimits) {
|
|
15960
16873
|
const el = document.getElementById('sidebar-footer');
|
|
15961
16874
|
if (!el) return;
|
|
16875
|
+
footerState.rl = rateLimits;
|
|
15962
16876
|
const fh = rateLimits?.five_hour?.used_percentage ?? null;
|
|
15963
16877
|
const sd = rateLimits?.seven_day?.used_percentage ?? null;
|
|
15964
16878
|
const children = [];
|
|
15965
16879
|
if (footerState.version) {
|
|
15966
16880
|
const v = document.createElement('span');
|
|
15967
16881
|
v.className = 'footer-version';
|
|
15968
|
-
|
|
16882
|
+
if (kanbotAvailable()) v.append(kanbotButton());
|
|
16883
|
+
v.append(`v${footerState.version}`);
|
|
15969
16884
|
const warn = pluginWarning(footerState.plugin);
|
|
15970
16885
|
if (warn) v.append(warn);
|
|
15971
16886
|
children.push(v);
|
|
@@ -16045,8 +16960,7 @@ searchQuery = urlState.search || '';
|
|
|
16045
16960
|
|
|
16046
16961
|
renderFilterState();
|
|
16047
16962
|
renderZenState();
|
|
16048
|
-
|
|
16049
|
-
stickySessionIds = loadStickySessions();
|
|
16963
|
+
syncSessionPins();
|
|
16050
16964
|
setupEventSource();
|
|
16051
16965
|
|
|
16052
16966
|
if (urlState.search) {
|
|
@@ -16062,6 +16976,7 @@ if (urlState.search) {
|
|
|
16062
16976
|
getJson('/api/config')
|
|
16063
16977
|
.then((c) => {
|
|
16064
16978
|
if (c) appConfig = c;
|
|
16979
|
+
if (appConfig.kanbot && footerState.version) renderSidebarFooter(footerState.rl);
|
|
16065
16980
|
})
|
|
16066
16981
|
.then(restorePendingSessions)
|
|
16067
16982
|
.then(() =>
|
|
@@ -16071,6 +16986,7 @@ getJson('/api/config')
|
|
|
16071
16986
|
),
|
|
16072
16987
|
)
|
|
16073
16988
|
.then(async () => {
|
|
16989
|
+
const focusZoneBeforeReload = tabStore.getItem(FOCUS_ZONE_KEY);
|
|
16074
16990
|
if (urlState.projectView) {
|
|
16075
16991
|
try {
|
|
16076
16992
|
await fetchProjectView(atob(urlState.projectView));
|
|
@@ -16078,7 +16994,7 @@ getJson('/api/config')
|
|
|
16078
16994
|
showNoSession();
|
|
16079
16995
|
}
|
|
16080
16996
|
} else if (urlState.session) {
|
|
16081
|
-
restoreTerminalFocus(urlState.session);
|
|
16997
|
+
restoreTerminalFocus(focusZoneBeforeReload, urlState.session);
|
|
16082
16998
|
await fetchTasks(urlState.session);
|
|
16083
16999
|
} else {
|
|
16084
17000
|
const last = loadLastView();
|
|
@@ -16089,12 +17005,13 @@ getJson('/api/config')
|
|
|
16089
17005
|
showNoSession();
|
|
16090
17006
|
}
|
|
16091
17007
|
} else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
|
|
16092
|
-
restoreTerminalFocus(last.session);
|
|
17008
|
+
restoreTerminalFocus(focusZoneBeforeReload, last.session);
|
|
16093
17009
|
await fetchTasks(last.session);
|
|
16094
17010
|
} else {
|
|
16095
17011
|
showNoSession();
|
|
16096
17012
|
}
|
|
16097
17013
|
}
|
|
17014
|
+
if (focusZoneBeforeReload === 'sidebar') setFocusZone('sidebar');
|
|
16098
17015
|
if (urlState.messages && currentSessionId) {
|
|
16099
17016
|
if (logAutoOpened) setMessagePanelVisible(false);
|
|
16100
17017
|
toggleMessagePanel();
|