claude-code-kanban 7.20.0 → 7.22.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/cli.js +334 -19
- package/hub-app.json +6 -0
- package/lib/addon-runner.js +177 -0
- package/lib/addons.js +238 -0
- package/lib/panes.js +14 -1
- package/lib/parent-cache.js +5 -1
- package/lib/retention.js +2 -1
- package/lib/terminal.js +5 -3
- package/lib/write-json-atomic.js +16 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/addon/SKILL.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/addon/references/build.md +77 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +2 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -0
- package/public/app.js +904 -163
- package/public/index.html +9 -4
- package/public/style.css +360 -72
- package/public/themes.css +93 -93
- package/server.js +165 -29
- package/skill-guides/dispatch.md +5 -2
package/public/app.js
CHANGED
|
@@ -1939,6 +1939,8 @@ const ICON_AGENT_ACTIVE =
|
|
|
1939
1939
|
const ICON_TERMINAL =
|
|
1940
1940
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
|
|
1941
1941
|
const TERMINAL_TITLE = 'Running in a terminal here';
|
|
1942
|
+
const ICON_FORK =
|
|
1943
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/></svg>';
|
|
1942
1944
|
const ICON_DISPATCHED =
|
|
1943
1945
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>';
|
|
1944
1946
|
const ICON_CHAT =
|
|
@@ -2767,32 +2769,58 @@ function createResizeGrip({ onReset, ...drag }) {
|
|
|
2767
2769
|
}
|
|
2768
2770
|
|
|
2769
2771
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
2770
|
-
const
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2772
|
+
const SHOW_ZOOM_KEY = 'show-zoom';
|
|
2773
|
+
const ZOOM_MIN = 0.7;
|
|
2774
|
+
const ZOOM_MAX = 2.0;
|
|
2775
|
+
let modalZoom = readZoom(MODAL_ZOOM_KEY);
|
|
2776
|
+
let showZoom = readZoom(SHOW_ZOOM_KEY);
|
|
2773
2777
|
|
|
2774
|
-
function
|
|
2775
|
-
return Math.min(
|
|
2778
|
+
function clampZoom(v) {
|
|
2779
|
+
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v));
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function readZoom(key) {
|
|
2783
|
+
return clampZoom(Number.parseFloat(store.getItem(key)) || 1);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
function zoomLabel(z) {
|
|
2787
|
+
return `${Math.round(z * 100)}%`;
|
|
2776
2788
|
}
|
|
2777
2789
|
|
|
2778
2790
|
function modalZoomLabel() {
|
|
2779
|
-
return
|
|
2791
|
+
return zoomLabel(modalZoom);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// delta 0 resets to 100%. At a limit it returns z as is; else it saves and toasts the new zoom.
|
|
2795
|
+
function stepZoom(z, delta, key, label) {
|
|
2796
|
+
const next = delta === 0 ? 1 : clampZoom(Math.round((z + delta) * 10) / 10);
|
|
2797
|
+
if (next === z && delta !== 0) return z;
|
|
2798
|
+
store.setItem(key, String(next));
|
|
2799
|
+
showToast(`${label} ${zoomLabel(next)}`);
|
|
2800
|
+
return next;
|
|
2780
2801
|
}
|
|
2781
2802
|
|
|
2782
2803
|
function applyModalZoom() {
|
|
2783
2804
|
document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
|
|
2784
2805
|
for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
|
|
2785
|
-
postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }));
|
|
2806
|
+
postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }), document.querySelectorAll(PANE_SHOW_FRAMES));
|
|
2786
2807
|
}
|
|
2787
2808
|
|
|
2788
|
-
// delta 0 resets to 100%
|
|
2789
2809
|
function adjustModalZoom(delta) {
|
|
2790
|
-
|
|
2791
|
-
if (next === modalZoom && delta !== 0) return;
|
|
2792
|
-
modalZoom = next;
|
|
2793
|
-
store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
2810
|
+
modalZoom = stepZoom(modalZoom, delta, MODAL_ZOOM_KEY, 'Text');
|
|
2794
2811
|
applyModalZoom();
|
|
2795
|
-
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
// The show card above the terminal keeps its own text zoom: it sits beside a pane, so one
|
|
2815
|
+
// shared scale would resize both.
|
|
2816
|
+
function applyShowZoom() {
|
|
2817
|
+
document.documentElement.style.setProperty('--show-zoom', String(showZoom));
|
|
2818
|
+
postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: showZoom }), [showState.frame]);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
function adjustShowZoom(delta) {
|
|
2822
|
+
showZoom = stepZoom(showZoom, delta, SHOW_ZOOM_KEY, 'Card text');
|
|
2823
|
+
applyShowZoom();
|
|
2796
2824
|
}
|
|
2797
2825
|
|
|
2798
2826
|
// The markup decides what scales: `.modal-zoomable` marks a modal's reading
|
|
@@ -2806,9 +2834,12 @@ function isAnyModalOpen() {
|
|
|
2806
2834
|
return document.querySelector('.modal-overlay.visible') !== null;
|
|
2807
2835
|
}
|
|
2808
2836
|
|
|
2809
|
-
// An open modal owns the
|
|
2810
|
-
function
|
|
2811
|
-
|
|
2837
|
+
// The zoom the keys change, or null. An open modal owns the keys even when it has nothing to scale.
|
|
2838
|
+
function zoomTarget() {
|
|
2839
|
+
if (isAnyModalOpen()) return isZoomableModalOpen() ? adjustModalZoom : null;
|
|
2840
|
+
if (isAskOnScreen()) return adjustModalZoom;
|
|
2841
|
+
if (isShowCardFocused()) return adjustShowZoom;
|
|
2842
|
+
return isPaneDocOnScreen() ? adjustModalZoom : null;
|
|
2812
2843
|
}
|
|
2813
2844
|
|
|
2814
2845
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
@@ -3955,6 +3986,10 @@ function renderSessions() {
|
|
|
3955
3986
|
const childDepth = new Map();
|
|
3956
3987
|
const parentOf = new Map();
|
|
3957
3988
|
for (const g of sessionGroups) for (const m of g.members) if (m.parent) parentOf.set(m.ref, m.parent);
|
|
3989
|
+
childAnchors = computeChildAnchors(filteredSessions);
|
|
3990
|
+
for (const s of filteredSessions) {
|
|
3991
|
+
if (childAnchors.has(s.id) && !parentOf.has(s.id)) parentOf.set(s.id, s.dispatched.parent);
|
|
3992
|
+
}
|
|
3958
3993
|
const nestChildren = (list) => {
|
|
3959
3994
|
if (parentOf.size === 0) return list;
|
|
3960
3995
|
const ids = new Set(list.map((s) => s.id));
|
|
@@ -3982,16 +4017,15 @@ function renderSessions() {
|
|
|
3982
4017
|
return out;
|
|
3983
4018
|
};
|
|
3984
4019
|
|
|
3985
|
-
const renderSessionCard = (session) => {
|
|
3986
|
-
if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
|
|
4020
|
+
const renderSessionCard = (session, hostProject = null) => {
|
|
4021
|
+
if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id), hostProject);
|
|
3987
4022
|
const total = session.taskCount;
|
|
3988
4023
|
const pct = (n) => Math.round((n / total) * 100);
|
|
3989
4024
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3990
4025
|
const isLive = isSessionLive(session);
|
|
3991
4026
|
const sessionName = sessionDisplayName(session);
|
|
3992
|
-
const projectHtml = renderProjectIdentity(session);
|
|
4027
|
+
const projectHtml = renderProjectIdentity(session, hostProject);
|
|
3993
4028
|
|
|
3994
|
-
const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
|
|
3995
4029
|
const isTeam = session.isTeam;
|
|
3996
4030
|
const memberCount = session.memberCount || 0;
|
|
3997
4031
|
|
|
@@ -4025,8 +4059,7 @@ function renderSessions() {
|
|
|
4025
4059
|
<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))}">
|
|
4026
4060
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
4027
4061
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
4028
|
-
${projectHtml
|
|
4029
|
-
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
4062
|
+
${renderSessionIdentity(projectHtml, session.gitBranch)}
|
|
4030
4063
|
${metricsHtml ? `<div class="session-metrics">${metricsHtml}</div>` : ''}
|
|
4031
4064
|
<div class="session-footer">
|
|
4032
4065
|
<div class="session-time">${formatDate(session.modifiedAt)}</div>
|
|
@@ -4042,6 +4075,7 @@ function renderSessions() {
|
|
|
4042
4075
|
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
|
|
4043
4076
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
4044
4077
|
${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>` : ''}
|
|
4078
|
+
${session.forkedFromSessionId ? `<span class="fork-badge" onclick="event.stopPropagation(); revealSession('${escAttrJs(session.forkedFromSessionId)}')" title="Forked from ${escapeHtml(session.forkedFromSessionId)}, click to reveal">${ICON_FORK}</span>` : ''}
|
|
4045
4079
|
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
|
|
4046
4080
|
${isWaitingSession(session) ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
4047
4081
|
${session.hasRunningAgents && !isWaitingSession(session) ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
@@ -4072,7 +4106,7 @@ function renderSessions() {
|
|
|
4072
4106
|
SECTION_OUTSIDE,
|
|
4073
4107
|
`Outside current filters ${countHtml(outside.rows)}`,
|
|
4074
4108
|
false,
|
|
4075
|
-
collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
|
|
4109
|
+
collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map((s) => renderSessionCard(s)).join(''),
|
|
4076
4110
|
)
|
|
4077
4111
|
: '';
|
|
4078
4112
|
|
|
@@ -4131,13 +4165,18 @@ function renderSessions() {
|
|
|
4131
4165
|
return;
|
|
4132
4166
|
}
|
|
4133
4167
|
|
|
4168
|
+
const nestedCards = (list) =>
|
|
4169
|
+
nestChildren(list)
|
|
4170
|
+
.map((s) => renderSessionCard(s))
|
|
4171
|
+
.join('');
|
|
4134
4172
|
const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
|
|
4135
4173
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
4136
4174
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
4137
|
-
const renderGroupSessions = (list, pinKey) => {
|
|
4138
|
-
|
|
4175
|
+
const renderGroupSessions = (list, pinKey, hostProject = null) => {
|
|
4176
|
+
const card = (s) => renderSessionCard(s, hostProject);
|
|
4177
|
+
if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(card).join('');
|
|
4139
4178
|
const gPinned = nestChildren(list.filter(isInPinnedGroup));
|
|
4140
|
-
if (gPinned.length === 0) return nestChildren(list).map(
|
|
4179
|
+
if (gPinned.length === 0) return nestChildren(list).map(card).join('');
|
|
4141
4180
|
const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
|
|
4142
4181
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
4143
4182
|
return (
|
|
@@ -4157,10 +4196,10 @@ function renderSessions() {
|
|
|
4157
4196
|
'<div class="pinned-sub-items' +
|
|
4158
4197
|
(pinCollapsed ? ' collapsed' : '') +
|
|
4159
4198
|
'">' +
|
|
4160
|
-
gPinned.map(
|
|
4199
|
+
gPinned.map(card).join('') +
|
|
4161
4200
|
'</div>' +
|
|
4162
4201
|
'</div>' +
|
|
4163
|
-
gUnpinned.map(
|
|
4202
|
+
gUnpinned.map(card).join('')
|
|
4164
4203
|
);
|
|
4165
4204
|
};
|
|
4166
4205
|
|
|
@@ -4192,7 +4231,7 @@ function renderSessions() {
|
|
|
4192
4231
|
</div>
|
|
4193
4232
|
<div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
4194
4233
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
|
|
4195
|
-
${renderGroupSessions(projectSessions, pinKey(projectPath))}
|
|
4234
|
+
${renderGroupSessions(projectSessions, pinKey(projectPath), projectPath)}
|
|
4196
4235
|
</div>
|
|
4197
4236
|
`;
|
|
4198
4237
|
};
|
|
@@ -4250,13 +4289,14 @@ function renderSessions() {
|
|
|
4250
4289
|
// Placed sessions follow the order the user dragged them into; a session that is here through
|
|
4251
4290
|
// its project takes the project's slot.
|
|
4252
4291
|
const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
|
|
4253
|
-
const slotOf = (
|
|
4292
|
+
const slotOf = (placed) => {
|
|
4293
|
+
const s = placementOf(placed);
|
|
4294
|
+
return slot.get(`session:${s.id}`) ?? slot.get(`project:${sessionProjectKey(s)}`) ?? 0;
|
|
4295
|
+
};
|
|
4254
4296
|
const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
|
|
4255
4297
|
let body = '';
|
|
4256
4298
|
if (flat) {
|
|
4257
|
-
body =
|
|
4258
|
-
.map(renderSessionCard)
|
|
4259
|
-
.join('');
|
|
4299
|
+
body = nestedCards([...bucket].sort(byMemberOrder));
|
|
4260
4300
|
} else {
|
|
4261
4301
|
const loose = [];
|
|
4262
4302
|
const byProject = new Map();
|
|
@@ -4310,7 +4350,7 @@ function renderSessions() {
|
|
|
4310
4350
|
const groups = new Map();
|
|
4311
4351
|
const ungrouped = [];
|
|
4312
4352
|
for (const session of sgRest) {
|
|
4313
|
-
const key = sessionProjectKey(session);
|
|
4353
|
+
const key = sessionProjectKey(placementOf(session));
|
|
4314
4354
|
if (key) {
|
|
4315
4355
|
if (!groups.has(key)) groups.set(key, []);
|
|
4316
4356
|
groups.get(key).push(session);
|
|
@@ -4361,7 +4401,7 @@ function renderSessions() {
|
|
|
4361
4401
|
</div>
|
|
4362
4402
|
`;
|
|
4363
4403
|
} else {
|
|
4364
|
-
projectsHtml += ungrouped
|
|
4404
|
+
projectsHtml += nestedCards(ungrouped);
|
|
4365
4405
|
}
|
|
4366
4406
|
}
|
|
4367
4407
|
|
|
@@ -4371,8 +4411,7 @@ function renderSessions() {
|
|
|
4371
4411
|
sessionsList.innerHTML = html + outsideHtml;
|
|
4372
4412
|
} else {
|
|
4373
4413
|
const sectioned = sessionGroups.length > 0;
|
|
4374
|
-
const restHtml =
|
|
4375
|
-
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
4414
|
+
const restHtml = sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : nestedCards(sgRest);
|
|
4376
4415
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
4377
4416
|
sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
|
|
4378
4417
|
}
|
|
@@ -4444,7 +4483,7 @@ function renderSession() {
|
|
|
4444
4483
|
const completed = currentTasks.filter((t) => t.status === 'completed').length;
|
|
4445
4484
|
const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
|
|
4446
4485
|
|
|
4447
|
-
progressPercent.textContent = `${percent}
|
|
4486
|
+
progressPercent.textContent = `${percent}% done`;
|
|
4448
4487
|
progressBar.style.width = `${percent}%`;
|
|
4449
4488
|
const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
|
|
4450
4489
|
progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
|
|
@@ -4468,7 +4507,7 @@ function renderProjectView() {
|
|
|
4468
4507
|
const completed = currentTasks.filter((t) => t.status === 'completed').length;
|
|
4469
4508
|
const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
|
|
4470
4509
|
|
|
4471
|
-
progressPercent.textContent = `${percent}
|
|
4510
|
+
progressPercent.textContent = `${percent}% done`;
|
|
4472
4511
|
progressBar.style.width = `${percent}%`;
|
|
4473
4512
|
const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
|
|
4474
4513
|
progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
|
|
@@ -4987,6 +5026,12 @@ function sgGroupById(id) {
|
|
|
4987
5026
|
// Matches MAX_DEPTH in lib/user-groups.js.
|
|
4988
5027
|
const SG_MAX_DEPTH = 8;
|
|
4989
5028
|
|
|
5029
|
+
function kbSelectedGroup() {
|
|
5030
|
+
if (focusZone !== 'sidebar') return null;
|
|
5031
|
+
const header = sessionsList.querySelector('.session-group-header.kb-selected');
|
|
5032
|
+
return header ? sgGroupById(header.dataset.groupId) : null;
|
|
5033
|
+
}
|
|
5034
|
+
|
|
4990
5035
|
function sgSubtreeIds(rootId) {
|
|
4991
5036
|
const ids = new Set([rootId]);
|
|
4992
5037
|
for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
|
|
@@ -5011,8 +5056,35 @@ function sgSessionParent(id) {
|
|
|
5011
5056
|
|
|
5012
5057
|
// An individually placed session wins over the placement of its project.
|
|
5013
5058
|
function sgGroupForSession(session) {
|
|
5014
|
-
const
|
|
5015
|
-
|
|
5059
|
+
const s = placementOf(session);
|
|
5060
|
+
const project = sessionProjectKey(s);
|
|
5061
|
+
return sgGroupOf('session', s.id) || (project ? sgGroupOf('project', project) : null);
|
|
5062
|
+
}
|
|
5063
|
+
|
|
5064
|
+
// A `dispatch start --child` session renders in its starter's place, unless the user placed it or
|
|
5065
|
+
// the starter is not in the rendered list. Set by renderSessions, so every other placement lookup
|
|
5066
|
+
// (drag, pins, search, reveal) agrees with what the sidebar shows.
|
|
5067
|
+
let childAnchors = new Map();
|
|
5068
|
+
const placementOf = (session) => childAnchors.get(session.id) ?? session;
|
|
5069
|
+
|
|
5070
|
+
function computeChildAnchors(list) {
|
|
5071
|
+
const out = new Map();
|
|
5072
|
+
if (!list.some((s) => s.dispatched?.child)) return out;
|
|
5073
|
+
const byId = new Map(list.map((s) => [s.id, s]));
|
|
5074
|
+
const placed = new Set();
|
|
5075
|
+
for (const g of sessionGroups) for (const m of g.members) if (m.type === 'session') placed.add(m.ref);
|
|
5076
|
+
const up = (s) => (s.dispatched?.child && !placed.has(s.id) ? byId.get(s.dispatched.parent) : null);
|
|
5077
|
+
for (const s of list) {
|
|
5078
|
+
let anchor = up(s);
|
|
5079
|
+
if (!anchor) continue;
|
|
5080
|
+
for (let hop = 1; hop < 4; hop++) {
|
|
5081
|
+
const next = up(anchor);
|
|
5082
|
+
if (!next || next === s) break;
|
|
5083
|
+
anchor = next;
|
|
5084
|
+
}
|
|
5085
|
+
out.set(s.id, anchor);
|
|
5086
|
+
}
|
|
5087
|
+
return out;
|
|
5016
5088
|
}
|
|
5017
5089
|
|
|
5018
5090
|
function sgListFiltered() {
|
|
@@ -5058,8 +5130,9 @@ function sgDetach(type, ref) {
|
|
|
5058
5130
|
|
|
5059
5131
|
// Where a session renders inside a group: the project block it was moved under, its own
|
|
5060
5132
|
// project's block, or - when the user lifted it out - the group's own loose list.
|
|
5061
|
-
function sgHostOf(group,
|
|
5062
|
-
if (!group || !
|
|
5133
|
+
function sgHostOf(group, placed) {
|
|
5134
|
+
if (!group || !placed) return null;
|
|
5135
|
+
const session = placementOf(placed);
|
|
5063
5136
|
const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
|
|
5064
5137
|
const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
|
|
5065
5138
|
if (m?.under && isMemberProject(m.under)) return m.under;
|
|
@@ -5944,7 +6017,8 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5944
6017
|
// (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
|
|
5945
6018
|
// Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
|
|
5946
6019
|
// whether anything changed.
|
|
5947
|
-
function uncollapseFor(
|
|
6020
|
+
function uncollapseFor(placed) {
|
|
6021
|
+
const session = placementOf(placed);
|
|
5948
6022
|
const group = sgGroupForSession(session);
|
|
5949
6023
|
const keys = [
|
|
5950
6024
|
...(group
|
|
@@ -5961,7 +6035,7 @@ function uncollapseFor(session) {
|
|
|
5961
6035
|
function pinnedCollapseKeys(session) {
|
|
5962
6036
|
if (!isInPinnedGroup(session)) return [];
|
|
5963
6037
|
const group = sgGroupForSession(session);
|
|
5964
|
-
if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
|
|
6038
|
+
if (!group) return [pinKey(sessionProjectKey(placementOf(session)) || '__ungrouped__')];
|
|
5965
6039
|
const host = sgHostOf(group, session);
|
|
5966
6040
|
return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
|
|
5967
6041
|
}
|
|
@@ -6512,7 +6586,7 @@ const SHORTCUT_TABS = [
|
|
|
6512
6586
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
6513
6587
|
{ keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
|
|
6514
6588
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
6515
|
-
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session
|
|
6589
|
+
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session, or delete the group under the cursor' },
|
|
6516
6590
|
{ keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
6517
6591
|
{ keys: ['Ctrl', 'Shift', 'M'], combo: true, label: 'Open last message' },
|
|
6518
6592
|
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
@@ -6550,6 +6624,7 @@ const SHORTCUT_TABS = [
|
|
|
6550
6624
|
rows: [
|
|
6551
6625
|
{ keys: ['[', ']'], label: 'Previous / next post' },
|
|
6552
6626
|
{ keys: ['Alt', 'Enter'], combo: true, label: 'Fill the terminal / back to the card' },
|
|
6627
|
+
{ keys: ['Alt', 'Shift', 'Enter'], combo: true, label: 'Dock beside the terminal / back to the overlay' },
|
|
6553
6628
|
{ keys: ['Esc'], label: 'Back to the card, else collapse to the header' },
|
|
6554
6629
|
{ keys: ['Alt', 'W'], combo: true, label: 'Close until the next post' },
|
|
6555
6630
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Remove the post' },
|
|
@@ -7765,15 +7840,20 @@ document.addEventListener('keydown', (e) => {
|
|
|
7765
7840
|
// the whole page. Sits above the text-field guard so it still works with the
|
|
7766
7841
|
// caret in a field inside the modal.
|
|
7767
7842
|
const zoom = zoomDelta(e);
|
|
7768
|
-
|
|
7843
|
+
const zoomTo = zoom !== undefined && zoomTarget();
|
|
7844
|
+
if (zoomTo) {
|
|
7769
7845
|
e.preventDefault();
|
|
7770
|
-
|
|
7846
|
+
zoomTo(zoom);
|
|
7771
7847
|
return;
|
|
7772
7848
|
}
|
|
7773
7849
|
if (isShowCardFocused() && !isAnyModalOpen() && showCardKey(e)) {
|
|
7774
7850
|
e.preventDefault();
|
|
7775
7851
|
return;
|
|
7776
7852
|
}
|
|
7853
|
+
if (isDockFocused() && !isAnyModalOpen() && dockHeadKey(e)) {
|
|
7854
|
+
e.preventDefault();
|
|
7855
|
+
return;
|
|
7856
|
+
}
|
|
7777
7857
|
|
|
7778
7858
|
// Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
|
|
7779
7859
|
const terminalPrompt = document.getElementById('terminal-prompt');
|
|
@@ -8121,7 +8201,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
8121
8201
|
}
|
|
8122
8202
|
if (ClaudeHub.comboOf(e) === 'ctrl+d') {
|
|
8123
8203
|
e.preventDefault();
|
|
8124
|
-
|
|
8204
|
+
const group = kbSelectedGroup();
|
|
8205
|
+
if (group) sgConfirmDelete(group);
|
|
8206
|
+
else dismissContextSession();
|
|
8125
8207
|
return;
|
|
8126
8208
|
}
|
|
8127
8209
|
if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
@@ -8730,10 +8812,11 @@ const PANE_CLAIMS = [
|
|
|
8730
8812
|
...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
|
|
8731
8813
|
];
|
|
8732
8814
|
|
|
8733
|
-
// Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
|
|
8815
|
+
// Ctrl+D, Alt+Enter and Alt+Shift+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
|
|
8734
8816
|
const SHOW_CLAIMS = [
|
|
8735
8817
|
...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
|
|
8736
8818
|
{ altKey: true, key: 'Enter', code: '' },
|
|
8819
|
+
{ altKey: true, shiftKey: true, key: 'Enter', code: '' },
|
|
8737
8820
|
];
|
|
8738
8821
|
|
|
8739
8822
|
// Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
|
|
@@ -9432,6 +9515,13 @@ function openFileInEditor(filePath) {
|
|
|
9432
9515
|
postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
|
|
9433
9516
|
}
|
|
9434
9517
|
|
|
9518
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9519
|
+
async function copyPreviewPath(el) {
|
|
9520
|
+
if (currentPreviewPath && (await copyText(currentPreviewPath, 'path'))) {
|
|
9521
|
+
el.animate([{ color: 'var(--success)' }, {}], { duration: 800, easing: 'ease-out' });
|
|
9522
|
+
}
|
|
9523
|
+
}
|
|
9524
|
+
|
|
9435
9525
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9436
9526
|
function openPreviewInEditor() {
|
|
9437
9527
|
if (!currentPreviewPath) return;
|
|
@@ -9447,7 +9537,7 @@ async function openPreviewByPath(filePath, base, onUnsupported, sessionId = prev
|
|
|
9447
9537
|
try {
|
|
9448
9538
|
const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
|
|
9449
9539
|
if (!r.ok) {
|
|
9450
|
-
showToast('Preview file unavailable', 'error');
|
|
9540
|
+
showToast('Preview file unavailable', 'error', { label: 'Copy path', onClick: () => copyText(filePath, 'path') });
|
|
9451
9541
|
return;
|
|
9452
9542
|
}
|
|
9453
9543
|
const data = await r.json();
|
|
@@ -9489,6 +9579,25 @@ function handleSessionOpenEvent(data) {
|
|
|
9489
9579
|
fetchTasks(id);
|
|
9490
9580
|
}
|
|
9491
9581
|
|
|
9582
|
+
// `claude-code-kanban new` started the PTY; this tab shows it as if its New session dialog had.
|
|
9583
|
+
// Under the hub the action round trip brings this tab on screen first.
|
|
9584
|
+
async function handleSessionLaunchedEvent(t) {
|
|
9585
|
+
const { id } = t;
|
|
9586
|
+
if (!id || !terminalAvailable()) return;
|
|
9587
|
+
const claim = await terminalFetch(apiPath`/api/terminal/launched/${id}`, 'POST').catch(() => null);
|
|
9588
|
+
if (!claim?.ok) return;
|
|
9589
|
+
if (!newSpecs.has(id)) newSpecs.set(id, specFromTerminal({ mode: 'new', startedAt: Date.now(), ...t }));
|
|
9590
|
+
setTerminalMode(id, true);
|
|
9591
|
+
termState.focusNext = true;
|
|
9592
|
+
if (hub.can('session.open')) hub.invoke('session.open', { session: id });
|
|
9593
|
+
else openSessionFromOutside(id);
|
|
9594
|
+
}
|
|
9595
|
+
|
|
9596
|
+
function openSessionFromOutside(id) {
|
|
9597
|
+
if (newSpecs.has(id)) openPlaceholder(id);
|
|
9598
|
+
else handleSessionOpenEvent({ id });
|
|
9599
|
+
}
|
|
9600
|
+
|
|
9492
9601
|
async function handlePreviewOpenEvent(data) {
|
|
9493
9602
|
const { path: filePath, sessionId } = data;
|
|
9494
9603
|
if (sessionId && sessionId !== currentSessionId) {
|
|
@@ -10290,6 +10399,10 @@ function setupEventSource() {
|
|
|
10290
10399
|
handleSessionOpenEvent(data);
|
|
10291
10400
|
}
|
|
10292
10401
|
|
|
10402
|
+
if (data.type === 'session:launched') {
|
|
10403
|
+
handleSessionLaunchedEvent(data);
|
|
10404
|
+
}
|
|
10405
|
+
|
|
10293
10406
|
// Alone, the page would open a tab without a user gesture, which the browser blocks; the
|
|
10294
10407
|
// CLI prints the link instead.
|
|
10295
10408
|
if (data.type === 'session:cost' && hub.inHub) {
|
|
@@ -10308,6 +10421,15 @@ function setupEventSource() {
|
|
|
10308
10421
|
applyPaneLayout(data.sessionId, data.layout);
|
|
10309
10422
|
}
|
|
10310
10423
|
|
|
10424
|
+
if (data.type === 'addon:changed' && dockSid) {
|
|
10425
|
+
fetchDockAddons(dockSid);
|
|
10426
|
+
}
|
|
10427
|
+
|
|
10428
|
+
if (data.type === 'addon:restarted' && dockFrames.has(data.id)) {
|
|
10429
|
+
dropDockView(data.id);
|
|
10430
|
+
renderShow();
|
|
10431
|
+
}
|
|
10432
|
+
|
|
10311
10433
|
if (data.type === 'pane:file-changed') {
|
|
10312
10434
|
onPaneFileChanged(data.path);
|
|
10313
10435
|
}
|
|
@@ -10371,6 +10493,8 @@ const CONTEXT_COLORS = {
|
|
|
10371
10493
|
red: 'var(--danger)',
|
|
10372
10494
|
};
|
|
10373
10495
|
const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
|
|
10496
|
+
// Status colors as small text measure 3.7–3.9:1 on light surfaces; bars and dots keep the pure color.
|
|
10497
|
+
const textTone = (color) => `color-mix(in srgb, ${color} 70%, var(--text-primary))`;
|
|
10374
10498
|
const MODEL_THRESHOLDS = [
|
|
10375
10499
|
{ match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
|
|
10376
10500
|
{ match: /haiku/i, yellow: 100000, orange: 130000, red: 150000 },
|
|
@@ -10477,7 +10601,7 @@ function renderContextBar(session) {
|
|
|
10477
10601
|
${renderMarkers(ctx.markers)}
|
|
10478
10602
|
</div>
|
|
10479
10603
|
<div class="context-bar-labels">
|
|
10480
|
-
<span style="color:${color}">${Math.round(ctx.pct)}% (${used})</span>
|
|
10604
|
+
<span>ctx <span style="color:${textTone(color)}">${Math.round(ctx.pct)}% (${used})</span></span>
|
|
10481
10605
|
<span>${Math.round(ctx.remaining)}% free</span>
|
|
10482
10606
|
</div>
|
|
10483
10607
|
</div>`;
|
|
@@ -10518,7 +10642,7 @@ function formatCacheLeft(expiresAt, expiredText) {
|
|
|
10518
10642
|
if (left === 0) return { text: expiredText, color: 'var(--text-muted)' };
|
|
10519
10643
|
const text = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
10520
10644
|
const color = left < 120 ? CONTEXT_COLORS.red : left < 300 ? CONTEXT_COLORS.orange : CONTEXT_COLORS.green;
|
|
10521
|
-
return { text, color };
|
|
10645
|
+
return { text, color: textTone(color) };
|
|
10522
10646
|
}
|
|
10523
10647
|
|
|
10524
10648
|
let cacheTimerInterval = null;
|
|
@@ -10587,7 +10711,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
|
|
|
10587
10711
|
</div>
|
|
10588
10712
|
</div>
|
|
10589
10713
|
<div class="detail-context-summary">
|
|
10590
|
-
<span style="color:${color}">${Math.round(ctx.pct)}% used</span>
|
|
10714
|
+
<span style="color:${textTone(color)}">${Math.round(ctx.pct)}% used</span>
|
|
10591
10715
|
<span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(ctx.size / 1000)}</span>
|
|
10592
10716
|
</div>`
|
|
10593
10717
|
: '';
|
|
@@ -10599,11 +10723,11 @@ function renderContextDetail(session, { tokens = true } = {}) {
|
|
|
10599
10723
|
<div class="detail-context-stats">
|
|
10600
10724
|
${usageRows}
|
|
10601
10725
|
${totalRows}
|
|
10602
|
-
${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
|
|
10726
|
+
${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${textTone(getCostColor(cost.total_cost_usd))}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
|
|
10603
10727
|
${reply ? renderCacheTimer(reply) : ''}
|
|
10604
10728
|
${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
|
|
10605
10729
|
${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
|
|
10606
|
-
${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
|
|
10730
|
+
${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${textTone(CONTEXT_COLORS.green)}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${textTone(CONTEXT_COLORS.red)}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
|
|
10607
10731
|
</div>
|
|
10608
10732
|
</div>`;
|
|
10609
10733
|
}
|
|
@@ -10876,8 +11000,8 @@ function showWaitingDetail() {
|
|
|
10876
11000
|
// With actions on screen the form/buttons are the content — tuck the raw
|
|
10877
11001
|
// JSON behind a toggle, but keep what the user is approving visible: for a
|
|
10878
11002
|
// permission ask surface the command (or description) as a one-line summary
|
|
11003
|
+
let summaryHtml = '';
|
|
10879
11004
|
if ((actionsHtml || footerHtml) && inputHtml) {
|
|
10880
|
-
let summaryHtml = '';
|
|
10881
11005
|
if (currentWaiting.kind === 'plan' && typeof params?.plan === 'string' && params.plan) {
|
|
10882
11006
|
// A plan ask IS the plan — render it in full so it can be reviewed and
|
|
10883
11007
|
// approved right here (follow mode included) instead of a JSON dump. The
|
|
@@ -10900,7 +11024,8 @@ function showWaitingDetail() {
|
|
|
10900
11024
|
autoSizeModal(modal, body);
|
|
10901
11025
|
setMsgDetailFollow(msgDetailFollowLatest);
|
|
10902
11026
|
const overlay = document.getElementById('msg-detail-modal');
|
|
10903
|
-
|
|
11027
|
+
// The permission card's header already shows how long the ask has waited.
|
|
11028
|
+
const meta = summaryHtml ? [] : [formatDate(currentWaiting.timestamp), 'waiting'];
|
|
10904
11029
|
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
10905
11030
|
currentPinDetailId = null;
|
|
10906
11031
|
updateMsgDetailPinState();
|
|
@@ -10930,17 +11055,34 @@ function pathBasename(p) {
|
|
|
10930
11055
|
|
|
10931
11056
|
// `session.worktree` is resolved server-side from the `.git` pointer file, so it holds for a
|
|
10932
11057
|
// worktree parked beside its repo as well as one under <repo>/.claude/worktrees/.
|
|
10933
|
-
|
|
11058
|
+
// Under its own project group the card leaves the repo name out: the group header shows it.
|
|
11059
|
+
function renderProjectIdentity(session, hostProject) {
|
|
10934
11060
|
if (!session.project) return null;
|
|
11061
|
+
const underHost = !!hostProject && sessionProjectKey(session) === hostProject;
|
|
10935
11062
|
const wt = session.worktree;
|
|
10936
|
-
if (!wt)
|
|
11063
|
+
if (!wt) {
|
|
11064
|
+
return underHost ? null : `<span class="session-project">${escapeHtml(pathBasename(session.project))}</span>`;
|
|
11065
|
+
}
|
|
10937
11066
|
const repo = pathBasename(wt.repo);
|
|
10938
11067
|
// Worktrees beside the repo are conventionally named `<repo>-<branch>`, and repeating the
|
|
10939
11068
|
// repo inside a chip that already sits next to it wastes the whole width of the card.
|
|
10940
11069
|
const name = wt.name.startsWith(repo) ? wt.name.slice(repo.length).replace(/^[-._]/, '') || wt.name : wt.name;
|
|
10941
|
-
|
|
11070
|
+
const chip = `<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
|
|
11071
|
+
return underHost ? chip : `<span class="session-project">${escapeHtml(repo)}</span>${chip}`;
|
|
11072
|
+
}
|
|
11073
|
+
|
|
11074
|
+
function renderSessionIdentity(projectHtml, gitBranch) {
|
|
11075
|
+
if (!projectHtml && !gitBranch) return '';
|
|
11076
|
+
const branch = gitBranch
|
|
11077
|
+
? `<span class="session-branch" title="${escapeHtml(gitBranch)}">${escapeHtml(gitBranch)}</span>`
|
|
11078
|
+
: '';
|
|
11079
|
+
const sep = projectHtml && branch ? '<span class="agent-ident-sep">·</span>' : '';
|
|
11080
|
+
return `<div class="session-secondary">${projectHtml || ''}${sep}${branch}</div>`;
|
|
10942
11081
|
}
|
|
10943
11082
|
|
|
11083
|
+
const SHORT_DATE = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
|
|
11084
|
+
const SHORT_DATE_YEAR = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
11085
|
+
|
|
10944
11086
|
function formatDate(dateStr) {
|
|
10945
11087
|
const date = new Date(dateStr);
|
|
10946
11088
|
const now = new Date();
|
|
@@ -10949,7 +11091,8 @@ function formatDate(dateStr) {
|
|
|
10949
11091
|
if (diff < JUST_NOW_MS) return 'just now';
|
|
10950
11092
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
10951
11093
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
10952
|
-
return
|
|
11094
|
+
if (diff < 7 * 86400000) return `${Math.floor(diff / 86400000)}d ago`;
|
|
11095
|
+
return (date.getFullYear() === now.getFullYear() ? SHORT_DATE : SHORT_DATE_YEAR).format(date);
|
|
10953
11096
|
}
|
|
10954
11097
|
|
|
10955
11098
|
// Terminal output reaches the transcript raw. A CSI-only pattern left OSC
|
|
@@ -11275,8 +11418,9 @@ function loadMermaid() {
|
|
|
11275
11418
|
}
|
|
11276
11419
|
|
|
11277
11420
|
let mermaidQueue = Promise.resolve();
|
|
11421
|
+
let mermaidSeq = 0;
|
|
11278
11422
|
|
|
11279
|
-
// mermaid.
|
|
11423
|
+
// mermaid.render is async, and two renders that overlap draw diagrams over each other.
|
|
11280
11424
|
function queueMermaid(work) {
|
|
11281
11425
|
mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
|
|
11282
11426
|
}
|
|
@@ -11303,10 +11447,15 @@ function initMermaidBlocks(container) {
|
|
|
11303
11447
|
return;
|
|
11304
11448
|
}
|
|
11305
11449
|
queueMermaid(async () => {
|
|
11306
|
-
// One block per
|
|
11450
|
+
// One block per render, so a bad diagram shows its own error and the others still render.
|
|
11451
|
+
// render, not run: run measures labels in place, and under a modal's CSS zoom the measured
|
|
11452
|
+
// widths are off, so node boxes clip their text.
|
|
11307
11453
|
for (const b of stampPending()) {
|
|
11308
11454
|
try {
|
|
11309
|
-
await mermaid.
|
|
11455
|
+
const { svg, bindFunctions } = await mermaid.render(`mermaid-${++mermaidSeq}`, b.textContent.trim());
|
|
11456
|
+
b.innerHTML = svg;
|
|
11457
|
+
b.setAttribute('data-processed', 'true');
|
|
11458
|
+
bindFunctions?.(b);
|
|
11310
11459
|
} catch (err) {
|
|
11311
11460
|
showMermaidError(b, err);
|
|
11312
11461
|
}
|
|
@@ -11621,8 +11770,7 @@ function toggleZenMode() {
|
|
|
11621
11770
|
if (zenMode) zenMode = false;
|
|
11622
11771
|
else if (zenGroup) zenGroup = null;
|
|
11623
11772
|
else {
|
|
11624
|
-
|
|
11625
|
-
zenGroup = header?.dataset.groupId || null;
|
|
11773
|
+
zenGroup = kbSelectedGroup()?.id || null;
|
|
11626
11774
|
zenMode = !zenGroup;
|
|
11627
11775
|
}
|
|
11628
11776
|
store.setItem(ZEN_KEY, String(zenMode));
|
|
@@ -12363,8 +12511,8 @@ function updateDismissBtnState() {
|
|
|
12363
12511
|
const btn = document.getElementById('session-info-dismiss-btn');
|
|
12364
12512
|
if (!btn || !_infoModalSessionId) return;
|
|
12365
12513
|
const isDismissed = dismissedSessionIds.has(_infoModalSessionId);
|
|
12366
|
-
btn.textContent = isDismissed ? '
|
|
12367
|
-
btn.title = isDismissed ? '
|
|
12514
|
+
btn.textContent = isDismissed ? 'Show session' : 'Hide session';
|
|
12515
|
+
btn.title = isDismissed ? 'Show in the active list again' : 'Hide from the active list';
|
|
12368
12516
|
}
|
|
12369
12517
|
|
|
12370
12518
|
let _planSessionId = null;
|
|
@@ -13351,8 +13499,10 @@ const PANE_EMPTY_LAYOUT = { panes: [] };
|
|
|
13351
13499
|
const paneLayouts = new Map();
|
|
13352
13500
|
let paneLayoutSid = null;
|
|
13353
13501
|
|
|
13354
|
-
//
|
|
13355
|
-
const
|
|
13502
|
+
// Target → whether the server's probe found it frameable, kept for the page's life; a reload asks again.
|
|
13503
|
+
const paneFraming = new Map();
|
|
13504
|
+
// Run add-ons whose port answered once while this page lives.
|
|
13505
|
+
const dockAddonUp = new Set();
|
|
13356
13506
|
|
|
13357
13507
|
// Every origin above a pane's frame. Firefox has no ancestorOrigins, so under the hub there it
|
|
13358
13508
|
// names the board only; a refused frame then shows the browser's own error.
|
|
@@ -13361,7 +13511,7 @@ function paneAncestors() {
|
|
|
13361
13511
|
}
|
|
13362
13512
|
|
|
13363
13513
|
function paneFrameable(pane) {
|
|
13364
|
-
return pane.kind !== 'url' ||
|
|
13514
|
+
return pane.kind !== 'url' || paneFraming.get(pane.target) !== false;
|
|
13365
13515
|
}
|
|
13366
13516
|
|
|
13367
13517
|
// allow-scripts with allow-same-origin on the board's or the hub's origin would lift the sandbox.
|
|
@@ -13383,7 +13533,7 @@ function paneOwnOrigin(pane) {
|
|
|
13383
13533
|
async function probePaneFraming(sid, pane) {
|
|
13384
13534
|
const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
|
|
13385
13535
|
const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
|
|
13386
|
-
if (frameable ===
|
|
13536
|
+
if (typeof frameable === 'boolean') paneFraming.set(pane.target, frameable);
|
|
13387
13537
|
return frameable;
|
|
13388
13538
|
}
|
|
13389
13539
|
|
|
@@ -13400,6 +13550,7 @@ function applyPaneLayout(sid, layout) {
|
|
|
13400
13550
|
if (targets.get(p.id) !== p.target) unmountPane(sid, p.id);
|
|
13401
13551
|
}
|
|
13402
13552
|
if (!targets.has(getActivePaneId(sid))) setActivePaneId(sid, 'board');
|
|
13553
|
+
if (sid === dockSid) renderDockRail();
|
|
13403
13554
|
if (sid !== paneSessionId()) return;
|
|
13404
13555
|
syncPanes();
|
|
13405
13556
|
autoRevealLog(sid);
|
|
@@ -13425,20 +13576,20 @@ async function paneRequest(method, path, body) {
|
|
|
13425
13576
|
}
|
|
13426
13577
|
}
|
|
13427
13578
|
|
|
13428
|
-
// Per-session pane state
|
|
13579
|
+
// Per-session pane state, {[sessionId]: value} in recency order; sessionStorage unless the caller passes `store`.
|
|
13429
13580
|
const PANE_STATE_CAP = 20;
|
|
13430
13581
|
|
|
13431
|
-
function loadPaneState(key) {
|
|
13432
|
-
const map =
|
|
13582
|
+
function loadPaneState(key, area = tabStore) {
|
|
13583
|
+
const map = area.readJson(key);
|
|
13433
13584
|
return map && typeof map === 'object' ? map : {};
|
|
13434
13585
|
}
|
|
13435
13586
|
|
|
13436
13587
|
// Puts `sid` last, so the cap drops the least recent session. A null value removes it.
|
|
13437
|
-
function savePaneState(key, map, sid, value) {
|
|
13588
|
+
function savePaneState(key, map, sid, value, area = tabStore) {
|
|
13438
13589
|
delete map[sid];
|
|
13439
13590
|
if (value != null) map[sid] = value;
|
|
13440
13591
|
const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
|
|
13441
|
-
|
|
13592
|
+
area.writeJson(key, capped);
|
|
13442
13593
|
return capped;
|
|
13443
13594
|
}
|
|
13444
13595
|
|
|
@@ -13520,7 +13671,8 @@ function neighborPair(sid, id, pairs) {
|
|
|
13520
13671
|
// tab, so grouping leaves the server's order alone; a drag saves the order on screen.
|
|
13521
13672
|
const PANE_GROUPS_KEY = 'pane-groups';
|
|
13522
13673
|
const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
|
|
13523
|
-
|
|
13674
|
+
// Groups outlive the window; a save re-reads the map, so other windows' groups stay.
|
|
13675
|
+
let paneGroups = loadPaneState(PANE_GROUPS_KEY, store);
|
|
13524
13676
|
|
|
13525
13677
|
// Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
|
|
13526
13678
|
function getPaneGroups(sid) {
|
|
@@ -13538,7 +13690,8 @@ function getPaneGroups(sid) {
|
|
|
13538
13690
|
}
|
|
13539
13691
|
|
|
13540
13692
|
function setPaneGroups(sid, groups) {
|
|
13541
|
-
|
|
13693
|
+
const map = loadPaneState(PANE_GROUPS_KEY, store);
|
|
13694
|
+
paneGroups = savePaneState(PANE_GROUPS_KEY, map, sid, groups.length ? groups : null, store);
|
|
13542
13695
|
}
|
|
13543
13696
|
|
|
13544
13697
|
// A pair is in the group of either side, and the board is in none.
|
|
@@ -13846,9 +13999,10 @@ function markPaneFrameFocus() {
|
|
|
13846
13999
|
view?.classList.add('frame-focus');
|
|
13847
14000
|
}
|
|
13848
14001
|
|
|
13849
|
-
|
|
13850
|
-
|
|
13851
|
-
|
|
14002
|
+
// `extra` is trusted HTML put above the button.
|
|
14003
|
+
function paneCardHtml(title, text, button, { attr = 'data-pane-open', extra = '' } = {}) {
|
|
14004
|
+
const btn = button ? `<button class="btn btn-primary" ${attr}>${escapeHtml(button)}</button>` : '';
|
|
14005
|
+
return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p>${extra}${btn}</div></div>`;
|
|
13852
14006
|
}
|
|
13853
14007
|
|
|
13854
14008
|
// Returns the key of the view to show.
|
|
@@ -13877,32 +14031,37 @@ function mountPane(sid, pane) {
|
|
|
13877
14031
|
return key;
|
|
13878
14032
|
}
|
|
13879
14033
|
|
|
13880
|
-
|
|
14034
|
+
// The card in place of a URL pane that cannot be framed, or '' when it can.
|
|
14035
|
+
function paneBlockedHtml(pane) {
|
|
13881
14036
|
if (paneOwnOrigin(pane)) {
|
|
13882
|
-
|
|
13883
|
-
'beforeend',
|
|
13884
|
-
paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab'),
|
|
13885
|
-
);
|
|
13886
|
-
return;
|
|
14037
|
+
return paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab');
|
|
13887
14038
|
}
|
|
13888
|
-
if (
|
|
13889
|
-
|
|
13890
|
-
|
|
13891
|
-
|
|
13892
|
-
|
|
13893
|
-
|
|
13894
|
-
|
|
13895
|
-
|
|
13896
|
-
|
|
14039
|
+
if (paneFrameable(pane)) return '';
|
|
14040
|
+
return paneCardHtml(
|
|
14041
|
+
`${new URL(pane.target).host} does not allow framing`,
|
|
14042
|
+
'The site sends X-Frame-Options or a frame-ancestors policy. The pane keeps the link with the session.',
|
|
14043
|
+
'Open in new tab',
|
|
14044
|
+
);
|
|
14045
|
+
}
|
|
14046
|
+
|
|
14047
|
+
function paneUrlFrame(pane, className) {
|
|
14048
|
+
const frame = document.createElement('iframe');
|
|
14049
|
+
frame.className = className;
|
|
14050
|
+
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
14051
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
14052
|
+
frame.title = pane.title;
|
|
14053
|
+
frame.src = pane.target;
|
|
14054
|
+
return frame;
|
|
14055
|
+
}
|
|
14056
|
+
|
|
14057
|
+
async function loadPaneView(sid, pane, view, scroll) {
|
|
14058
|
+
const blocked = paneBlockedHtml(pane);
|
|
14059
|
+
if (blocked) {
|
|
14060
|
+
view.insertAdjacentHTML('beforeend', blocked);
|
|
13897
14061
|
return;
|
|
13898
14062
|
}
|
|
13899
14063
|
if (pane.kind === 'url') {
|
|
13900
|
-
const frame =
|
|
13901
|
-
frame.className = 'pane-frame pane-url-frame modal-zoomable';
|
|
13902
|
-
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
13903
|
-
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
13904
|
-
frame.title = pane.title;
|
|
13905
|
-
frame.src = pane.target;
|
|
14064
|
+
const frame = paneUrlFrame(pane, 'pane-frame pane-url-frame modal-zoomable');
|
|
13906
14065
|
frame.addEventListener('load', () => {
|
|
13907
14066
|
view.classList.remove('keys-bridged');
|
|
13908
14067
|
frame.hostKeysSig = null;
|
|
@@ -13926,7 +14085,7 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
13926
14085
|
if (data?.kind === 'html') {
|
|
13927
14086
|
// A show card's own zoom follows the text zoom (showBridge), so its frame is not .modal-zoomable.
|
|
13928
14087
|
const [cls, doc] = pane.show
|
|
13929
|
-
? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y)]
|
|
14088
|
+
? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y, modalZoom)]
|
|
13930
14089
|
: ['pane-frame modal-zoomable themed-frame', themedSrcdoc(data.content)];
|
|
13931
14090
|
const frame = createPreviewFrame(cls, doc);
|
|
13932
14091
|
frame.title = pane.title;
|
|
@@ -14336,7 +14495,7 @@ async function reloadPane(id) {
|
|
|
14336
14495
|
await reloadPaneView(sid, pane, view);
|
|
14337
14496
|
} else {
|
|
14338
14497
|
unmountPane(sid, pane.id);
|
|
14339
|
-
|
|
14498
|
+
paneFraming.delete(pane.target);
|
|
14340
14499
|
syncPanes();
|
|
14341
14500
|
}
|
|
14342
14501
|
showToast('Pane reloaded', 'success');
|
|
@@ -14549,6 +14708,7 @@ function openPaneMenu(x, y, id) {
|
|
|
14549
14708
|
: paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
|
|
14550
14709
|
paneMenuItem('rename', 'Rename') +
|
|
14551
14710
|
paneMenuItem('reload', 'Reload') +
|
|
14711
|
+
(url ? paneMenuItem('dock', pane.dock ? 'Remove from the dock' : 'Dock beside the terminal') : '') +
|
|
14552
14712
|
paneGroupMenuItems(pane.id) +
|
|
14553
14713
|
paneMenuItem('close', 'Close pane'),
|
|
14554
14714
|
);
|
|
@@ -14560,6 +14720,20 @@ function paneIsLinked(pane) {
|
|
|
14560
14720
|
return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
|
|
14561
14721
|
}
|
|
14562
14722
|
|
|
14723
|
+
function togglePaneDock(id) {
|
|
14724
|
+
const pane = paneById(id);
|
|
14725
|
+
const sid = paneSessionId();
|
|
14726
|
+
if (pane && sid) setPaneDock(sid, id, !pane.dock);
|
|
14727
|
+
}
|
|
14728
|
+
|
|
14729
|
+
async function setPaneDock(sid, id, on) {
|
|
14730
|
+
const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, { dock: on });
|
|
14731
|
+
if (res.error) return showToast(res.error, 'error');
|
|
14732
|
+
applyPaneLayout(sid, res.layout);
|
|
14733
|
+
if (on) showToast('Docked: click its icon in the rail beside the terminal', 'success');
|
|
14734
|
+
else showToast('Removed from the dock. It stays a pane of the session', 'success');
|
|
14735
|
+
}
|
|
14736
|
+
|
|
14563
14737
|
function togglePaneLink(id) {
|
|
14564
14738
|
const pane = paneById(id);
|
|
14565
14739
|
const sid = paneSessionId();
|
|
@@ -14591,6 +14765,7 @@ const PANE_MENU_ACTIONS = {
|
|
|
14591
14765
|
link: togglePaneLink,
|
|
14592
14766
|
rename: beginPaneRename,
|
|
14593
14767
|
reload: reloadPane,
|
|
14768
|
+
dock: togglePaneDock,
|
|
14594
14769
|
close: closePane,
|
|
14595
14770
|
group: (id, to) => groupPaneTab(id, to || null),
|
|
14596
14771
|
groupRename: beginPaneGroupRename,
|
|
@@ -15237,6 +15412,11 @@ function terminalShortcut(e, probe = false) {
|
|
|
15237
15412
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
15238
15413
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
15239
15414
|
}
|
|
15415
|
+
if ((e.code === 'ArrowUp' || e.code === 'ArrowDown') && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
|
15416
|
+
if (!(probe || terminalPaneFocused() || isShowCardFocused() || isDockFocused()) || dockRailItems().length < 2)
|
|
15417
|
+
return null;
|
|
15418
|
+
return () => stepDockRail(e.code === 'ArrowDown' ? 1 : -1);
|
|
15419
|
+
}
|
|
15240
15420
|
if (e.code !== 'Backquote' || e.metaKey) return null;
|
|
15241
15421
|
if (ctrlAlt) return toggleShowFocus;
|
|
15242
15422
|
if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
|
|
@@ -15280,14 +15460,29 @@ function toggleTerminalFocus() {
|
|
|
15280
15460
|
}
|
|
15281
15461
|
|
|
15282
15462
|
function toggleShowFocus() {
|
|
15283
|
-
if (isShowCardFocused()) {
|
|
15463
|
+
if (isShowCardFocused() || isDockFocused()) {
|
|
15284
15464
|
focusTerminalPane();
|
|
15285
15465
|
return;
|
|
15286
15466
|
}
|
|
15287
|
-
if (!wantsTerminal()
|
|
15288
|
-
|
|
15289
|
-
|
|
15290
|
-
|
|
15467
|
+
if (!wantsTerminal()) return;
|
|
15468
|
+
const items = dockItems();
|
|
15469
|
+
const ids = items.map((p) => p.id);
|
|
15470
|
+
const rail = showState.posts.length ? ['show', ...ids] : ids;
|
|
15471
|
+
const last = showState.terminalId && dockLast[showState.terminalId];
|
|
15472
|
+
const pick = rail.includes(last) ? last : rail[0];
|
|
15473
|
+
if (!pick) return;
|
|
15474
|
+
if (pick !== 'show') {
|
|
15475
|
+
if (dockActivePane(items)?.id !== pick) {
|
|
15476
|
+
setDockAddon(pick);
|
|
15477
|
+
renderShow();
|
|
15478
|
+
}
|
|
15479
|
+
focusDockHead();
|
|
15480
|
+
return;
|
|
15481
|
+
}
|
|
15482
|
+
const addon = dockActivePane(items);
|
|
15483
|
+
if (addon) setDockAddon(null);
|
|
15484
|
+
if (addon || showState.closed || showState.collapsed) {
|
|
15485
|
+
Object.assign(showState, { closed: false, collapsed: false, ...(addon && { docked: true }) });
|
|
15291
15486
|
renderShow();
|
|
15292
15487
|
}
|
|
15293
15488
|
focusShowBody();
|
|
@@ -15297,6 +15492,8 @@ function toggleShowFocus() {
|
|
|
15297
15492
|
function syncTerminal() {
|
|
15298
15493
|
const btn = document.getElementById('terminal-toggle');
|
|
15299
15494
|
const on = wantsTerminal();
|
|
15495
|
+
// A focused frame keeps the keys after its pane hides, and then the board never sees them.
|
|
15496
|
+
if (!on && document.getElementById('terminal-pane').contains(document.activeElement)) document.activeElement.blur();
|
|
15300
15497
|
const arrived = viewMode === 'session' ? currentSessionId : null;
|
|
15301
15498
|
const view = `${arrived}:${on}`;
|
|
15302
15499
|
// Only on a change of session or terminal state, so a panel opened by hand over the terminal stays.
|
|
@@ -15625,10 +15822,16 @@ const urlPaneFrames = () => [...paneViews.querySelectorAll('.pane-url-frame')];
|
|
|
15625
15822
|
window.addEventListener('message', (e) => {
|
|
15626
15823
|
const type = e.data?.type;
|
|
15627
15824
|
if (type !== 'hub:hostKey' && type !== 'hub:hostReady') return;
|
|
15628
|
-
const
|
|
15825
|
+
const dock = dockFrameEls().find((f) => f.contentWindow === e.source);
|
|
15826
|
+
const frame = dock || urlPaneFrames().find((f) => f.contentWindow === e.source);
|
|
15629
15827
|
if (!frame || URL.parse(frame.src)?.origin !== e.origin) return;
|
|
15630
|
-
if (type === 'hub:hostReady')
|
|
15631
|
-
|
|
15828
|
+
if (type === 'hub:hostReady') {
|
|
15829
|
+
if (!dock) frame.parentElement.classList.add('keys-bridged');
|
|
15830
|
+
return;
|
|
15831
|
+
}
|
|
15832
|
+
// A page has focus when it gets a keydown, even one that never posts its focus.
|
|
15833
|
+
if (dock) setDockFrameFocused(dock, true);
|
|
15834
|
+
replayKey(e.data, frame);
|
|
15632
15835
|
});
|
|
15633
15836
|
|
|
15634
15837
|
function pushTerminalClaims() {
|
|
@@ -15638,11 +15841,13 @@ function pushTerminalClaims() {
|
|
|
15638
15841
|
terminalClaimsQueued = false;
|
|
15639
15842
|
const bridged = bridgedFrameEls();
|
|
15640
15843
|
const urlFrames = urlPaneFrames();
|
|
15641
|
-
|
|
15844
|
+
const dockEls = dockFrameEls();
|
|
15845
|
+
if (!termFrame.inited && !kanbotState.inited && !bridged.length && !urlFrames.length && !dockEls.length) return;
|
|
15642
15846
|
const claims = keyClaims();
|
|
15643
15847
|
const base = bridgeClaims(claims);
|
|
15644
15848
|
sendBridgeClaimsAll(bridged, base);
|
|
15645
15849
|
sendHostKeys(urlFrames, base);
|
|
15850
|
+
sendHostKeys(dockEls, { keys: [...base.keys, ...DOCK_CLAIMS, ...claims.forward.flatMap(comboClaims)] });
|
|
15646
15851
|
const sig = JSON.stringify(claims);
|
|
15647
15852
|
sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
|
|
15648
15853
|
sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
|
|
@@ -15989,7 +16194,10 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
15989
16194
|
if (msg.t === 'ready') {
|
|
15990
16195
|
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
15991
16196
|
const spec = newSpecs.get(sessionId);
|
|
15992
|
-
if (spec)
|
|
16197
|
+
if (spec) {
|
|
16198
|
+
spec.prompted ||= !!spec.prompt;
|
|
16199
|
+
spec.prompt = null;
|
|
16200
|
+
}
|
|
15993
16201
|
clearTerminalSlow();
|
|
15994
16202
|
hideTerminalPrompt();
|
|
15995
16203
|
setTerminalStatus('');
|
|
@@ -16472,7 +16680,7 @@ function setAsk(a) {
|
|
|
16472
16680
|
if (a.content == null) {
|
|
16473
16681
|
body.innerHTML = '<p class="ask-missing">The card file is gone. Skip, so the agent stops waiting.</p>';
|
|
16474
16682
|
} else {
|
|
16475
|
-
const frame = createPreviewFrame('ask-frame', showSrcdoc(a.content, 0, ASK_BRIDGE_TAG), SHOW_SANDBOX);
|
|
16683
|
+
const frame = createPreviewFrame('ask-frame', showSrcdoc(a.content, 0, modalZoom, ASK_BRIDGE_TAG), SHOW_SANDBOX);
|
|
16476
16684
|
frame.addEventListener('load', () => {
|
|
16477
16685
|
if (askState.frame === frame && !askState.hidden) frame.focus();
|
|
16478
16686
|
});
|
|
@@ -16602,6 +16810,11 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
|
|
|
16602
16810
|
const SHOW_MIN_W = 280;
|
|
16603
16811
|
const SHOW_MIN_H = 120;
|
|
16604
16812
|
const SHOW_INSET = 8;
|
|
16813
|
+
const SHOW_DOCK_KEY = 'cck-show-dock-w';
|
|
16814
|
+
const SHOW_DOCK_TERM_MIN = 320;
|
|
16815
|
+
const DOCK_ADDON_KEY = 'cck-dock-addon-v1';
|
|
16816
|
+
const DOCK_LAST_KEY = 'cck-dock-last-v1';
|
|
16817
|
+
const DOCK_EXPANDED_KEY = 'cck-dock-expanded-v1';
|
|
16605
16818
|
const SHOW_SANDBOX = 'allow-scripts allow-forms';
|
|
16606
16819
|
const SHOW_CSP =
|
|
16607
16820
|
"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'";
|
|
@@ -16655,6 +16868,9 @@ const SHOW_ICONS = {
|
|
|
16655
16868
|
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"/>',
|
|
16656
16869
|
close: '<path d="M18 6L6 18M6 6l12 12"/>',
|
|
16657
16870
|
pane: '<use href="#icon-pane"/>',
|
|
16871
|
+
card: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M13 4v7h8"/>',
|
|
16872
|
+
external: '<path d="M15 3h6v6M10 14L21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
|
16873
|
+
reload: '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
|
|
16658
16874
|
};
|
|
16659
16875
|
|
|
16660
16876
|
// PTY id → terminal id, from the terminal list. A terminal started after the last list read is
|
|
@@ -16668,6 +16884,7 @@ const showState = {
|
|
|
16668
16884
|
closed: false,
|
|
16669
16885
|
expanded: false,
|
|
16670
16886
|
collapsed: false,
|
|
16887
|
+
docked: false,
|
|
16671
16888
|
marked: new Set(),
|
|
16672
16889
|
rendered: null,
|
|
16673
16890
|
frame: null,
|
|
@@ -16681,12 +16898,31 @@ const showState = {
|
|
|
16681
16898
|
path: null,
|
|
16682
16899
|
};
|
|
16683
16900
|
let showSize = store.readJson(SHOW_SIZE_KEY);
|
|
16684
|
-
|
|
16901
|
+
let showDockW = store.readJson(SHOW_DOCK_KEY);
|
|
16902
|
+
// Terminal id → { closed?, collapsed?, expanded?, docked? }; a terminal with no entry yet starts collapsed. Ids gone from the terminal list are pruned.
|
|
16685
16903
|
const showViews = store.readJson(SHOW_VIEW_KEY, {});
|
|
16686
16904
|
// Older boards stored the string 'collapsed' or 'expanded'.
|
|
16687
16905
|
for (const [tid, v] of Object.entries(showViews)) if (typeof v === 'string') showViews[tid] = { [v]: true };
|
|
16688
|
-
const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded'];
|
|
16906
|
+
const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded', 'docked'];
|
|
16689
16907
|
let showHostObserver = null;
|
|
16908
|
+
// Terminal id → the id of the add-on pane in the dock. Ids gone from the terminal list are pruned.
|
|
16909
|
+
const dockAddons = store.readJson(DOCK_ADDON_KEY, {});
|
|
16910
|
+
// Terminal id → the rail item last on screen ('show' or an add-on id), kept while the dock is hidden,
|
|
16911
|
+
// so Ctrl+Alt+` goes back to it.
|
|
16912
|
+
const dockLast = store.readJson(DOCK_LAST_KEY, {});
|
|
16913
|
+
let dockExpanded = store.getItem(DOCK_EXPANDED_KEY) === '1';
|
|
16914
|
+
// Dock item id → its view, kept while its filled URL stays the same. A view hidden for
|
|
16915
|
+
// DOCK_UNLOAD_MS goes, so a page that polls stops until it is opened again.
|
|
16916
|
+
const dockFrames = new Map();
|
|
16917
|
+
const dockHideTimers = new Map();
|
|
16918
|
+
const DOCK_UNLOAD_MS = 60_000;
|
|
16919
|
+
let dockSid = null;
|
|
16920
|
+
// The add-ons from /api/addons for `sid`, refetched on `addon:changed`.
|
|
16921
|
+
let dockAddonList = null;
|
|
16922
|
+
let dockRailHtml = '';
|
|
16923
|
+
let dockRailClaimed = false;
|
|
16924
|
+
let dockShowDot = false;
|
|
16925
|
+
let dockObserver = null;
|
|
16690
16926
|
|
|
16691
16927
|
function showCardEl() {
|
|
16692
16928
|
return document.getElementById('show-card');
|
|
@@ -16713,7 +16949,7 @@ function focusShowBody() {
|
|
|
16713
16949
|
else showCardEl()?.querySelector('.show-body')?.focus();
|
|
16714
16950
|
}
|
|
16715
16951
|
|
|
16716
|
-
const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+w': 'close' };
|
|
16952
|
+
const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+shift+Enter': 'dock', 'alt+w': 'close' };
|
|
16717
16953
|
|
|
16718
16954
|
// The body keeps focus, because a render replaces an HTML card's frame.
|
|
16719
16955
|
function showCardKey(e) {
|
|
@@ -16723,10 +16959,12 @@ function showCardKey(e) {
|
|
|
16723
16959
|
return true;
|
|
16724
16960
|
}
|
|
16725
16961
|
const combo = ClaudeHub.comboOf(e);
|
|
16726
|
-
|
|
16962
|
+
let cmd = SHOW_KEY_COMMANDS[combo];
|
|
16963
|
+
if (combo === 'Escape') cmd = showState.expanded && !showState.docked ? 'expand' : 'collapse';
|
|
16964
|
+
else if (cmd === 'expand' && showState.docked) cmd = null;
|
|
16727
16965
|
if (cmd) {
|
|
16728
16966
|
showCommand(cmd);
|
|
16729
|
-
if (cmd === 'expand') focusShowBody();
|
|
16967
|
+
if (cmd === 'expand' || cmd === 'dock') focusShowBody();
|
|
16730
16968
|
else focusTerminalPane();
|
|
16731
16969
|
return true;
|
|
16732
16970
|
}
|
|
@@ -16761,10 +16999,16 @@ function rememberTerminalIds(list) {
|
|
|
16761
16999
|
for (const t of list) if (t.terminalId) terminalIds.set(t.id, t.terminalId);
|
|
16762
17000
|
if (!terminalIds.size) return;
|
|
16763
17001
|
const live = new Set(terminalIds.values());
|
|
16764
|
-
|
|
17002
|
+
pruneTerminalMap(showViews, SHOW_VIEW_KEY, live);
|
|
17003
|
+
pruneTerminalMap(dockAddons, DOCK_ADDON_KEY, live);
|
|
17004
|
+
pruneTerminalMap(dockLast, DOCK_LAST_KEY, live);
|
|
17005
|
+
}
|
|
17006
|
+
|
|
17007
|
+
function pruneTerminalMap(map, key, live) {
|
|
17008
|
+
const gone = Object.keys(map).filter((tid) => !live.has(tid));
|
|
16765
17009
|
if (!gone.length) return;
|
|
16766
|
-
for (const tid of gone) delete
|
|
16767
|
-
store.writeJson(
|
|
17010
|
+
for (const tid of gone) delete map[tid];
|
|
17011
|
+
store.writeJson(key, map);
|
|
16768
17012
|
}
|
|
16769
17013
|
|
|
16770
17014
|
// A session resumed after its terminal ended runs under the same PTY id in a new terminal, with a
|
|
@@ -16872,16 +17116,29 @@ function onShowRemoved(d) {
|
|
|
16872
17116
|
if (dropShowPost(d.id)) renderShow();
|
|
16873
17117
|
}
|
|
16874
17118
|
|
|
17119
|
+
// `attr` names the data attribute that the header's click handler reads: show or dock.
|
|
17120
|
+
function showBtnHtml(attr, cmd, icon, title, disabled = false, pressed = null) {
|
|
17121
|
+
return `<button type="button" class="show-btn${cmd === 'remove' ? ' remove' : ''}${pressed ? ' active' : ''}" data-${attr}="${escapeHtml(cmd)}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"${pressed === null ? '' : pressed ? ' aria-pressed="true"' : ' aria-pressed="false"'}${disabled ? ' disabled' : ''}>${showIcon(icon)}</button>`;
|
|
17122
|
+
}
|
|
17123
|
+
|
|
16875
17124
|
function showHeaderHtml(p) {
|
|
16876
17125
|
const n = showState.posts.length;
|
|
16877
17126
|
const i = showState.idx;
|
|
16878
17127
|
const key = p.key ? `<span class="show-sep">·</span><span class="show-key">${escapeHtml(p.key)}</span>` : '';
|
|
16879
|
-
const btn = (
|
|
16880
|
-
|
|
16881
|
-
const
|
|
17128
|
+
const btn = (...args) => showBtnHtml('show', ...args);
|
|
17129
|
+
const { collapsed, expanded, docked } = showState;
|
|
17130
|
+
const dockTitle = docked ? 'Back to the overlay · Alt+Shift+Enter' : 'Dock beside the terminal · Alt+Shift+Enter';
|
|
17131
|
+
const dock = btn('dock', 'dock', dockTitle, false, docked);
|
|
17132
|
+
const expand = docked
|
|
17133
|
+
? ''
|
|
17134
|
+
: btn(
|
|
17135
|
+
'expand',
|
|
17136
|
+
expanded ? 'restore' : 'expand',
|
|
17137
|
+
expanded ? 'Back to the card · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter',
|
|
17138
|
+
);
|
|
16882
17139
|
return `<span class="show-nav"><span class="show-title" title="${escapeHtml(p.title)}">${escapeHtml(p.title)}</span>${key}</span>
|
|
16883
17140
|
<span class="show-pager">${btn('prev', 'prev', 'Previous post', i === 0)}<span class="show-pos">${i + 1}/${n}</span>${btn('next', 'next', 'Next post', i === n - 1)}</span>
|
|
16884
|
-
<span class="show-actions">${btn('pane', 'pane', 'Open in a pane of the session')}${btn('collapse', collapsed ? 'uncollapse' : 'collapse', collapsed ? 'Show the post' : 'Collapse to the header · Esc')}${
|
|
17141
|
+
<span class="show-actions">${btn('pane', 'pane', 'Open in a pane of the session')}${dock}${btn('collapse', collapsed ? 'uncollapse' : 'collapse', collapsed ? 'Show the post' : 'Collapse to the header · Esc')}${expand}${btn('remove', 'clear', 'Remove this post · Ctrl+D · Shift+click clears every post')}${btn('close', 'close', 'Close until the next post · Alt+W')}</span>`;
|
|
16885
17142
|
}
|
|
16886
17143
|
|
|
16887
17144
|
function showTicksHtml() {
|
|
@@ -16904,6 +17161,10 @@ function renderShow({ flash = false } = {}) {
|
|
|
16904
17161
|
chip.hidden = !n || open;
|
|
16905
17162
|
chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
|
|
16906
17163
|
card.classList.toggle('open', open);
|
|
17164
|
+
const docked = open && showState.docked && !showState.collapsed;
|
|
17165
|
+
card.classList.toggle('docked', docked);
|
|
17166
|
+
if (flash && dockActivePane()) dockShowDot = true;
|
|
17167
|
+
renderDockRail();
|
|
16907
17168
|
if (!open) {
|
|
16908
17169
|
showState.rendered = null;
|
|
16909
17170
|
showState.frame = null;
|
|
@@ -16915,12 +17176,13 @@ function renderShow({ flash = false } = {}) {
|
|
|
16915
17176
|
}
|
|
16916
17177
|
const p = showState.posts[showState.idx];
|
|
16917
17178
|
showState.marked.delete(p.id);
|
|
16918
|
-
card.classList.toggle('expanded', showState.expanded && !showState.collapsed);
|
|
17179
|
+
card.classList.toggle('expanded', showState.expanded && !showState.collapsed && !showState.docked);
|
|
16919
17180
|
card.classList.toggle('collapsed', showState.collapsed);
|
|
16920
17181
|
if (!card.firstChild) {
|
|
16921
17182
|
card.innerHTML = `<header class="show-head"></header><div class="show-ticks"></div><div class="show-body" tabindex="-1"></div>
|
|
16922
17183
|
<aside class="review-panel show-review" hidden></aside>
|
|
16923
|
-
<div class="show-grip" title="Resize · double-click to fit"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1 1v8h8M1 5l4 4"/></svg></div
|
|
17184
|
+
<div class="show-grip" title="Resize · double-click to fit"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1 1v8h8M1 5l4 4"/></svg></div>
|
|
17185
|
+
<div class="show-dock-edge" title="Resize · double-click to reset"></div>`;
|
|
16924
17186
|
}
|
|
16925
17187
|
card.querySelector('.show-head').innerHTML = showHeaderHtml(p);
|
|
16926
17188
|
card.querySelector('.show-ticks').innerHTML = showTicksHtml();
|
|
@@ -16958,10 +17220,10 @@ async function renderShowBody(p, samePost) {
|
|
|
16958
17220
|
const restoreY = samePost && showState.frame ? showState.frameY : 0;
|
|
16959
17221
|
showState.frameY = restoreY;
|
|
16960
17222
|
if (samePost && showState.frame?.isConnected) {
|
|
16961
|
-
showState.frame.srcdoc = showSrcdoc(data.content, restoreY);
|
|
17223
|
+
showState.frame.srcdoc = showSrcdoc(data.content, restoreY, showZoom);
|
|
16962
17224
|
} else {
|
|
16963
17225
|
showState.frameH = 0;
|
|
16964
|
-
body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0)));
|
|
17226
|
+
body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0, showZoom)));
|
|
16965
17227
|
}
|
|
16966
17228
|
refocus();
|
|
16967
17229
|
setShowReview(showReviewOpts(p, data, body, showState.frame));
|
|
@@ -16971,7 +17233,7 @@ async function renderShowBody(p, samePost) {
|
|
|
16971
17233
|
showState.frame = null;
|
|
16972
17234
|
const keepScroll = samePost ? body.scrollTop : 0;
|
|
16973
17235
|
const md = document.createElement('div');
|
|
16974
|
-
md.className = 'show-md
|
|
17236
|
+
md.className = 'show-md';
|
|
16975
17237
|
if (!data) md.innerHTML = '<p class="show-gone">This post is gone.</p>';
|
|
16976
17238
|
else if (data.waiting) md.innerHTML = '<p class="show-gone">Waiting for content.</p>';
|
|
16977
17239
|
else renderPreviewContent(md, data.file || p.file || '', data.content ?? '', data.kind);
|
|
@@ -17058,9 +17320,9 @@ function withHeadFirst(content, head) {
|
|
|
17058
17320
|
const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
|
|
17059
17321
|
|
|
17060
17322
|
// A whole document gets the tokens and base styles first in its head, so its own styles win.
|
|
17061
|
-
function showSrcdoc(content, restoreY, extraHead = '') {
|
|
17323
|
+
function showSrcdoc(content, restoreY, textZoom, extraHead = '') {
|
|
17062
17324
|
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>${extraHead}`;
|
|
17063
|
-
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${
|
|
17325
|
+
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${textZoom});</script>${REVIEW_BRIDGE_TAG}`;
|
|
17064
17326
|
if (!/^\s*(<!doctype|<html)/i.test(content)) {
|
|
17065
17327
|
return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
|
|
17066
17328
|
}
|
|
@@ -17163,11 +17425,11 @@ new MutationObserver(() => {
|
|
|
17163
17425
|
});
|
|
17164
17426
|
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme', 'style'] });
|
|
17165
17427
|
|
|
17428
|
+
const PANE_SHOW_FRAMES = '.show-pane-frame, .themed-frame, .ask-frame, .dock-addon-frame';
|
|
17429
|
+
|
|
17166
17430
|
// Builds the message only when a frame is there, because showTokens forces a style recalc.
|
|
17167
|
-
function postToShowFrames(makeMsg) {
|
|
17168
|
-
|
|
17169
|
-
(f) => f?.contentWindow,
|
|
17170
|
-
);
|
|
17431
|
+
function postToShowFrames(makeMsg, frames = [showState.frame, ...document.querySelectorAll(PANE_SHOW_FRAMES)]) {
|
|
17432
|
+
frames = [...frames].filter((f) => f?.contentWindow);
|
|
17171
17433
|
if (!frames.length) return;
|
|
17172
17434
|
const msg = makeMsg();
|
|
17173
17435
|
if (!msg) return;
|
|
@@ -17185,16 +17447,16 @@ function showClampSize(size, w, h) {
|
|
|
17185
17447
|
|
|
17186
17448
|
function layoutShow() {
|
|
17187
17449
|
const card = showCardEl();
|
|
17188
|
-
if (!card.classList.contains('open')) return;
|
|
17189
|
-
const
|
|
17190
|
-
const
|
|
17191
|
-
|
|
17192
|
-
if (!w || !h) return;
|
|
17450
|
+
if (!card.classList.contains('open') || card.classList.contains('under-addon')) return;
|
|
17451
|
+
const w = dockHostWidth();
|
|
17452
|
+
const h = document.getElementById('terminal-host').clientHeight;
|
|
17453
|
+
if (w <= 0 || !h) return;
|
|
17193
17454
|
const fit = showFitSize(w, h);
|
|
17194
17455
|
card.style.setProperty('--show-fit-w', `${fit.w}px`);
|
|
17195
17456
|
card.style.setProperty('--show-fit-h', `${fit.h}px`);
|
|
17196
17457
|
card.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
|
|
17197
|
-
const
|
|
17458
|
+
const docked = card.classList.contains('docked');
|
|
17459
|
+
const sized = !!showSize && !showState.expanded && !showState.collapsed && !docked;
|
|
17198
17460
|
card.classList.toggle('sized', sized);
|
|
17199
17461
|
if (sized) {
|
|
17200
17462
|
const s = showClampSize(showSize, w, h);
|
|
@@ -17203,7 +17465,7 @@ function layoutShow() {
|
|
|
17203
17465
|
}
|
|
17204
17466
|
const frame = showState.frame;
|
|
17205
17467
|
if (!frame) return;
|
|
17206
|
-
const peek = !
|
|
17468
|
+
const peek = !card.classList.contains('expanded') && !sized && !docked;
|
|
17207
17469
|
const chrome =
|
|
17208
17470
|
card.querySelector('.show-head').offsetHeight +
|
|
17209
17471
|
card.querySelector('.show-ticks').offsetHeight +
|
|
@@ -17213,6 +17475,460 @@ function layoutShow() {
|
|
|
17213
17475
|
frame.style.height = peek ? `${Math.min(showState.frameH || SHOW_MIN_H, fit.h - chrome)}px` : '';
|
|
17214
17476
|
}
|
|
17215
17477
|
|
|
17478
|
+
// A docked card gets 40% of the host until dragged, and always leaves the terminal SHOW_DOCK_TERM_MIN.
|
|
17479
|
+
function showDockWidth(want, w) {
|
|
17480
|
+
return Math.max(SHOW_MIN_W, Math.min(want ?? Math.round(w * 0.4), w - SHOW_DOCK_TERM_MIN));
|
|
17481
|
+
}
|
|
17482
|
+
|
|
17483
|
+
// The terminal pane holds the width, because the terminal frame and the terminal bar read it too.
|
|
17484
|
+
// Each change refits the terminal and resizes its PTY, so a drag sets it only at the end.
|
|
17485
|
+
function setShowDockWidth(px) {
|
|
17486
|
+
const pane = document.getElementById('terminal-pane');
|
|
17487
|
+
const value = `${px}px`;
|
|
17488
|
+
if (pane.style.getPropertyValue('--show-dock-w') !== value) pane.style.setProperty('--show-dock-w', value);
|
|
17489
|
+
}
|
|
17490
|
+
|
|
17491
|
+
// The host's width less the rail, which is 0 wide while hidden.
|
|
17492
|
+
function dockHostWidth() {
|
|
17493
|
+
return document.getElementById('terminal-host').clientWidth - document.getElementById('dock-rail').offsetWidth;
|
|
17494
|
+
}
|
|
17495
|
+
|
|
17496
|
+
function fitDockWidth() {
|
|
17497
|
+
if (!document.getElementById('terminal-pane').classList.contains('show-docked')) return;
|
|
17498
|
+
const w = dockHostWidth();
|
|
17499
|
+
if (w > 0) setShowDockWidth(showDockWidth(showDockW, w));
|
|
17500
|
+
}
|
|
17501
|
+
|
|
17502
|
+
// A terminal with no posts has no session from the show list yet, so its PTY id stands in,
|
|
17503
|
+
// which is the session it started.
|
|
17504
|
+
function dockSessionId() {
|
|
17505
|
+
return showState.terminalId ? (showState.sessionId ?? currentSessionId) : null;
|
|
17506
|
+
}
|
|
17507
|
+
|
|
17508
|
+
function dockPanes(sid) {
|
|
17509
|
+
return sid ? paneLayout(sid).panes.filter((p) => p.dock) : [];
|
|
17510
|
+
}
|
|
17511
|
+
|
|
17512
|
+
function fillAddonUrl(url, values) {
|
|
17513
|
+
return url.replace(/\{([^{}]*)\}/g, (m, k) => (values[k] == null ? m : encodeURIComponent(values[k])));
|
|
17514
|
+
}
|
|
17515
|
+
|
|
17516
|
+
function addonValues(sid) {
|
|
17517
|
+
const s = sessions.find((x) => x.id === sid);
|
|
17518
|
+
const cwd = s?.cwd || s?.project || newSpecs.get(sid)?.cwd || '';
|
|
17519
|
+
return { sessionId: sid, cwd, project: s?.project || cwd };
|
|
17520
|
+
}
|
|
17521
|
+
|
|
17522
|
+
// The rail's items after the card: global add-ons, the session's, then its docked URL panes.
|
|
17523
|
+
// The same filled URL shows once. Until the list for a new session arrives, the global
|
|
17524
|
+
// entries of the last one stand, so their frames are kept when the URL stays the same.
|
|
17525
|
+
function dockItems(sid = dockSid) {
|
|
17526
|
+
if (!sid) return [];
|
|
17527
|
+
const list = dockAddonList;
|
|
17528
|
+
const own = list?.sid === sid ? list : null;
|
|
17529
|
+
const items = [];
|
|
17530
|
+
const seen = new Set();
|
|
17531
|
+
const push = (item) => {
|
|
17532
|
+
if (seen.has(item.target)) return;
|
|
17533
|
+
seen.add(item.target);
|
|
17534
|
+
items.push(item);
|
|
17535
|
+
};
|
|
17536
|
+
if (list && (list.global.length || own?.session.length)) {
|
|
17537
|
+
const values = addonValues(sid);
|
|
17538
|
+
const entries = [...list.global.map((a) => [a, true]), ...(own ? own.session.map((a) => [a, false]) : [])];
|
|
17539
|
+
for (const [a, global] of entries) {
|
|
17540
|
+
const target = fillAddonUrl(a.url, { ...a.params, ...own?.params[a.id], ...values });
|
|
17541
|
+
push({ id: a.id, kind: 'url', title: a.title, icon: a.icon, target, addon: true, global, run: !!a.run });
|
|
17542
|
+
}
|
|
17543
|
+
}
|
|
17544
|
+
for (const p of dockPanes(sid)) push(p);
|
|
17545
|
+
return items;
|
|
17546
|
+
}
|
|
17547
|
+
|
|
17548
|
+
async function fetchDockAddons(sid) {
|
|
17549
|
+
const list = await getJson(`${apiPath`/api/addons`}?session=${encodeURIComponent(sid)}`);
|
|
17550
|
+
if (!list || sid !== dockSid) return;
|
|
17551
|
+
dockAddonList = { sid, ...list };
|
|
17552
|
+
renderDockRail();
|
|
17553
|
+
}
|
|
17554
|
+
|
|
17555
|
+
function dockActivePane(items = dockItems()) {
|
|
17556
|
+
const id = showState.terminalId && dockAddons[showState.terminalId];
|
|
17557
|
+
return (id && items.find((p) => p.id === id)) || null;
|
|
17558
|
+
}
|
|
17559
|
+
|
|
17560
|
+
function dropDockView(id) {
|
|
17561
|
+
dockFrames.get(id)?.remove();
|
|
17562
|
+
dockFrames.delete(id);
|
|
17563
|
+
clearTimeout(dockHideTimers.get(id));
|
|
17564
|
+
dockHideTimers.delete(id);
|
|
17565
|
+
}
|
|
17566
|
+
|
|
17567
|
+
function dockShowOn(active) {
|
|
17568
|
+
return !active && showState.posts.length > 0 && !showState.closed;
|
|
17569
|
+
}
|
|
17570
|
+
|
|
17571
|
+
function dockOnScreen(active = dockActivePane()) {
|
|
17572
|
+
return active ? active.id : dockShowOn(active) ? 'show' : null;
|
|
17573
|
+
}
|
|
17574
|
+
|
|
17575
|
+
function setDockAddon(id) {
|
|
17576
|
+
const tid = showState.terminalId;
|
|
17577
|
+
if (!tid || (dockAddons[tid] ?? null) === id) return;
|
|
17578
|
+
if (id) dockAddons[tid] = id;
|
|
17579
|
+
else delete dockAddons[tid];
|
|
17580
|
+
store.writeJson(DOCK_ADDON_KEY, dockAddons);
|
|
17581
|
+
}
|
|
17582
|
+
|
|
17583
|
+
function dockRailButton(id, label, title, on, dot) {
|
|
17584
|
+
return `<button type="button" class="dock-rail-btn${on ? ' on' : ''}${dot ? ' dot' : ''}" data-rail="${escapeHtml(id)}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}" aria-pressed="${on ? 'true' : 'false'}">${label}</button>`;
|
|
17585
|
+
}
|
|
17586
|
+
|
|
17587
|
+
// Runs from every renderShow, so it does no work past a class toggle until a session has add-ons.
|
|
17588
|
+
function renderDockRail() {
|
|
17589
|
+
const sid = dockSessionId();
|
|
17590
|
+
if (sid !== dockSid) {
|
|
17591
|
+
dockSid = sid;
|
|
17592
|
+
if (sid && !paneLayouts.has(sid)) fetchPaneLayout(sid);
|
|
17593
|
+
if (sid) fetchDockAddons(sid);
|
|
17594
|
+
}
|
|
17595
|
+
const addons = dockItems(sid);
|
|
17596
|
+
for (const [id, view] of dockFrames) {
|
|
17597
|
+
if (!addons.some((p) => p.id === id && p.target === view.dataset.target)) dropDockView(id);
|
|
17598
|
+
}
|
|
17599
|
+
const on = addons.length > 0;
|
|
17600
|
+
const termPane = document.getElementById('terminal-pane');
|
|
17601
|
+
const rail = document.getElementById('dock-rail');
|
|
17602
|
+
const card = showCardEl();
|
|
17603
|
+
const active = dockActivePane(addons);
|
|
17604
|
+
const onScreen = dockOnScreen(active);
|
|
17605
|
+
const tid = showState.terminalId;
|
|
17606
|
+
if (tid && onScreen && dockLast[tid] !== onScreen) {
|
|
17607
|
+
dockLast[tid] = onScreen;
|
|
17608
|
+
store.writeJson(DOCK_LAST_KEY, dockLast);
|
|
17609
|
+
}
|
|
17610
|
+
termPane.classList.toggle('dock-rail-on', on);
|
|
17611
|
+
rail.hidden = !on;
|
|
17612
|
+
card.classList.toggle('under-addon', !!active);
|
|
17613
|
+
const docked = !!active || card.classList.contains('docked');
|
|
17614
|
+
termPane.classList.toggle('show-docked', docked);
|
|
17615
|
+
if (on) document.getElementById('show-reopen').hidden = true;
|
|
17616
|
+
if (!active) dockShowDot = false;
|
|
17617
|
+
let html = '';
|
|
17618
|
+
if (on) {
|
|
17619
|
+
const n = showState.posts.length;
|
|
17620
|
+
if (n) html += dockRailButton('show', showIcon('card'), `Show · ${n}`, onScreen === 'show', dockShowDot);
|
|
17621
|
+
for (const p of addons) {
|
|
17622
|
+
html += dockRailButton(
|
|
17623
|
+
p.id,
|
|
17624
|
+
escapeHtml(p.icon || [...p.title][0]?.toUpperCase() || '?'),
|
|
17625
|
+
p.title,
|
|
17626
|
+
p === active,
|
|
17627
|
+
false,
|
|
17628
|
+
);
|
|
17629
|
+
}
|
|
17630
|
+
}
|
|
17631
|
+
if (html !== dockRailHtml) {
|
|
17632
|
+
dockRailHtml = html;
|
|
17633
|
+
rail.innerHTML = html;
|
|
17634
|
+
}
|
|
17635
|
+
const claimed = dockRailItems(addons).length > 1;
|
|
17636
|
+
if (claimed !== dockRailClaimed) {
|
|
17637
|
+
dockRailClaimed = claimed;
|
|
17638
|
+
pushTerminalClaims();
|
|
17639
|
+
}
|
|
17640
|
+
renderDockAddon(active);
|
|
17641
|
+
if (docked && !dockObserver) {
|
|
17642
|
+
dockObserver = new ResizeObserver(fitDockWidth);
|
|
17643
|
+
dockObserver.observe(document.getElementById('terminal-host'));
|
|
17644
|
+
} else if (!docked) {
|
|
17645
|
+
dockObserver?.disconnect();
|
|
17646
|
+
dockObserver = null;
|
|
17647
|
+
}
|
|
17648
|
+
fitDockWidth();
|
|
17649
|
+
}
|
|
17650
|
+
|
|
17651
|
+
function renderDockAddon(pane) {
|
|
17652
|
+
const panel = document.getElementById('dock-addon');
|
|
17653
|
+
panel.hidden = !pane;
|
|
17654
|
+
if (pane) {
|
|
17655
|
+
if (!panel.firstChild) {
|
|
17656
|
+
panel.innerHTML = `<header class="show-head dock-addon-head" tabindex="-1"><span class="show-nav"><span class="show-title"></span></span>
|
|
17657
|
+
<span class="show-actions">${showBtnHtml('dock', 'open', 'external', 'Open in a new tab')}
|
|
17658
|
+
${showBtnHtml('dock', 'expand', 'expand', 'Fill the terminal · Alt+Enter')}
|
|
17659
|
+
${showBtnHtml('dock', 'reload', 'reload', 'Reload')}
|
|
17660
|
+
${showBtnHtml('dock', 'remove', 'clear', 'Remove from the rail')}
|
|
17661
|
+
${showBtnHtml('dock', 'close', 'close', 'Hide the dock · Alt+W')}</span></header>
|
|
17662
|
+
<div class="dock-addon-body"></div><div class="show-dock-edge" title="Resize · double-click to reset"></div>`;
|
|
17663
|
+
}
|
|
17664
|
+
const title = panel.querySelector('.show-title');
|
|
17665
|
+
title.textContent = pane.title;
|
|
17666
|
+
title.title = pane.global ? `${pane.title} · every session` : pane.title;
|
|
17667
|
+
const open = panel.querySelector('[data-dock="open"]');
|
|
17668
|
+
open.title = `Open ${pane.target} in a new tab`;
|
|
17669
|
+
open.setAttribute('aria-label', open.title);
|
|
17670
|
+
panel.classList.toggle('expanded', dockExpanded);
|
|
17671
|
+
panel.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
|
|
17672
|
+
const expand = panel.querySelector('[data-dock="expand"]');
|
|
17673
|
+
const expandTitle = dockExpanded ? 'Back to the dock · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter';
|
|
17674
|
+
if (expand.title !== expandTitle) {
|
|
17675
|
+
expand.innerHTML = showIcon(dockExpanded ? 'restore' : 'expand');
|
|
17676
|
+
expand.title = expandTitle;
|
|
17677
|
+
expand.setAttribute('aria-label', expandTitle);
|
|
17678
|
+
}
|
|
17679
|
+
if (!dockFrames.has(pane.id)) {
|
|
17680
|
+
const view = createDockView(pane);
|
|
17681
|
+
panel.querySelector('.dock-addon-body').appendChild(view);
|
|
17682
|
+
dockFrames.set(pane.id, view);
|
|
17683
|
+
}
|
|
17684
|
+
}
|
|
17685
|
+
for (const [id, view] of dockFrames) {
|
|
17686
|
+
view.hidden = id !== pane?.id;
|
|
17687
|
+
if (!view.hidden) {
|
|
17688
|
+
clearTimeout(dockHideTimers.get(id));
|
|
17689
|
+
dockHideTimers.delete(id);
|
|
17690
|
+
} else if (!dockHideTimers.has(id))
|
|
17691
|
+
dockHideTimers.set(
|
|
17692
|
+
id,
|
|
17693
|
+
setTimeout(() => dropDockView(id), DOCK_UNLOAD_MS),
|
|
17694
|
+
);
|
|
17695
|
+
}
|
|
17696
|
+
syncDockFocus();
|
|
17697
|
+
}
|
|
17698
|
+
|
|
17699
|
+
function dockCardView(pane, html) {
|
|
17700
|
+
const view = document.createElement('div');
|
|
17701
|
+
view.className = 'dock-addon-view';
|
|
17702
|
+
view.dataset.target = pane.target;
|
|
17703
|
+
view.innerHTML = html;
|
|
17704
|
+
return view;
|
|
17705
|
+
}
|
|
17706
|
+
|
|
17707
|
+
function createDockView(pane) {
|
|
17708
|
+
const blocked = paneBlockedHtml(pane);
|
|
17709
|
+
if (blocked) return dockCardView(pane, blocked);
|
|
17710
|
+
if (pane.run && !dockAddonUp.has(pane.id)) return startDockAddon(pane);
|
|
17711
|
+
const frame = paneUrlFrame(pane, 'dock-addon-frame');
|
|
17712
|
+
frame.dataset.target = pane.target;
|
|
17713
|
+
frame.addEventListener('load', () => {
|
|
17714
|
+
postToShowFrames(() => ({ type: `${SHOW_MSG}tokens`, tokens: showTokens() }), [frame]);
|
|
17715
|
+
frame.hostKeysSig = null;
|
|
17716
|
+
pushTerminalClaims();
|
|
17717
|
+
});
|
|
17718
|
+
if (pane.addon || paneFraming.get(pane.target)) return frame;
|
|
17719
|
+
const sid = dockSid;
|
|
17720
|
+
probePaneFraming(sid, pane).then((frameable) => {
|
|
17721
|
+
if (frameable !== false || dockFrames.get(pane.id) !== frame) return;
|
|
17722
|
+
dropDockView(pane.id);
|
|
17723
|
+
if (sid === dockSid) renderDockRail();
|
|
17724
|
+
});
|
|
17725
|
+
return frame;
|
|
17726
|
+
}
|
|
17727
|
+
|
|
17728
|
+
// The board starts the page's server when its port does not answer (lib/addon-runner.js);
|
|
17729
|
+
// the frame comes once it does. Reload asks again.
|
|
17730
|
+
function startDockAddon(pane) {
|
|
17731
|
+
const view = dockCardView(
|
|
17732
|
+
pane,
|
|
17733
|
+
`<div class="pane-card"><div class="pane-card-box"><p>Starting ${escapeHtml(pane.title)}…</p></div></div>`,
|
|
17734
|
+
);
|
|
17735
|
+
paneRequest('POST', apiPath`/api/addons/${pane.id}/start`).then((r) => {
|
|
17736
|
+
if (dockFrames.get(pane.id) !== view) return;
|
|
17737
|
+
if (r.state === 'up' || r.state === 'none') {
|
|
17738
|
+
dockAddonUp.add(pane.id);
|
|
17739
|
+
dropDockView(pane.id);
|
|
17740
|
+
renderDockRail();
|
|
17741
|
+
return;
|
|
17742
|
+
}
|
|
17743
|
+
const log = r.log ? `<p>Log: <code>${escapeHtml(r.log)}</code></p>` : '';
|
|
17744
|
+
view.innerHTML = paneCardHtml(`${pane.title} did not start`, r.error || 'No answer', 'Retry', {
|
|
17745
|
+
attr: 'data-dock="reload"',
|
|
17746
|
+
extra: log,
|
|
17747
|
+
});
|
|
17748
|
+
});
|
|
17749
|
+
return view;
|
|
17750
|
+
}
|
|
17751
|
+
|
|
17752
|
+
async function removeDockItem(item) {
|
|
17753
|
+
if (!item.addon) return setPaneDock(dockSid, item.id, false);
|
|
17754
|
+
if (item.global) {
|
|
17755
|
+
const ok = await confirmModal({
|
|
17756
|
+
title: 'Remove this add-on from every session?',
|
|
17757
|
+
message: 'It was added with --global, so it is on the rail of each session.',
|
|
17758
|
+
chip: item.title,
|
|
17759
|
+
okLabel: 'Remove',
|
|
17760
|
+
});
|
|
17761
|
+
if (!ok) return;
|
|
17762
|
+
}
|
|
17763
|
+
const r = await paneRequest('DELETE', `${apiPath`/api/addons/${item.id}`}?session=${encodeURIComponent(dockSid)}`);
|
|
17764
|
+
if (r.error) showToast(r.error, 'error');
|
|
17765
|
+
}
|
|
17766
|
+
|
|
17767
|
+
// Page-wide, like the session log's full width. The panel covers the terminal above its prompt
|
|
17768
|
+
// rows, so the PTY keeps its size.
|
|
17769
|
+
function setDockExpanded(on) {
|
|
17770
|
+
dockExpanded = on;
|
|
17771
|
+
store.setItem(DOCK_EXPANDED_KEY, on ? '1' : '');
|
|
17772
|
+
renderDockAddon(dockActivePane());
|
|
17773
|
+
}
|
|
17774
|
+
|
|
17775
|
+
// Alt+Enter toggles the full width and Esc goes back; Esc in the dock focuses the terminal.
|
|
17776
|
+
function dockHeadKey(e) {
|
|
17777
|
+
const combo = ClaudeHub.comboOf(e);
|
|
17778
|
+
if (combo !== 'alt+w' && combo !== 'alt+Enter' && combo !== 'Escape') return false;
|
|
17779
|
+
const pane = dockActivePane();
|
|
17780
|
+
if (!pane) return false;
|
|
17781
|
+
if (combo === 'alt+w') {
|
|
17782
|
+
dockRailCommand(pane.id);
|
|
17783
|
+
focusTerminalPane();
|
|
17784
|
+
} else if (combo === 'alt+Enter') setDockExpanded(!dockExpanded);
|
|
17785
|
+
else if (combo === 'Escape' && dockExpanded) setDockExpanded(false);
|
|
17786
|
+
else if (combo === 'Escape') focusTerminalPane();
|
|
17787
|
+
else return false;
|
|
17788
|
+
return true;
|
|
17789
|
+
}
|
|
17790
|
+
|
|
17791
|
+
// Focus moving from the terminal's frame into a dock page leaves this document's activeElement on the
|
|
17792
|
+
// terminal frame and fires no event here, so the page posts its own focus (skills/addon/references/build.md).
|
|
17793
|
+
let dockFocusFrame = null;
|
|
17794
|
+
|
|
17795
|
+
const dockFrameFocused = () => !!dockFocusFrame?.isConnected && !dockFocusFrame.hidden;
|
|
17796
|
+
|
|
17797
|
+
function isDockFocused() {
|
|
17798
|
+
return dockFrameFocused() || !!document.activeElement?.closest('#dock-addon');
|
|
17799
|
+
}
|
|
17800
|
+
|
|
17801
|
+
function setDockFrameFocused(frame, on) {
|
|
17802
|
+
if (on) dockFocusFrame = frame;
|
|
17803
|
+
else if (dockFocusFrame === frame) dockFocusFrame = null;
|
|
17804
|
+
syncDockFocus();
|
|
17805
|
+
}
|
|
17806
|
+
|
|
17807
|
+
function syncDockFocus() {
|
|
17808
|
+
document.getElementById('dock-addon').classList.toggle('focused', dockFrameFocused());
|
|
17809
|
+
}
|
|
17810
|
+
|
|
17811
|
+
const dockFrameEls = () => [...dockFrames.values()].filter((v) => v.tagName === 'IFRAME');
|
|
17812
|
+
|
|
17813
|
+
window.addEventListener('message', (e) => {
|
|
17814
|
+
if (e.data?.type !== `${SHOW_MSG}focus`) return;
|
|
17815
|
+
const frame = dockFrameEls().find((f) => f.contentWindow === e.source);
|
|
17816
|
+
if (frame) setDockFrameFocused(frame, e.data.on === true);
|
|
17817
|
+
});
|
|
17818
|
+
|
|
17819
|
+
// A dock page gets the pane keys plus these (hub protocol §5.2). A page with no hub SDK cannot
|
|
17820
|
+
// forward the hub's own combos, so they go as claims too.
|
|
17821
|
+
const DOCK_CLAIMS = [{ altKey: true, key: 'Enter', code: '' }];
|
|
17822
|
+
|
|
17823
|
+
function comboClaims(combo) {
|
|
17824
|
+
const parts = combo.split('+');
|
|
17825
|
+
const key = parts.pop() || '+';
|
|
17826
|
+
const mods = Object.fromEntries(parts.filter(Boolean).map((m) => [`${m}Key`, true]));
|
|
17827
|
+
const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[1-9]$/.test(key) ? `Digit${key}` : '';
|
|
17828
|
+
return [{ ...mods, key, code: '' }, ...(code ? [{ ...mods, key: 'Unidentified', code }] : [])];
|
|
17829
|
+
}
|
|
17830
|
+
|
|
17831
|
+
// Focus goes to the header, not the page: a frame on another origin would keep the keys.
|
|
17832
|
+
function focusDockHead() {
|
|
17833
|
+
document.querySelector('#dock-addon .dock-addon-head')?.focus();
|
|
17834
|
+
}
|
|
17835
|
+
|
|
17836
|
+
// Show and each add-on take the dock in turn; the one on screen hides it.
|
|
17837
|
+
function dockRailCommand(id) {
|
|
17838
|
+
const active = dockActivePane();
|
|
17839
|
+
if (id === 'show') {
|
|
17840
|
+
const showOn = dockShowOn(active);
|
|
17841
|
+
setDockAddon(null);
|
|
17842
|
+
if (showOn) showState.closed = true;
|
|
17843
|
+
else Object.assign(showState, { closed: false, collapsed: false });
|
|
17844
|
+
} else if (active?.id === id) {
|
|
17845
|
+
setDockAddon(null);
|
|
17846
|
+
if (showState.docked) showState.closed = true;
|
|
17847
|
+
} else setDockAddon(id);
|
|
17848
|
+
renderShow();
|
|
17849
|
+
}
|
|
17850
|
+
|
|
17851
|
+
function dockRailItems(items = dockItems()) {
|
|
17852
|
+
const ids = items.map((p) => p.id);
|
|
17853
|
+
return ids.length && showState.posts.length ? ['show', ...ids] : ids;
|
|
17854
|
+
}
|
|
17855
|
+
|
|
17856
|
+
// With nothing in the dock, Down starts at the first item and Up at the last.
|
|
17857
|
+
function stepDockRail(dir) {
|
|
17858
|
+
const items = dockRailItems();
|
|
17859
|
+
const cur = dockOnScreen();
|
|
17860
|
+
const i = items.indexOf(cur);
|
|
17861
|
+
const next = items[i < 0 ? (dir > 0 ? 0 : items.length - 1) : (i + dir + items.length) % items.length];
|
|
17862
|
+
if (!next || next === cur) return;
|
|
17863
|
+
dockRailCommand(next);
|
|
17864
|
+
if (next === 'show') focusShowBody();
|
|
17865
|
+
else focusDockHead();
|
|
17866
|
+
}
|
|
17867
|
+
|
|
17868
|
+
// The panel follows the pointer over the terminal; the terminal takes the new width on release.
|
|
17869
|
+
function startDockDrag(e, panel) {
|
|
17870
|
+
const edge = e.target.closest('.show-dock-edge');
|
|
17871
|
+
if (!edge || e.button !== 0) return;
|
|
17872
|
+
e.preventDefault();
|
|
17873
|
+
edge.setPointerCapture(e.pointerId);
|
|
17874
|
+
const hostW = dockHostWidth();
|
|
17875
|
+
const start = { x: e.clientX, w: panel.offsetWidth };
|
|
17876
|
+
let w = start.w;
|
|
17877
|
+
let raf = 0;
|
|
17878
|
+
document.body.classList.add('show-resizing');
|
|
17879
|
+
const move = (ev) => {
|
|
17880
|
+
w = showDockWidth(Math.round(start.w + start.x - ev.clientX), hostW);
|
|
17881
|
+
raf ||= requestAnimationFrame(() => {
|
|
17882
|
+
raf = 0;
|
|
17883
|
+
panel.style.setProperty('--show-dock-drag', `${w}px`);
|
|
17884
|
+
});
|
|
17885
|
+
};
|
|
17886
|
+
const up = () => {
|
|
17887
|
+
cancelAnimationFrame(raf);
|
|
17888
|
+
edge.removeEventListener('pointermove', move);
|
|
17889
|
+
edge.removeEventListener('pointerup', up);
|
|
17890
|
+
edge.removeEventListener('pointercancel', up);
|
|
17891
|
+
document.body.classList.remove('show-resizing');
|
|
17892
|
+
panel.style.removeProperty('--show-dock-drag');
|
|
17893
|
+
showDockW = w;
|
|
17894
|
+
store.writeJson(SHOW_DOCK_KEY, showDockW);
|
|
17895
|
+
fitDockWidth();
|
|
17896
|
+
};
|
|
17897
|
+
edge.addEventListener('pointermove', move);
|
|
17898
|
+
edge.addEventListener('pointerup', up);
|
|
17899
|
+
edge.addEventListener('pointercancel', up);
|
|
17900
|
+
}
|
|
17901
|
+
|
|
17902
|
+
function resetDockWidth() {
|
|
17903
|
+
showDockW = null;
|
|
17904
|
+
store.removeItem(SHOW_DOCK_KEY);
|
|
17905
|
+
fitDockWidth();
|
|
17906
|
+
}
|
|
17907
|
+
|
|
17908
|
+
function initDockRail() {
|
|
17909
|
+
document.getElementById('dock-rail').addEventListener('click', (e) => {
|
|
17910
|
+
const b = e.target.closest('[data-rail]');
|
|
17911
|
+
if (b) dockRailCommand(b.dataset.rail);
|
|
17912
|
+
});
|
|
17913
|
+
const panel = document.getElementById('dock-addon');
|
|
17914
|
+
panel.addEventListener('click', (e) => {
|
|
17915
|
+
const pane = dockActivePane();
|
|
17916
|
+
if (!pane) return;
|
|
17917
|
+
if (e.target.closest('[data-dock="close"]')) dockRailCommand(pane.id);
|
|
17918
|
+
else if (e.target.closest('[data-dock="expand"]')) setDockExpanded(!dockExpanded);
|
|
17919
|
+
else if (e.target.closest('[data-dock="remove"]')) removeDockItem(pane);
|
|
17920
|
+
else if (e.target.closest('[data-dock="reload"]')) {
|
|
17921
|
+
dockAddonUp.delete(pane.id);
|
|
17922
|
+
dropDockView(pane.id);
|
|
17923
|
+
renderDockRail();
|
|
17924
|
+
} else if (e.target.closest('[data-dock="open"], [data-pane-open]')) hub.openExternal(pane.target);
|
|
17925
|
+
});
|
|
17926
|
+
panel.addEventListener('pointerdown', (e) => startDockDrag(e, panel));
|
|
17927
|
+
panel.addEventListener('dblclick', (e) => {
|
|
17928
|
+
if (e.target.closest('.show-dock-edge')) resetDockWidth();
|
|
17929
|
+
});
|
|
17930
|
+
}
|
|
17931
|
+
|
|
17216
17932
|
async function openShowInPane() {
|
|
17217
17933
|
const sid = showState.sessionId;
|
|
17218
17934
|
const target = showState.path;
|
|
@@ -17246,7 +17962,10 @@ function showCommand(cmd) {
|
|
|
17246
17962
|
showState.idx = i;
|
|
17247
17963
|
showState.collapsed = false;
|
|
17248
17964
|
} else if (cmd === 'collapse') showState.collapsed = !showState.collapsed;
|
|
17249
|
-
else if (cmd === '
|
|
17965
|
+
else if (cmd === 'dock') {
|
|
17966
|
+
showState.docked = !showState.docked;
|
|
17967
|
+
showState.collapsed = false;
|
|
17968
|
+
} else if (cmd === 'expand') {
|
|
17250
17969
|
showState.expanded = !showState.expanded;
|
|
17251
17970
|
showState.collapsed = false;
|
|
17252
17971
|
} else if (cmd === 'close') showState.closed = true;
|
|
@@ -17332,7 +18051,12 @@ function initShowCard() {
|
|
|
17332
18051
|
grip.addEventListener('pointerup', up);
|
|
17333
18052
|
grip.addEventListener('pointercancel', up);
|
|
17334
18053
|
});
|
|
18054
|
+
card.addEventListener('pointerdown', (e) => startDockDrag(e, card));
|
|
17335
18055
|
card.addEventListener('dblclick', (e) => {
|
|
18056
|
+
if (e.target.closest('.show-dock-edge')) {
|
|
18057
|
+
resetDockWidth();
|
|
18058
|
+
return;
|
|
18059
|
+
}
|
|
17336
18060
|
if (!e.target.closest('.show-grip')) return;
|
|
17337
18061
|
showSize = null;
|
|
17338
18062
|
store.removeItem(SHOW_SIZE_KEY);
|
|
@@ -17371,6 +18095,7 @@ const PLACEHOLDER_HINTS = {
|
|
|
17371
18095
|
pick: 'pick a session to resume',
|
|
17372
18096
|
dispatch: 'starting',
|
|
17373
18097
|
new: 'waiting for your first message',
|
|
18098
|
+
prompted: 'starting',
|
|
17374
18099
|
edit: 'press Enter in the terminal to start',
|
|
17375
18100
|
};
|
|
17376
18101
|
|
|
@@ -17385,8 +18110,9 @@ function placeholderSession(id, spec) {
|
|
|
17385
18110
|
placeholder: true,
|
|
17386
18111
|
mode: spec.mode,
|
|
17387
18112
|
edit: !!spec.edit,
|
|
18113
|
+
prompted: !!(spec.prompted || spec.prompt),
|
|
17388
18114
|
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
17389
|
-
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
18115
|
+
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null, child: !!spec.child } : undefined,
|
|
17390
18116
|
project: spec.cwd,
|
|
17391
18117
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
17392
18118
|
modifiedAt: new Date(spec.startedAt).toISOString(),
|
|
@@ -17435,6 +18161,7 @@ async function loadDispatches() {
|
|
|
17435
18161
|
worktree: r.worktree,
|
|
17436
18162
|
group: r.group,
|
|
17437
18163
|
parent: r.parent,
|
|
18164
|
+
child: r.child,
|
|
17438
18165
|
startedAt: r.startedAt,
|
|
17439
18166
|
});
|
|
17440
18167
|
}
|
|
@@ -17468,23 +18195,36 @@ function forgetPlaceholder(id) {
|
|
|
17468
18195
|
return true;
|
|
17469
18196
|
}
|
|
17470
18197
|
|
|
17471
|
-
function renderPlaceholderCard(session, depth) {
|
|
18198
|
+
function renderPlaceholderCard(session, depth, hostProject) {
|
|
17472
18199
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
17473
|
-
const projectHtml = renderProjectIdentity(session);
|
|
18200
|
+
const projectHtml = renderProjectIdentity(session, hostProject);
|
|
17474
18201
|
return `
|
|
17475
18202
|
<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}`)}">
|
|
17476
18203
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
17477
|
-
${projectHtml
|
|
18204
|
+
${renderSessionIdentity(projectHtml, null)}
|
|
17478
18205
|
${
|
|
17479
18206
|
trustWaiting.has(session.id)
|
|
17480
18207
|
? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
|
|
17481
|
-
: `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
|
|
18208
|
+
: `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.prompted ? 'prompted' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
|
|
17482
18209
|
}
|
|
17483
18210
|
</button>
|
|
17484
18211
|
`;
|
|
17485
18212
|
}
|
|
17486
18213
|
|
|
17487
18214
|
// After a reload the form is gone, but the PTY still runs; its hello options come back from the server.
|
|
18215
|
+
function specFromTerminal(t) {
|
|
18216
|
+
return {
|
|
18217
|
+
cwd: t.cwd,
|
|
18218
|
+
name: t.name,
|
|
18219
|
+
worktree: t.worktree,
|
|
18220
|
+
mode: t.mode,
|
|
18221
|
+
edit: t.edit,
|
|
18222
|
+
forkOf: t.forkOf,
|
|
18223
|
+
prompted: t.prompted,
|
|
18224
|
+
startedAt: t.startedAt,
|
|
18225
|
+
};
|
|
18226
|
+
}
|
|
18227
|
+
|
|
17488
18228
|
async function restorePendingSessions() {
|
|
17489
18229
|
document.getElementById('new-session-btn').hidden = !terminalAvailable();
|
|
17490
18230
|
const terminals = terminalAvailable() ? loadTerminals() : null;
|
|
@@ -17492,15 +18232,7 @@ async function restorePendingSessions() {
|
|
|
17492
18232
|
try {
|
|
17493
18233
|
for (const t of terminals ? await terminals : []) {
|
|
17494
18234
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
17495
|
-
newSpecs.set(t.id,
|
|
17496
|
-
cwd: t.cwd,
|
|
17497
|
-
name: t.name,
|
|
17498
|
-
worktree: t.worktree,
|
|
17499
|
-
mode: t.mode,
|
|
17500
|
-
edit: t.edit,
|
|
17501
|
-
forkOf: t.forkOf,
|
|
17502
|
-
startedAt: t.startedAt,
|
|
17503
|
-
});
|
|
18235
|
+
newSpecs.set(t.id, specFromTerminal(t));
|
|
17504
18236
|
}
|
|
17505
18237
|
} catch (_) {}
|
|
17506
18238
|
// Sessions that were already running keep the collapse state saved before the reload.
|
|
@@ -18105,10 +18837,12 @@ initSessionGroupsDnd();
|
|
|
18105
18837
|
initSessionPicker();
|
|
18106
18838
|
initProjectPicker();
|
|
18107
18839
|
initShowCard();
|
|
18840
|
+
initDockRail();
|
|
18108
18841
|
initAsk();
|
|
18109
18842
|
for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
|
|
18110
18843
|
initSidebarResize();
|
|
18111
18844
|
applyModalZoom();
|
|
18845
|
+
applyShowZoom();
|
|
18112
18846
|
loadModalFullscreen();
|
|
18113
18847
|
initModalResize();
|
|
18114
18848
|
loadPanelWidths();
|
|
@@ -18187,7 +18921,13 @@ function makeLimitCell(label, bucket) {
|
|
|
18187
18921
|
function makeLimitSpan(rl) {
|
|
18188
18922
|
const span = document.createElement('span');
|
|
18189
18923
|
span.className = 'footer-limits';
|
|
18190
|
-
span.
|
|
18924
|
+
span.title = 'Plan usage: 5-hour and 7-day limits, used % (time until reset)';
|
|
18925
|
+
span.append(
|
|
18926
|
+
'Usage ',
|
|
18927
|
+
makeLimitCell('5h', rl?.five_hour),
|
|
18928
|
+
document.createTextNode(' · '),
|
|
18929
|
+
makeLimitCell('7d', rl?.seven_day),
|
|
18930
|
+
);
|
|
18191
18931
|
return span;
|
|
18192
18932
|
}
|
|
18193
18933
|
function renderSidebarFooter(rateLimits) {
|
|
@@ -18394,6 +19134,7 @@ hub.onActive((active) => {
|
|
|
18394
19134
|
});
|
|
18395
19135
|
hub.onStatus(pushTerminalClaims);
|
|
18396
19136
|
hub.onSettings(applyHubSettings);
|
|
19137
|
+
hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session));
|
|
18397
19138
|
|
|
18398
19139
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
18399
19140
|
let publishTimer = 0;
|