claude-code-kanban 7.6.1 → 7.8.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 +391 -70
- package/hub-app.json +3 -3
- package/install.js +11 -9
- 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/live-sessions.js +1 -1
- 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-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +86 -20
- package/lib/user-groups.js +384 -0
- package/package.json +3 -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 +1647 -464
- package/public/group-tree.js +21 -0
- package/public/index.html +53 -28
- package/public/style.css +820 -106
- package/public/sw.js +1 -1
- package/public/terminal-frame.js +12 -0
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +333 -148
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
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';
|
|
@@ -92,6 +94,8 @@ let sessionsHasMore = false;
|
|
|
92
94
|
// URL, so a query string would not survive a hub reload.
|
|
93
95
|
const ZEN_KEY = 'zenMode';
|
|
94
96
|
let zenMode = store.getItem(ZEN_KEY) === 'true';
|
|
97
|
+
// Group zen: the sidebar shows one group's subtree. A view of the moment, so it is not persisted.
|
|
98
|
+
let zenGroup = null;
|
|
95
99
|
let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
|
|
96
100
|
let recentProjects = new Set();
|
|
97
101
|
let projectsCacheDirty = true;
|
|
@@ -107,8 +111,6 @@ const SESSION_ORDER_KEY = 'session-order';
|
|
|
107
111
|
let stableGroupOrder = readStoredList(PROJECT_ORDER_KEY);
|
|
108
112
|
let sessionOrder = readStoredList(SESSION_ORDER_KEY);
|
|
109
113
|
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
110
|
-
// Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
|
|
111
|
-
let sgReleased = new Set();
|
|
112
114
|
let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
|
|
113
115
|
let searchQuery = ''; // Search query for fuzzy search
|
|
114
116
|
let allTasksCache = []; // Cache all tasks for search
|
|
@@ -144,6 +146,8 @@ let taskHighlightDimmed = false;
|
|
|
144
146
|
let focusZone = 'board'; // 'board' | 'sidebar'
|
|
145
147
|
let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
|
|
146
148
|
let runningTerminals = new Set();
|
|
149
|
+
// Embedded sessions stuck on Claude Code's folder-trust dialog. No hook runs before it, so the server reads it off the screen.
|
|
150
|
+
let trustWaiting = new Set();
|
|
147
151
|
let selectedSessionIdx = -1;
|
|
148
152
|
let selectedSessionKbId = null;
|
|
149
153
|
let sessionJustSelected = false;
|
|
@@ -186,6 +190,7 @@ function setSessionDismissed(id, dismissed) {
|
|
|
186
190
|
else dismissedSessionIds.delete(id);
|
|
187
191
|
persistDismissedSessions();
|
|
188
192
|
updateDismissBtnState();
|
|
193
|
+
if (dismissed) sgSweepTransient();
|
|
189
194
|
renderSessions();
|
|
190
195
|
renderActivityChip();
|
|
191
196
|
return true;
|
|
@@ -256,6 +261,7 @@ function resetState() {
|
|
|
256
261
|
sessionLimit = SESSION_PAGE;
|
|
257
262
|
filterProject = FILTER_DEFAULTS.project;
|
|
258
263
|
zenMode = false;
|
|
264
|
+
zenGroup = null;
|
|
259
265
|
store.removeItem(ZEN_KEY);
|
|
260
266
|
renderZenState();
|
|
261
267
|
searchQuery = '';
|
|
@@ -323,21 +329,30 @@ async function getJson(path, fallback = null, opts) {
|
|
|
323
329
|
|
|
324
330
|
async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
325
331
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
332
|
+
// The focused session must come back whatever the filters say: renderSession and the
|
|
333
|
+
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
334
|
+
// opened from outside the current filter would leave the view on the previous one.
|
|
335
|
+
// The server adds the pinned sessions itself; the ones `session open` showed are this page's.
|
|
336
|
+
// Group zen ignores the filters, and its session members come back through `include`.
|
|
337
|
+
const zenIds = zenGroup ? sgSubtreeIds(zenGroup) : new Set();
|
|
338
|
+
const zenMembers = sessionGroups
|
|
339
|
+
.filter((g) => zenIds.has(g.id))
|
|
340
|
+
.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref));
|
|
341
|
+
const include = [
|
|
342
|
+
focusId,
|
|
343
|
+
revealedPlanSessionId,
|
|
344
|
+
revealedStorageSessionId,
|
|
345
|
+
...openedStickyIds,
|
|
346
|
+
...zenMembers,
|
|
347
|
+
].filter(Boolean);
|
|
348
|
+
const recent = !zenGroup && filterProject === '__recent__';
|
|
330
349
|
const res = await api('/api/sessions', {
|
|
331
350
|
query: {
|
|
332
351
|
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,
|
|
352
|
+
include: include.length ? include.join(',') : null,
|
|
338
353
|
recentHours: recent ? RECENT_PROJECT_HOURS : null,
|
|
339
|
-
project: !recent && filterProject ? filterProject : null,
|
|
340
|
-
filter: sessionFilter === 'active' ? 'active' : null,
|
|
354
|
+
project: !zenGroup && !recent && filterProject ? filterProject : null,
|
|
355
|
+
filter: !zenGroup && sessionFilter === 'active' ? 'active' : null,
|
|
341
356
|
},
|
|
342
357
|
});
|
|
343
358
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -348,6 +363,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
|
348
363
|
lastSessionsHash = sessionsHash;
|
|
349
364
|
sessions = mergePlaceholders(newSessions);
|
|
350
365
|
markOpenSessionRead();
|
|
366
|
+
sgSweepTransient();
|
|
351
367
|
renderSessions();
|
|
352
368
|
renderActivityChip();
|
|
353
369
|
schedulePublishSession();
|
|
@@ -544,14 +560,14 @@ function toggleSection(containerId, chevronId) {
|
|
|
544
560
|
}
|
|
545
561
|
|
|
546
562
|
function isWaitingSession(s) {
|
|
547
|
-
return !!s.hasWaitingForUser;
|
|
563
|
+
return !!s.hasWaitingForUser || trustWaiting.has(s.id);
|
|
548
564
|
}
|
|
549
565
|
function sessionBarClass(s) {
|
|
550
566
|
if (isWaitingSession(s)) return 'permission-pending';
|
|
551
567
|
return s.unread ? 'unread' : '';
|
|
552
568
|
}
|
|
553
569
|
function isActiveSession(s) {
|
|
554
|
-
return !s
|
|
570
|
+
return !isWaitingSession(s) && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
|
|
555
571
|
}
|
|
556
572
|
// hasRecentLog carries the server's registry-idle correction: an open-but-idle
|
|
557
573
|
// terminal keeps touching its JSONL, so mtime alone is not proof of activity.
|
|
@@ -765,8 +781,7 @@ async function fetchTasks(sessionId) {
|
|
|
765
781
|
if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
|
|
766
782
|
revealedStorageSessionId = null;
|
|
767
783
|
}
|
|
768
|
-
if (currentSessionId && currentSessionId !== sessionId
|
|
769
|
-
expandPinnedFor(currentSessionId);
|
|
784
|
+
if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
|
|
770
785
|
if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
|
|
771
786
|
const switched = sessionId !== currentSessionId;
|
|
772
787
|
currentSessionId = sessionId;
|
|
@@ -1655,8 +1670,12 @@ function toggleToolGroup(id) {
|
|
|
1655
1670
|
|
|
1656
1671
|
// Legacy markers (written before the gate minted kind:"plan") carry no kind
|
|
1657
1672
|
// for plans — the tool name is the durable signal there.
|
|
1673
|
+
function isPlanAsk(kind, tool) {
|
|
1674
|
+
return kind === 'plan' || tool === 'ExitPlanMode';
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1658
1677
|
function getWaitingPill(kind, tool) {
|
|
1659
|
-
if (kind
|
|
1678
|
+
if (isPlanAsk(kind, tool)) return 'Plan awaiting approval';
|
|
1660
1679
|
if (kind === 'question') return 'Question pending';
|
|
1661
1680
|
return 'Awaiting permission';
|
|
1662
1681
|
}
|
|
@@ -1735,27 +1754,34 @@ function waitingDecisionButtons(cls) {
|
|
|
1735
1754
|
const plan = currentWaiting.kind === 'plan';
|
|
1736
1755
|
// Code-authored call argument, not data — hence the constant-style name the
|
|
1737
1756
|
// escaping check reads as safe.
|
|
1738
|
-
const DENY_ARG = plan ? "
|
|
1739
|
-
|
|
1757
|
+
const DENY_ARG = plan ? "rejectWaitingPlan('inline-plan-feedback')" : "respondWaiting({behavior:'deny'})";
|
|
1758
|
+
const feedback = plan
|
|
1759
|
+
? `<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()">`
|
|
1760
|
+
: '';
|
|
1761
|
+
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
1762
|
}
|
|
1741
1763
|
|
|
1764
|
+
let inlinePlanFeedback = '';
|
|
1765
|
+
|
|
1742
1766
|
function renderWaitingEntry() {
|
|
1743
1767
|
if (!isWaitingFresh()) return '';
|
|
1744
1768
|
const tool = currentWaiting.toolName || 'unknown';
|
|
1745
1769
|
const params = parseWaitingInput();
|
|
1746
|
-
const
|
|
1770
|
+
const plan = isPlanAsk(currentWaiting.kind, tool);
|
|
1747
1771
|
const detail = deriveWaitingDetail(tool, params);
|
|
1748
1772
|
const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
|
|
1749
1773
|
const bodyHtml = renderWaitingBody(tool, params);
|
|
1750
1774
|
const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
|
|
1751
|
-
const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
|
|
1752
1775
|
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
1776
|
// Questions are answered in the detail modal; plans render there too.
|
|
1754
1777
|
const buttons = waitingDecisionButtons('msg-waiting');
|
|
1755
1778
|
const actions = buttons ? `<span class="msg-waiting-actions">${buttons}</span>` : '';
|
|
1756
1779
|
// Lapsed = gate stopped polling — the ask is only answerable in the terminal
|
|
1757
1780
|
const lapsed = !!currentWaiting.lapsed;
|
|
1758
|
-
|
|
1781
|
+
const keyHint = lapsed ? 'answer in the terminal' : buttons ? `${sendKbd()} ${plan ? 'approve' : 'allow'}` : '';
|
|
1782
|
+
const hintHtml = keyHint ? `<div class="msg-time waiting-key-hint-line">${keyHint}</div>` : '';
|
|
1783
|
+
const footer = hintHtml || actions ? `<div class="msg-waiting-footer">${hintHtml}${actions}</div>` : '';
|
|
1784
|
+
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
1785
|
}
|
|
1760
1786
|
|
|
1761
1787
|
// Shared approval controls for a fresh plan ask — the waiting-detail footer and
|
|
@@ -1804,6 +1830,7 @@ async function respondWaiting(payload) {
|
|
|
1804
1830
|
// Drop the answered/discarded ask from every surface it renders on
|
|
1805
1831
|
function clearWaitingUi() {
|
|
1806
1832
|
currentWaiting = null;
|
|
1833
|
+
inlinePlanFeedback = '';
|
|
1807
1834
|
waitingAnswerDraft = {};
|
|
1808
1835
|
waitingCustomDraft = {};
|
|
1809
1836
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
@@ -2123,45 +2150,95 @@ let stickySessionIds = new Set();
|
|
|
2123
2150
|
// Pinning the currently-selected session keeps it in place until deselected (less UI movement).
|
|
2124
2151
|
const deferredPinPlacement = new Set();
|
|
2125
2152
|
|
|
2126
|
-
|
|
2127
|
-
|
|
2153
|
+
// Sessions that `session open` keeps on screen for this page only; the server never has them.
|
|
2154
|
+
const openedStickyIds = new Set();
|
|
2155
|
+
|
|
2156
|
+
// The server keeps the pins (`/api/session/pins`, lib/session-pins.js); the two sets are only the
|
|
2157
|
+
// page's copy. The localStorage lists are from before that. Each origin has its own (the hub's
|
|
2158
|
+
// proxied port, the standalone board), so each one merges its lists into the server's once, then
|
|
2159
|
+
// deletes them.
|
|
2160
|
+
async function syncSessionPins() {
|
|
2161
|
+
try {
|
|
2162
|
+
const body = { pinned: readStoredList(PINNED_SESSIONS_KEY), sticky: readStoredList(STICKY_SESSIONS_KEY) };
|
|
2163
|
+
const local = body.pinned.length || body.sticky.length;
|
|
2164
|
+
const res = await (local ? api('/api/session/pins/import', { method: 'POST', body }) : api('/api/session/pins'));
|
|
2165
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2166
|
+
const data = await res.json();
|
|
2167
|
+
store.removeItem(PINNED_SESSIONS_KEY);
|
|
2168
|
+
store.removeItem(STICKY_SESSIONS_KEY);
|
|
2169
|
+
applyServerPins(data.pins);
|
|
2170
|
+
} catch (e) {
|
|
2171
|
+
console.warn('[pins] server sync failed:', e.message);
|
|
2172
|
+
}
|
|
2128
2173
|
}
|
|
2129
2174
|
|
|
2130
|
-
|
|
2131
|
-
|
|
2175
|
+
// The first apply needs no fetch: `/api/sessions` adds the pinned sessions itself.
|
|
2176
|
+
let pinsApplied = false;
|
|
2177
|
+
|
|
2178
|
+
function applyServerPins(pins) {
|
|
2179
|
+
const before = new Map([...pinnedSessionIds].map((id) => [id, getSessionPinState(id)]));
|
|
2180
|
+
pinnedSessionIds = new Set();
|
|
2181
|
+
stickySessionIds = new Set(openedStickyIds);
|
|
2182
|
+
for (const [id, state] of Object.entries(pins || {})) {
|
|
2183
|
+
pinnedSessionIds.add(id);
|
|
2184
|
+
if (state === 'sticky') {
|
|
2185
|
+
stickySessionIds.add(id);
|
|
2186
|
+
openedStickyIds.delete(id);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
for (const id of deferredPinPlacement) if (!isAnyPinned(id)) deferredPinPlacement.delete(id);
|
|
2190
|
+
const first = !pinsApplied;
|
|
2191
|
+
pinsApplied = true;
|
|
2192
|
+
const unchanged =
|
|
2193
|
+
before.size === pinnedSessionIds.size && [...before].every(([id, s]) => getSessionPinState(id) === s);
|
|
2194
|
+
if (unchanged && !first) return;
|
|
2195
|
+
const added = first ? [] : [...pinnedSessionIds].filter((id) => !before.has(id));
|
|
2196
|
+
showPinnedSessions(added);
|
|
2132
2197
|
}
|
|
2133
2198
|
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2199
|
+
// A pinned session outside the current filter is only in the list once it is fetched with the pins.
|
|
2200
|
+
function showPinnedSessions(added) {
|
|
2201
|
+
const known = new Set(sessions.map((s) => s.id));
|
|
2202
|
+
if (added.some((id) => !known.has(id))) fetchSessions(false);
|
|
2203
|
+
else renderSessions();
|
|
2137
2204
|
}
|
|
2138
2205
|
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2206
|
+
function sendPinChange(sessionId) {
|
|
2207
|
+
return postPins({ ids: [sessionId], state: getSessionPinState(sessionId, { saved: true }) });
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// A write names only its sessions, so the last write for a session wins. A failed save re-reads the
|
|
2211
|
+
// server so the page shows what is saved.
|
|
2212
|
+
async function postPins(body) {
|
|
2213
|
+
let error;
|
|
2214
|
+
try {
|
|
2215
|
+
const res = await api('/api/session/pin', { method: 'POST', body });
|
|
2216
|
+
if (res.ok) return;
|
|
2217
|
+
error = (await res.json().catch(() => null))?.error || `HTTP ${res.status}`;
|
|
2218
|
+
} catch (e) {
|
|
2219
|
+
error = e.message;
|
|
2220
|
+
}
|
|
2221
|
+
showToast(`Pin not saved (${error}); showing the saved pins`, 'error');
|
|
2222
|
+
await syncSessionPins();
|
|
2143
2223
|
}
|
|
2144
2224
|
|
|
2145
2225
|
function clearSessionPin(sessionId) {
|
|
2146
2226
|
pinnedSessionIds.delete(sessionId);
|
|
2147
2227
|
stickySessionIds.delete(sessionId);
|
|
2228
|
+
openedStickyIds.delete(sessionId);
|
|
2148
2229
|
deferredPinPlacement.delete(sessionId);
|
|
2149
2230
|
}
|
|
2150
2231
|
|
|
2151
2232
|
function unpinSession(sessionId) {
|
|
2152
2233
|
clearSessionPin(sessionId);
|
|
2153
|
-
|
|
2154
|
-
offloadSessionPin(sessionId);
|
|
2234
|
+
sendPinChange(sessionId);
|
|
2155
2235
|
renderSessions();
|
|
2156
2236
|
}
|
|
2157
2237
|
|
|
2158
2238
|
function toggleSessionPin(sessionId) {
|
|
2159
2239
|
if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
|
|
2160
2240
|
pinnedSessionIds.add(sessionId);
|
|
2161
|
-
|
|
2162
|
-
expandPinnedFor(sessionId);
|
|
2163
|
-
savePinnedSessions();
|
|
2164
|
-
offloadSessionPin(sessionId);
|
|
2241
|
+
sendPinChange(sessionId);
|
|
2165
2242
|
renderSessions();
|
|
2166
2243
|
}
|
|
2167
2244
|
|
|
@@ -2173,33 +2250,32 @@ function toggleSessionSticky(sessionId) {
|
|
|
2173
2250
|
stickySessionIds.add(sessionId);
|
|
2174
2251
|
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
2175
2252
|
}
|
|
2176
|
-
|
|
2177
|
-
offloadSessionPin(sessionId);
|
|
2253
|
+
sendPinChange(sessionId);
|
|
2178
2254
|
renderSessions();
|
|
2179
2255
|
}
|
|
2180
2256
|
|
|
2181
|
-
|
|
2182
|
-
return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2183
|
-
}
|
|
2184
|
-
function isPlacedSticky(id) {
|
|
2185
|
-
return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2257
|
+
// The echo of this page's own change is a no-op, so the selected session keeps its deferred place.
|
|
2188
2258
|
function handleSessionPinEvent({ id, state }) {
|
|
2189
|
-
if (!id) return;
|
|
2259
|
+
if (!id || getSessionPinState(id, { saved: true }) === state) return;
|
|
2190
2260
|
clearSessionPin(id);
|
|
2191
2261
|
if (state === 'pinned') pinnedSessionIds.add(id);
|
|
2192
2262
|
if (state === 'sticky') {
|
|
2193
2263
|
pinnedSessionIds.add(id);
|
|
2194
2264
|
stickySessionIds.add(id);
|
|
2195
2265
|
}
|
|
2196
|
-
|
|
2197
|
-
savePinnedSessions();
|
|
2198
|
-
renderSessions();
|
|
2266
|
+
showPinnedSessions(isAnyPinned(id) ? [id] : []);
|
|
2199
2267
|
}
|
|
2200
2268
|
|
|
2201
|
-
function
|
|
2202
|
-
|
|
2269
|
+
function isPlacedPinned(id) {
|
|
2270
|
+
return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2271
|
+
}
|
|
2272
|
+
function isPlacedSticky(id) {
|
|
2273
|
+
return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// `saved` gives the pin the user set: a sticky that `session open` added is only this page's.
|
|
2277
|
+
function getSessionPinState(sessionId, { saved = false } = {}) {
|
|
2278
|
+
if (stickySessionIds.has(sessionId) && !(saved && openedStickyIds.has(sessionId))) return 'sticky';
|
|
2203
2279
|
if (pinnedSessionIds.has(sessionId)) return 'pinned';
|
|
2204
2280
|
return 'none';
|
|
2205
2281
|
}
|
|
@@ -3674,12 +3750,14 @@ function getFilteredSessions() {
|
|
|
3674
3750
|
if (filterProject) {
|
|
3675
3751
|
// The open session keeps its row even when it belongs to another project, so that
|
|
3676
3752
|
// revealing one by id has something to scroll to. Same exemption the active branch
|
|
3677
|
-
// above already makes, for the same reason.
|
|
3753
|
+
// above already makes, for the same reason. recentProjects lags behind the session list, so a
|
|
3754
|
+
// session in a new folder counts as recent on its own.
|
|
3755
|
+
const recentCutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
|
|
3678
3756
|
filteredSessions = filteredSessions.filter(
|
|
3679
3757
|
(s) =>
|
|
3680
3758
|
matchesProjectFilter(s.project, s.worktree?.repo) ||
|
|
3681
3759
|
s.id === currentSessionId ||
|
|
3682
|
-
(filterProject === '__recent__' && isAnyPinned(s.id)),
|
|
3760
|
+
(filterProject === '__recent__' && (isAnyPinned(s.id) || Date.parse(s.modifiedAt) >= recentCutoff)),
|
|
3683
3761
|
);
|
|
3684
3762
|
}
|
|
3685
3763
|
|
|
@@ -3782,16 +3860,55 @@ function renderSessions() {
|
|
|
3782
3860
|
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3783
3861
|
scheduleJustFinishedExpiry();
|
|
3784
3862
|
refreshProjectList();
|
|
3863
|
+
syncPinPeek();
|
|
3785
3864
|
|
|
3865
|
+
if (zenGroup && !sgGroupById(zenGroup)) {
|
|
3866
|
+
zenGroup = null;
|
|
3867
|
+
renderZenState();
|
|
3868
|
+
}
|
|
3786
3869
|
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3787
3870
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3871
|
+
// Group zen renders only its subtree's buckets, so it takes every session unfiltered.
|
|
3788
3872
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3873
|
+
let filteredSessions;
|
|
3874
|
+
if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
|
|
3875
|
+
else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
|
|
3876
|
+
else filteredSessions = applyStableSessionOrder(getFilteredSessions());
|
|
3877
|
+
|
|
3878
|
+
const outside = sidebarSearch.rows(zenMode || zenGroup ? '' : searchQuery, filteredSessions);
|
|
3879
|
+
|
|
3880
|
+
const childDepth = new Map();
|
|
3881
|
+
const parentOf = new Map();
|
|
3882
|
+
for (const g of sessionGroups) for (const m of g.members) if (m.parent) parentOf.set(m.ref, m.parent);
|
|
3883
|
+
const nestChildren = (list) => {
|
|
3884
|
+
if (parentOf.size === 0) return list;
|
|
3885
|
+
const ids = new Set(list.map((s) => s.id));
|
|
3886
|
+
const kids = new Map();
|
|
3887
|
+
for (const s of list) {
|
|
3888
|
+
const p = parentOf.get(s.id);
|
|
3889
|
+
if (p && ids.has(p)) {
|
|
3890
|
+
if (!kids.has(p)) kids.set(p, []);
|
|
3891
|
+
kids.get(p).push(s);
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
if (kids.size === 0) return list;
|
|
3895
|
+
const out = [];
|
|
3896
|
+
const seen = new Set();
|
|
3897
|
+
const add = (s, depth) => {
|
|
3898
|
+
if (seen.has(s.id)) return;
|
|
3899
|
+
seen.add(s.id);
|
|
3900
|
+
out.push(s);
|
|
3901
|
+
if (depth) childDepth.set(s.id, Math.min(depth, 3));
|
|
3902
|
+
for (const k of kids.get(s.id) || []) add(k, depth + 1);
|
|
3903
|
+
};
|
|
3904
|
+
for (const s of list) if (!ids.has(parentOf.get(s.id))) add(s, 0);
|
|
3905
|
+
// Sessions that are each other's parents have no top; show them as they came.
|
|
3906
|
+
for (const s of list) add(s, 0);
|
|
3907
|
+
return out;
|
|
3908
|
+
};
|
|
3792
3909
|
|
|
3793
3910
|
const renderSessionCard = (session) => {
|
|
3794
|
-
if (session.placeholder) return renderPlaceholderCard(session);
|
|
3911
|
+
if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
|
|
3795
3912
|
const total = session.taskCount;
|
|
3796
3913
|
const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
|
|
3797
3914
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
@@ -3814,12 +3931,12 @@ function renderSessions() {
|
|
|
3814
3931
|
const padEmoji = store.getItem(_padEmojiKey(session.id));
|
|
3815
3932
|
const hasScratchpad = !!padEmoji || _hasScratchpad(_sessionScratchpadKey(session.id));
|
|
3816
3933
|
const tempClass =
|
|
3817
|
-
session.hasRecentLog || session.hasRecentActivity || session.inProgress || session
|
|
3934
|
+
session.hasRecentLog || session.hasRecentActivity || session.inProgress || isWaitingSession(session)
|
|
3818
3935
|
? 'warm'
|
|
3819
3936
|
: 'stale';
|
|
3820
3937
|
// Open tasks keep an idle session warm, but the green dot is for a session that is working.
|
|
3821
3938
|
const idleClass =
|
|
3822
|
-
tempClass === 'warm' && !session
|
|
3939
|
+
tempClass === 'warm' && !isWaitingSession(session) && !session.hasRecentLog && !session.hasRunningAgents
|
|
3823
3940
|
? 'idle'
|
|
3824
3941
|
: '';
|
|
3825
3942
|
const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
|
|
@@ -3830,7 +3947,7 @@ function renderSessions() {
|
|
|
3830
3947
|
: '';
|
|
3831
3948
|
const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
|
|
3832
3949
|
return `
|
|
3833
|
-
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}">
|
|
3950
|
+
<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
3951
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3835
3952
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3836
3953
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
@@ -3851,8 +3968,8 @@ function renderSessions() {
|
|
|
3851
3968
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
3852
3969
|
${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
|
|
3853
3970
|
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
|
|
3854
|
-
${session
|
|
3855
|
-
${session.hasRunningAgents && !session
|
|
3971
|
+
${isWaitingSession(session) ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3972
|
+
${session.hasRunningAgents && !isWaitingSession(session) ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
3856
3973
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
3857
3974
|
</span>
|
|
3858
3975
|
</div>
|
|
@@ -3884,7 +4001,7 @@ function renderSessions() {
|
|
|
3884
4001
|
)
|
|
3885
4002
|
: '';
|
|
3886
4003
|
|
|
3887
|
-
if (filteredSessions.length === 0) {
|
|
4004
|
+
if (filteredSessions.length === 0 && !zenGroup) {
|
|
3888
4005
|
let emptyMsg = 'No sessions found';
|
|
3889
4006
|
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3890
4007
|
|
|
@@ -3939,11 +4056,11 @@ function renderSessions() {
|
|
|
3939
4056
|
const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
|
|
3940
4057
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3941
4058
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3942
|
-
const renderGroupSessions = (
|
|
3943
|
-
if (!groupPinned || pinnedSessionIds.size === 0) return
|
|
3944
|
-
const
|
|
3945
|
-
if (
|
|
3946
|
-
const gUnpinned =
|
|
4059
|
+
const renderGroupSessions = (list, pinKey) => {
|
|
4060
|
+
if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(renderSessionCard).join('');
|
|
4061
|
+
const gPinned = nestChildren(list.filter(isInPinnedGroup));
|
|
4062
|
+
if (gPinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
|
|
4063
|
+
const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
|
|
3947
4064
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3948
4065
|
return (
|
|
3949
4066
|
'<div class="pinned-sub-section">' +
|
|
@@ -3955,14 +4072,14 @@ function renderSessions() {
|
|
|
3955
4072
|
groupChevronSvg(10) +
|
|
3956
4073
|
'<span class="pinned-sub-label">Pinned</span>' +
|
|
3957
4074
|
'<span class="group-count">' +
|
|
3958
|
-
|
|
4075
|
+
gPinned.length +
|
|
3959
4076
|
'</span>' +
|
|
3960
4077
|
'<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">×</span>' +
|
|
3961
4078
|
'</div>' +
|
|
3962
4079
|
'<div class="pinned-sub-items' +
|
|
3963
4080
|
(pinCollapsed ? ' collapsed' : '') +
|
|
3964
4081
|
'">' +
|
|
3965
|
-
|
|
4082
|
+
gPinned.map(renderSessionCard).join('') +
|
|
3966
4083
|
'</div>' +
|
|
3967
4084
|
'</div>' +
|
|
3968
4085
|
gUnpinned.map(renderSessionCard).join('')
|
|
@@ -4015,72 +4132,85 @@ function renderSessions() {
|
|
|
4015
4132
|
sgRest.push(session);
|
|
4016
4133
|
}
|
|
4017
4134
|
}
|
|
4018
|
-
for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
|
|
4019
4135
|
// A group emptied by a filter hides its header; a group the user just created (no filters)
|
|
4020
4136
|
// shows its drop-here state instead of vanishing.
|
|
4021
|
-
const sgFiltering =
|
|
4137
|
+
const sgFiltering = !zenGroup && sgListFiltered();
|
|
4138
|
+
|
|
4139
|
+
// A group whose parent is gone renders at the top.
|
|
4140
|
+
const sgIds = new Set(sessionGroups.map((g) => g.id));
|
|
4141
|
+
const sgChildren = new Map();
|
|
4142
|
+
for (const g of sessionGroups) {
|
|
4143
|
+
const p = g.parent && sgIds.has(g.parent) ? g.parent : null;
|
|
4144
|
+
if (!sgChildren.has(p)) sgChildren.set(p, []);
|
|
4145
|
+
sgChildren.get(p).push(g);
|
|
4146
|
+
}
|
|
4147
|
+
const sgSubtree = new Map();
|
|
4148
|
+
const sgSubtreeOf = (group) => {
|
|
4149
|
+
let all = sgSubtree.get(group.id);
|
|
4150
|
+
if (!all) {
|
|
4151
|
+
all = [...(sgBuckets.get(group.id) || []), ...(sgChildren.get(group.id) || []).flatMap(sgSubtreeOf)];
|
|
4152
|
+
sgSubtree.set(group.id, all);
|
|
4153
|
+
}
|
|
4154
|
+
return all;
|
|
4155
|
+
};
|
|
4022
4156
|
|
|
4023
4157
|
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
4024
4158
|
const sgSectionHtml = (flat) => {
|
|
4025
|
-
|
|
4026
|
-
if (sessionGroups.length === 0 && transient.length === 0) return '';
|
|
4159
|
+
if (sessionGroups.length === 0) return '';
|
|
4027
4160
|
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
|
-
// Project members first (in the user's member order), then the individually placed
|
|
4060
|
-
// sessions — one Pinned sub-section per group instead of one per interleaved run.
|
|
4061
|
-
for (const m of group.members) {
|
|
4062
|
-
if (m.type !== 'project') continue;
|
|
4063
|
-
const arr = byProject.get(m.ref);
|
|
4064
|
-
// A member project with nothing left in it still renders: it shows the membership, and
|
|
4065
|
-
// it is the drop target for putting a session back under it.
|
|
4066
|
-
if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
|
|
4067
|
-
}
|
|
4068
|
-
if (loose.length) {
|
|
4069
|
-
// Individually placed sessions follow the order the user dragged them into.
|
|
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}__`);
|
|
4161
|
+
for (const group of sgChildren.get(null) || []) out += sgGroupHtml(group, flat);
|
|
4162
|
+
if (!out) return '';
|
|
4163
|
+
return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
|
|
4164
|
+
};
|
|
4165
|
+
|
|
4166
|
+
const sgGroupHtml = (group, flat) => {
|
|
4167
|
+
const bucket = sgBuckets.get(group.id) || [];
|
|
4168
|
+
const subtree = sgSubtreeOf(group);
|
|
4169
|
+
if (subtree.length === 0 && sgFiltering) return '';
|
|
4170
|
+
if (!groupPinned) bucket.sort(pinSort);
|
|
4171
|
+
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
4172
|
+
// Placed sessions follow the order the user dragged them into; a session that is here through
|
|
4173
|
+
// its project takes the project's slot.
|
|
4174
|
+
const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
|
|
4175
|
+
const slotOf = (s) => slot.get(`session:${s.id}`) ?? slot.get(`project:${sessionProjectKey(s)}`) ?? 0;
|
|
4176
|
+
const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
|
|
4177
|
+
let body = '';
|
|
4178
|
+
if (flat) {
|
|
4179
|
+
body = nestChildren([...bucket].sort(byMemberOrder))
|
|
4180
|
+
.map(renderSessionCard)
|
|
4181
|
+
.join('');
|
|
4182
|
+
} else {
|
|
4183
|
+
const loose = [];
|
|
4184
|
+
const byProject = new Map();
|
|
4185
|
+
for (const s of bucket) {
|
|
4186
|
+
const host = sgHostOf(group, s);
|
|
4187
|
+
if (host) {
|
|
4188
|
+
if (!byProject.has(host)) byProject.set(host, []);
|
|
4189
|
+
byProject.get(host).push(s);
|
|
4190
|
+
} else {
|
|
4191
|
+
loose.push(s);
|
|
4075
4192
|
}
|
|
4076
4193
|
}
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4194
|
+
// Project members first (in the user's member order), then the individually placed
|
|
4195
|
+
// sessions — one Pinned sub-section per group instead of one per interleaved run.
|
|
4196
|
+
for (const m of group.members) {
|
|
4197
|
+
if (m.type !== 'project') continue;
|
|
4198
|
+
const arr = byProject.get(m.ref);
|
|
4199
|
+
// A member project with nothing left in it still renders: it shows the membership, and
|
|
4200
|
+
// it is the drop target for putting a session back under it.
|
|
4201
|
+
if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
|
|
4202
|
+
}
|
|
4203
|
+
if (loose.length) {
|
|
4204
|
+
loose.sort(byMemberOrder);
|
|
4205
|
+
// Label them, else a card sitting under the project blocks reads as stranded.
|
|
4206
|
+
if (body) body += '<div class="sg-sub-label">Sessions</div>';
|
|
4207
|
+
body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
|
|
4208
|
+
}
|
|
4081
4209
|
}
|
|
4082
|
-
|
|
4083
|
-
|
|
4210
|
+
let childHtml = '';
|
|
4211
|
+
for (const child of sgChildren.get(group.id) || []) childHtml += sgGroupHtml(child, flat);
|
|
4212
|
+
if (!body && !childHtml) body = '<div class="sg-empty">Drop a session or a project here</div>';
|
|
4213
|
+
return `${sgHeaderHtml(group, countHtml(subtree))}<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}${childHtml}</div>`;
|
|
4084
4214
|
};
|
|
4085
4215
|
|
|
4086
4216
|
let html = '';
|
|
@@ -4091,8 +4221,10 @@ function renderSessions() {
|
|
|
4091
4221
|
</div>`;
|
|
4092
4222
|
}
|
|
4093
4223
|
|
|
4094
|
-
|
|
4095
|
-
|
|
4224
|
+
if (zenGroup) {
|
|
4225
|
+
sessionsList.innerHTML = sgGroupHtml(sgGroupById(zenGroup), sessionFilter !== 'active');
|
|
4226
|
+
} else if (sessionFilter === 'active') {
|
|
4227
|
+
// Group active sessions by project
|
|
4096
4228
|
// Auto-expand a collapsed section when newly-active work lands in it (live refresh).
|
|
4097
4229
|
expandActiveGroups({ onlyNew: true });
|
|
4098
4230
|
html += sgSectionHtml(false);
|
|
@@ -4131,7 +4263,7 @@ function renderSessions() {
|
|
|
4131
4263
|
// The section header is rendered even when nothing is left under it: it is the drop target
|
|
4132
4264
|
// for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
|
|
4133
4265
|
// the whole project list, re-parsed on every live refresh.
|
|
4134
|
-
const sectioned = sessionGroups.length > 0
|
|
4266
|
+
const sectioned = sessionGroups.length > 0;
|
|
4135
4267
|
let projectsHtml = '';
|
|
4136
4268
|
if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
|
|
4137
4269
|
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
@@ -4160,7 +4292,7 @@ function renderSessions() {
|
|
|
4160
4292
|
|
|
4161
4293
|
sessionsList.innerHTML = html + outsideHtml;
|
|
4162
4294
|
} else {
|
|
4163
|
-
const sectioned = sessionGroups.length > 0
|
|
4295
|
+
const sectioned = sessionGroups.length > 0;
|
|
4164
4296
|
const restHtml =
|
|
4165
4297
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
4166
4298
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
@@ -4283,6 +4415,30 @@ function renderOwnerBadge(task) {
|
|
|
4283
4415
|
return `<button type="button" class="task-owner-badge task-owner-link" style="${style}" title="Open dispatched session" onclick="event.stopPropagation(); revealSession('${escAttrJs(target)}')">${ICON_DISPATCHED}${escapeHtml(task.owner)}</button>`;
|
|
4284
4416
|
}
|
|
4285
4417
|
|
|
4418
|
+
// Card chips that a tool writing the task file adds: metadata.chips = [{label, title?, tone?}], tone one of
|
|
4419
|
+
// muted, warn, accent. The writer is outside cck, so the list is capped and every field is checked.
|
|
4420
|
+
function taskChips(task) {
|
|
4421
|
+
const chips = task.metadata?.chips;
|
|
4422
|
+
if (!Array.isArray(chips)) return [];
|
|
4423
|
+
return chips
|
|
4424
|
+
.filter((c) => typeof c?.label === 'string' && c.label.trim())
|
|
4425
|
+
.slice(0, 4)
|
|
4426
|
+
.map((c) => ({
|
|
4427
|
+
label: c.label.trim().slice(0, 24),
|
|
4428
|
+
title: typeof c.title === 'string' ? c.title.slice(0, 200) : '',
|
|
4429
|
+
tone: ['muted', 'warn', 'accent'].includes(c.tone) ? c.tone : 'muted',
|
|
4430
|
+
}));
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
function renderTaskChips(chips) {
|
|
4434
|
+
return chips
|
|
4435
|
+
.map(
|
|
4436
|
+
(c) =>
|
|
4437
|
+
`<span class="task-chip task-chip-${escapeHtml(c.tone)}"${c.title ? ` title="${escapeHtml(c.title)}"` : ''}>${escapeHtml(c.label)}</span>`,
|
|
4438
|
+
)
|
|
4439
|
+
.join('');
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4286
4442
|
function renderTaskCard(task) {
|
|
4287
4443
|
const blockers = openBlockers(task);
|
|
4288
4444
|
const isBlocked = blockers.length > 0;
|
|
@@ -4290,6 +4446,9 @@ function renderTaskCard(task) {
|
|
|
4290
4446
|
const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
|
|
4291
4447
|
const statusClass = task.status.replace('_', '-');
|
|
4292
4448
|
const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
|
|
4449
|
+
const isDim = task.metadata?.dim === true;
|
|
4450
|
+
const chips = taskChips(task);
|
|
4451
|
+
const state = [task.status.replace('_', ' '), ...chips.map((c) => c.label)].join(', ');
|
|
4293
4452
|
|
|
4294
4453
|
return `
|
|
4295
4454
|
<div
|
|
@@ -4301,10 +4460,11 @@ function renderTaskCard(task) {
|
|
|
4301
4460
|
draggable="true"
|
|
4302
4461
|
ondragstart="onCardDragStart(event)"
|
|
4303
4462
|
ondragend="onCardDragEnd(event)"
|
|
4304
|
-
class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''}"
|
|
4305
|
-
aria-label="${escapeHtml(task.subject)} — ${
|
|
4463
|
+
class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''} ${isDim ? 'dim' : ''}"
|
|
4464
|
+
aria-label="${escapeHtml(task.subject)} — ${escapeHtml(state)}">
|
|
4306
4465
|
<div class="task-id">
|
|
4307
4466
|
<span>#${taskId}</span>
|
|
4467
|
+
${renderTaskChips(chips)}
|
|
4308
4468
|
${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
|
|
4309
4469
|
${task.owner ? renderOwnerBadge(task) : ''}
|
|
4310
4470
|
</div>
|
|
@@ -4588,9 +4748,10 @@ async function onColumnDrop(e) {
|
|
|
4588
4748
|
|
|
4589
4749
|
//#region SESSION_GROUPS
|
|
4590
4750
|
// 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
|
-
//
|
|
4593
|
-
// windows the list), so membership is never garbage-collected on load.
|
|
4751
|
+
// sidebar. They render above the project groups. Membership is a workspace preference kept on
|
|
4752
|
+
// the server; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
|
|
4753
|
+
// windows the list), so membership is never garbage-collected on load. Transient groups go only
|
|
4754
|
+
// on a full Active list (sgSweepTransient).
|
|
4594
4755
|
const SESSION_GROUPS_KEY = 'sessionGroups';
|
|
4595
4756
|
const SG_ACTION_SELECTOR =
|
|
4596
4757
|
'.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .project-new-btn, .header-pad-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
|
|
@@ -4611,89 +4772,201 @@ function pinKey(projectPath) {
|
|
|
4611
4772
|
return projectPath === '__ungrouped__' ? '__pinned___ungrouped__' : `__pinned_${projectPath}__`;
|
|
4612
4773
|
}
|
|
4613
4774
|
|
|
4614
|
-
|
|
4615
|
-
|
|
4775
|
+
// The server keeps the groups (`/api/groups`, lib/user-groups.js). `sessionGroups` in localStorage
|
|
4776
|
+
// is the copy from before that: the board imports it once and never writes it, so it stays as a
|
|
4777
|
+
// backup.
|
|
4778
|
+
const GROUPS_MIGRATED_AT = 'groupsMigratedAt';
|
|
4779
|
+
let sgRev = null;
|
|
4780
|
+
let sgSaving = null;
|
|
4781
|
+
let sgDirty = false;
|
|
4782
|
+
let sgUnsent = false;
|
|
4783
|
+
let sgSeenRev = 0;
|
|
4784
|
+
|
|
4785
|
+
// Until it succeeds the board shows no groups, and it tries again on the next load or SSE reconnect.
|
|
4786
|
+
async function syncSessionGroups() {
|
|
4787
|
+
const migrated = !!store.getItem(GROUPS_MIGRATED_AT);
|
|
4788
|
+
const local = migrated ? null : store.readJson(SESSION_GROUPS_KEY);
|
|
4789
|
+
const hasLocal = local?.groups?.length > 0;
|
|
4790
|
+
try {
|
|
4791
|
+
const res = hasLocal
|
|
4792
|
+
? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups } })
|
|
4793
|
+
: await api('/api/groups');
|
|
4794
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4795
|
+
sgTakeServer(await res.json());
|
|
4796
|
+
if (!migrated) store.setItem(GROUPS_MIGRATED_AT, new Date().toISOString());
|
|
4797
|
+
if (sgSeenRev > sgRev) sgRefresh(sgSeenRev);
|
|
4798
|
+
} catch (e) {
|
|
4799
|
+
console.warn('[groups] server sync failed:', e.message);
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
|
|
4803
|
+
// The SSE event for this board's own write often arrives before the write's response.
|
|
4804
|
+
async function sgRefresh(rev = Infinity) {
|
|
4805
|
+
await sgSaving;
|
|
4806
|
+
if (sgRev !== null && sgRev >= rev) return;
|
|
4807
|
+
const data = await getJson('/api/groups');
|
|
4808
|
+
if (data) sgTakeServer(data);
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4811
|
+
function sgTakeServer(data, force = false) {
|
|
4812
|
+
if (!Number.isInteger(data?.rev) || (!force && sgRev !== null && data.rev <= sgRev)) return;
|
|
4813
|
+
sgRev = data.rev;
|
|
4814
|
+
sgApplyState(data);
|
|
4815
|
+
renderSessions();
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
// An event during the first sync is kept, because the sync's read may be older than it.
|
|
4819
|
+
function onGroupChanged(data) {
|
|
4820
|
+
if (!Number.isInteger(data?.rev)) return;
|
|
4821
|
+
sgSeenRev = Math.max(sgSeenRev, data.rev);
|
|
4822
|
+
if (sgRev !== null && data.rev > sgRev) sgRefresh(data.rev);
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4825
|
+
function sgApplyState(saved) {
|
|
4616
4826
|
const list = saved?.groups;
|
|
4617
4827
|
sessionGroups = (Array.isArray(list) ? list : [])
|
|
4618
4828
|
.filter((g) => g && typeof g.id === 'string')
|
|
4619
4829
|
.map((g) => ({
|
|
4620
4830
|
id: g.id,
|
|
4621
4831
|
name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
|
|
4832
|
+
parent: typeof g.parent === 'string' ? g.parent : null,
|
|
4833
|
+
...(g.transient === true ? { transient: true } : {}),
|
|
4622
4834
|
members: (Array.isArray(g.members) ? g.members : [])
|
|
4623
4835
|
.filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
|
|
4624
4836
|
.map((m) => {
|
|
4625
4837
|
const out = { type: m.type, ref: m.ref };
|
|
4626
4838
|
if (typeof m.under === 'string' && m.under) out.under = m.under;
|
|
4627
4839
|
if (m.loose === true) out.loose = true;
|
|
4840
|
+
if (typeof m.parent === 'string' && m.parent) out.parent = m.parent;
|
|
4628
4841
|
return out;
|
|
4629
4842
|
}),
|
|
4630
4843
|
}));
|
|
4631
|
-
const released = saved?.released;
|
|
4632
|
-
sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
|
|
4633
4844
|
}
|
|
4634
4845
|
|
|
4635
4846
|
function persistSessionGroups() {
|
|
4636
|
-
|
|
4847
|
+
if (sgRev === null) {
|
|
4848
|
+
showToast('Groups are not loaded from the server; this change is not saved', 'error');
|
|
4849
|
+
return;
|
|
4850
|
+
}
|
|
4851
|
+
sgDirty = true;
|
|
4852
|
+
sgSaving ??= sgFlush().finally(() => {
|
|
4853
|
+
sgSaving = null;
|
|
4854
|
+
});
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
// Writes run one at a time; edits made meanwhile go out in the next write. A 409 means another
|
|
4858
|
+
// board wrote first: its state wins and this board's unsent edit is dropped. A write that does not
|
|
4859
|
+
// reach the server is sent again on SSE reconnect.
|
|
4860
|
+
async function sgFlush() {
|
|
4861
|
+
while (sgDirty) {
|
|
4862
|
+
sgDirty = false;
|
|
4863
|
+
let res;
|
|
4864
|
+
try {
|
|
4865
|
+
res = await api('/api/groups', {
|
|
4866
|
+
method: 'PUT',
|
|
4867
|
+
body: { rev: sgRev, groups: sessionGroups },
|
|
4868
|
+
});
|
|
4869
|
+
} catch (e) {
|
|
4870
|
+
console.warn('[groups] save failed:', e.message);
|
|
4871
|
+
sgUnsent = true;
|
|
4872
|
+
showToast('Groups not saved: the server is unreachable. Retrying when it is back', 'error');
|
|
4873
|
+
return;
|
|
4874
|
+
}
|
|
4875
|
+
sgUnsent = false;
|
|
4876
|
+
const data = await res.json().catch(() => null);
|
|
4877
|
+
if (res.ok && Number.isInteger(data?.rev)) {
|
|
4878
|
+
sgRev = data.rev;
|
|
4879
|
+
if (!sgDirty) sgTakeCleaned(data);
|
|
4880
|
+
continue;
|
|
4881
|
+
}
|
|
4882
|
+
showToast(
|
|
4883
|
+
res.status === 409
|
|
4884
|
+
? 'Groups changed in another window; showing the latest'
|
|
4885
|
+
: `Groups not saved (${data?.error || res.status})`,
|
|
4886
|
+
res.status === 409 ? undefined : 'error',
|
|
4887
|
+
);
|
|
4888
|
+
sgDirty = false;
|
|
4889
|
+
const fresh = res.status === 409 ? data : await getJson('/api/groups');
|
|
4890
|
+
if (fresh) sgTakeServer(fresh, true);
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4894
|
+
// The server drops what breaks its rules (a member in two groups, a group past the limit) and
|
|
4895
|
+
// trims long names; the board shows what was saved.
|
|
4896
|
+
function sgTakeCleaned(data) {
|
|
4897
|
+
if (JSON.stringify(data.groups) === JSON.stringify(sessionGroups)) return;
|
|
4898
|
+
sgApplyState(data);
|
|
4899
|
+
renderSessions();
|
|
4637
4900
|
}
|
|
4638
4901
|
|
|
4639
4902
|
function sgGroupById(id) {
|
|
4640
4903
|
return sessionGroups.find((g) => g.id === id) || null;
|
|
4641
4904
|
}
|
|
4642
4905
|
|
|
4906
|
+
// Matches MAX_DEPTH in lib/user-groups.js.
|
|
4907
|
+
const SG_MAX_DEPTH = 8;
|
|
4908
|
+
|
|
4909
|
+
function sgSubtreeIds(rootId) {
|
|
4910
|
+
const ids = new Set([rootId]);
|
|
4911
|
+
for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
|
|
4912
|
+
return ids;
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
function sgPathOf(group) {
|
|
4916
|
+
const names = [];
|
|
4917
|
+
for (let g = group; g && names.length < SG_MAX_DEPTH; g = sgGroupById(g.parent)) names.unshift(g.name);
|
|
4918
|
+
return names.join('/');
|
|
4919
|
+
}
|
|
4920
|
+
|
|
4643
4921
|
function sgGroupOf(type, ref) {
|
|
4644
4922
|
return sessionGroups.find((g) => g.members.some((m) => m.type === type && m.ref === ref)) || null;
|
|
4645
4923
|
}
|
|
4646
4924
|
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4925
|
+
const sgMemberOf = (group, type, ref) => group?.members.find((m) => m.type === type && m.ref === ref);
|
|
4926
|
+
|
|
4927
|
+
function sgSessionParent(id) {
|
|
4928
|
+
return sgMemberOf(sgGroupOf('session', id), 'session', id)?.parent || null;
|
|
4650
4929
|
}
|
|
4651
4930
|
|
|
4652
|
-
|
|
4931
|
+
// An individually placed session wins over the placement of its project.
|
|
4932
|
+
function sgGroupForSession(session) {
|
|
4653
4933
|
const project = sessionProjectKey(session);
|
|
4654
4934
|
return sgGroupOf('session', session.id) || (project ? sgGroupOf('project', project) : null);
|
|
4655
4935
|
}
|
|
4656
4936
|
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4937
|
+
function sgListFiltered() {
|
|
4938
|
+
return !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
// A transient group (`dispatch start --group` made it) goes once the Active view shows none of its
|
|
4942
|
+
// sessions: they ended or were dismissed. A session still starting has no card yet, so its
|
|
4943
|
+
// terminal or placeholder counts. Only the full Active list can tell, so other views keep them.
|
|
4944
|
+
function sgSweepTransient() {
|
|
4945
|
+
if (sgRev === null || !sessionGroups.some((g) => g.transient)) return;
|
|
4946
|
+
if (zenGroup || zenMode || sessionFilter !== 'active' || sessionsHasMore || sgListFiltered()) return;
|
|
4947
|
+
const shown = new Set(getFilteredSessions().map((s) => s.id));
|
|
4948
|
+
const starting = (id) => !dismissedSessionIds.has(id) && (runningTerminals.has(id) || dispatchSpecs.has(id));
|
|
4949
|
+
const present = (m) => m.type !== 'session' || shown.has(m.ref) || starting(m.ref);
|
|
4950
|
+
const gone = groupTree.cascade(sessionGroups, (g) => !!g.transient && !g.members.some(present));
|
|
4951
|
+
if (!gone.size) return;
|
|
4952
|
+
let collapsed = false;
|
|
4953
|
+
for (const g of gone) collapsed = collapsedProjectGroups.delete(sgKey(g.id)) || collapsed;
|
|
4954
|
+
if (collapsed) persistCollapsedGroups();
|
|
4955
|
+
sessionGroups = sessionGroups.filter((g) => !gone.has(g));
|
|
4956
|
+
persistSessionGroups();
|
|
4669
4957
|
}
|
|
4670
4958
|
|
|
4959
|
+
const childDepthClass = (depth) => (depth ? ` sg-child sg-depth-${depth}` : '');
|
|
4960
|
+
|
|
4671
4961
|
function dispatchedTitle({ parent }) {
|
|
4672
4962
|
const starter = parent && sessions.find((s) => s.id === parent);
|
|
4673
4963
|
const by = starter ? sessionDisplayName(starter) : 'another session';
|
|
4674
4964
|
return `Started by ${by}${parent ? ' — click to reveal it' : ''}`;
|
|
4675
4965
|
}
|
|
4676
4966
|
|
|
4677
|
-
// A
|
|
4678
|
-
function
|
|
4679
|
-
|
|
4680
|
-
const name = session.dispatchGroup;
|
|
4681
|
-
if (!name) return null;
|
|
4682
|
-
return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
|
|
4683
|
-
}
|
|
4684
|
-
|
|
4685
|
-
function sgKeepTransient(name) {
|
|
4686
|
-
const group = sgTransientGroup(name);
|
|
4687
|
-
const members = sessions.filter((s) => sgGroupForSession(s) === group);
|
|
4688
|
-
const kept = sgCreateGroup(name);
|
|
4689
|
-
for (const s of members) kept.members.push({ type: 'session', ref: s.id });
|
|
4690
|
-
persistSessionGroups();
|
|
4691
|
-
}
|
|
4692
|
-
|
|
4693
|
-
function sgDeleteTransient(name) {
|
|
4694
|
-
const group = sgTransientGroup(name);
|
|
4695
|
-
for (const s of sessions) if (sgGroupForSession(s) === group) sgReleased.add(s.id);
|
|
4696
|
-
persistSessionGroups();
|
|
4967
|
+
// A group the user edits is theirs: Keep, a rename or a placement clears the flag.
|
|
4968
|
+
function sgKeep(group) {
|
|
4969
|
+
delete group.transient;
|
|
4697
4970
|
}
|
|
4698
4971
|
|
|
4699
4972
|
function sgDetach(type, ref) {
|
|
@@ -4714,10 +4987,13 @@ function sgHostOf(group, session) {
|
|
|
4714
4987
|
return null;
|
|
4715
4988
|
}
|
|
4716
4989
|
// `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.
|
|
4990
|
+
// out of its project block onto the group's own level. `opts.parent` follows the server's place():
|
|
4991
|
+
// left out, a move inside the group keeps it; null clears it.
|
|
4718
4992
|
function sgAssign(groupId, type, ref, opts = {}) {
|
|
4719
4993
|
const group = sgGroupById(groupId);
|
|
4720
4994
|
if (!group) return;
|
|
4995
|
+
sgKeep(group);
|
|
4996
|
+
const parent = opts.parent !== undefined ? opts.parent : sgMemberOf(group, type, ref)?.parent;
|
|
4721
4997
|
sgDetach(type, ref);
|
|
4722
4998
|
// Pulling a whole project in supersedes the individual placements of its sessions.
|
|
4723
4999
|
if (type === 'project') {
|
|
@@ -4731,10 +5007,11 @@ function sgAssign(groupId, type, ref, opts = {}) {
|
|
|
4731
5007
|
type === 'session' && !under && !loose && group.members.some((m) => m.type === 'project' && m.ref === own);
|
|
4732
5008
|
// The group already holds the session's project — a separate placement would only strand
|
|
4733
5009
|
// the card at the bottom of the group, away from its project block.
|
|
4734
|
-
if (!covered) {
|
|
5010
|
+
if (!covered || parent) {
|
|
4735
5011
|
const member = { type, ref };
|
|
4736
5012
|
if (under) member.under = under;
|
|
4737
5013
|
if (loose) member.loose = true;
|
|
5014
|
+
if (parent) member.parent = parent;
|
|
4738
5015
|
group.members.push(member);
|
|
4739
5016
|
}
|
|
4740
5017
|
persistSessionGroups();
|
|
@@ -4744,6 +5021,7 @@ function sgCreateGroup(name) {
|
|
|
4744
5021
|
const group = {
|
|
4745
5022
|
id: `g_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
4746
5023
|
name: (name || '').trim() || `Group ${sessionGroups.length + 1}`,
|
|
5024
|
+
parent: null,
|
|
4747
5025
|
members: [],
|
|
4748
5026
|
};
|
|
4749
5027
|
sessionGroups.push(group);
|
|
@@ -4752,11 +5030,27 @@ function sgCreateGroup(name) {
|
|
|
4752
5030
|
}
|
|
4753
5031
|
|
|
4754
5032
|
function sgDeleteGroup(id) {
|
|
5033
|
+
const parent = sgGroupById(id)?.parent ?? null;
|
|
4755
5034
|
sessionGroups = sessionGroups.filter((g) => g.id !== id);
|
|
5035
|
+
for (const g of sessionGroups) if (g.parent === id) g.parent = parent;
|
|
4756
5036
|
if (collapsedProjectGroups.delete(sgKey(id))) persistCollapsedGroups();
|
|
4757
5037
|
persistSessionGroups();
|
|
4758
5038
|
}
|
|
4759
5039
|
|
|
5040
|
+
async function sgConfirmDelete(group) {
|
|
5041
|
+
const ok = await confirmModal({
|
|
5042
|
+
title: 'Delete this group?',
|
|
5043
|
+
message: 'Its sessions and projects go back to Projects.',
|
|
5044
|
+
chip: group.name,
|
|
5045
|
+
okLabel: 'Delete group',
|
|
5046
|
+
});
|
|
5047
|
+
if (ok) {
|
|
5048
|
+
sgDeleteGroup(group.id);
|
|
5049
|
+
renderSessions();
|
|
5050
|
+
}
|
|
5051
|
+
return ok;
|
|
5052
|
+
}
|
|
5053
|
+
|
|
4760
5054
|
// Move one member of a group in front of another — the user's own order inside the group.
|
|
4761
5055
|
function sgReorderMember(groupId, drag, target) {
|
|
4762
5056
|
const group = sgGroupById(groupId);
|
|
@@ -4778,13 +5072,17 @@ function sgReorderGroup(dragId, targetId) {
|
|
|
4778
5072
|
persistSessionGroups();
|
|
4779
5073
|
}
|
|
4780
5074
|
|
|
4781
|
-
// Typing a group name should surface the sessions inside it.
|
|
5075
|
+
// Typing a group name should surface the sessions inside it and inside its child groups.
|
|
4782
5076
|
function sgSearchMatchIds(query) {
|
|
4783
5077
|
const ids = new Set();
|
|
4784
5078
|
if (!query) return ids;
|
|
4785
|
-
const hits = sessionGroups.filter((g) => fuzzyMatch(g.name, query));
|
|
4786
|
-
if (hits.
|
|
4787
|
-
const
|
|
5079
|
+
const hits = new Set(sessionGroups.filter((g) => fuzzyMatch(g.name, query)).map((g) => g.id));
|
|
5080
|
+
if (hits.size === 0) return ids;
|
|
5081
|
+
const underHit = (g) => {
|
|
5082
|
+
for (let p = g, n = 0; p && n < SG_MAX_DEPTH; p = sgGroupById(p.parent), n++) if (hits.has(p.id)) return true;
|
|
5083
|
+
return false;
|
|
5084
|
+
};
|
|
5085
|
+
const hitIds = new Set(sessionGroups.filter(underHit).map((g) => g.id));
|
|
4788
5086
|
for (const s of sessions) {
|
|
4789
5087
|
const group = sgGroupForSession(s);
|
|
4790
5088
|
if (group && hitIds.has(group.id)) ids.add(s.id);
|
|
@@ -4806,13 +5104,10 @@ function sgHeaderHtml(group, countHtml) {
|
|
|
4806
5104
|
const nameHtml = editing
|
|
4807
5105
|
? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
|
|
4808
5106
|
: `<span class="group-name">${escapeHtml(group.name)}</span>`;
|
|
4809
|
-
const
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
|
|
4814
|
-
<span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">×</span>`
|
|
4815
|
-
: `<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
|
|
5107
|
+
const hint = group.transient ? 'goes when its sessions leave the Active view' : 'drop sessions or projects here';
|
|
5108
|
+
const attrs = `class="session-group-header${group.transient ? ' sg-transient' : ''}${collapsed ? ' collapsed' : ''}${group.id === zenGroup ? ' sg-zen' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — ${hint}"`;
|
|
5109
|
+
const actions = `${group.transient ? '<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>' : ''}
|
|
5110
|
+
<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
|
|
4816
5111
|
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>
|
|
4817
5112
|
${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}`;
|
|
4818
5113
|
return `
|
|
@@ -4847,6 +5142,7 @@ function sgCommitRename(input, save) {
|
|
|
4847
5142
|
sgEditingId = null;
|
|
4848
5143
|
if (save && group && value && value !== group.name) {
|
|
4849
5144
|
group.name = value;
|
|
5145
|
+
sgKeep(group);
|
|
4850
5146
|
persistSessionGroups();
|
|
4851
5147
|
}
|
|
4852
5148
|
renderSessions();
|
|
@@ -4857,7 +5153,7 @@ function sgCommitRename(input, save) {
|
|
|
4857
5153
|
// At most one zone is lit, so the reference is enough - dragover fires many times a second.
|
|
4858
5154
|
let sgLitZone = null;
|
|
4859
5155
|
let sgDragEl = null;
|
|
4860
|
-
const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after'];
|
|
5156
|
+
const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after', 'sg-insert-parent'];
|
|
4861
5157
|
const SG_LIT_SELECTOR = SG_LIT_CLASSES.map((c) => `.${c}`).join(', ');
|
|
4862
5158
|
|
|
4863
5159
|
function sgClearDropTargets() {
|
|
@@ -4882,15 +5178,43 @@ function sgInsertHit(card, e, share) {
|
|
|
4882
5178
|
return { zone: card, cls: after ? 'sg-insert-after' : 'sg-insert-before', insert: { card, after } };
|
|
4883
5179
|
}
|
|
4884
5180
|
|
|
5181
|
+
// Outside the Active filter the sidebar sorts ungrouped cards itself, so only a group's members,
|
|
5182
|
+
// which keep their own order, take an insert there.
|
|
4885
5183
|
function sgSiblingCard(target) {
|
|
4886
|
-
if (sgDrag?.kind !== 'session' ||
|
|
5184
|
+
if (sgDrag?.kind !== 'session' || !sgDragEl) return null;
|
|
4887
5185
|
const card = target.closest('.session-item');
|
|
4888
|
-
|
|
5186
|
+
if (!card || card === sgDragEl || card.parentElement !== sgDragEl.parentElement) return null;
|
|
5187
|
+
if (sessionFilter === 'active') return card;
|
|
5188
|
+
const group = sgGroupOf('session', sgDrag.ref);
|
|
5189
|
+
return group && sgGroupOf('session', card.dataset.sessionId) === group ? card : null;
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
function sgDescendsFrom(id, ancestor) {
|
|
5193
|
+
for (let p = sgSessionParent(id), n = 0; p && n < SG_MAX_DEPTH; p = sgSessionParent(p), n++) {
|
|
5194
|
+
if (p === ancestor) return true;
|
|
5195
|
+
}
|
|
5196
|
+
return false;
|
|
5197
|
+
}
|
|
5198
|
+
|
|
5199
|
+
// A child group's header drops into its parent group on its top edge, and wherever its own group
|
|
5200
|
+
// would refuse the drop. Without it a session in the child group has no way up when the parent's
|
|
5201
|
+
// header is off screen: the gap above is a few pixels.
|
|
5202
|
+
// A child group renders inside its parent's body, so that body is the parent's drop zone.
|
|
5203
|
+
function sgParentEdgeHit(target, e, hit) {
|
|
5204
|
+
const header = sgDrag?.kind === 'group' ? null : target.closest('.session-group-header[data-group-id]');
|
|
5205
|
+
const parentBody = header?.parentElement.closest('.session-group-sessions[data-group-id]');
|
|
5206
|
+
if (!parentBody) return null;
|
|
5207
|
+
const rect = header.getBoundingClientRect();
|
|
5208
|
+
if (e.clientY - rect.top > rect.height * 0.4 && hit) return null;
|
|
5209
|
+
const up = sgGroupDropZone(parentBody);
|
|
5210
|
+
return up ? { ...up, zone: header, cls: 'sg-insert-parent' } : null;
|
|
4889
5211
|
}
|
|
4890
5212
|
|
|
4891
5213
|
function sgDropZone(target, e) {
|
|
4892
|
-
const sibling = sgSiblingCard(target);
|
|
4893
5214
|
const hit = sgGroupDropZone(target);
|
|
5215
|
+
const edge = sgParentEdgeHit(target, e, hit);
|
|
5216
|
+
if (edge) return edge;
|
|
5217
|
+
const sibling = sgSiblingCard(target);
|
|
4894
5218
|
// Where the middle of a card does nothing, the whole card is an insert target.
|
|
4895
5219
|
return (sibling && sgInsertHit(sibling, e, hit ? 0.25 : 0.5)) || hit;
|
|
4896
5220
|
}
|
|
@@ -4900,10 +5224,14 @@ function sgInsertSession(dragId, { card, after }) {
|
|
|
4900
5224
|
const group = card.closest('.project-group-sessions') ? null : sgGroupOf('session', ref);
|
|
4901
5225
|
if (group && sgGroupOf('session', dragId)?.id === group.id) {
|
|
4902
5226
|
// Loose sessions in a named group follow the group's member order, not the sidebar order.
|
|
5227
|
+
// A card set next to another becomes its sibling: it takes that card's parent.
|
|
5228
|
+
const { parent: _old, ...moved } = sgMemberOf(group, 'session', dragId);
|
|
5229
|
+
const parent = sgMemberOf(group, 'session', ref)?.parent;
|
|
5230
|
+
if (parent && parent !== dragId && !sgDescendsFrom(parent, dragId)) moved.parent = parent;
|
|
4903
5231
|
const members = insertNear(
|
|
4904
5232
|
group.members.filter((m) => !(m.type === 'session' && m.ref === dragId)),
|
|
4905
5233
|
(m) => m.type === 'session' && m.ref === ref,
|
|
4906
|
-
|
|
5234
|
+
moved,
|
|
4907
5235
|
after,
|
|
4908
5236
|
);
|
|
4909
5237
|
if (!members) return;
|
|
@@ -4949,8 +5277,7 @@ function sgGroupDropZone(target) {
|
|
|
4949
5277
|
: null;
|
|
4950
5278
|
}
|
|
4951
5279
|
const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
|
|
4952
|
-
|
|
4953
|
-
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup || releasable ? { zone } : null;
|
|
5280
|
+
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
|
|
4954
5281
|
const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
|
|
4955
5282
|
const fromGroupId = fromGroup?.id || null;
|
|
4956
5283
|
// A drop that lands on the host the session already has changes nothing, and highlighting it
|
|
@@ -4960,8 +5287,9 @@ function sgGroupDropZone(target) {
|
|
|
4960
5287
|
if (groupId) {
|
|
4961
5288
|
if (!dragSession) return dragGroup?.id === groupId ? null : { zone, groupId };
|
|
4962
5289
|
if (fromGroupId !== groupId) return { zone, groupId };
|
|
4963
|
-
// Already in this group: its own level is a move only out of a project block
|
|
4964
|
-
|
|
5290
|
+
// Already in this group: its own level is a move only out of a project block or from under
|
|
5291
|
+
// a parent session.
|
|
5292
|
+
return fromHost || sgSessionParent(dragSession.id) ? { zone, groupId, loose: true, parent: null } : null;
|
|
4965
5293
|
}
|
|
4966
5294
|
const targetSessionId = zone.dataset.sessionId;
|
|
4967
5295
|
if (targetSessionId) {
|
|
@@ -4969,22 +5297,18 @@ function sgGroupDropZone(target) {
|
|
|
4969
5297
|
const targetSession = sessions.find((s) => s.id === targetSessionId);
|
|
4970
5298
|
const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
|
|
4971
5299
|
const targetGroupId = targetGroup?.id || null;
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
//
|
|
5300
|
+
if (targetGroup) {
|
|
5301
|
+
// Stacking onto a card nests the dragged session under it, in that card's placement, project
|
|
5302
|
+
// block included.
|
|
5303
|
+
if (sgSessionParent(sgDrag.ref) === targetSessionId && fromGroupId === targetGroupId) return null;
|
|
5304
|
+
if (sgDescendsFrom(targetSessionId, sgDrag.ref)) return null;
|
|
4975
5305
|
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 };
|
|
5306
|
+
return {
|
|
5307
|
+
zone,
|
|
5308
|
+
groupId: targetGroupId,
|
|
5309
|
+
...(targetHost ? { under: targetHost } : { loose: true }),
|
|
5310
|
+
parent: targetSessionId,
|
|
5311
|
+
};
|
|
4988
5312
|
}
|
|
4989
5313
|
return { zone, pairSession: targetSessionId };
|
|
4990
5314
|
}
|
|
@@ -5070,13 +5394,16 @@ function sgOnDrop(e) {
|
|
|
5070
5394
|
if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
|
|
5071
5395
|
else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
|
|
5072
5396
|
else {
|
|
5073
|
-
sgAssign(hit.groupId, drag.kind, drag.ref, {
|
|
5397
|
+
sgAssign(hit.groupId, drag.kind, drag.ref, {
|
|
5398
|
+
under: hit.under,
|
|
5399
|
+
loose: hit.loose,
|
|
5400
|
+
parent: hit.parent,
|
|
5401
|
+
});
|
|
5074
5402
|
// A drop shows where the card landed; a move from the context menu leaves the group as it was.
|
|
5075
5403
|
if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
|
|
5076
5404
|
}
|
|
5077
5405
|
} else {
|
|
5078
5406
|
sgDetach(drag.kind, drag.ref);
|
|
5079
|
-
if (drag.kind === 'session') sgReleased.add(drag.ref);
|
|
5080
5407
|
persistSessionGroups();
|
|
5081
5408
|
}
|
|
5082
5409
|
sgFinishDrag();
|
|
@@ -5136,11 +5463,6 @@ function initSessionGroupsDnd() {
|
|
|
5136
5463
|
sessionsList.addEventListener('touchstart', sgOnTouchStart, { passive: true });
|
|
5137
5464
|
sessionsList.addEventListener('touchend', sgCancelLongPress);
|
|
5138
5465
|
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
5466
|
}
|
|
5145
5467
|
//#endregion
|
|
5146
5468
|
|
|
@@ -5218,7 +5540,7 @@ function sgOpenMenu(x, y, kind, ref) {
|
|
|
5218
5540
|
.filter((g) => g.id !== current?.id)
|
|
5219
5541
|
.map(
|
|
5220
5542
|
(g) =>
|
|
5221
|
-
`<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g
|
|
5543
|
+
`<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(sgPathOf(g))}</button>`,
|
|
5222
5544
|
)
|
|
5223
5545
|
.join('');
|
|
5224
5546
|
const menu = document.createElement('div');
|
|
@@ -5414,7 +5736,7 @@ function getNavigableItems() {
|
|
|
5414
5736
|
}
|
|
5415
5737
|
} else if (child.classList.contains('session-item')) {
|
|
5416
5738
|
items.push(child);
|
|
5417
|
-
} else if (child.classList.contains('project-group-header')) {
|
|
5739
|
+
} else if (child.classList.contains('project-group-header') || child.classList.contains('session-group-header')) {
|
|
5418
5740
|
items.push(child);
|
|
5419
5741
|
if (!collapsedProjectGroups.has(child.dataset.groupPath)) {
|
|
5420
5742
|
walkGroupContainer(getGroupSessionsContainer(child));
|
|
@@ -5494,6 +5816,10 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
5494
5816
|
if (!header) return;
|
|
5495
5817
|
const projectPath = header.dataset.groupPath;
|
|
5496
5818
|
if (collapsed === collapsedProjectGroups.has(projectPath)) return;
|
|
5819
|
+
if (collapsed && projectPath === pinPeekKey) {
|
|
5820
|
+
pinPeekKey = null;
|
|
5821
|
+
pinPeekClosedFor = currentSessionId;
|
|
5822
|
+
}
|
|
5497
5823
|
if (collapsed) collapsedProjectGroups.add(projectPath);
|
|
5498
5824
|
else collapsedProjectGroups.delete(projectPath);
|
|
5499
5825
|
header.classList.toggle('collapsed', collapsed);
|
|
@@ -5503,7 +5829,7 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
5503
5829
|
}
|
|
5504
5830
|
|
|
5505
5831
|
function persistCollapsedGroups() {
|
|
5506
|
-
store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
|
|
5832
|
+
store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups, ...(pinPeekKey ? [pinPeekKey] : [])]);
|
|
5507
5833
|
}
|
|
5508
5834
|
|
|
5509
5835
|
let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
|
|
@@ -5533,16 +5859,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5533
5859
|
}
|
|
5534
5860
|
|
|
5535
5861
|
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
5536
|
-
// the project or the session itself and the project block it renders under there, the
|
|
5537
|
-
//
|
|
5862
|
+
// the project or the session itself and the project block it renders under there, and the section header that holds it
|
|
5863
|
+
// (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
|
|
5538
5864
|
// Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
|
|
5539
5865
|
// whether anything changed.
|
|
5540
5866
|
function uncollapseFor(session) {
|
|
5541
5867
|
const group = sgGroupForSession(session);
|
|
5542
5868
|
const keys = [
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5869
|
+
...(group
|
|
5870
|
+
? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS]
|
|
5871
|
+
: [sessionProjectKey(session) || '__ungrouped__', SECTION_PROJECTS, SECTION_SESSIONS]),
|
|
5546
5872
|
];
|
|
5547
5873
|
let changed = false;
|
|
5548
5874
|
for (const key of keys) {
|
|
@@ -5554,23 +5880,25 @@ function uncollapseFor(session) {
|
|
|
5554
5880
|
function pinnedCollapseKeys(session) {
|
|
5555
5881
|
if (!isInPinnedGroup(session)) return [];
|
|
5556
5882
|
const group = sgGroupForSession(session);
|
|
5883
|
+
if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
|
|
5557
5884
|
const host = sgHostOf(group, session);
|
|
5558
|
-
return [
|
|
5559
|
-
pinKey(sessionProjectKey(session) || '__ungrouped__'),
|
|
5560
|
-
host && pinKey(host),
|
|
5561
|
-
group && `__pinned_group_${group.id}__`,
|
|
5562
|
-
];
|
|
5885
|
+
return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
|
|
5563
5886
|
}
|
|
5564
5887
|
|
|
5565
|
-
// A
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5888
|
+
// A Pinned sub-section the user collapsed opens while the open session is in it and closes once
|
|
5889
|
+
// the user leaves that session. The peek is never saved: persistCollapsedGroups writes the key back.
|
|
5890
|
+
let pinPeekKey = null;
|
|
5891
|
+
let pinPeekClosedFor = null;
|
|
5892
|
+
|
|
5893
|
+
function syncPinPeek() {
|
|
5894
|
+
if (pinPeekClosedFor !== currentSessionId) pinPeekClosedFor = null;
|
|
5895
|
+
const session = currentSessionId && !pinPeekClosedFor ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
5896
|
+
const key = session ? (pinnedCollapseKeys(session)[0] ?? null) : null;
|
|
5897
|
+
const want = key && (key === pinPeekKey || collapsedProjectGroups.has(key)) ? key : null;
|
|
5898
|
+
if (want === pinPeekKey) return;
|
|
5899
|
+
if (pinPeekKey) collapsedProjectGroups.add(pinPeekKey);
|
|
5900
|
+
if (want) collapsedProjectGroups.delete(want);
|
|
5901
|
+
pinPeekKey = want;
|
|
5574
5902
|
}
|
|
5575
5903
|
|
|
5576
5904
|
function isGroupHeader(el) {
|
|
@@ -5636,6 +5964,7 @@ function activateSelectedSession(items) {
|
|
|
5636
5964
|
}
|
|
5637
5965
|
|
|
5638
5966
|
function setFocusZone(zone, target) {
|
|
5967
|
+
passTerminalZoneOn = false;
|
|
5639
5968
|
clearKbSelection();
|
|
5640
5969
|
clearTaskSelection();
|
|
5641
5970
|
|
|
@@ -5898,6 +6227,23 @@ async function saveTaskField(taskId, sessionId, field, value) {
|
|
|
5898
6227
|
}
|
|
5899
6228
|
}
|
|
5900
6229
|
|
|
6230
|
+
const DETAIL_LAYOUT_KEY = 'cck-detail-layout';
|
|
6231
|
+
|
|
6232
|
+
function setDetailExpanded(expanded) {
|
|
6233
|
+
detailPanel.classList.toggle('expanded', expanded);
|
|
6234
|
+
const btn = document.getElementById('expand-detail');
|
|
6235
|
+
btn.innerHTML = showIcon(expanded ? 'restore' : 'expand');
|
|
6236
|
+
btn.title = expanded ? 'Restore (E)' : 'Expand (E)';
|
|
6237
|
+
btn.setAttribute('aria-label', expanded ? 'Restore' : 'Expand');
|
|
6238
|
+
store.setItem(DETAIL_LAYOUT_KEY, expanded ? 'expanded' : '');
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
function initDetailLayout() {
|
|
6242
|
+
document.getElementById('expand-detail').onclick = () =>
|
|
6243
|
+
setDetailExpanded(!detailPanel.classList.contains('expanded'));
|
|
6244
|
+
setDetailExpanded(store.getItem(DETAIL_LAYOUT_KEY) === 'expanded');
|
|
6245
|
+
}
|
|
6246
|
+
|
|
5901
6247
|
function closeDetailPanel() {
|
|
5902
6248
|
detailPanel.classList.remove('visible');
|
|
5903
6249
|
document.getElementById('delete-task-btn').style.display = 'none';
|
|
@@ -5911,10 +6257,30 @@ function closeDetailPanel() {
|
|
|
5911
6257
|
// Set while #confirm-modal is open; settles the open confirmModal() promise.
|
|
5912
6258
|
let closeConfirmModal = null;
|
|
5913
6259
|
|
|
5914
|
-
|
|
6260
|
+
// chip: the object named in one line. items: [{state, label, meta}] rows, state in busy|wait|idle (a dot) or
|
|
6261
|
+
// omitted (no dot); steps: plain strings shown as what will happen; warn: a line under the list.
|
|
6262
|
+
function confirmModal({ title, message, okLabel, chip, items, steps, warn }) {
|
|
5915
6263
|
const modal = document.getElementById('confirm-modal');
|
|
5916
6264
|
document.getElementById('confirm-modal-title').textContent = title;
|
|
5917
6265
|
document.getElementById('confirm-modal-message').textContent = message;
|
|
6266
|
+
const chipEl = document.getElementById('confirm-modal-chip');
|
|
6267
|
+
chipEl.hidden = !chip;
|
|
6268
|
+
chipEl.textContent = chip || '';
|
|
6269
|
+
const listEl = document.getElementById('confirm-modal-list');
|
|
6270
|
+
const rows = [
|
|
6271
|
+
...(items || []).map(
|
|
6272
|
+
(it) =>
|
|
6273
|
+
`<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>`,
|
|
6274
|
+
),
|
|
6275
|
+
...(steps || []).map(
|
|
6276
|
+
(s) => `<div class="confirm-row confirm-step"><span class="confirm-row-label">${escapeHtml(s)}</span></div>`,
|
|
6277
|
+
),
|
|
6278
|
+
];
|
|
6279
|
+
listEl.hidden = !rows.length;
|
|
6280
|
+
listEl.innerHTML = rows.join('');
|
|
6281
|
+
const warnEl = document.getElementById('confirm-modal-warn');
|
|
6282
|
+
warnEl.hidden = !warn;
|
|
6283
|
+
warnEl.textContent = warn || '';
|
|
5918
6284
|
const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
|
|
5919
6285
|
buttons[1].textContent = okLabel;
|
|
5920
6286
|
const returnFocus = document.activeElement;
|
|
@@ -5946,11 +6312,15 @@ async function deleteTask(taskId, sessionId) {
|
|
|
5946
6312
|
const task = currentTasks.find((t) => t.id === taskId);
|
|
5947
6313
|
if (!task) return;
|
|
5948
6314
|
const ok = await confirmModal({
|
|
5949
|
-
title: 'Delete
|
|
5950
|
-
message:
|
|
5951
|
-
|
|
6315
|
+
title: 'Delete this task?',
|
|
6316
|
+
message: 'It is removed from the board. This cannot be undone.',
|
|
6317
|
+
chip: task.subject,
|
|
6318
|
+
okLabel: 'Delete task',
|
|
5952
6319
|
});
|
|
5953
6320
|
if (!ok) return;
|
|
6321
|
+
const info = taskId === selectedTaskId ? getSelectedCardInfo() : null;
|
|
6322
|
+
const colCards = info ? Array.from(COLUMNS[info.colIndex].el.querySelectorAll('.task-card')) : [];
|
|
6323
|
+
const next = colCards[info?.cardIndex + 1] || colCards[info?.cardIndex - 1];
|
|
5954
6324
|
|
|
5955
6325
|
try {
|
|
5956
6326
|
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
|
|
@@ -5958,6 +6328,14 @@ async function deleteTask(taskId, sessionId) {
|
|
|
5958
6328
|
if (res.ok) {
|
|
5959
6329
|
closeDetailPanel();
|
|
5960
6330
|
await refreshCurrentView();
|
|
6331
|
+
if (info && focusZone === 'board') {
|
|
6332
|
+
if (next) {
|
|
6333
|
+
selectTask(next.dataset.taskId, next.dataset.sessionId);
|
|
6334
|
+
} else {
|
|
6335
|
+
selectedTaskId = null;
|
|
6336
|
+
navigateVertical(1);
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
5961
6339
|
} else {
|
|
5962
6340
|
const error = await res.json().catch(() => ({}));
|
|
5963
6341
|
showToast(`Failed to delete task: ${error.error || 'Unknown error'}`, 'error');
|
|
@@ -5994,6 +6372,7 @@ const SHORTCUT_TABS = [
|
|
|
5994
6372
|
rows: [
|
|
5995
6373
|
{ keys: ['Enter'], label: 'Toggle task detail panel' },
|
|
5996
6374
|
{ keys: ['D'], label: 'Delete selected task' },
|
|
6375
|
+
{ keys: ['E'], label: 'Expand / restore task detail panel' },
|
|
5997
6376
|
{ keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
|
|
5998
6377
|
{ keys: ['Ctrl', 'E'], combo: true, label: 'Set the session scratchpad emoji (in the scratchpad)' },
|
|
5999
6378
|
{ keys: ['R'], label: 'Refresh data' },
|
|
@@ -6017,7 +6396,13 @@ const SHORTCUT_TABS = [
|
|
|
6017
6396
|
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
6018
6397
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
6019
6398
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
6020
|
-
{ keys: ['
|
|
6399
|
+
{ keys: ['←', '→'], label: 'Storage manager: switch tab' },
|
|
6400
|
+
{
|
|
6401
|
+
keys: ['Ctrl', 'Shift', 'Z'],
|
|
6402
|
+
combo: true,
|
|
6403
|
+
label: 'Zen mode (current session, or the group under the cursor)',
|
|
6404
|
+
},
|
|
6405
|
+
{ keys: ['Ctrl', 'Shift', 'K'], combo: true, label: 'Show / hide Kanbot' },
|
|
6021
6406
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
6022
6407
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
6023
6408
|
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
@@ -6135,6 +6520,14 @@ const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platf
|
|
|
6135
6520
|
const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
|
|
6136
6521
|
if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
|
|
6137
6522
|
|
|
6523
|
+
// Ctrl+Enter sends on macOS as Cmd+Enter too (the handlers accept metaKey), so the hints show ⌘ and ⌥ there.
|
|
6524
|
+
function sendKbd() {
|
|
6525
|
+
return `<kbd>${IS_MAC ? '⌘' : 'Ctrl'}</kbd>+<kbd>Enter</kbd>`;
|
|
6526
|
+
}
|
|
6527
|
+
function termKbd() {
|
|
6528
|
+
return `<kbd>${IS_MAC ? '⌥' : 'Alt'}</kbd>+<kbd>\`</kbd>`;
|
|
6529
|
+
}
|
|
6530
|
+
|
|
6138
6531
|
// The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
|
|
6139
6532
|
// macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
|
|
6140
6533
|
// row shows the keys the hub reports, which the user can change; row.keys until it answers.
|
|
@@ -6624,12 +7017,17 @@ function _renderOrphanGroup(count, innerHtml) {
|
|
|
6624
7017
|
return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
|
|
6625
7018
|
}
|
|
6626
7019
|
|
|
7020
|
+
function _markStorageTab(tab) {
|
|
7021
|
+
document.querySelectorAll('.storage-tab').forEach((t) => {
|
|
7022
|
+
t.classList.toggle('active', t.dataset.tab === tab);
|
|
7023
|
+
t.setAttribute('aria-selected', String(t.dataset.tab === tab));
|
|
7024
|
+
});
|
|
7025
|
+
}
|
|
7026
|
+
|
|
6627
7027
|
async function showStorageManager() {
|
|
6628
7028
|
_storageKnownSessions = null;
|
|
6629
7029
|
_updateStorageTotal();
|
|
6630
|
-
|
|
6631
|
-
t.classList.toggle('active', t.dataset.tab === 'sessions');
|
|
6632
|
-
});
|
|
7030
|
+
_markStorageTab('sessions');
|
|
6633
7031
|
const modal = document.getElementById('storage-modal');
|
|
6634
7032
|
modal.classList.add('visible');
|
|
6635
7033
|
const known = await _fetchKnownSessions().catch(() => null);
|
|
@@ -6644,9 +7042,14 @@ function closeStorageManager() {
|
|
|
6644
7042
|
|
|
6645
7043
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
6646
7044
|
function switchStorageTab(tab) {
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
7045
|
+
_markStorageTab(tab);
|
|
7046
|
+
_renderStorageTab();
|
|
7047
|
+
}
|
|
7048
|
+
|
|
7049
|
+
function stepStorageTab(step) {
|
|
7050
|
+
const tabs = [...document.querySelectorAll('.storage-tab')];
|
|
7051
|
+
const i = tabs.findIndex((t) => t.classList.contains('active'));
|
|
7052
|
+
_markStorageTab(tabs[(i + step + tabs.length) % tabs.length].dataset.tab);
|
|
6650
7053
|
_renderStorageTab();
|
|
6651
7054
|
}
|
|
6652
7055
|
|
|
@@ -6655,9 +7058,14 @@ function _renderStorageTab() {
|
|
|
6655
7058
|
if (!modal?.classList.contains('visible')) return;
|
|
6656
7059
|
const body = document.getElementById('storage-modal-body');
|
|
6657
7060
|
const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
|
|
7061
|
+
const groupsClean = document.getElementById('storage-groups-clean-btn');
|
|
7062
|
+
groupsClean.style.display = tab === 'groups' ? '' : 'none';
|
|
7063
|
+
groupsClean.disabled = true;
|
|
6658
7064
|
if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
|
|
7065
|
+
else if (tab === 'dismissed') body.innerHTML = _renderStorageDismissed();
|
|
6659
7066
|
else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
|
|
6660
7067
|
else if (tab === 'linked-docs') body.innerHTML = _renderStorageLinkedDocs();
|
|
7068
|
+
else if (tab === 'groups') _renderStorageGroups(body);
|
|
6661
7069
|
}
|
|
6662
7070
|
|
|
6663
7071
|
function _renderStorageSessions() {
|
|
@@ -6733,6 +7141,35 @@ function _renderStorageSessions() {
|
|
|
6733
7141
|
return html;
|
|
6734
7142
|
}
|
|
6735
7143
|
|
|
7144
|
+
function _renderStorageDismissed() {
|
|
7145
|
+
const ids = [...dismissedSessionIds.keys()];
|
|
7146
|
+
if (!ids.length) return '<div class="storage-empty">No dismissed sessions</div>';
|
|
7147
|
+
const { groups, orphans } = _groupByProject(ids);
|
|
7148
|
+
const renderItem = ({ id, session }) => `<div class="storage-item">
|
|
7149
|
+
<span class="storage-item-id">${_sessionLabel(session, id)}</span>
|
|
7150
|
+
<span class="storage-item-meta">dismissed ${formatDate(dismissedSessionIds.get(id))}</span>
|
|
7151
|
+
<div class="storage-item-actions">
|
|
7152
|
+
<button onclick="_storageViewSession('${escAttrJs(id)}')">View</button>
|
|
7153
|
+
<button onclick="_storageRestoreSession('${escAttrJs(id)}')">Restore</button>
|
|
7154
|
+
</div>
|
|
7155
|
+
</div>`;
|
|
7156
|
+
let html = '';
|
|
7157
|
+
for (const [project, items] of groups) {
|
|
7158
|
+
html += _renderProjectGroup(
|
|
7159
|
+
escapeHtml(_projectLabel(project)),
|
|
7160
|
+
`${items.length} session${items.length > 1 ? 's' : ''}`,
|
|
7161
|
+
items.map(renderItem).join(''),
|
|
7162
|
+
);
|
|
7163
|
+
}
|
|
7164
|
+
if (orphans.length) html += _renderOrphanGroup(orphans.length, orphans.map(renderItem).join(''));
|
|
7165
|
+
return html;
|
|
7166
|
+
}
|
|
7167
|
+
|
|
7168
|
+
function _storageRestoreSession(id) {
|
|
7169
|
+
setSessionDismissed(id, false);
|
|
7170
|
+
_renderStorageTab();
|
|
7171
|
+
}
|
|
7172
|
+
|
|
6736
7173
|
async function _storageViewSession(id) {
|
|
6737
7174
|
closeStorageManager();
|
|
6738
7175
|
revealedStorageSessionId = id;
|
|
@@ -6741,7 +7178,7 @@ async function _storageViewSession(id) {
|
|
|
6741
7178
|
|
|
6742
7179
|
function _storageUnpinSession(id) {
|
|
6743
7180
|
clearSessionPin(id);
|
|
6744
|
-
|
|
7181
|
+
sendPinChange(id);
|
|
6745
7182
|
renderSessions();
|
|
6746
7183
|
_renderStorageTab();
|
|
6747
7184
|
_updateStorageTotal();
|
|
@@ -6963,6 +7400,76 @@ function _storageClearLinkedDocs(sessionId) {
|
|
|
6963
7400
|
afterLinkedDocsChanged(sessionId);
|
|
6964
7401
|
}
|
|
6965
7402
|
|
|
7403
|
+
async function _storageGroupsClean(group, dryRun) {
|
|
7404
|
+
const shown = await getJson('/api/sessions', null, { query: { filter: 'active', limit: 'all', lite: '1' } });
|
|
7405
|
+
if (!Array.isArray(shown)) throw new Error('could not list sessions');
|
|
7406
|
+
const res = await api('/api/groups/clean', {
|
|
7407
|
+
method: 'POST',
|
|
7408
|
+
body: { group, dryRun, keep: shown.map((s) => s.id), hide: [...dismissedSessionIds.keys()] },
|
|
7409
|
+
});
|
|
7410
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
7411
|
+
return res.json();
|
|
7412
|
+
}
|
|
7413
|
+
|
|
7414
|
+
async function _renderStorageGroups(body) {
|
|
7415
|
+
body.innerHTML = '<div class="storage-empty">Loading groups…</div>';
|
|
7416
|
+
let out;
|
|
7417
|
+
try {
|
|
7418
|
+
out = await _storageGroupsClean(null, true);
|
|
7419
|
+
} catch (e) {
|
|
7420
|
+
out = { error: e.message };
|
|
7421
|
+
}
|
|
7422
|
+
_showStorageGroups(body, out);
|
|
7423
|
+
}
|
|
7424
|
+
|
|
7425
|
+
function _showStorageGroups(body, out) {
|
|
7426
|
+
if (document.querySelector('.storage-tab.active')?.dataset.tab !== 'groups') return;
|
|
7427
|
+
document.getElementById('storage-groups-clean-btn').disabled = !out.groups?.some((g) => g.hidden);
|
|
7428
|
+
if (out.error) {
|
|
7429
|
+
body.innerHTML = `<div class="storage-empty">Could not load groups: ${escapeHtml(out.error)}</div>`;
|
|
7430
|
+
return;
|
|
7431
|
+
}
|
|
7432
|
+
if (!out.groups.length) {
|
|
7433
|
+
body.innerHTML = '<div class="storage-empty">No groups</div>';
|
|
7434
|
+
return;
|
|
7435
|
+
}
|
|
7436
|
+
const rows = out.groups
|
|
7437
|
+
.map((g) => {
|
|
7438
|
+
const badge = g.hidden ? ` <span class="storage-item-badge">${g.hidden} hidden</span>` : '';
|
|
7439
|
+
return `<div class="storage-group-header storage-group-row">
|
|
7440
|
+
<span>${escapeHtml(g.path)}${badge}</span>
|
|
7441
|
+
<div class="storage-item-actions">
|
|
7442
|
+
<span class="storage-item-meta">${g.members} member${g.members === 1 ? '' : 's'}</span>
|
|
7443
|
+
<button class="danger" title="Take out the hidden sessions, here and in the groups under it" onclick="_storageCleanGroup('${escAttrJs(g.id)}')">Clean</button>
|
|
7444
|
+
<button class="danger" onclick="_storageDeleteGroup('${escAttrJs(g.id)}')">Delete</button>
|
|
7445
|
+
</div>
|
|
7446
|
+
</div>`;
|
|
7447
|
+
})
|
|
7448
|
+
.join('');
|
|
7449
|
+
const hidden = out.groups.reduce((n, g) => n + g.hidden, 0);
|
|
7450
|
+
body.innerHTML = _renderProjectGroup('Groups', `${hidden} hidden`, rows);
|
|
7451
|
+
}
|
|
7452
|
+
|
|
7453
|
+
async function _storageCleanGroup(group) {
|
|
7454
|
+
let out;
|
|
7455
|
+
try {
|
|
7456
|
+
out = await _storageGroupsClean(group, false);
|
|
7457
|
+
} catch (e) {
|
|
7458
|
+
showToast(`Clean failed: ${e.message}`, 'error');
|
|
7459
|
+
return;
|
|
7460
|
+
}
|
|
7461
|
+
const n = out.dropped;
|
|
7462
|
+
showToast(n ? `Took ${n} hidden session${n === 1 ? '' : 's'} out of the groups` : 'No hidden sessions');
|
|
7463
|
+
_showStorageGroups(document.getElementById('storage-modal-body'), out);
|
|
7464
|
+
}
|
|
7465
|
+
|
|
7466
|
+
async function _storageDeleteGroup(id) {
|
|
7467
|
+
const group = sgGroupById(id);
|
|
7468
|
+
if (!group || !(await sgConfirmDelete(group))) return;
|
|
7469
|
+
await sgSaving;
|
|
7470
|
+
_renderStorageTab();
|
|
7471
|
+
}
|
|
7472
|
+
|
|
6966
7473
|
function _findOrphanedKeys(known) {
|
|
6967
7474
|
if (!known.size) return [];
|
|
6968
7475
|
const orphaned = [];
|
|
@@ -6972,7 +7479,7 @@ function _findOrphanedKeys(known) {
|
|
|
6972
7479
|
for (const key of store.keys()) {
|
|
6973
7480
|
const pad = _parsePadKey(key);
|
|
6974
7481
|
if (pad?.kind === 'group') {
|
|
6975
|
-
if (!sgGroupById(pad.id)) orphaned.push(key);
|
|
7482
|
+
if (sgRev !== null && !sgGroupById(pad.id)) orphaned.push(key);
|
|
6976
7483
|
continue;
|
|
6977
7484
|
}
|
|
6978
7485
|
const prefix = pad ? null : sessionKeyPrefixes.find((p) => key.startsWith(p));
|
|
@@ -7001,13 +7508,10 @@ async function cleanupOrphanedStorage() {
|
|
|
7001
7508
|
const orphaned = _findOrphanedKeys(known);
|
|
7002
7509
|
const unpinned = new Set();
|
|
7003
7510
|
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);
|
|
7511
|
+
const pin = /^__(pinned|sticky)__/.exec(key);
|
|
7512
|
+
if (pin) {
|
|
7513
|
+
const id = key.slice(pin[0].length);
|
|
7514
|
+
clearSessionPin(id);
|
|
7011
7515
|
unpinned.add(id);
|
|
7012
7516
|
} else {
|
|
7013
7517
|
store.removeItem(key);
|
|
@@ -7015,8 +7519,7 @@ async function cleanupOrphanedStorage() {
|
|
|
7015
7519
|
if (key.startsWith(PREVIEW_STORAGE_PREFIX)) forgetServerLinkedDoc(key.slice(PREVIEW_STORAGE_PREFIX.length));
|
|
7016
7520
|
}
|
|
7017
7521
|
}
|
|
7018
|
-
if (unpinned.size)
|
|
7019
|
-
for (const id of unpinned) offloadSessionPin(id);
|
|
7522
|
+
if (unpinned.size) postPins({ ids: [...unpinned], state: 'none' });
|
|
7020
7523
|
const removed = orphaned.length;
|
|
7021
7524
|
|
|
7022
7525
|
showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
|
|
@@ -7077,21 +7580,28 @@ function dismissSessionFromList(sid) {
|
|
|
7077
7580
|
}
|
|
7078
7581
|
|
|
7079
7582
|
async function confirmDismissBlockers(sid) {
|
|
7080
|
-
const
|
|
7583
|
+
const pinState = getSessionPinState(sid);
|
|
7584
|
+
const pinned = pinState !== 'none';
|
|
7081
7585
|
const terminal = runningTerminals.has(sid);
|
|
7082
|
-
if (!
|
|
7586
|
+
if (!pinned && !terminal) return true;
|
|
7083
7587
|
const session = sessions.find((s) => s.id === sid);
|
|
7084
7588
|
const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
|
|
7085
|
-
const
|
|
7086
|
-
const [
|
|
7087
|
-
|
|
7088
|
-
|
|
7089
|
-
|
|
7090
|
-
|
|
7091
|
-
|
|
7092
|
-
|
|
7589
|
+
const pinLabel = pinState === 'sticky' ? 'sticky pin' : 'pin';
|
|
7590
|
+
const has = [pinned && `a ${pinLabel}`, terminal && 'a running terminal'].filter(Boolean).join(' and ');
|
|
7591
|
+
const okLabel = pinned && terminal ? 'Unpin, end and dismiss' : pinned ? 'Unpin and dismiss' : 'End and dismiss';
|
|
7592
|
+
const steps = [
|
|
7593
|
+
pinned && `Remove the ${pinLabel}`,
|
|
7594
|
+
terminal && 'End the terminal',
|
|
7595
|
+
'Hide the session from the sidebar',
|
|
7596
|
+
].filter(Boolean);
|
|
7597
|
+
const ok = await confirmModal({
|
|
7598
|
+
title: `Dismiss “${name}”?`,
|
|
7599
|
+
message: `The session has ${has}.`,
|
|
7600
|
+
steps,
|
|
7601
|
+
okLabel,
|
|
7602
|
+
});
|
|
7093
7603
|
if (!ok) return false;
|
|
7094
|
-
if (
|
|
7604
|
+
if (isAnyPinned(sid)) unpinSession(sid);
|
|
7095
7605
|
if (terminal) closeTerminalSession(sid);
|
|
7096
7606
|
return true;
|
|
7097
7607
|
}
|
|
@@ -7190,19 +7700,36 @@ document.addEventListener('keydown', (e) => {
|
|
|
7190
7700
|
} else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
|
|
7191
7701
|
e.preventDefault();
|
|
7192
7702
|
closeHelpModal();
|
|
7703
|
+
} else if (
|
|
7704
|
+
matchKey(e, 'ArrowLeft', 'ArrowRight') &&
|
|
7705
|
+
[...document.querySelectorAll('.modal-overlay.visible')].at(-1)?.id === 'storage-modal'
|
|
7706
|
+
) {
|
|
7707
|
+
e.preventDefault();
|
|
7708
|
+
stepStorageTab(matchKey(e, 'ArrowLeft') ? -1 : 1);
|
|
7193
7709
|
} else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7194
7710
|
e.preventDefault();
|
|
7195
7711
|
closeMsgDetailModal();
|
|
7196
7712
|
} 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
|
-
) {
|
|
7713
|
+
const askOpen = currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX && isWaitingAnswerable();
|
|
7714
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen && currentWaiting.kind !== 'question') {
|
|
7204
7715
|
e.preventDefault();
|
|
7205
7716
|
respondWaiting({ behavior: 'allow' });
|
|
7717
|
+
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen) {
|
|
7718
|
+
e.preventDefault();
|
|
7719
|
+
submitWaitingAnswers();
|
|
7720
|
+
} else if (
|
|
7721
|
+
askOpen &&
|
|
7722
|
+
currentWaiting.kind === 'question' &&
|
|
7723
|
+
!e.ctrlKey &&
|
|
7724
|
+
!e.metaKey &&
|
|
7725
|
+
!e.altKey &&
|
|
7726
|
+
/^[1-9]$/.test(e.key)
|
|
7727
|
+
) {
|
|
7728
|
+
e.preventDefault();
|
|
7729
|
+
pickWaitingOptionByNumber(Number(e.key) - 1);
|
|
7730
|
+
} else if (askOpen && currentWaiting.kind === 'question' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
7731
|
+
e.preventDefault();
|
|
7732
|
+
setWaitingTab(waitingTab + (e.key === 'ArrowRight' ? 1 : -1));
|
|
7206
7733
|
} else if (matchKey(e, 'ArrowDown')) {
|
|
7207
7734
|
e.preventDefault();
|
|
7208
7735
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
@@ -7234,6 +7761,20 @@ document.addEventListener('keydown', (e) => {
|
|
|
7234
7761
|
return;
|
|
7235
7762
|
}
|
|
7236
7763
|
|
|
7764
|
+
if (
|
|
7765
|
+
e.key === 'Enter' &&
|
|
7766
|
+
(e.ctrlKey || e.metaKey) &&
|
|
7767
|
+
!e.altKey &&
|
|
7768
|
+
!e.shiftKey &&
|
|
7769
|
+
isWaitingFresh() &&
|
|
7770
|
+
isWaitingAnswerable() &&
|
|
7771
|
+
currentWaiting.kind !== 'question'
|
|
7772
|
+
) {
|
|
7773
|
+
e.preventDefault();
|
|
7774
|
+
respondWaiting({ behavior: 'allow' });
|
|
7775
|
+
return;
|
|
7776
|
+
}
|
|
7777
|
+
|
|
7237
7778
|
// Global shortcuts
|
|
7238
7779
|
if (matchKey(e, '\\')) {
|
|
7239
7780
|
e.preventDefault();
|
|
@@ -7242,11 +7783,15 @@ document.addEventListener('keydown', (e) => {
|
|
|
7242
7783
|
}
|
|
7243
7784
|
if (matchKey(e, '[', ']') && cyclePaneStrip(e.key === ']' ? 1 : -1)) {
|
|
7244
7785
|
e.preventDefault();
|
|
7786
|
+
focusActivePane();
|
|
7245
7787
|
return;
|
|
7246
7788
|
}
|
|
7247
7789
|
const altOnly = e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
|
|
7248
7790
|
if (altOnly && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
|
|
7249
|
-
if (cyclePane(e.code === 'BracketRight' ? 1 : -1))
|
|
7791
|
+
if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) {
|
|
7792
|
+
e.preventDefault();
|
|
7793
|
+
focusActivePane();
|
|
7794
|
+
}
|
|
7250
7795
|
return;
|
|
7251
7796
|
}
|
|
7252
7797
|
if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
|
|
@@ -7264,6 +7809,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7264
7809
|
}
|
|
7265
7810
|
if (plainShift && (e.key === '{' || e.key === '}') && focusPaneSide(e.key === '{' ? 'l' : 'r')) {
|
|
7266
7811
|
e.preventDefault();
|
|
7812
|
+
focusActivePane();
|
|
7267
7813
|
return;
|
|
7268
7814
|
}
|
|
7269
7815
|
if (shiftOnly(e, 'KeyL')) {
|
|
@@ -7311,8 +7857,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7311
7857
|
|
|
7312
7858
|
// Tab toggles focus zone
|
|
7313
7859
|
if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
|
|
7860
|
+
// A focused pane document keeps the arrow, page and space keys for scrolling.
|
|
7861
|
+
const inPane = paneViews.contains(e.target);
|
|
7862
|
+
if (inPane && e.key !== 'Tab' && e.key !== 'Escape') return;
|
|
7314
7863
|
if (e.key === 'Tab') {
|
|
7315
7864
|
e.preventDefault();
|
|
7865
|
+
if (inPane) e.target.blur();
|
|
7316
7866
|
// The terminal replaces the board, so it takes the board's place in the Tab cycle.
|
|
7317
7867
|
if (wantsTerminal()) {
|
|
7318
7868
|
if (focusZone === 'sidebar') focusTerminalPane();
|
|
@@ -7393,6 +7943,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7393
7943
|
deleteTask(selectedTaskId, selectedSessionId || currentSessionId);
|
|
7394
7944
|
return;
|
|
7395
7945
|
}
|
|
7946
|
+
|
|
7947
|
+
if (matchKey(e, 'KeyE') && detailPanel.classList.contains('visible')) {
|
|
7948
|
+
e.preventDefault();
|
|
7949
|
+
setDetailExpanded(!detailPanel.classList.contains('expanded'));
|
|
7950
|
+
return;
|
|
7951
|
+
}
|
|
7396
7952
|
}
|
|
7397
7953
|
|
|
7398
7954
|
if (e.key === 'Escape') {
|
|
@@ -7739,6 +8295,31 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
7739
8295
|
(document.head || document.documentElement).append(style);
|
|
7740
8296
|
}
|
|
7741
8297
|
const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
|
|
8298
|
+
const fields = (form, submitter) => {
|
|
8299
|
+
const out = {};
|
|
8300
|
+
for (const [k, v] of new FormData(form, submitter)) {
|
|
8301
|
+
if (typeof v !== 'string') continue;
|
|
8302
|
+
out[k] = k in out ? [].concat(out[k], v) : v;
|
|
8303
|
+
}
|
|
8304
|
+
return out;
|
|
8305
|
+
};
|
|
8306
|
+
const labelOf = (el, fallback) => (el?.textContent || el?.value || '').trim().slice(0, 200) || fallback;
|
|
8307
|
+
addEventListener('click', (e) => {
|
|
8308
|
+
const b = e.target.closest?.('[data-cck-action]');
|
|
8309
|
+
if (!b || (b.form?.matches('[data-cck-submit]') && b.type === 'submit')) return;
|
|
8310
|
+
e.preventDefault();
|
|
8311
|
+
const action = b.dataset.cckAction;
|
|
8312
|
+
send({ type: 'action', action, label: labelOf(b, action), data: b.form ? fields(b.form) : {} });
|
|
8313
|
+
});
|
|
8314
|
+
// The sandbox allows forms so validation runs. Every submit stops here, so a page still cannot
|
|
8315
|
+
// navigate its frame with a form: a show card's form-action 'none' does not stop a GET to about:srcdoc.
|
|
8316
|
+
addEventListener('submit', (e) => {
|
|
8317
|
+
e.preventDefault();
|
|
8318
|
+
const f = e.target.closest?.('form[data-cck-submit]');
|
|
8319
|
+
if (!f) return;
|
|
8320
|
+
const action = f.dataset.cckSubmit;
|
|
8321
|
+
send({ type: 'action', action, label: labelOf(e.submitter, action), data: fields(f, e.submitter) });
|
|
8322
|
+
});
|
|
7742
8323
|
const describe = (node) => {
|
|
7743
8324
|
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
7744
8325
|
if (!el) return {};
|
|
@@ -7922,10 +8503,11 @@ function sendBridgeClaims(frames, claims) {
|
|
|
7922
8503
|
window.addEventListener('message', (e) => {
|
|
7923
8504
|
const type =
|
|
7924
8505
|
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;
|
|
8506
|
+
if (type !== 'link' && type !== 'key' && type !== 'scroll' && type !== 'action') return;
|
|
7926
8507
|
const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
|
|
7927
8508
|
if (!frame) return;
|
|
7928
|
-
if (type === '
|
|
8509
|
+
if (type === 'action') sendPreviewAction(frame, e.data);
|
|
8510
|
+
else if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
|
|
7929
8511
|
else if (type === 'key') replayKey(e.data, frame);
|
|
7930
8512
|
else {
|
|
7931
8513
|
const url = linkUrl(e.data.href);
|
|
@@ -8138,6 +8720,24 @@ function renderReviewPanel() {
|
|
|
8138
8720
|
.join('')}</ol>`;
|
|
8139
8721
|
}
|
|
8140
8722
|
|
|
8723
|
+
async function toastDelivery({ delivered, markdown }, noun) {
|
|
8724
|
+
if (delivered === 'doorbell') return showToast('Queued for the session', 'success');
|
|
8725
|
+
if (delivered === 'terminal') return showToast('Pasted into the terminal: press Enter there to send', 'info');
|
|
8726
|
+
const copy = async () => {
|
|
8727
|
+
const copied = await navigator.clipboard.writeText(markdown).then(
|
|
8728
|
+
() => true,
|
|
8729
|
+
() => false,
|
|
8730
|
+
);
|
|
8731
|
+
showToast(
|
|
8732
|
+
copied ? `Copied the ${noun}. Paste it into the session.` : `Failed to copy the ${noun}`,
|
|
8733
|
+
'info',
|
|
8734
|
+
{ label: 'Copy again', onClick: copy },
|
|
8735
|
+
`Sessions get ${noun}s directly when the cck plugin runs there and board events are on.`,
|
|
8736
|
+
);
|
|
8737
|
+
};
|
|
8738
|
+
await copy();
|
|
8739
|
+
}
|
|
8740
|
+
|
|
8141
8741
|
async function sendReview() {
|
|
8142
8742
|
if (!activeReview) return;
|
|
8143
8743
|
const { sessionId, source, key, onSent } = activeReview;
|
|
@@ -8150,30 +8750,49 @@ async function sendReview() {
|
|
|
8150
8750
|
});
|
|
8151
8751
|
const data = await res.json();
|
|
8152
8752
|
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
|
-
}
|
|
8753
|
+
await toastDelivery(data, 'review');
|
|
8171
8754
|
clearReviewDrafts(key);
|
|
8172
8755
|
if (activeReview?.key === key) onSent?.();
|
|
8173
8756
|
} catch {
|
|
8174
8757
|
showToast('Failed to send the review', 'error');
|
|
8175
8758
|
}
|
|
8176
8759
|
}
|
|
8760
|
+
|
|
8761
|
+
// A page script can post an action without a click, so the board sends it only with transient
|
|
8762
|
+
// user activation, which a click in the frame gives to the board too.
|
|
8763
|
+
const ACTION_REPEAT_MS = 1000;
|
|
8764
|
+
let lastAction = null;
|
|
8765
|
+
|
|
8766
|
+
function reviewOptsOfFrame(frame) {
|
|
8767
|
+
return (
|
|
8768
|
+
[...reviewStack, reviewBase, showState.review].find((o) => o?.frame === frame) ||
|
|
8769
|
+
paneReviews.get(frame.parentElement) ||
|
|
8770
|
+
null
|
|
8771
|
+
);
|
|
8772
|
+
}
|
|
8773
|
+
|
|
8774
|
+
async function sendPreviewAction(frame, msg) {
|
|
8775
|
+
if (!navigator.userActivation?.isActive) return;
|
|
8776
|
+
const opts = reviewOptsOfFrame(frame);
|
|
8777
|
+
if (!opts?.sessionId) return;
|
|
8778
|
+
const key = `${msg.action}\n${JSON.stringify(msg.data)}`;
|
|
8779
|
+
const now = Date.now();
|
|
8780
|
+
if (lastAction?.frame === frame && lastAction.key === key && now - lastAction.at < ACTION_REPEAT_MS) return;
|
|
8781
|
+
lastAction = { frame, key, at: now };
|
|
8782
|
+
try {
|
|
8783
|
+
const res = await terminalFetch(apiPath`/api/sessions/${opts.sessionId}/action`, 'POST', {
|
|
8784
|
+
source: opts.source,
|
|
8785
|
+
action: msg.action,
|
|
8786
|
+
label: msg.label,
|
|
8787
|
+
data: msg.data,
|
|
8788
|
+
});
|
|
8789
|
+
const out = await res.json();
|
|
8790
|
+
if (!res.ok) return showToast(out.error || 'Send failed', 'error');
|
|
8791
|
+
await toastDelivery(out, 'answer');
|
|
8792
|
+
} catch {
|
|
8793
|
+
showToast('Failed to send the answer', 'error');
|
|
8794
|
+
}
|
|
8795
|
+
}
|
|
8177
8796
|
//#endregion
|
|
8178
8797
|
|
|
8179
8798
|
//#region MARKDOWN_PREVIEW
|
|
@@ -8284,7 +8903,7 @@ function renderFrontmatterBlock(fm) {
|
|
|
8284
8903
|
// allow-same-origin puts it on an opaque origin, so it cannot touch this app's
|
|
8285
8904
|
// storage, DOM or API. srcdoc has no base URL, so the server has already embedded
|
|
8286
8905
|
// the document's local assets (lib/inline-assets.js); remote refs load normally.
|
|
8287
|
-
function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups') {
|
|
8906
|
+
function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups allow-forms') {
|
|
8288
8907
|
const frame = document.createElement('iframe');
|
|
8289
8908
|
frame.className = className;
|
|
8290
8909
|
frame.setAttribute('sandbox', sandbox);
|
|
@@ -8306,6 +8925,10 @@ function isAbsolutePath(p) {
|
|
|
8306
8925
|
return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
|
|
8307
8926
|
}
|
|
8308
8927
|
|
|
8928
|
+
function isUrlRef(s) {
|
|
8929
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(s) || s.startsWith('//');
|
|
8930
|
+
}
|
|
8931
|
+
|
|
8309
8932
|
function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
8310
8933
|
if (bodyEl.dataset.relLinkBound) return;
|
|
8311
8934
|
bodyEl.addEventListener('click', (e) => {
|
|
@@ -8313,8 +8936,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
|
8313
8936
|
if (!a) return;
|
|
8314
8937
|
const href = a.getAttribute('href');
|
|
8315
8938
|
if (!href || href.startsWith('#')) return;
|
|
8316
|
-
|
|
8317
|
-
if (isAbsoluteUrl) return;
|
|
8939
|
+
if (isUrlRef(href)) return;
|
|
8318
8940
|
const cleanHref = href.replace(/#.*$/, '');
|
|
8319
8941
|
e.preventDefault();
|
|
8320
8942
|
openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
|
|
@@ -8366,10 +8988,37 @@ function renderPreviewContent(bodyEl, filePath, content, kind) {
|
|
|
8366
8988
|
bodyEl.replaceChildren(img);
|
|
8367
8989
|
} else {
|
|
8368
8990
|
const { fm, body } = splitFrontmatter(content);
|
|
8369
|
-
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') +
|
|
8991
|
+
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdownFile(body, filePath);
|
|
8992
|
+
}
|
|
8993
|
+
}
|
|
8994
|
+
|
|
8995
|
+
// A relative src would resolve against the board's URL, and DOMPurify drops a drive path (`C:/x`
|
|
8996
|
+
// reads as a scheme), so the hook moves a local image to the image route before that check.
|
|
8997
|
+
function renderMarkdownFile(text, filePath) {
|
|
8998
|
+
if (!filePath || typeof DOMPurify === 'undefined') return renderMarkdown(text);
|
|
8999
|
+
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
|
9000
|
+
if (node.nodeName === 'IMG' && data.attrName === 'src') data.attrValue = localImageUrl(data.attrValue, filePath);
|
|
9001
|
+
});
|
|
9002
|
+
try {
|
|
9003
|
+
return renderMarkdown(text);
|
|
9004
|
+
} finally {
|
|
9005
|
+
DOMPurify.removeHook('uponSanitizeAttribute');
|
|
8370
9006
|
}
|
|
8371
9007
|
}
|
|
8372
9008
|
|
|
9009
|
+
function localImageUrl(src, base) {
|
|
9010
|
+
const fileUrl = /^file:/i.test(src);
|
|
9011
|
+
if (src.startsWith('//') || (!fileUrl && !isAbsolutePath(src) && isUrlRef(src))) return src;
|
|
9012
|
+
let p = src;
|
|
9013
|
+
if (!fileUrl) {
|
|
9014
|
+
p = src.replace(/[?#].*$/, '');
|
|
9015
|
+
try {
|
|
9016
|
+
p = decodeURI(p);
|
|
9017
|
+
} catch {}
|
|
9018
|
+
}
|
|
9019
|
+
return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
|
|
9020
|
+
}
|
|
9021
|
+
|
|
8373
9022
|
function openPreviewModal(filePath, content, kind) {
|
|
8374
9023
|
currentPreviewPath = filePath;
|
|
8375
9024
|
const fileName = filePath.split(/[\\/]/).pop();
|
|
@@ -8521,6 +9170,7 @@ function handleSessionOpenEvent(data) {
|
|
|
8521
9170
|
}
|
|
8522
9171
|
if (!isSessionActive(target)) {
|
|
8523
9172
|
stickySessionIds.add(id);
|
|
9173
|
+
openedStickyIds.add(id);
|
|
8524
9174
|
}
|
|
8525
9175
|
setSessionDismissed(id, false);
|
|
8526
9176
|
cliOpenedId = id;
|
|
@@ -9184,6 +9834,10 @@ function setupEventSource() {
|
|
|
9184
9834
|
fetchSessions().catch(() => {});
|
|
9185
9835
|
if (currentSessionId) fetchTasks(currentSessionId);
|
|
9186
9836
|
refetchPaneLayout();
|
|
9837
|
+
if (sgRev === null) syncSessionGroups();
|
|
9838
|
+
else if (sgUnsent) persistSessionGroups();
|
|
9839
|
+
else sgRefresh();
|
|
9840
|
+
syncSessionPins();
|
|
9187
9841
|
if (terminalAvailable())
|
|
9188
9842
|
loadTerminals()
|
|
9189
9843
|
.then(renderSessions)
|
|
@@ -9247,7 +9901,10 @@ function setupEventSource() {
|
|
|
9247
9901
|
if (!skipOffScreen()) refreshOpenPlan();
|
|
9248
9902
|
}
|
|
9249
9903
|
|
|
9250
|
-
if (data.type === 'terminals-update' && Array.isArray(data.ids))
|
|
9904
|
+
if (data.type === 'terminals-update' && Array.isArray(data.ids))
|
|
9905
|
+
setRunningTerminals(data.ids, data.awaitingTrust);
|
|
9906
|
+
|
|
9907
|
+
if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
|
|
9251
9908
|
|
|
9252
9909
|
if (data.type === 'dispatch-update') onDispatchUpdate();
|
|
9253
9910
|
|
|
@@ -9296,6 +9953,10 @@ function setupEventSource() {
|
|
|
9296
9953
|
handleSessionPinEvent(data);
|
|
9297
9954
|
}
|
|
9298
9955
|
|
|
9956
|
+
if (data.type === 'group:changed') {
|
|
9957
|
+
onGroupChanged(data);
|
|
9958
|
+
}
|
|
9959
|
+
|
|
9299
9960
|
if (data.type === 'pane:changed') {
|
|
9300
9961
|
applyPaneLayout(data.sessionId, data.layout);
|
|
9301
9962
|
}
|
|
@@ -9663,45 +10324,93 @@ function renderWaitingFooter(tool, params) {
|
|
|
9663
10324
|
if (currentWaiting?.kind === 'plan') {
|
|
9664
10325
|
return isWaitingAnswerable() ? planApprovalControlsHtml('plan-reject-feedback') : '';
|
|
9665
10326
|
}
|
|
9666
|
-
if (currentWaiting?.kind !== 'question')
|
|
10327
|
+
if (currentWaiting?.kind !== 'question') {
|
|
10328
|
+
const buttons = waitingDecisionButtons('waiting-btn');
|
|
10329
|
+
const hint = `<span class="waiting-footer-hint">${sendKbd()} allow · ${termKbd()} terminal</span>`;
|
|
10330
|
+
return buttons ? hint + buttons : '';
|
|
10331
|
+
}
|
|
9667
10332
|
if (!isWaitingAnswerable()) return '';
|
|
9668
10333
|
if (tool !== 'AskUserQuestion') return '';
|
|
9669
10334
|
const questions = Array.isArray(params?.questions) ? params.questions : [];
|
|
9670
10335
|
if (!questions.length) return '';
|
|
9671
10336
|
const picked = questions.filter((q) => waitingAnswerFor(q)).length;
|
|
9672
10337
|
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()">
|
|
10338
|
+
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
10339
|
}
|
|
9675
10340
|
|
|
9676
10341
|
function renderWaitingActions(tool, params) {
|
|
9677
10342
|
if (!isWaitingAnswerable() || currentWaiting.kind !== 'question' || tool !== 'AskUserQuestion') return '';
|
|
9678
10343
|
waitingQuestions = Array.isArray(params?.questions) ? params.questions : [];
|
|
9679
10344
|
if (!waitingQuestions.length) return '';
|
|
10345
|
+
const multiQ = waitingQuestions.length > 1;
|
|
10346
|
+
waitingTab = Math.max(0, Math.min(waitingTab, multiQ ? waitingQuestions.length : 0));
|
|
10347
|
+
const onReview = multiQ && waitingTab === waitingQuestions.length;
|
|
10348
|
+
const tabs = multiQ
|
|
10349
|
+
? `<div class="waiting-qtabs" role="tablist">${waitingQuestions
|
|
10350
|
+
.map((q, idx) => {
|
|
10351
|
+
const done = !!waitingAnswerFor(q);
|
|
10352
|
+
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>`;
|
|
10353
|
+
})
|
|
10354
|
+
.join(
|
|
10355
|
+
'',
|
|
10356
|
+
)}<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>`
|
|
10357
|
+
: '';
|
|
10358
|
+
if (onReview) {
|
|
10359
|
+
const rows = waitingQuestions
|
|
10360
|
+
.map((q) => {
|
|
10361
|
+
const a = waitingAnswerFor(q);
|
|
10362
|
+
const text = Array.isArray(a) ? a.join(', ') : a;
|
|
10363
|
+
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>`;
|
|
10364
|
+
})
|
|
10365
|
+
.join('');
|
|
10366
|
+
return `<div class="waiting-actions">${tabs}<div class="waiting-review">${rows}</div></div>`;
|
|
10367
|
+
}
|
|
9680
10368
|
const qs = waitingQuestions
|
|
9681
10369
|
.map((q, idx) => {
|
|
10370
|
+
if (idx !== waitingTab && multiQ) return '';
|
|
9682
10371
|
const picks = waitingAnswerDraft[q.question] || [];
|
|
9683
10372
|
const opts = (q.options || [])
|
|
9684
|
-
.map((o) => {
|
|
9685
|
-
const label =
|
|
10373
|
+
.map((o, oi) => {
|
|
10374
|
+
const label = waitingOptionLabel(o);
|
|
9686
10375
|
const desc = typeof o === 'object' ? o?.description || '' : '';
|
|
9687
10376
|
const descHtml = desc ? `<span class="waiting-option-desc">${renderMarkdown(desc)}</span>` : '';
|
|
9688
10377
|
// The option `preview` field is the TUI's side-by-side visualization
|
|
9689
10378
|
// pane (mockups, code snippets, diagrams) — rendered as markdown
|
|
9690
10379
|
const preview = typeof o === 'object' ? o?.preview || '' : '';
|
|
9691
10380
|
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>`;
|
|
10381
|
+
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
10382
|
})
|
|
9694
10383
|
.join('');
|
|
9695
10384
|
const header = q.header ? `<span class="waiting-question-header">${escapeHtml(q.header)}</span>` : '';
|
|
9696
10385
|
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>`;
|
|
10386
|
+
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()}">`;
|
|
10387
|
+
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
10388
|
})
|
|
9700
10389
|
.join('');
|
|
9701
|
-
return `<div class="waiting-actions">${qs}</div>`;
|
|
10390
|
+
return `<div class="waiting-actions">${tabs}${qs}</div>`;
|
|
10391
|
+
}
|
|
10392
|
+
|
|
10393
|
+
let waitingTab = 0;
|
|
10394
|
+
|
|
10395
|
+
function waitingOptionLabel(o) {
|
|
10396
|
+
return typeof o === 'string' ? o : o?.label || '';
|
|
10397
|
+
}
|
|
10398
|
+
|
|
10399
|
+
function setWaitingTab(i) {
|
|
10400
|
+
const last = waitingQuestions.length > 1 ? waitingQuestions.length : 0;
|
|
10401
|
+
const next = Math.max(0, Math.min(i, last));
|
|
10402
|
+
if (next === waitingTab) return;
|
|
10403
|
+
waitingTab = next;
|
|
10404
|
+
showWaitingDetail();
|
|
10405
|
+
}
|
|
10406
|
+
|
|
10407
|
+
function pickWaitingOptionByNumber(n) {
|
|
10408
|
+
const qi = waitingQuestions.length > 1 ? waitingTab : 0;
|
|
10409
|
+
const o = waitingQuestions[qi]?.options?.[n];
|
|
10410
|
+
if (!o) return;
|
|
10411
|
+
selectWaitingAnswer(qi, waitingOptionLabel(o));
|
|
9702
10412
|
}
|
|
9703
10413
|
|
|
9704
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
9705
10414
|
function selectWaitingAnswer(qi, label) {
|
|
9706
10415
|
const q = waitingQuestions[qi];
|
|
9707
10416
|
if (!q) return;
|
|
@@ -9714,6 +10423,7 @@ function selectWaitingAnswer(qi, label) {
|
|
|
9714
10423
|
} else {
|
|
9715
10424
|
waitingAnswerDraft[q.question] = [label];
|
|
9716
10425
|
delete waitingCustomDraft[q.question];
|
|
10426
|
+
if (waitingQuestions.length > 1 && waitingTab < waitingQuestions.length) waitingTab += 1;
|
|
9717
10427
|
}
|
|
9718
10428
|
showWaitingDetail();
|
|
9719
10429
|
}
|
|
@@ -9739,11 +10449,12 @@ function setWaitingCustomAnswer(qi, el) {
|
|
|
9739
10449
|
const picked = waitingQuestions.filter((qq) => waitingAnswerFor(qq)).length;
|
|
9740
10450
|
btn.disabled = picked === 0;
|
|
9741
10451
|
btn.textContent =
|
|
9742
|
-
picked > 0 && picked < waitingQuestions.length
|
|
10452
|
+
picked > 0 && picked < waitingQuestions.length
|
|
10453
|
+
? `Send answers (${picked}/${waitingQuestions.length})`
|
|
10454
|
+
: 'Send answers';
|
|
9743
10455
|
}
|
|
9744
10456
|
}
|
|
9745
10457
|
|
|
9746
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
9747
10458
|
function submitWaitingAnswers() {
|
|
9748
10459
|
// Partial submits are fine — send only the questions actually answered.
|
|
9749
10460
|
// multiSelect answers go as arrays of labels (the shape the TUI validates).
|
|
@@ -9756,6 +10467,38 @@ function submitWaitingAnswers() {
|
|
|
9756
10467
|
respondWaiting({ answers });
|
|
9757
10468
|
}
|
|
9758
10469
|
|
|
10470
|
+
function renderPermissionDiff(oldText, newText) {
|
|
10471
|
+
const rows = (text, sign, cls) =>
|
|
10472
|
+
String(text)
|
|
10473
|
+
.split('\n')
|
|
10474
|
+
.map(
|
|
10475
|
+
(l) =>
|
|
10476
|
+
`<div class="perm-diff-row ${escapeHtml(cls)}"><span class="perm-diff-sign">${sign}</span>${escapeHtml(l)}</div>`,
|
|
10477
|
+
)
|
|
10478
|
+
.join('');
|
|
10479
|
+
return `<div class="perm-diff">${rows(oldText, '−', 'del')}${rows(newText, '+', 'add')}</div>`;
|
|
10480
|
+
}
|
|
10481
|
+
|
|
10482
|
+
// The permission card shows what Claude wants to run in full, not a one-line gist:
|
|
10483
|
+
// the command or the diff, where it runs, and how long the ask has been waiting.
|
|
10484
|
+
function renderPermissionSummary(tool, params) {
|
|
10485
|
+
const waited = formatDuration(Math.max(0, Date.now() - new Date(currentWaiting.timestamp).getTime()));
|
|
10486
|
+
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>`;
|
|
10487
|
+
let body = '';
|
|
10488
|
+
if (typeof params.command === 'string') {
|
|
10489
|
+
body = `<div class="perm-label">Command</div><pre class="perm-code">${escapeHtml(params.command)}</pre>`;
|
|
10490
|
+
if (params.description) body += `<div class="perm-note">${escapeHtml(params.description)}</div>`;
|
|
10491
|
+
} else if (typeof params.old_string === 'string' && typeof params.new_string === 'string') {
|
|
10492
|
+
body = `<div class="perm-label">${escapeHtml(params.file_path || 'File')}</div>${renderPermissionDiff(params.old_string, params.new_string)}`;
|
|
10493
|
+
} else if (typeof params.file_path === 'string') {
|
|
10494
|
+
body = `<div class="perm-label">File</div><pre class="perm-code">${escapeHtml(params.file_path)}</pre>`;
|
|
10495
|
+
} else if (typeof params.description === 'string') {
|
|
10496
|
+
body = `<pre class="perm-code">${escapeHtml(params.description)}</pre>`;
|
|
10497
|
+
}
|
|
10498
|
+
const cwd = currentWaiting.cwd ? `<div class="perm-cwd">in ${escapeHtml(currentWaiting.cwd)}</div>` : '';
|
|
10499
|
+
return `<div class="perm-card">${head}${body}${cwd}</div>`;
|
|
10500
|
+
}
|
|
10501
|
+
|
|
9759
10502
|
function showWaitingDetail() {
|
|
9760
10503
|
if (!isWaitingFresh()) return;
|
|
9761
10504
|
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
@@ -9770,6 +10513,7 @@ function showWaitingDetail() {
|
|
|
9770
10513
|
waitingDraftId = currentWaiting.id || null;
|
|
9771
10514
|
waitingAnswerDraft = {};
|
|
9772
10515
|
waitingCustomDraft = {};
|
|
10516
|
+
waitingTab = 0;
|
|
9773
10517
|
}
|
|
9774
10518
|
const tool = currentWaiting.toolName || 'unknown';
|
|
9775
10519
|
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
@@ -9791,17 +10535,9 @@ function showWaitingDetail() {
|
|
|
9791
10535
|
// A plan ask IS the plan — render it in full so it can be reviewed and
|
|
9792
10536
|
// approved right here (follow mode included) instead of a JSON dump. The
|
|
9793
10537
|
// raw tool input is dropped entirely to match the saved-plan modal.
|
|
9794
|
-
inputHtml = `<div class="detail-desc rendered-md">${renderMarkdown(params.plan)}</div>`;
|
|
10538
|
+
inputHtml = `<div class="detail-desc rendered-md waiting-plan">${renderMarkdown(params.plan)}</div>`;
|
|
9795
10539
|
} 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
|
-
}
|
|
10540
|
+
if (currentWaiting.kind !== 'question' && params) summaryHtml = renderPermissionSummary(tool, params);
|
|
9805
10541
|
inputHtml = `${summaryHtml}<details class="waiting-raw-input"><summary>Raw tool input</summary>${inputHtml}</details>`;
|
|
9806
10542
|
}
|
|
9807
10543
|
}
|
|
@@ -9829,9 +10565,8 @@ function isSessionActive(s) {
|
|
|
9829
10565
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
9830
10566
|
}
|
|
9831
10567
|
|
|
9832
|
-
// An active pinned session renders outside the Pinned group until the user dismisses it.
|
|
9833
10568
|
function isInPinnedGroup(s) {
|
|
9834
|
-
return isPlacedPinned(s.id) && !isPlacedSticky(s.id)
|
|
10569
|
+
return isPlacedPinned(s.id) && !isPlacedSticky(s.id);
|
|
9835
10570
|
}
|
|
9836
10571
|
|
|
9837
10572
|
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -10102,7 +10837,6 @@ const MERMAID_VARS = {
|
|
|
10102
10837
|
pieOuterStrokeColor: '--border',
|
|
10103
10838
|
pieTitleTextColor: '--text-primary',
|
|
10104
10839
|
pieLegendTextColor: '--text-primary',
|
|
10105
|
-
fontFamily: '--mono',
|
|
10106
10840
|
};
|
|
10107
10841
|
// --bg-elevated sits too close to the dark page for a node, and the base theme derives near-black ER
|
|
10108
10842
|
// rows from a dark primaryColor.
|
|
@@ -10111,15 +10845,39 @@ const MERMAID_MODE_VARS = {
|
|
|
10111
10845
|
light: { primaryColor: '--bg-elevated', rowOdd: '--bg-elevated', rowEven: '--bg-elevated' },
|
|
10112
10846
|
};
|
|
10113
10847
|
let mermaidThemeKey = '';
|
|
10848
|
+
let colorProbe = null;
|
|
10849
|
+
const probedColors = new Map();
|
|
10850
|
+
|
|
10851
|
+
// Mermaid's color parser knows only plain hex, rgb() and hsl(), and a hub theme may set oklch() or
|
|
10852
|
+
// relative color syntax; its initialize throws on one, and no diagram renders.
|
|
10853
|
+
function mermaidColor(value) {
|
|
10854
|
+
if (!value || value.startsWith('#')) return value;
|
|
10855
|
+
let rgb = probedColors.get(value);
|
|
10856
|
+
if (rgb) return rgb;
|
|
10857
|
+
colorProbe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
10858
|
+
colorProbe.clearRect(0, 0, 1, 1);
|
|
10859
|
+
colorProbe.fillStyle = 'transparent';
|
|
10860
|
+
colorProbe.fillStyle = value;
|
|
10861
|
+
colorProbe.fillRect(0, 0, 1, 1);
|
|
10862
|
+
const [r, g, b, a] = colorProbe.getImageData(0, 0, 1, 1).data;
|
|
10863
|
+
rgb = a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${+(a / 255).toFixed(3)})`;
|
|
10864
|
+
probedColors.set(value, rgb);
|
|
10865
|
+
return rgb;
|
|
10866
|
+
}
|
|
10114
10867
|
|
|
10115
10868
|
// The base theme derives its other colors from these, and it parses colors itself, so it takes
|
|
10116
10869
|
// resolved values rather than var() references.
|
|
10117
10870
|
function mermaidConfig() {
|
|
10118
10871
|
const css = getComputedStyle(document.body);
|
|
10119
10872
|
const mode = isLightTheme() ? 'light' : 'dark';
|
|
10120
|
-
const themeVariables = {
|
|
10873
|
+
const themeVariables = {
|
|
10874
|
+
darkMode: mode === 'dark',
|
|
10875
|
+
pieOpacity: '1',
|
|
10876
|
+
pieSectionTextColor: '#fff',
|
|
10877
|
+
fontFamily: css.getPropertyValue('--mono').trim(),
|
|
10878
|
+
};
|
|
10121
10879
|
for (const [k, name] of Object.entries({ ...MERMAID_VARS, ...MERMAID_MODE_VARS[mode] })) {
|
|
10122
|
-
themeVariables[k] = css.getPropertyValue(name).trim();
|
|
10880
|
+
themeVariables[k] = mermaidColor(css.getPropertyValue(name).trim());
|
|
10123
10881
|
}
|
|
10124
10882
|
SHOW_PALETTE[mode].series.forEach((c, i) => {
|
|
10125
10883
|
themeVariables[`pie${i + 1}`] = c;
|
|
@@ -10145,10 +10903,14 @@ function loadMermaid() {
|
|
|
10145
10903
|
script.integrity = MERMAID_SCRIPT.integrity;
|
|
10146
10904
|
script.crossOrigin = 'anonymous';
|
|
10147
10905
|
script.onload = () => {
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10906
|
+
try {
|
|
10907
|
+
const config = mermaidConfig();
|
|
10908
|
+
mermaidThemeKey = JSON.stringify(config);
|
|
10909
|
+
mermaid.initialize(config);
|
|
10910
|
+
resolve();
|
|
10911
|
+
} catch (err) {
|
|
10912
|
+
reject(err);
|
|
10913
|
+
}
|
|
10152
10914
|
};
|
|
10153
10915
|
script.onerror = () => {
|
|
10154
10916
|
script.remove();
|
|
@@ -10167,24 +10929,45 @@ function queueMermaid(work) {
|
|
|
10167
10929
|
mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
|
|
10168
10930
|
}
|
|
10169
10931
|
|
|
10932
|
+
const MERMAID_PENDING = 'pre.mermaid:not([data-processed])';
|
|
10933
|
+
|
|
10170
10934
|
function initMermaidBlocks(container) {
|
|
10171
10935
|
const root = container || document;
|
|
10172
|
-
if (!root.querySelector(
|
|
10936
|
+
if (!root.querySelector(MERMAID_PENDING)) return;
|
|
10937
|
+
// Stamped here, after DOMPurify: it drops an attribute whose value holds `-->`, as most diagrams do.
|
|
10938
|
+
const stampPending = () =>
|
|
10939
|
+
[...root.querySelectorAll(MERMAID_PENDING)].map((b) => {
|
|
10940
|
+
if (!b.hasAttribute('data-original')) b.setAttribute('data-original', b.textContent);
|
|
10941
|
+
return b;
|
|
10942
|
+
});
|
|
10173
10943
|
if (typeof mermaid === 'undefined') {
|
|
10174
10944
|
loadMermaid().then(
|
|
10175
10945
|
() => initMermaidBlocks(container),
|
|
10176
|
-
(err) =>
|
|
10946
|
+
(err) => {
|
|
10947
|
+
console.warn(err.message);
|
|
10948
|
+
for (const b of stampPending()) showMermaidError(b, err);
|
|
10949
|
+
},
|
|
10177
10950
|
);
|
|
10178
10951
|
return;
|
|
10179
10952
|
}
|
|
10180
|
-
queueMermaid(() => {
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
|
|
10953
|
+
queueMermaid(async () => {
|
|
10954
|
+
// One block per run, so a bad diagram shows its own error and the others still render.
|
|
10955
|
+
for (const b of stampPending()) {
|
|
10956
|
+
try {
|
|
10957
|
+
await mermaid.run({ nodes: [b] });
|
|
10958
|
+
} catch (err) {
|
|
10959
|
+
showMermaidError(b, err);
|
|
10960
|
+
}
|
|
10961
|
+
}
|
|
10185
10962
|
});
|
|
10186
10963
|
}
|
|
10187
10964
|
|
|
10965
|
+
function showMermaidError(block, err) {
|
|
10966
|
+
block.setAttribute('data-processed', 'true');
|
|
10967
|
+
block.setAttribute('data-mermaid-error', '');
|
|
10968
|
+
block.textContent = err?.message || String(err);
|
|
10969
|
+
}
|
|
10970
|
+
|
|
10188
10971
|
function reinitMermaidTheme() {
|
|
10189
10972
|
if (typeof mermaid === 'undefined') return;
|
|
10190
10973
|
queueMermaid(() => {
|
|
@@ -10195,6 +10978,7 @@ function reinitMermaidTheme() {
|
|
|
10195
10978
|
mermaid.initialize(config);
|
|
10196
10979
|
document.querySelectorAll('pre.mermaid[data-processed]').forEach((el) => {
|
|
10197
10980
|
el.removeAttribute('data-processed');
|
|
10981
|
+
el.removeAttribute('data-mermaid-error');
|
|
10198
10982
|
el.innerHTML = escapeHtml(el.getAttribute('data-original') || '');
|
|
10199
10983
|
});
|
|
10200
10984
|
initMermaidBlocks();
|
|
@@ -10479,24 +11263,32 @@ function renderFilterState() {
|
|
|
10479
11263
|
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
10480
11264
|
}
|
|
10481
11265
|
|
|
11266
|
+
// With the sidebar cursor on a group header, zen shows that group instead of the open session.
|
|
10482
11267
|
function toggleZenMode() {
|
|
10483
|
-
|
|
11268
|
+
const leavingSessionZen = zenMode;
|
|
11269
|
+
if (zenMode) zenMode = false;
|
|
11270
|
+
else if (zenGroup) zenGroup = null;
|
|
11271
|
+
else {
|
|
11272
|
+
const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
|
|
11273
|
+
zenGroup = header?.dataset.groupId || null;
|
|
11274
|
+
zenMode = !zenGroup;
|
|
11275
|
+
}
|
|
10484
11276
|
store.setItem(ZEN_KEY, String(zenMode));
|
|
10485
11277
|
renderZenState();
|
|
10486
|
-
if (zenMode) closeFilterMenu();
|
|
11278
|
+
if (zenMode || zenGroup) closeFilterMenu();
|
|
10487
11279
|
renderSessions();
|
|
10488
|
-
// The open session may be outside the current server page/filter
|
|
10489
|
-
|
|
11280
|
+
// The open session or the group's members may be outside the current server page/filter,
|
|
11281
|
+
// and leaving group zen needs the filtered page back.
|
|
11282
|
+
if (!leavingSessionZen) fetchSessions(false);
|
|
10490
11283
|
}
|
|
10491
11284
|
|
|
10492
11285
|
function renderZenState() {
|
|
10493
11286
|
const btn = document.getElementById('zen-mode-btn');
|
|
10494
11287
|
if (!btn) return;
|
|
10495
|
-
|
|
10496
|
-
btn.
|
|
10497
|
-
btn.
|
|
10498
|
-
|
|
10499
|
-
: 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
11288
|
+
const on = zenMode || !!zenGroup;
|
|
11289
|
+
btn.classList.toggle('active', on);
|
|
11290
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
11291
|
+
btn.title = on ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)' : 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
10500
11292
|
}
|
|
10501
11293
|
|
|
10502
11294
|
function filterBySessions(value) {
|
|
@@ -10611,44 +11403,23 @@ document.addEventListener('click', (e) => {
|
|
|
10611
11403
|
if (sgHeader) {
|
|
10612
11404
|
if (e.target.closest('.sg-name-input')) return;
|
|
10613
11405
|
e.stopPropagation();
|
|
11406
|
+
const groupId = sgHeader.dataset.groupId;
|
|
11407
|
+
const group = sgGroupById(groupId);
|
|
11408
|
+
if (!group) return;
|
|
10614
11409
|
if (e.target.closest('.sg-keep')) {
|
|
10615
|
-
|
|
11410
|
+
sgKeep(group);
|
|
11411
|
+
persistSessionGroups();
|
|
10616
11412
|
renderSessions();
|
|
10617
11413
|
return;
|
|
10618
11414
|
}
|
|
10619
|
-
if (
|
|
10620
|
-
|
|
10621
|
-
|
|
10622
|
-
|
|
10623
|
-
|
|
10624
|
-
|
|
10625
|
-
}).then((ok) => {
|
|
10626
|
-
if (!ok) return;
|
|
10627
|
-
sgDeleteTransient(name);
|
|
10628
|
-
renderSessions();
|
|
10629
|
-
});
|
|
11415
|
+
if (e.target.closest('.sg-rename')) {
|
|
11416
|
+
sgBeginRename(groupId);
|
|
11417
|
+
return;
|
|
11418
|
+
}
|
|
11419
|
+
if (e.target.closest('.sg-delete')) {
|
|
11420
|
+
sgConfirmDelete(group);
|
|
10630
11421
|
return;
|
|
10631
11422
|
}
|
|
10632
|
-
const groupId = sgHeader.dataset.groupId;
|
|
10633
|
-
const group = sgGroupById(groupId);
|
|
10634
|
-
if (group) {
|
|
10635
|
-
if (e.target.closest('.sg-rename')) {
|
|
10636
|
-
sgBeginRename(groupId);
|
|
10637
|
-
return;
|
|
10638
|
-
}
|
|
10639
|
-
if (e.target.closest('.sg-delete')) {
|
|
10640
|
-
confirmModal({
|
|
10641
|
-
title: 'Delete Group',
|
|
10642
|
-
message: `Delete group “${group.name}”? Its sessions and projects go back to Projects.`,
|
|
10643
|
-
okLabel: 'Delete',
|
|
10644
|
-
}).then((ok) => {
|
|
10645
|
-
if (!ok) return;
|
|
10646
|
-
sgDeleteGroup(groupId);
|
|
10647
|
-
renderSessions();
|
|
10648
|
-
});
|
|
10649
|
-
return;
|
|
10650
|
-
}
|
|
10651
|
-
} else if (!sgHeader.dataset.transientGroup) return;
|
|
10652
11423
|
}
|
|
10653
11424
|
|
|
10654
11425
|
const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
|
|
@@ -10914,22 +11685,22 @@ function initSidebarResize() {
|
|
|
10914
11685
|
}
|
|
10915
11686
|
|
|
10916
11687
|
function initPanelResize(panelId, handleId, cssVar) {
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
let startWidth;
|
|
11688
|
+
initResize(document.getElementById(panelId), document.getElementById(handleId), cssVar, panelWidthKey(panelId), 200);
|
|
11689
|
+
}
|
|
10920
11690
|
|
|
11691
|
+
function initResize(panel, handle, cssVar, key, min) {
|
|
11692
|
+
let startWidth;
|
|
10921
11693
|
_initDragResize(handle, {
|
|
10922
11694
|
onStart() {
|
|
10923
11695
|
startWidth = panel.offsetWidth;
|
|
10924
11696
|
panel.classList.add('resizing');
|
|
10925
11697
|
},
|
|
10926
11698
|
onMove(dx) {
|
|
10927
|
-
|
|
10928
|
-
panel.style.setProperty(cssVar, `${w}px`);
|
|
11699
|
+
panel.style.setProperty(cssVar, `${Math.max(min, startWidth - dx)}px`);
|
|
10929
11700
|
},
|
|
10930
11701
|
onEnd() {
|
|
10931
11702
|
panel.classList.remove('resizing');
|
|
10932
|
-
store.setItem(
|
|
11703
|
+
store.setItem(key, panel.style.getPropertyValue(cssVar));
|
|
10933
11704
|
},
|
|
10934
11705
|
});
|
|
10935
11706
|
}
|
|
@@ -11003,7 +11774,7 @@ function updateStickyBtnState() {
|
|
|
11003
11774
|
const isSticky = stickySessionIds.has(_infoModalSessionId);
|
|
11004
11775
|
stickyBtn.style.display = '';
|
|
11005
11776
|
stickyBtn.classList.toggle('active', isSticky);
|
|
11006
|
-
stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show
|
|
11777
|
+
stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show in the list';
|
|
11007
11778
|
const svg = stickyBtn.querySelector('svg');
|
|
11008
11779
|
if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
|
|
11009
11780
|
}
|
|
@@ -12504,6 +13275,15 @@ function markPaneSide(el, side, focused) {
|
|
|
12504
13275
|
|
|
12505
13276
|
const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
|
|
12506
13277
|
|
|
13278
|
+
function paneMenuLabel(pane, label) {
|
|
13279
|
+
const cut =
|
|
13280
|
+
pane.kind !== 'url' && pane.kind !== 'message' ? Math.max(label.lastIndexOf('/'), label.lastIndexOf('\\')) + 1 : 0;
|
|
13281
|
+
if (!cut || cut === label.length) {
|
|
13282
|
+
return `<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>`;
|
|
13283
|
+
}
|
|
13284
|
+
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>`;
|
|
13285
|
+
}
|
|
13286
|
+
|
|
12507
13287
|
function renderPaneTabs(sid, layout, split) {
|
|
12508
13288
|
// A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
|
|
12509
13289
|
if (paneDragId || paneRenameId) return;
|
|
@@ -12587,7 +13367,9 @@ function mountPane(sid, pane) {
|
|
|
12587
13367
|
view.dataset.key = key;
|
|
12588
13368
|
paneViews.appendChild(view);
|
|
12589
13369
|
paneFrames.set(key, view);
|
|
12590
|
-
loadPaneView(sid, pane, view)
|
|
13370
|
+
loadPaneView(sid, pane, view).then(() => {
|
|
13371
|
+
if (paneFocusWanted === key && activePaneView() === view) focusActivePane();
|
|
13372
|
+
});
|
|
12591
13373
|
while (paneFrames.size > PANE_FRAME_LIMIT) {
|
|
12592
13374
|
const [oldKey, oldView] = paneFrames.entries().next().value;
|
|
12593
13375
|
paneFrames.delete(oldKey);
|
|
@@ -12648,6 +13430,10 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12648
13430
|
: ['pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore];
|
|
12649
13431
|
const frame = createPreviewFrame(cls, doc);
|
|
12650
13432
|
frame.title = pane.title;
|
|
13433
|
+
// Focus given before the document loads stays on the frame element, out of the document's keys.
|
|
13434
|
+
frame.addEventListener('load', () => {
|
|
13435
|
+
if (document.activeElement === frame) frame.contentWindow?.focus();
|
|
13436
|
+
});
|
|
12651
13437
|
view.appendChild(frame);
|
|
12652
13438
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
12653
13439
|
return;
|
|
@@ -12694,6 +13480,7 @@ function appendPaneDoc(view) {
|
|
|
12694
13480
|
doc.className = 'pane-doc';
|
|
12695
13481
|
const body = document.createElement('div');
|
|
12696
13482
|
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
13483
|
+
body.tabIndex = -1;
|
|
12697
13484
|
doc.append(body, paneDocGrip(doc));
|
|
12698
13485
|
view.appendChild(doc);
|
|
12699
13486
|
return body;
|
|
@@ -12769,8 +13556,11 @@ async function reloadPaneView(sid, pane, view) {
|
|
|
12769
13556
|
paneReloads.delete(key);
|
|
12770
13557
|
fresh.className = view.className;
|
|
12771
13558
|
fresh.style.visibility = '';
|
|
13559
|
+
const hadFocus = view.contains(document.activeElement);
|
|
12772
13560
|
view.remove();
|
|
12773
13561
|
paneFrames.set(key, fresh);
|
|
13562
|
+
const target = hadFocus && paneFocusTarget(fresh);
|
|
13563
|
+
if (target) focusPaneTarget(target);
|
|
12774
13564
|
syncPaneReview();
|
|
12775
13565
|
return true;
|
|
12776
13566
|
}
|
|
@@ -12815,6 +13605,27 @@ function activePaneView() {
|
|
|
12815
13605
|
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
|
|
12816
13606
|
}
|
|
12817
13607
|
|
|
13608
|
+
// A URL page is on another origin and would keep every key from the board, so it gets no focus.
|
|
13609
|
+
function paneFocusTarget(view) {
|
|
13610
|
+
return view?.querySelector(':scope > .pane-doc > .pane-doc-body, :scope > .pane-frame:not(.pane-url-frame)');
|
|
13611
|
+
}
|
|
13612
|
+
|
|
13613
|
+
let paneFocusWanted = null;
|
|
13614
|
+
|
|
13615
|
+
// The key that switches panes also moves focus into the pane, so its content takes the scroll keys.
|
|
13616
|
+
function focusActivePane() {
|
|
13617
|
+
const view = activePaneView();
|
|
13618
|
+
const target = paneFocusTarget(view);
|
|
13619
|
+
paneFocusWanted = view && !view.firstElementChild ? view.dataset.key : null;
|
|
13620
|
+
if (target) focusPaneTarget(target);
|
|
13621
|
+
else if (paneViews.contains(document.activeElement)) document.activeElement.blur();
|
|
13622
|
+
}
|
|
13623
|
+
|
|
13624
|
+
function focusPaneTarget(target) {
|
|
13625
|
+
target.focus({ preventScroll: true });
|
|
13626
|
+
target.contentWindow?.focus();
|
|
13627
|
+
}
|
|
13628
|
+
|
|
12818
13629
|
function isPaneDocOnScreen() {
|
|
12819
13630
|
return !!activePaneView()?.querySelector('.modal-zoomable');
|
|
12820
13631
|
}
|
|
@@ -12994,16 +13805,22 @@ function paneMenuKeydown(e) {
|
|
|
12994
13805
|
async function deleteBoardTasks(onlyCompleted) {
|
|
12995
13806
|
const sid = paneSessionId();
|
|
12996
13807
|
if (!sid) return;
|
|
12997
|
-
const
|
|
13808
|
+
const doomed = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed');
|
|
13809
|
+
const count = doomed.length;
|
|
12998
13810
|
if (!count) {
|
|
12999
13811
|
showToast(onlyCompleted ? 'No completed tasks' : 'No tasks', 'info');
|
|
13000
13812
|
return;
|
|
13001
13813
|
}
|
|
13002
13814
|
const noun = `${count} ${onlyCompleted ? 'completed ' : ''}task${count === 1 ? '' : 's'}`;
|
|
13815
|
+
const shown = doomed.slice(0, 3).map((t) => ({ label: t.subject, meta: t.status.replace('_', ' ') }));
|
|
13816
|
+
if (doomed.length > shown.length) shown.push({ label: `+ ${doomed.length - shown.length} more`, meta: '' });
|
|
13003
13817
|
const ok = await confirmModal({
|
|
13004
|
-
title:
|
|
13005
|
-
message:
|
|
13006
|
-
|
|
13818
|
+
title: `Delete ${noun}?`,
|
|
13819
|
+
message: onlyCompleted
|
|
13820
|
+
? 'Every completed task in this session. This cannot be undone.'
|
|
13821
|
+
: 'Every task in this session. This cannot be undone.',
|
|
13822
|
+
items: shown,
|
|
13823
|
+
okLabel: `Delete ${noun}`,
|
|
13007
13824
|
});
|
|
13008
13825
|
if (!ok) return;
|
|
13009
13826
|
const q = onlyCompleted ? '?status=completed' : '';
|
|
@@ -13079,9 +13896,12 @@ function openPaneMenu(x, y, id) {
|
|
|
13079
13896
|
y,
|
|
13080
13897
|
pane.id,
|
|
13081
13898
|
'Pane actions',
|
|
13082
|
-
|
|
13899
|
+
paneMenuLabel(pane, label) +
|
|
13083
13900
|
(message ? '' : paneMenuItem('copy', url ? 'Copy URL' : 'Copy path')) +
|
|
13084
13901
|
paneMenuItem('open', url ? 'Open in new tab' : message ? 'Open in message dialog' : 'Open in preview') +
|
|
13902
|
+
(message
|
|
13903
|
+
? ''
|
|
13904
|
+
: paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
|
|
13085
13905
|
paneMenuItem('rename', 'Rename') +
|
|
13086
13906
|
paneMenuItem('reload', 'Reload') +
|
|
13087
13907
|
paneMenuItem('close', 'Close pane'),
|
|
@@ -13089,6 +13909,21 @@ function openPaneMenu(x, y, id) {
|
|
|
13089
13909
|
return true;
|
|
13090
13910
|
}
|
|
13091
13911
|
|
|
13912
|
+
function paneIsLinked(pane) {
|
|
13913
|
+
const key = canonicalPath(pane.target);
|
|
13914
|
+
return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
|
|
13915
|
+
}
|
|
13916
|
+
|
|
13917
|
+
function togglePaneLink(id) {
|
|
13918
|
+
const pane = paneById(id);
|
|
13919
|
+
const sid = paneSessionId();
|
|
13920
|
+
if (!pane || !sid) return;
|
|
13921
|
+
const unlink = paneIsLinked(pane);
|
|
13922
|
+
setSessionDocLink(sid, pane.target, unlink);
|
|
13923
|
+
if (unlink) forgetServerLinkedDoc(sid, pane.target);
|
|
13924
|
+
showToast(unlink ? 'Unlinked from session' : 'Linked to session', 'success');
|
|
13925
|
+
}
|
|
13926
|
+
|
|
13092
13927
|
function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
13093
13928
|
closePaneMenu();
|
|
13094
13929
|
sgCloseMenu();
|
|
@@ -13107,6 +13942,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
|
13107
13942
|
const PANE_MENU_ACTIONS = {
|
|
13108
13943
|
copy: copyPaneTarget,
|
|
13109
13944
|
open: openPaneExternally,
|
|
13945
|
+
link: togglePaneLink,
|
|
13110
13946
|
rename: beginPaneRename,
|
|
13111
13947
|
reload: reloadPane,
|
|
13112
13948
|
close: closePane,
|
|
@@ -13315,7 +14151,9 @@ paneStrip.addEventListener('click', (e) => {
|
|
|
13315
14151
|
return;
|
|
13316
14152
|
}
|
|
13317
14153
|
const tab = e.target.closest('.pane-tab');
|
|
13318
|
-
if (tab)
|
|
14154
|
+
if (!tab) return;
|
|
14155
|
+
selectPane(tab.dataset.pane);
|
|
14156
|
+
focusActivePane();
|
|
13319
14157
|
});
|
|
13320
14158
|
paneStrip.addEventListener('auxclick', (e) => {
|
|
13321
14159
|
const tab = e.target.closest('.pane-tab');
|
|
@@ -13579,7 +14417,6 @@ panePop.addEventListener('keydown', (e) => {
|
|
|
13579
14417
|
// leaving the session view or switching sessions drops only the socket; coming back
|
|
13580
14418
|
// reattaches and the server replays the screen.
|
|
13581
14419
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
13582
|
-
const TERMINAL_FOCUS_KEY = 'terminal-focus';
|
|
13583
14420
|
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
13584
14421
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
13585
14422
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
@@ -13686,6 +14523,7 @@ function toggleTerminal() {
|
|
|
13686
14523
|
setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
|
|
13687
14524
|
termState.focusNext = true;
|
|
13688
14525
|
syncTerminal();
|
|
14526
|
+
if (!wantsTerminal()) focusActivePane();
|
|
13689
14527
|
}
|
|
13690
14528
|
|
|
13691
14529
|
// The show card sits in the pane but is not the terminal, so Alt+` from it goes to the terminal.
|
|
@@ -13720,6 +14558,7 @@ function terminalShortcut(e, probe = false) {
|
|
|
13720
14558
|
if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
|
|
13721
14559
|
// Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
|
|
13722
14560
|
if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
|
|
14561
|
+
if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
|
|
13723
14562
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
13724
14563
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
13725
14564
|
}
|
|
@@ -13749,7 +14588,7 @@ function setSwapPair(last, previous) {
|
|
|
13749
14588
|
async function swapToPreviousSession() {
|
|
13750
14589
|
const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
|
|
13751
14590
|
if (!target || target === currentSessionId) return;
|
|
13752
|
-
if (terminalPaneFocused()) termState.focusNext = true;
|
|
14591
|
+
if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
|
|
13753
14592
|
await revealSession(target);
|
|
13754
14593
|
}
|
|
13755
14594
|
|
|
@@ -13848,9 +14687,12 @@ function takeTerminalFocus(sessionId) {
|
|
|
13848
14687
|
}
|
|
13849
14688
|
|
|
13850
14689
|
// The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
|
|
14690
|
+
// A session on the trust dialog has no transcript yet, so it may not be in `sessions`.
|
|
13851
14691
|
function promptAwaitsUser(sessionId) {
|
|
13852
|
-
if (
|
|
13853
|
-
|
|
14692
|
+
if (isAnyModalOpen() || document.activeElement?.matches(TEXT_FIELD_SELECTOR)) return false;
|
|
14693
|
+
if (trustWaiting.has(sessionId)) return true;
|
|
14694
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
14695
|
+
return !!session && isWaitingSession(session);
|
|
13854
14696
|
}
|
|
13855
14697
|
|
|
13856
14698
|
function onTerminalShown(focus = true) {
|
|
@@ -13867,6 +14709,8 @@ function focusTerminalPane() {
|
|
|
13867
14709
|
termFrame.el.focus();
|
|
13868
14710
|
terminalFrameSend('focus');
|
|
13869
14711
|
}
|
|
14712
|
+
// After a page load the frame may still be loading; its socket's ready takes the focus then.
|
|
14713
|
+
else termState.focusNext = true;
|
|
13870
14714
|
}
|
|
13871
14715
|
|
|
13872
14716
|
function leaveTerminalPane() {
|
|
@@ -13927,12 +14771,16 @@ function loadTerminalFrame(origin, timeoutMs) {
|
|
|
13927
14771
|
}
|
|
13928
14772
|
|
|
13929
14773
|
function sendTerminalTheme() {
|
|
13930
|
-
if (!termFrame.inited) return;
|
|
13931
14774
|
const options = terminalThemeOptions();
|
|
13932
14775
|
const key = JSON.stringify(options);
|
|
13933
|
-
|
|
13934
|
-
|
|
13935
|
-
|
|
14776
|
+
for (const [frame, post] of [
|
|
14777
|
+
[termFrame, postToTerminalFrame],
|
|
14778
|
+
[kanbotState, postToKanbotFrame],
|
|
14779
|
+
]) {
|
|
14780
|
+
if (!frame.inited || key === frame.theme) continue;
|
|
14781
|
+
frame.theme = key;
|
|
14782
|
+
post('theme', { options });
|
|
14783
|
+
}
|
|
13936
14784
|
}
|
|
13937
14785
|
|
|
13938
14786
|
// Keyed on the colors, and style is watched, because under the hub a theme change lands in two
|
|
@@ -13957,6 +14805,18 @@ window.addEventListener('message', (e) => {
|
|
|
13957
14805
|
onTerminalFrameMessage(m.type.slice(TERMINAL_MSG.length), m);
|
|
13958
14806
|
});
|
|
13959
14807
|
|
|
14808
|
+
function terminalInitOptions(themeOptions) {
|
|
14809
|
+
return {
|
|
14810
|
+
fontFamily:
|
|
14811
|
+
appConfig.terminal.fontFamily ||
|
|
14812
|
+
getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
|
|
14813
|
+
'monospace',
|
|
14814
|
+
fontSize: currentTerminalFontSize(),
|
|
14815
|
+
scrollback: appConfig.terminal.scrollback,
|
|
14816
|
+
themeOptions,
|
|
14817
|
+
};
|
|
14818
|
+
}
|
|
14819
|
+
|
|
13960
14820
|
function onTerminalFrameMessage(t, m) {
|
|
13961
14821
|
const socket = termState.socket;
|
|
13962
14822
|
const current = socket && m.socketId === socket.id;
|
|
@@ -13964,15 +14824,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
13964
14824
|
// A second load is the frame reloaded (its context menu), with no terminal and no socket.
|
|
13965
14825
|
const themeOptions = terminalThemeOptions();
|
|
13966
14826
|
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
|
-
});
|
|
14827
|
+
postToTerminalFrame('init', terminalInitOptions(themeOptions));
|
|
13976
14828
|
} else if (t === 'term') {
|
|
13977
14829
|
termFrame.inited = true;
|
|
13978
14830
|
pushTerminalClaims();
|
|
@@ -14077,17 +14929,21 @@ function pushTerminalClaims() {
|
|
|
14077
14929
|
queueMicrotask(() => {
|
|
14078
14930
|
terminalClaimsQueued = false;
|
|
14079
14931
|
const bridged = bridgedFrameEls();
|
|
14080
|
-
if (!termFrame.inited && !bridged.length) return;
|
|
14932
|
+
if (!termFrame.inited && !kanbotState.inited && !bridged.length) return;
|
|
14081
14933
|
const claims = keyClaims();
|
|
14082
14934
|
sendBridgeClaimsAll(bridged, claims);
|
|
14083
|
-
if (!termFrame.inited) return;
|
|
14084
14935
|
const sig = JSON.stringify(claims);
|
|
14085
|
-
|
|
14086
|
-
termFrame
|
|
14087
|
-
terminalFrameSend('claims', claims);
|
|
14936
|
+
sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
|
|
14937
|
+
sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
|
|
14088
14938
|
});
|
|
14089
14939
|
}
|
|
14090
14940
|
|
|
14941
|
+
function sendFrameClaims(frameState, claims, sig, send) {
|
|
14942
|
+
if (!frameState.inited || sig === frameState.claims) return;
|
|
14943
|
+
frameState.claims = sig;
|
|
14944
|
+
send('claims', claims);
|
|
14945
|
+
}
|
|
14946
|
+
|
|
14091
14947
|
function terminalTheme() {
|
|
14092
14948
|
const css = getComputedStyle(document.body);
|
|
14093
14949
|
const v = (name) => css.getPropertyValue(name).trim();
|
|
@@ -14217,7 +15073,7 @@ function setTerminalAttached(on) {
|
|
|
14217
15073
|
|
|
14218
15074
|
// A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
|
|
14219
15075
|
function syncCloseGuard() {
|
|
14220
|
-
const on = termState.attached && termState.shown;
|
|
15076
|
+
const on = (termState.attached && termState.shown) || kanbotOnScreen();
|
|
14221
15077
|
if (termState.closeGuard === on) return;
|
|
14222
15078
|
termState.closeGuard = on;
|
|
14223
15079
|
hub.closeGuard(on);
|
|
@@ -14231,6 +15087,7 @@ window.addEventListener('beforeunload', (e) => {
|
|
|
14231
15087
|
// The hub's palette takes focus first and clears activeElement here, so the last focus inside this
|
|
14232
15088
|
// page is tracked instead; focus leaving the frame keeps it.
|
|
14233
15089
|
let terminalHadFocus = false;
|
|
15090
|
+
let passTerminalZoneOn = false;
|
|
14234
15091
|
document.addEventListener('focusin', () => {
|
|
14235
15092
|
terminalHadFocus = terminalPaneFocused();
|
|
14236
15093
|
});
|
|
@@ -14247,7 +15104,14 @@ document.addEventListener('focusout', (e) => {
|
|
|
14247
15104
|
// Chrome fires this before focusin when a click or a focus() call in this page takes focus from the
|
|
14248
15105
|
// frame, so it waits a task and gives way to whatever took focus.
|
|
14249
15106
|
let boardPointerDown = false;
|
|
14250
|
-
document.addEventListener(
|
|
15107
|
+
document.addEventListener(
|
|
15108
|
+
'pointerdown',
|
|
15109
|
+
() => {
|
|
15110
|
+
boardPointerDown = true;
|
|
15111
|
+
passTerminalZoneOn = false;
|
|
15112
|
+
},
|
|
15113
|
+
true,
|
|
15114
|
+
);
|
|
14251
15115
|
for (const type of ['pointerup', 'pointercancel']) {
|
|
14252
15116
|
document.addEventListener(type, () => (boardPointerDown = false), true);
|
|
14253
15117
|
}
|
|
@@ -14260,14 +15124,24 @@ window.addEventListener('focus', () => {
|
|
|
14260
15124
|
}
|
|
14261
15125
|
});
|
|
14262
15126
|
});
|
|
15127
|
+
// A board with no session open passes the zone on, so a stop at an empty config dir keeps it. So does a
|
|
15128
|
+
// board whose session has no terminal for an inherited terminal zone, until the user picks a zone here.
|
|
14263
15129
|
window.addEventListener('pagehide', () => {
|
|
14264
|
-
if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(
|
|
14265
|
-
else
|
|
15130
|
+
if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(FOCUS_ZONE_KEY, 'terminal');
|
|
15131
|
+
else if (passTerminalZoneOn) return;
|
|
15132
|
+
else if (currentSessionId || focusZone === 'sidebar') tabStore.setItem(FOCUS_ZONE_KEY, focusZone);
|
|
14266
15133
|
});
|
|
14267
15134
|
|
|
14268
|
-
|
|
14269
|
-
|
|
14270
|
-
|
|
15135
|
+
// While the hub shows another app this frame cannot take focus, so terminalHadFocus lets the
|
|
15136
|
+
// window focus handler finish the restore when the hub focuses this frame.
|
|
15137
|
+
function restoreTerminalFocus(zone, sessionId) {
|
|
15138
|
+
if (zone !== 'terminal') return;
|
|
15139
|
+
if (!wantsTerminalFor(sessionId)) {
|
|
15140
|
+
passTerminalZoneOn = true;
|
|
15141
|
+
return;
|
|
15142
|
+
}
|
|
15143
|
+
termState.focusNext = true;
|
|
15144
|
+
terminalHadFocus = true;
|
|
14271
15145
|
}
|
|
14272
15146
|
|
|
14273
15147
|
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
@@ -14328,6 +15202,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
14328
15202
|
name: spec.name,
|
|
14329
15203
|
worktree: spec.worktree,
|
|
14330
15204
|
model: spec.model,
|
|
15205
|
+
effort: spec.effort,
|
|
14331
15206
|
prompt: spec.prompt,
|
|
14332
15207
|
edit: spec.edit,
|
|
14333
15208
|
forkOf: spec.forkOf,
|
|
@@ -14459,13 +15334,25 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
14459
15334
|
// Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
|
|
14460
15335
|
// Terminal mode must go too: left on, opening the session again reconnects in 'auto' and resumes claude.
|
|
14461
15336
|
async function closeTerminalSession(id = termState.sessionId) {
|
|
15337
|
+
exitTerminalMode(id);
|
|
15338
|
+
await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
|
|
15339
|
+
dropPlaceholder(id);
|
|
15340
|
+
}
|
|
15341
|
+
|
|
15342
|
+
function exitTerminalMode(id) {
|
|
14462
15343
|
if (id === termState.sessionId) {
|
|
14463
15344
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
14464
15345
|
detachTerminal();
|
|
14465
15346
|
}
|
|
14466
15347
|
setTerminalMode(id, false);
|
|
14467
15348
|
syncTerminal();
|
|
14468
|
-
|
|
15349
|
+
}
|
|
15350
|
+
|
|
15351
|
+
// An end from another tab or `dispatch end` must clear terminal mode here too, or focusing the
|
|
15352
|
+
// session reconnects in 'auto' and resumes claude. The mode set is shared by same-origin tabs, so the
|
|
15353
|
+
// tab that ended it may have cleared it already while this tab still shows its pane.
|
|
15354
|
+
function onTerminalEnded(id) {
|
|
15355
|
+
if (id === termState.sessionId || terminalModes().has(id)) exitTerminalMode(id);
|
|
14469
15356
|
dropPlaceholder(id);
|
|
14470
15357
|
}
|
|
14471
15358
|
|
|
@@ -14531,7 +15418,7 @@ async function renderTerminalManager() {
|
|
|
14531
15418
|
body.innerHTML = list
|
|
14532
15419
|
.map((t) => {
|
|
14533
15420
|
const session = sessions.find((s) => s.id === t.id);
|
|
14534
|
-
const name = session ? sessionDisplayName(session) : t.id.slice(0, 8);
|
|
15421
|
+
const name = t.kanbot ? 'Kanbot' : session ? sessionDisplayName(session) : t.id.slice(0, 8);
|
|
14535
15422
|
const here = t.id === termState.sessionId ? '<span class="terminal-manager-here">this tab</span>' : '';
|
|
14536
15423
|
const attached = t.clients ? `${t.clients} attached` : 'detached';
|
|
14537
15424
|
return `<div class="terminal-manager-row${t.clients ? ' attached' : ''}">
|
|
@@ -14547,8 +15434,8 @@ async function renderTerminalManager() {
|
|
|
14547
15434
|
</div>
|
|
14548
15435
|
</div>
|
|
14549
15436
|
<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>
|
|
15437
|
+
<button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}"${t.kanbot ? ' data-kanbot' : ''}>Open</button>
|
|
15438
|
+
<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
15439
|
</div>
|
|
14553
15440
|
</div>`;
|
|
14554
15441
|
})
|
|
@@ -14556,7 +15443,8 @@ async function renderTerminalManager() {
|
|
|
14556
15443
|
body.querySelectorAll('[data-open]').forEach((b) => {
|
|
14557
15444
|
b.onclick = () => {
|
|
14558
15445
|
closeTerminalManager();
|
|
14559
|
-
|
|
15446
|
+
if ('kanbot' in b.dataset) toggleKanbot(true);
|
|
15447
|
+
else showSessionTerminal(b.dataset.open);
|
|
14560
15448
|
};
|
|
14561
15449
|
});
|
|
14562
15450
|
body.querySelectorAll('[data-end]').forEach((b) => {
|
|
@@ -14574,15 +15462,254 @@ async function terminalFetch(url, method, body) {
|
|
|
14574
15462
|
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
14575
15463
|
}
|
|
14576
15464
|
|
|
15465
|
+
const TERMINAL_STATE_LABEL = { busy: 'working', wait: 'waiting', idle: 'idle' };
|
|
15466
|
+
|
|
14577
15467
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
14578
15468
|
async function endAllTerminals() {
|
|
14579
|
-
const
|
|
14580
|
-
|
|
15469
|
+
const items = [...document.querySelectorAll('#terminal-manager-body [data-end]')].map((b) => {
|
|
15470
|
+
const id = b.dataset.end;
|
|
15471
|
+
const session = sessions.find((s) => s.id === id);
|
|
15472
|
+
const state = !session ? 'idle' : isWaitingSession(session) ? 'wait' : isActiveSession(session) ? 'busy' : 'idle';
|
|
15473
|
+
const started = Number(b.dataset.started);
|
|
15474
|
+
const meta = [
|
|
15475
|
+
TERMINAL_STATE_LABEL[state],
|
|
15476
|
+
started && `up ${formatDuration(Date.now() - started)}`,
|
|
15477
|
+
formatTerminalProc(terminalProcStats[id]),
|
|
15478
|
+
]
|
|
15479
|
+
.filter(Boolean)
|
|
15480
|
+
.join(' · ');
|
|
15481
|
+
return { id, state, label: session ? sessionDisplayName(session) : id.slice(0, 8), meta };
|
|
15482
|
+
});
|
|
15483
|
+
const ids = items.map((i) => i.id);
|
|
15484
|
+
const notIdle = items.filter((i) => i.state !== 'idle').length;
|
|
15485
|
+
if (notIdle) {
|
|
15486
|
+
const ok = await confirmModal({
|
|
15487
|
+
title: `End ${ids.length} terminals?`,
|
|
15488
|
+
message: 'The Claude Code processes stop. Sessions stay on the board and can be resumed.',
|
|
15489
|
+
items,
|
|
15490
|
+
warn: `${notIdle} ${notIdle === 1 ? 'is' : 'are'} not idle. Work in progress is cut off.`,
|
|
15491
|
+
okLabel: `End ${ids.length} terminals`,
|
|
15492
|
+
});
|
|
15493
|
+
if (!ok) return;
|
|
15494
|
+
}
|
|
15495
|
+
await Promise.all(ids.map((id) => closeTerminalSession(id)));
|
|
14581
15496
|
renderTerminalManager();
|
|
14582
15497
|
}
|
|
14583
15498
|
|
|
14584
15499
|
//#endregion
|
|
14585
15500
|
|
|
15501
|
+
//#region KANBOT
|
|
15502
|
+
// The board's own assistant (docs/kanbot.md): a claude session the server keeps hidden from the list,
|
|
15503
|
+
// shown in a popover through a second terminal frame on the board terminal's frame origin.
|
|
15504
|
+
const KANBOT_SVG =
|
|
15505
|
+
'<svg viewBox="0 0 16 13" width="16" height="13" shape-rendering="crispEdges" aria-hidden="true">' +
|
|
15506
|
+
'<path fill="#d97757" d="M3 0h10v4h3v3h-3v3H3V7H0V4h3z"/>' +
|
|
15507
|
+
'<path fill="#d97757" d="M4 10h1v3H4zM6 10h1v3H6zM9 10h1v3H9zM11 10h1v3h-1z"/>' +
|
|
15508
|
+
'<path fill="#1f1e1d" d="M5 2h1v2H5zM10 2h1v2h-1z"/></svg>';
|
|
15509
|
+
const KANBOT_LAYOUT_KEY = 'cck-kanbot-layout';
|
|
15510
|
+
const KANBOT_WIDTH_KEY = 'cck-kanbot-width';
|
|
15511
|
+
const kanbotState = {
|
|
15512
|
+
el: null,
|
|
15513
|
+
frame: null,
|
|
15514
|
+
origin: null,
|
|
15515
|
+
ptyId: null,
|
|
15516
|
+
inited: false,
|
|
15517
|
+
attached: false,
|
|
15518
|
+
ended: false,
|
|
15519
|
+
socketId: 0,
|
|
15520
|
+
theme: null,
|
|
15521
|
+
claims: null,
|
|
15522
|
+
returnFocus: null,
|
|
15523
|
+
};
|
|
15524
|
+
|
|
15525
|
+
function kanbotFocused() {
|
|
15526
|
+
return !!kanbotState.el?.contains(document.activeElement);
|
|
15527
|
+
}
|
|
15528
|
+
|
|
15529
|
+
function kanbotAvailable() {
|
|
15530
|
+
return !!appConfig.kanbot && terminalAvailable();
|
|
15531
|
+
}
|
|
15532
|
+
|
|
15533
|
+
function kanbotButton() {
|
|
15534
|
+
const b = document.createElement('button');
|
|
15535
|
+
b.type = 'button';
|
|
15536
|
+
b.className = 'footer-kanbot';
|
|
15537
|
+
b.title = 'Kanbot, the board assistant';
|
|
15538
|
+
b.setAttribute('aria-label', b.title);
|
|
15539
|
+
b.innerHTML = KANBOT_SVG;
|
|
15540
|
+
b.onclick = toggleKanbot;
|
|
15541
|
+
return b;
|
|
15542
|
+
}
|
|
15543
|
+
|
|
15544
|
+
function ensureKanbotPopover() {
|
|
15545
|
+
if (kanbotState.el) return kanbotState.el;
|
|
15546
|
+
const el = document.createElement('div');
|
|
15547
|
+
el.className = 'kanbot-popover';
|
|
15548
|
+
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>`;
|
|
15549
|
+
el.querySelector('.kanbot-dock').onclick = () => setKanbotLayout(el.classList.contains('docked') ? '' : 'docked');
|
|
15550
|
+
el.querySelector('.kanbot-expand').onclick = () =>
|
|
15551
|
+
setKanbotLayout(el.classList.contains('expanded') ? '' : 'expanded');
|
|
15552
|
+
el.querySelector('.kanbot-collapse').onclick = () => toggleKanbot(false);
|
|
15553
|
+
el.querySelector('.kanbot-close').onclick = endKanbot;
|
|
15554
|
+
const width = store.getItem(KANBOT_WIDTH_KEY);
|
|
15555
|
+
if (width) el.style.setProperty('--kanbot-width', width);
|
|
15556
|
+
initResize(el, el.querySelector('.kanbot-resize'), '--kanbot-width', KANBOT_WIDTH_KEY, 320);
|
|
15557
|
+
document.body.appendChild(el);
|
|
15558
|
+
kanbotState.el = el;
|
|
15559
|
+
setKanbotLayout(store.getItem(KANBOT_LAYOUT_KEY) || '');
|
|
15560
|
+
return el;
|
|
15561
|
+
}
|
|
15562
|
+
|
|
15563
|
+
function setKanbotLayout(layout) {
|
|
15564
|
+
const el = kanbotState.el;
|
|
15565
|
+
const docked = layout === 'docked';
|
|
15566
|
+
const expanded = layout === 'expanded';
|
|
15567
|
+
el.classList.toggle('docked', docked);
|
|
15568
|
+
el.classList.toggle('expanded', expanded);
|
|
15569
|
+
const dock = el.querySelector('.kanbot-dock');
|
|
15570
|
+
dock.classList.toggle('active', docked);
|
|
15571
|
+
dock.title = docked ? 'Undock' : 'Dock to the right';
|
|
15572
|
+
dock.setAttribute('aria-label', dock.title);
|
|
15573
|
+
dock.setAttribute('aria-pressed', String(docked));
|
|
15574
|
+
const expand = el.querySelector('.kanbot-expand');
|
|
15575
|
+
expand.innerHTML = showIcon(expanded ? 'restore' : 'expand');
|
|
15576
|
+
expand.title = expanded ? 'Restore' : 'Expand';
|
|
15577
|
+
expand.setAttribute('aria-label', expand.title);
|
|
15578
|
+
store.setItem(KANBOT_LAYOUT_KEY, layout);
|
|
15579
|
+
postToKanbotFrame('focus');
|
|
15580
|
+
}
|
|
15581
|
+
|
|
15582
|
+
function setKanbotStatus(text) {
|
|
15583
|
+
kanbotState.el.querySelector('.kanbot-status').textContent = text;
|
|
15584
|
+
}
|
|
15585
|
+
|
|
15586
|
+
function postToKanbotFrame(t, data) {
|
|
15587
|
+
kanbotState.frame?.contentWindow?.postMessage({ ...data, type: TERMINAL_MSG + t }, kanbotState.origin);
|
|
15588
|
+
}
|
|
15589
|
+
|
|
15590
|
+
function kanbotOnScreen() {
|
|
15591
|
+
return kanbotState.attached && !!kanbotState.el?.classList.contains('visible');
|
|
15592
|
+
}
|
|
15593
|
+
|
|
15594
|
+
function endKanbot() {
|
|
15595
|
+
const id = kanbotState.ptyId;
|
|
15596
|
+
toggleKanbot(false);
|
|
15597
|
+
kanbotState.ptyId = null;
|
|
15598
|
+
if (id) terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
|
|
15599
|
+
}
|
|
15600
|
+
|
|
15601
|
+
function setKanbotAttached(on) {
|
|
15602
|
+
kanbotState.attached = on;
|
|
15603
|
+
syncCloseGuard();
|
|
15604
|
+
}
|
|
15605
|
+
|
|
15606
|
+
function toggleKanbot(force) {
|
|
15607
|
+
const el = ensureKanbotPopover();
|
|
15608
|
+
const show = typeof force === 'boolean' ? force : !el.classList.contains('visible');
|
|
15609
|
+
const wasShown = el.classList.contains('visible');
|
|
15610
|
+
el.classList.toggle('visible', show);
|
|
15611
|
+
if (!show) {
|
|
15612
|
+
const hadFocus = kanbotFocused();
|
|
15613
|
+
postToKanbotFrame('detach');
|
|
15614
|
+
setKanbotAttached(false);
|
|
15615
|
+
const back = kanbotState.returnFocus;
|
|
15616
|
+
kanbotState.returnFocus = null;
|
|
15617
|
+
if (hadFocus) {
|
|
15618
|
+
document.activeElement.blur();
|
|
15619
|
+
if (back === 'terminal') focusTerminalPane();
|
|
15620
|
+
else if (back?.isConnected) back.focus();
|
|
15621
|
+
}
|
|
15622
|
+
return;
|
|
15623
|
+
}
|
|
15624
|
+
if (!wasShown && !kanbotFocused()) {
|
|
15625
|
+
kanbotState.returnFocus = terminalPaneFocused() ? 'terminal' : document.activeElement;
|
|
15626
|
+
}
|
|
15627
|
+
kanbotState.frame?.focus();
|
|
15628
|
+
if (!kanbotState.frame) {
|
|
15629
|
+
const frame = document.createElement('iframe');
|
|
15630
|
+
frame.className = 'kanbot-frame';
|
|
15631
|
+
frame.title = 'Kanbot';
|
|
15632
|
+
frame.allow = 'clipboard-read; clipboard-write';
|
|
15633
|
+
kanbotState.origin = termFrame.sameOrigin ? location.origin : terminalFrameOrigin();
|
|
15634
|
+
frame.src = `${kanbotState.origin}/terminal.html#p=${encodeURIComponent(location.origin)}`;
|
|
15635
|
+
kanbotState.frame = frame;
|
|
15636
|
+
el.querySelector('.kanbot-body').appendChild(frame);
|
|
15637
|
+
return;
|
|
15638
|
+
}
|
|
15639
|
+
if (kanbotState.inited) {
|
|
15640
|
+
postToKanbotFrame('refresh');
|
|
15641
|
+
postToKanbotFrame('focus');
|
|
15642
|
+
if (!kanbotState.attached) openKanbot();
|
|
15643
|
+
}
|
|
15644
|
+
}
|
|
15645
|
+
|
|
15646
|
+
async function openKanbot() {
|
|
15647
|
+
setKanbotStatus('starting…');
|
|
15648
|
+
let res;
|
|
15649
|
+
try {
|
|
15650
|
+
res = await terminalFetch('/api/kanbot/start', 'POST', {});
|
|
15651
|
+
} catch {
|
|
15652
|
+
setKanbotStatus('board unreachable, retrying…');
|
|
15653
|
+
setTimeout(() => {
|
|
15654
|
+
if (kanbotState.el.classList.contains('visible') && !kanbotState.attached) openKanbot();
|
|
15655
|
+
}, 3000);
|
|
15656
|
+
return;
|
|
15657
|
+
}
|
|
15658
|
+
const data = await res.json().catch(() => ({}));
|
|
15659
|
+
if (!res.ok) return setKanbotStatus(data.error || `error ${res.status}`);
|
|
15660
|
+
kanbotState.ended = false;
|
|
15661
|
+
kanbotState.ptyId = data.ptyId;
|
|
15662
|
+
setKanbotStatus(data.sessionId ? data.sessionId.slice(0, 8) : '');
|
|
15663
|
+
postToKanbotFrame('open', {
|
|
15664
|
+
socketId: ++kanbotState.socketId,
|
|
15665
|
+
reset: true,
|
|
15666
|
+
hello: { token: terminalToken, id: data.ptyId, mode: 'auto' },
|
|
15667
|
+
});
|
|
15668
|
+
postToKanbotFrame('focus');
|
|
15669
|
+
}
|
|
15670
|
+
|
|
15671
|
+
window.addEventListener('message', (e) => {
|
|
15672
|
+
if (!kanbotState.frame || e.source !== kanbotState.frame.contentWindow || e.origin !== kanbotState.origin) return;
|
|
15673
|
+
const m = e.data;
|
|
15674
|
+
if (typeof m?.type !== 'string' || !m.type.startsWith(TERMINAL_MSG)) return;
|
|
15675
|
+
const t = m.type.slice(TERMINAL_MSG.length);
|
|
15676
|
+
if (t === 'loaded') {
|
|
15677
|
+
const themeOptions = terminalThemeOptions();
|
|
15678
|
+
kanbotState.inited = false;
|
|
15679
|
+
kanbotState.claims = null;
|
|
15680
|
+
kanbotState.theme = JSON.stringify(themeOptions);
|
|
15681
|
+
postToKanbotFrame('init', terminalInitOptions(themeOptions));
|
|
15682
|
+
return;
|
|
15683
|
+
}
|
|
15684
|
+
if (t === 'term') {
|
|
15685
|
+
kanbotState.inited = true;
|
|
15686
|
+
const claims = keyClaims();
|
|
15687
|
+
sendFrameClaims(kanbotState, claims, JSON.stringify(claims), postToKanbotFrame);
|
|
15688
|
+
// The first show created the frame after its focus call, so the frame takes focus once it is up.
|
|
15689
|
+
if (kanbotState.el.classList.contains('visible')) kanbotState.frame.focus();
|
|
15690
|
+
openKanbot();
|
|
15691
|
+
return;
|
|
15692
|
+
}
|
|
15693
|
+
if (t === 'key') {
|
|
15694
|
+
replayKey(m, document.getElementById('terminal-key-proxy'));
|
|
15695
|
+
return;
|
|
15696
|
+
}
|
|
15697
|
+
if (m.socketId !== kanbotState.socketId) return;
|
|
15698
|
+
if (t === 'ws') {
|
|
15699
|
+
if (m.msg?.t === 'ready') setKanbotAttached(true);
|
|
15700
|
+
else if (m.msg?.t === 'exit') {
|
|
15701
|
+
setKanbotAttached(false);
|
|
15702
|
+
kanbotState.ended = true;
|
|
15703
|
+
setKanbotStatus('ended, reopen to resume');
|
|
15704
|
+
} else if (m.msg?.t === 'error') setKanbotStatus(m.msg.msg || 'error');
|
|
15705
|
+
} else if (t === 'close') {
|
|
15706
|
+
setKanbotAttached(false);
|
|
15707
|
+
if (!kanbotState.ended && kanbotState.el.classList.contains('visible')) setTimeout(openKanbot, 1000);
|
|
15708
|
+
}
|
|
15709
|
+
});
|
|
15710
|
+
|
|
15711
|
+
//#endregion
|
|
15712
|
+
|
|
14586
15713
|
//#region SHOW
|
|
14587
15714
|
// The card the session in the embedded terminal posts to with the show tool. It belongs to the
|
|
14588
15715
|
// terminal, not the session: /clear and the --resume picker change the session id, the terminal id
|
|
@@ -14593,6 +15720,7 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
|
|
|
14593
15720
|
const SHOW_MIN_W = 280;
|
|
14594
15721
|
const SHOW_MIN_H = 120;
|
|
14595
15722
|
const SHOW_INSET = 8;
|
|
15723
|
+
const SHOW_SANDBOX = 'allow-scripts allow-forms';
|
|
14596
15724
|
const SHOW_CSP =
|
|
14597
15725
|
"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
15726
|
// The card's token contract (the show skill lists these names) mapped to the board's theme vars.
|
|
@@ -14641,6 +15769,7 @@ const SHOW_ICONS = {
|
|
|
14641
15769
|
uncollapse: '<path d="M6 9l6 6 6-6"/>',
|
|
14642
15770
|
expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
|
|
14643
15771
|
restore: '<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/>',
|
|
15772
|
+
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
15773
|
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
15774
|
close: '<path d="M18 6L6 18M6 6l12 12"/>',
|
|
14646
15775
|
pane: '<use href="#icon-pane"/>',
|
|
@@ -14698,7 +15827,7 @@ function syncShowFocus() {
|
|
|
14698
15827
|
// An HTML card's own keys and scroll keys reach it only inside its frame.
|
|
14699
15828
|
function focusShowBody() {
|
|
14700
15829
|
const frame = showState.frame;
|
|
14701
|
-
if (frame?.isConnected) frame.focus();
|
|
15830
|
+
if (frame?.isConnected && frame.loaded) frame.focus();
|
|
14702
15831
|
else showCardEl()?.querySelector('.show-body')?.focus();
|
|
14703
15832
|
}
|
|
14704
15833
|
|
|
@@ -14721,8 +15850,12 @@ function showCardKey(e) {
|
|
|
14721
15850
|
}
|
|
14722
15851
|
const p = showState.posts[showState.idx];
|
|
14723
15852
|
if (combo !== 'ctrl+d' || !p) return false;
|
|
14724
|
-
|
|
14725
|
-
|
|
15853
|
+
confirmModal({
|
|
15854
|
+
title: 'Remove this post?',
|
|
15855
|
+
message: 'It is taken off the card.',
|
|
15856
|
+
chip: p.title,
|
|
15857
|
+
okLabel: 'Remove post',
|
|
15858
|
+
}).then((ok) => {
|
|
14726
15859
|
if (!ok || showState.posts[showState.idx]?.id !== p.id) return;
|
|
14727
15860
|
showCommand('remove');
|
|
14728
15861
|
focusAfterShowRemove();
|
|
@@ -14995,11 +16128,15 @@ function setShowReview(opts) {
|
|
|
14995
16128
|
// No allow-popups: it opens window.open as a way out. The CSP blocks fetch and remote sub-resources,
|
|
14996
16129
|
// but not navigation or WebRTC; the real guard is that only the terminal's own agent can post.
|
|
14997
16130
|
function createShowFrame(title, srcdoc) {
|
|
14998
|
-
const frame = createPreviewFrame('show-frame', srcdoc,
|
|
16131
|
+
const frame = createPreviewFrame('show-frame', srcdoc, SHOW_SANDBOX);
|
|
14999
16132
|
frame.title = title;
|
|
15000
|
-
// Focus given before
|
|
16133
|
+
// Focus given before the first document loads stays on the frame element, out of the document's
|
|
16134
|
+
// keys, and the document never reports focus or blur, so the body holds focus until then.
|
|
15001
16135
|
frame.addEventListener('load', () => {
|
|
15002
|
-
|
|
16136
|
+
const waited = !frame.loaded && document.activeElement === frame.parentElement;
|
|
16137
|
+
frame.loaded = true;
|
|
16138
|
+
if (waited) frame.focus();
|
|
16139
|
+
else if (frame === document.activeElement) frame.contentWindow?.focus();
|
|
15003
16140
|
});
|
|
15004
16141
|
showState.frame = frame;
|
|
15005
16142
|
showState.frameFocused = false;
|
|
@@ -15313,7 +16450,6 @@ function placeholderSession(id, spec) {
|
|
|
15313
16450
|
mode: spec.mode,
|
|
15314
16451
|
edit: !!spec.edit,
|
|
15315
16452
|
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
15316
|
-
dispatchGroup: spec.group || undefined,
|
|
15317
16453
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
15318
16454
|
project: spec.cwd,
|
|
15319
16455
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
@@ -15332,7 +16468,22 @@ function mergePlaceholders(list) {
|
|
|
15332
16468
|
for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
|
|
15333
16469
|
}
|
|
15334
16470
|
const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
|
|
15335
|
-
|
|
16471
|
+
const placeholders = pending.map(([id, spec]) => placeholderSession(id, spec));
|
|
16472
|
+
revealNewPlaceholders(placeholders);
|
|
16473
|
+
return [...placeholders, ...list];
|
|
16474
|
+
}
|
|
16475
|
+
|
|
16476
|
+
// Ids whose sections were expanded once; a section the user collapses again stays collapsed.
|
|
16477
|
+
const revealedPlaceholderIds = new Set();
|
|
16478
|
+
|
|
16479
|
+
function revealNewPlaceholders(placeholders) {
|
|
16480
|
+
let changed = false;
|
|
16481
|
+
for (const p of placeholders) {
|
|
16482
|
+
if (revealedPlaceholderIds.has(p.id)) continue;
|
|
16483
|
+
revealedPlaceholderIds.add(p.id);
|
|
16484
|
+
if (uncollapseFor(p)) changed = true;
|
|
16485
|
+
}
|
|
16486
|
+
if (changed) persistCollapsedGroups();
|
|
15336
16487
|
}
|
|
15337
16488
|
|
|
15338
16489
|
async function loadDispatches() {
|
|
@@ -15382,14 +16533,18 @@ function forgetPlaceholder(id) {
|
|
|
15382
16533
|
return true;
|
|
15383
16534
|
}
|
|
15384
16535
|
|
|
15385
|
-
function renderPlaceholderCard(session) {
|
|
16536
|
+
function renderPlaceholderCard(session, depth) {
|
|
15386
16537
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
15387
16538
|
const projectHtml = renderProjectIdentity(session);
|
|
15388
16539
|
return `
|
|
15389
|
-
<button onclick="
|
|
16540
|
+
<button onclick="openSession('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
15390
16541
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
15391
16542
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
15392
|
-
|
|
16543
|
+
${
|
|
16544
|
+
trustWaiting.has(session.id)
|
|
16545
|
+
? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
|
|
16546
|
+
: `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
|
|
16547
|
+
}
|
|
15393
16548
|
</button>
|
|
15394
16549
|
`;
|
|
15395
16550
|
}
|
|
@@ -15399,9 +16554,8 @@ async function restorePendingSessions() {
|
|
|
15399
16554
|
document.getElementById('new-session-btn').hidden = !terminalAvailable();
|
|
15400
16555
|
const terminals = terminalAvailable() ? loadTerminals() : null;
|
|
15401
16556
|
await loadDispatches();
|
|
15402
|
-
if (!terminals) return;
|
|
15403
16557
|
try {
|
|
15404
|
-
for (const t of await terminals) {
|
|
16558
|
+
for (const t of terminals ? await terminals : []) {
|
|
15405
16559
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
15406
16560
|
newSpecs.set(t.id, {
|
|
15407
16561
|
cwd: t.cwd,
|
|
@@ -15414,6 +16568,8 @@ async function restorePendingSessions() {
|
|
|
15414
16568
|
});
|
|
15415
16569
|
}
|
|
15416
16570
|
} catch (_) {}
|
|
16571
|
+
// Sessions that were already running keep the collapse state saved before the reload.
|
|
16572
|
+
for (const id of [...newSpecs.keys(), ...dispatchSpecs.keys()]) revealedPlaceholderIds.add(id);
|
|
15417
16573
|
}
|
|
15418
16574
|
|
|
15419
16575
|
async function loadTerminals() {
|
|
@@ -15422,6 +16578,7 @@ async function loadTerminals() {
|
|
|
15422
16578
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
15423
16579
|
const list = data.sessions || [];
|
|
15424
16580
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
16581
|
+
setTrustWaiting(list.filter((t) => t.awaitingTrust).map((t) => t.id));
|
|
15425
16582
|
rememberTerminalIds(list);
|
|
15426
16583
|
syncShowCard();
|
|
15427
16584
|
pushTerminalClaims();
|
|
@@ -15429,8 +16586,26 @@ async function loadTerminals() {
|
|
|
15429
16586
|
return list;
|
|
15430
16587
|
}
|
|
15431
16588
|
|
|
15432
|
-
|
|
16589
|
+
// A dispatched session never had its terminal toggled on, so the dialog would stay out of sight.
|
|
16590
|
+
function setTrustWaiting(ids) {
|
|
16591
|
+
const before = trustWaiting;
|
|
16592
|
+
trustWaiting = new Set(ids);
|
|
16593
|
+
for (const id of trustWaiting) {
|
|
16594
|
+
if (before.has(id)) continue;
|
|
16595
|
+
setTerminalMode(id, true);
|
|
16596
|
+
if (id !== currentSessionId || viewMode !== 'session') continue;
|
|
16597
|
+
if (termState.shown && termState.attached) {
|
|
16598
|
+
if (promptAwaitsUser(id)) focusTerminalPane();
|
|
16599
|
+
} else {
|
|
16600
|
+
termState.openedByUser = id;
|
|
16601
|
+
syncTerminal();
|
|
16602
|
+
}
|
|
16603
|
+
}
|
|
16604
|
+
}
|
|
16605
|
+
|
|
16606
|
+
function setRunningTerminals(ids, awaitingTrust = []) {
|
|
15433
16607
|
runningTerminals = new Set(ids);
|
|
16608
|
+
setTrustWaiting(awaitingTrust);
|
|
15434
16609
|
forgetEndedTerminals();
|
|
15435
16610
|
syncShowCard();
|
|
15436
16611
|
pushTerminalClaims();
|
|
@@ -15454,10 +16629,12 @@ function openNewSession(folder, resume = false) {
|
|
|
15454
16629
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
15455
16630
|
document.getElementById('ns-wt').checked = false;
|
|
15456
16631
|
document.getElementById('ns-model').value = '';
|
|
16632
|
+
document.getElementById('ns-effort').value = '';
|
|
15457
16633
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
15458
16634
|
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
15459
16635
|
setNewSessionError('');
|
|
15460
|
-
const
|
|
16636
|
+
const session = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
16637
|
+
const current = session?.worktree?.repo || session?.project;
|
|
15461
16638
|
setNewSessionFolder(folder || current || '');
|
|
15462
16639
|
document.getElementById('new-session-modal').classList.add('visible');
|
|
15463
16640
|
const input = document.getElementById('ns-folder');
|
|
@@ -15561,6 +16738,7 @@ function newSessionValues() {
|
|
|
15561
16738
|
name: document.getElementById('ns-name').value.trim() || null,
|
|
15562
16739
|
worktree: wtOn ? wtName || true : false,
|
|
15563
16740
|
model: document.getElementById('ns-model').value || null,
|
|
16741
|
+
effort: document.getElementById('ns-effort').value || null,
|
|
15564
16742
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
15565
16743
|
edit,
|
|
15566
16744
|
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
@@ -15769,7 +16947,7 @@ function initNewSession() {
|
|
|
15769
16947
|
store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
|
|
15770
16948
|
renderNewSessionForm();
|
|
15771
16949
|
});
|
|
15772
|
-
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
16950
|
+
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
|
|
15773
16951
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
15774
16952
|
}
|
|
15775
16953
|
if (SpeechRecognitionApi) {
|
|
@@ -15884,7 +17062,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
15884
17062
|
|
|
15885
17063
|
loadSidebarState();
|
|
15886
17064
|
for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
|
|
15887
|
-
|
|
17065
|
+
syncSessionGroups();
|
|
15888
17066
|
initSessionGroupsDnd();
|
|
15889
17067
|
initSessionPicker();
|
|
15890
17068
|
initProjectPicker();
|
|
@@ -15896,6 +17074,7 @@ loadModalFullscreen();
|
|
|
15896
17074
|
initModalResize();
|
|
15897
17075
|
loadPanelWidths();
|
|
15898
17076
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
|
|
17077
|
+
initDetailLayout();
|
|
15899
17078
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
|
|
15900
17079
|
initMessagePanelFull();
|
|
15901
17080
|
|
|
@@ -15975,13 +17154,15 @@ function makeLimitSpan(rl) {
|
|
|
15975
17154
|
function renderSidebarFooter(rateLimits) {
|
|
15976
17155
|
const el = document.getElementById('sidebar-footer');
|
|
15977
17156
|
if (!el) return;
|
|
17157
|
+
footerState.rl = rateLimits;
|
|
15978
17158
|
const fh = rateLimits?.five_hour?.used_percentage ?? null;
|
|
15979
17159
|
const sd = rateLimits?.seven_day?.used_percentage ?? null;
|
|
15980
17160
|
const children = [];
|
|
15981
17161
|
if (footerState.version) {
|
|
15982
17162
|
const v = document.createElement('span');
|
|
15983
17163
|
v.className = 'footer-version';
|
|
15984
|
-
|
|
17164
|
+
if (kanbotAvailable()) v.append(kanbotButton());
|
|
17165
|
+
v.append(`v${footerState.version}`);
|
|
15985
17166
|
const warn = pluginWarning(footerState.plugin);
|
|
15986
17167
|
if (warn) v.append(warn);
|
|
15987
17168
|
children.push(v);
|
|
@@ -16061,8 +17242,7 @@ searchQuery = urlState.search || '';
|
|
|
16061
17242
|
|
|
16062
17243
|
renderFilterState();
|
|
16063
17244
|
renderZenState();
|
|
16064
|
-
|
|
16065
|
-
stickySessionIds = loadStickySessions();
|
|
17245
|
+
syncSessionPins();
|
|
16066
17246
|
setupEventSource();
|
|
16067
17247
|
|
|
16068
17248
|
if (urlState.search) {
|
|
@@ -16078,6 +17258,7 @@ if (urlState.search) {
|
|
|
16078
17258
|
getJson('/api/config')
|
|
16079
17259
|
.then((c) => {
|
|
16080
17260
|
if (c) appConfig = c;
|
|
17261
|
+
if (appConfig.kanbot && footerState.version) renderSidebarFooter(footerState.rl);
|
|
16081
17262
|
})
|
|
16082
17263
|
.then(restorePendingSessions)
|
|
16083
17264
|
.then(() =>
|
|
@@ -16087,6 +17268,7 @@ getJson('/api/config')
|
|
|
16087
17268
|
),
|
|
16088
17269
|
)
|
|
16089
17270
|
.then(async () => {
|
|
17271
|
+
const focusZoneBeforeReload = tabStore.getItem(FOCUS_ZONE_KEY);
|
|
16090
17272
|
if (urlState.projectView) {
|
|
16091
17273
|
try {
|
|
16092
17274
|
await fetchProjectView(atob(urlState.projectView));
|
|
@@ -16094,7 +17276,7 @@ getJson('/api/config')
|
|
|
16094
17276
|
showNoSession();
|
|
16095
17277
|
}
|
|
16096
17278
|
} else if (urlState.session) {
|
|
16097
|
-
restoreTerminalFocus(urlState.session);
|
|
17279
|
+
restoreTerminalFocus(focusZoneBeforeReload, urlState.session);
|
|
16098
17280
|
await fetchTasks(urlState.session);
|
|
16099
17281
|
} else {
|
|
16100
17282
|
const last = loadLastView();
|
|
@@ -16105,12 +17287,13 @@ getJson('/api/config')
|
|
|
16105
17287
|
showNoSession();
|
|
16106
17288
|
}
|
|
16107
17289
|
} else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
|
|
16108
|
-
restoreTerminalFocus(last.session);
|
|
17290
|
+
restoreTerminalFocus(focusZoneBeforeReload, last.session);
|
|
16109
17291
|
await fetchTasks(last.session);
|
|
16110
17292
|
} else {
|
|
16111
17293
|
showNoSession();
|
|
16112
17294
|
}
|
|
16113
17295
|
}
|
|
17296
|
+
if (focusZoneBeforeReload === 'sidebar') setFocusZone('sidebar');
|
|
16114
17297
|
if (urlState.messages && currentSessionId) {
|
|
16115
17298
|
if (logAutoOpened) setMessagePanelVisible(false);
|
|
16116
17299
|
toggleMessagePanel();
|