claude-code-kanban 7.21.0 → 7.23.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/retention.js +2 -1
- package/lib/show.js +15 -0
- 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 +834 -128
- package/public/index.html +6 -1
- package/public/style.css +224 -7
- package/server.js +159 -24
- package/skill-guides/dispatch.md +5 -2
package/public/app.js
CHANGED
|
@@ -2769,32 +2769,58 @@ function createResizeGrip({ onReset, ...drag }) {
|
|
|
2769
2769
|
}
|
|
2770
2770
|
|
|
2771
2771
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
2772
|
-
const
|
|
2773
|
-
const
|
|
2774
|
-
|
|
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);
|
|
2775
2777
|
|
|
2776
|
-
function
|
|
2777
|
-
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)}%`;
|
|
2778
2788
|
}
|
|
2779
2789
|
|
|
2780
2790
|
function modalZoomLabel() {
|
|
2781
|
-
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;
|
|
2782
2801
|
}
|
|
2783
2802
|
|
|
2784
2803
|
function applyModalZoom() {
|
|
2785
2804
|
document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
|
|
2786
2805
|
for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
|
|
2787
|
-
postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }));
|
|
2806
|
+
postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }), document.querySelectorAll(PANE_SHOW_FRAMES));
|
|
2788
2807
|
}
|
|
2789
2808
|
|
|
2790
|
-
// delta 0 resets to 100%
|
|
2791
2809
|
function adjustModalZoom(delta) {
|
|
2792
|
-
|
|
2793
|
-
if (next === modalZoom && delta !== 0) return;
|
|
2794
|
-
modalZoom = next;
|
|
2795
|
-
store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
2810
|
+
modalZoom = stepZoom(modalZoom, delta, MODAL_ZOOM_KEY, 'Text');
|
|
2796
2811
|
applyModalZoom();
|
|
2797
|
-
|
|
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();
|
|
2798
2824
|
}
|
|
2799
2825
|
|
|
2800
2826
|
// The markup decides what scales: `.modal-zoomable` marks a modal's reading
|
|
@@ -2808,9 +2834,12 @@ function isAnyModalOpen() {
|
|
|
2808
2834
|
return document.querySelector('.modal-overlay.visible') !== null;
|
|
2809
2835
|
}
|
|
2810
2836
|
|
|
2811
|
-
// An open modal owns the
|
|
2812
|
-
function
|
|
2813
|
-
|
|
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;
|
|
2814
2843
|
}
|
|
2815
2844
|
|
|
2816
2845
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
@@ -3957,6 +3986,10 @@ function renderSessions() {
|
|
|
3957
3986
|
const childDepth = new Map();
|
|
3958
3987
|
const parentOf = new Map();
|
|
3959
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
|
+
}
|
|
3960
3993
|
const nestChildren = (list) => {
|
|
3961
3994
|
if (parentOf.size === 0) return list;
|
|
3962
3995
|
const ids = new Set(list.map((s) => s.id));
|
|
@@ -4132,6 +4165,10 @@ function renderSessions() {
|
|
|
4132
4165
|
return;
|
|
4133
4166
|
}
|
|
4134
4167
|
|
|
4168
|
+
const nestedCards = (list) =>
|
|
4169
|
+
nestChildren(list)
|
|
4170
|
+
.map((s) => renderSessionCard(s))
|
|
4171
|
+
.join('');
|
|
4135
4172
|
const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
|
|
4136
4173
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
4137
4174
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
@@ -4252,13 +4289,14 @@ function renderSessions() {
|
|
|
4252
4289
|
// Placed sessions follow the order the user dragged them into; a session that is here through
|
|
4253
4290
|
// its project takes the project's slot.
|
|
4254
4291
|
const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
|
|
4255
|
-
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
|
+
};
|
|
4256
4296
|
const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
|
|
4257
4297
|
let body = '';
|
|
4258
4298
|
if (flat) {
|
|
4259
|
-
body =
|
|
4260
|
-
.map((s) => renderSessionCard(s))
|
|
4261
|
-
.join('');
|
|
4299
|
+
body = nestedCards([...bucket].sort(byMemberOrder));
|
|
4262
4300
|
} else {
|
|
4263
4301
|
const loose = [];
|
|
4264
4302
|
const byProject = new Map();
|
|
@@ -4312,7 +4350,7 @@ function renderSessions() {
|
|
|
4312
4350
|
const groups = new Map();
|
|
4313
4351
|
const ungrouped = [];
|
|
4314
4352
|
for (const session of sgRest) {
|
|
4315
|
-
const key = sessionProjectKey(session);
|
|
4353
|
+
const key = sessionProjectKey(placementOf(session));
|
|
4316
4354
|
if (key) {
|
|
4317
4355
|
if (!groups.has(key)) groups.set(key, []);
|
|
4318
4356
|
groups.get(key).push(session);
|
|
@@ -4363,7 +4401,7 @@ function renderSessions() {
|
|
|
4363
4401
|
</div>
|
|
4364
4402
|
`;
|
|
4365
4403
|
} else {
|
|
4366
|
-
projectsHtml += ungrouped
|
|
4404
|
+
projectsHtml += nestedCards(ungrouped);
|
|
4367
4405
|
}
|
|
4368
4406
|
}
|
|
4369
4407
|
|
|
@@ -4373,8 +4411,7 @@ function renderSessions() {
|
|
|
4373
4411
|
sessionsList.innerHTML = html + outsideHtml;
|
|
4374
4412
|
} else {
|
|
4375
4413
|
const sectioned = sessionGroups.length > 0;
|
|
4376
|
-
const restHtml =
|
|
4377
|
-
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map((s) => renderSessionCard(s)).join('');
|
|
4414
|
+
const restHtml = sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : nestedCards(sgRest);
|
|
4378
4415
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
4379
4416
|
sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
|
|
4380
4417
|
}
|
|
@@ -4989,6 +5026,12 @@ function sgGroupById(id) {
|
|
|
4989
5026
|
// Matches MAX_DEPTH in lib/user-groups.js.
|
|
4990
5027
|
const SG_MAX_DEPTH = 8;
|
|
4991
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
|
+
|
|
4992
5035
|
function sgSubtreeIds(rootId) {
|
|
4993
5036
|
const ids = new Set([rootId]);
|
|
4994
5037
|
for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
|
|
@@ -5013,8 +5056,35 @@ function sgSessionParent(id) {
|
|
|
5013
5056
|
|
|
5014
5057
|
// An individually placed session wins over the placement of its project.
|
|
5015
5058
|
function sgGroupForSession(session) {
|
|
5016
|
-
const
|
|
5017
|
-
|
|
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;
|
|
5018
5088
|
}
|
|
5019
5089
|
|
|
5020
5090
|
function sgListFiltered() {
|
|
@@ -5060,8 +5130,9 @@ function sgDetach(type, ref) {
|
|
|
5060
5130
|
|
|
5061
5131
|
// Where a session renders inside a group: the project block it was moved under, its own
|
|
5062
5132
|
// project's block, or - when the user lifted it out - the group's own loose list.
|
|
5063
|
-
function sgHostOf(group,
|
|
5064
|
-
if (!group || !
|
|
5133
|
+
function sgHostOf(group, placed) {
|
|
5134
|
+
if (!group || !placed) return null;
|
|
5135
|
+
const session = placementOf(placed);
|
|
5065
5136
|
const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
|
|
5066
5137
|
const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
|
|
5067
5138
|
if (m?.under && isMemberProject(m.under)) return m.under;
|
|
@@ -5946,7 +6017,8 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5946
6017
|
// (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
|
|
5947
6018
|
// Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
|
|
5948
6019
|
// whether anything changed.
|
|
5949
|
-
function uncollapseFor(
|
|
6020
|
+
function uncollapseFor(placed) {
|
|
6021
|
+
const session = placementOf(placed);
|
|
5950
6022
|
const group = sgGroupForSession(session);
|
|
5951
6023
|
const keys = [
|
|
5952
6024
|
...(group
|
|
@@ -5963,7 +6035,7 @@ function uncollapseFor(session) {
|
|
|
5963
6035
|
function pinnedCollapseKeys(session) {
|
|
5964
6036
|
if (!isInPinnedGroup(session)) return [];
|
|
5965
6037
|
const group = sgGroupForSession(session);
|
|
5966
|
-
if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
|
|
6038
|
+
if (!group) return [pinKey(sessionProjectKey(placementOf(session)) || '__ungrouped__')];
|
|
5967
6039
|
const host = sgHostOf(group, session);
|
|
5968
6040
|
return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
|
|
5969
6041
|
}
|
|
@@ -6514,7 +6586,7 @@ const SHORTCUT_TABS = [
|
|
|
6514
6586
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
6515
6587
|
{ keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
|
|
6516
6588
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
6517
|
-
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session
|
|
6589
|
+
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session, or delete the group under the cursor' },
|
|
6518
6590
|
{ keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
6519
6591
|
{ keys: ['Ctrl', 'Shift', 'M'], combo: true, label: 'Open last message' },
|
|
6520
6592
|
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
@@ -6552,6 +6624,7 @@ const SHORTCUT_TABS = [
|
|
|
6552
6624
|
rows: [
|
|
6553
6625
|
{ keys: ['[', ']'], label: 'Previous / next post' },
|
|
6554
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' },
|
|
6555
6628
|
{ keys: ['Esc'], label: 'Back to the card, else collapse to the header' },
|
|
6556
6629
|
{ keys: ['Alt', 'W'], combo: true, label: 'Close until the next post' },
|
|
6557
6630
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Remove the post' },
|
|
@@ -7767,15 +7840,20 @@ document.addEventListener('keydown', (e) => {
|
|
|
7767
7840
|
// the whole page. Sits above the text-field guard so it still works with the
|
|
7768
7841
|
// caret in a field inside the modal.
|
|
7769
7842
|
const zoom = zoomDelta(e);
|
|
7770
|
-
|
|
7843
|
+
const zoomTo = zoom !== undefined && zoomTarget();
|
|
7844
|
+
if (zoomTo) {
|
|
7771
7845
|
e.preventDefault();
|
|
7772
|
-
|
|
7846
|
+
zoomTo(zoom);
|
|
7773
7847
|
return;
|
|
7774
7848
|
}
|
|
7775
7849
|
if (isShowCardFocused() && !isAnyModalOpen() && showCardKey(e)) {
|
|
7776
7850
|
e.preventDefault();
|
|
7777
7851
|
return;
|
|
7778
7852
|
}
|
|
7853
|
+
if (isDockFocused() && !isAnyModalOpen() && dockHeadKey(e)) {
|
|
7854
|
+
e.preventDefault();
|
|
7855
|
+
return;
|
|
7856
|
+
}
|
|
7779
7857
|
|
|
7780
7858
|
// Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
|
|
7781
7859
|
const terminalPrompt = document.getElementById('terminal-prompt');
|
|
@@ -8123,7 +8201,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
8123
8201
|
}
|
|
8124
8202
|
if (ClaudeHub.comboOf(e) === 'ctrl+d') {
|
|
8125
8203
|
e.preventDefault();
|
|
8126
|
-
|
|
8204
|
+
const group = kbSelectedGroup();
|
|
8205
|
+
if (group) sgConfirmDelete(group);
|
|
8206
|
+
else dismissContextSession();
|
|
8127
8207
|
return;
|
|
8128
8208
|
}
|
|
8129
8209
|
if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
@@ -8732,10 +8812,11 @@ const PANE_CLAIMS = [
|
|
|
8732
8812
|
...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
|
|
8733
8813
|
];
|
|
8734
8814
|
|
|
8735
|
-
// 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.
|
|
8736
8816
|
const SHOW_CLAIMS = [
|
|
8737
8817
|
...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
|
|
8738
8818
|
{ altKey: true, key: 'Enter', code: '' },
|
|
8819
|
+
{ altKey: true, shiftKey: true, key: 'Enter', code: '' },
|
|
8739
8820
|
];
|
|
8740
8821
|
|
|
8741
8822
|
// Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
|
|
@@ -9434,6 +9515,13 @@ function openFileInEditor(filePath) {
|
|
|
9434
9515
|
postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
|
|
9435
9516
|
}
|
|
9436
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
|
+
|
|
9437
9525
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9438
9526
|
function openPreviewInEditor() {
|
|
9439
9527
|
if (!currentPreviewPath) return;
|
|
@@ -9491,6 +9579,25 @@ function handleSessionOpenEvent(data) {
|
|
|
9491
9579
|
fetchTasks(id);
|
|
9492
9580
|
}
|
|
9493
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
|
+
|
|
9494
9601
|
async function handlePreviewOpenEvent(data) {
|
|
9495
9602
|
const { path: filePath, sessionId } = data;
|
|
9496
9603
|
if (sessionId && sessionId !== currentSessionId) {
|
|
@@ -10292,6 +10399,10 @@ function setupEventSource() {
|
|
|
10292
10399
|
handleSessionOpenEvent(data);
|
|
10293
10400
|
}
|
|
10294
10401
|
|
|
10402
|
+
if (data.type === 'session:launched') {
|
|
10403
|
+
handleSessionLaunchedEvent(data);
|
|
10404
|
+
}
|
|
10405
|
+
|
|
10295
10406
|
// Alone, the page would open a tab without a user gesture, which the browser blocks; the
|
|
10296
10407
|
// CLI prints the link instead.
|
|
10297
10408
|
if (data.type === 'session:cost' && hub.inHub) {
|
|
@@ -10310,6 +10421,15 @@ function setupEventSource() {
|
|
|
10310
10421
|
applyPaneLayout(data.sessionId, data.layout);
|
|
10311
10422
|
}
|
|
10312
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
|
+
|
|
10313
10433
|
if (data.type === 'pane:file-changed') {
|
|
10314
10434
|
onPaneFileChanged(data.path);
|
|
10315
10435
|
}
|
|
@@ -11650,8 +11770,7 @@ function toggleZenMode() {
|
|
|
11650
11770
|
if (zenMode) zenMode = false;
|
|
11651
11771
|
else if (zenGroup) zenGroup = null;
|
|
11652
11772
|
else {
|
|
11653
|
-
|
|
11654
|
-
zenGroup = header?.dataset.groupId || null;
|
|
11773
|
+
zenGroup = kbSelectedGroup()?.id || null;
|
|
11655
11774
|
zenMode = !zenGroup;
|
|
11656
11775
|
}
|
|
11657
11776
|
store.setItem(ZEN_KEY, String(zenMode));
|
|
@@ -13380,8 +13499,10 @@ const PANE_EMPTY_LAYOUT = { panes: [] };
|
|
|
13380
13499
|
const paneLayouts = new Map();
|
|
13381
13500
|
let paneLayoutSid = null;
|
|
13382
13501
|
|
|
13383
|
-
//
|
|
13384
|
-
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();
|
|
13385
13506
|
|
|
13386
13507
|
// Every origin above a pane's frame. Firefox has no ancestorOrigins, so under the hub there it
|
|
13387
13508
|
// names the board only; a refused frame then shows the browser's own error.
|
|
@@ -13390,7 +13511,7 @@ function paneAncestors() {
|
|
|
13390
13511
|
}
|
|
13391
13512
|
|
|
13392
13513
|
function paneFrameable(pane) {
|
|
13393
|
-
return pane.kind !== 'url' ||
|
|
13514
|
+
return pane.kind !== 'url' || paneFraming.get(pane.target) !== false;
|
|
13394
13515
|
}
|
|
13395
13516
|
|
|
13396
13517
|
// allow-scripts with allow-same-origin on the board's or the hub's origin would lift the sandbox.
|
|
@@ -13412,7 +13533,7 @@ function paneOwnOrigin(pane) {
|
|
|
13412
13533
|
async function probePaneFraming(sid, pane) {
|
|
13413
13534
|
const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
|
|
13414
13535
|
const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
|
|
13415
|
-
if (frameable ===
|
|
13536
|
+
if (typeof frameable === 'boolean') paneFraming.set(pane.target, frameable);
|
|
13416
13537
|
return frameable;
|
|
13417
13538
|
}
|
|
13418
13539
|
|
|
@@ -13429,6 +13550,7 @@ function applyPaneLayout(sid, layout) {
|
|
|
13429
13550
|
if (targets.get(p.id) !== p.target) unmountPane(sid, p.id);
|
|
13430
13551
|
}
|
|
13431
13552
|
if (!targets.has(getActivePaneId(sid))) setActivePaneId(sid, 'board');
|
|
13553
|
+
if (sid === dockSid) renderDockRail();
|
|
13432
13554
|
if (sid !== paneSessionId()) return;
|
|
13433
13555
|
syncPanes();
|
|
13434
13556
|
autoRevealLog(sid);
|
|
@@ -13454,20 +13576,20 @@ async function paneRequest(method, path, body) {
|
|
|
13454
13576
|
}
|
|
13455
13577
|
}
|
|
13456
13578
|
|
|
13457
|
-
// Per-session pane state
|
|
13579
|
+
// Per-session pane state, {[sessionId]: value} in recency order; sessionStorage unless the caller passes `store`.
|
|
13458
13580
|
const PANE_STATE_CAP = 20;
|
|
13459
13581
|
|
|
13460
|
-
function loadPaneState(key) {
|
|
13461
|
-
const map =
|
|
13582
|
+
function loadPaneState(key, area = tabStore) {
|
|
13583
|
+
const map = area.readJson(key);
|
|
13462
13584
|
return map && typeof map === 'object' ? map : {};
|
|
13463
13585
|
}
|
|
13464
13586
|
|
|
13465
13587
|
// Puts `sid` last, so the cap drops the least recent session. A null value removes it.
|
|
13466
|
-
function savePaneState(key, map, sid, value) {
|
|
13588
|
+
function savePaneState(key, map, sid, value, area = tabStore) {
|
|
13467
13589
|
delete map[sid];
|
|
13468
13590
|
if (value != null) map[sid] = value;
|
|
13469
13591
|
const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
|
|
13470
|
-
|
|
13592
|
+
area.writeJson(key, capped);
|
|
13471
13593
|
return capped;
|
|
13472
13594
|
}
|
|
13473
13595
|
|
|
@@ -13549,7 +13671,8 @@ function neighborPair(sid, id, pairs) {
|
|
|
13549
13671
|
// tab, so grouping leaves the server's order alone; a drag saves the order on screen.
|
|
13550
13672
|
const PANE_GROUPS_KEY = 'pane-groups';
|
|
13551
13673
|
const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
|
|
13552
|
-
|
|
13674
|
+
// Groups outlive the window; a save re-reads the map, so other windows' groups stay.
|
|
13675
|
+
let paneGroups = loadPaneState(PANE_GROUPS_KEY, store);
|
|
13553
13676
|
|
|
13554
13677
|
// Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
|
|
13555
13678
|
function getPaneGroups(sid) {
|
|
@@ -13567,7 +13690,8 @@ function getPaneGroups(sid) {
|
|
|
13567
13690
|
}
|
|
13568
13691
|
|
|
13569
13692
|
function setPaneGroups(sid, groups) {
|
|
13570
|
-
|
|
13693
|
+
const map = loadPaneState(PANE_GROUPS_KEY, store);
|
|
13694
|
+
paneGroups = savePaneState(PANE_GROUPS_KEY, map, sid, groups.length ? groups : null, store);
|
|
13571
13695
|
}
|
|
13572
13696
|
|
|
13573
13697
|
// A pair is in the group of either side, and the board is in none.
|
|
@@ -13875,9 +13999,10 @@ function markPaneFrameFocus() {
|
|
|
13875
13999
|
view?.classList.add('frame-focus');
|
|
13876
14000
|
}
|
|
13877
14001
|
|
|
13878
|
-
|
|
13879
|
-
|
|
13880
|
-
|
|
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>`;
|
|
13881
14006
|
}
|
|
13882
14007
|
|
|
13883
14008
|
// Returns the key of the view to show.
|
|
@@ -13906,32 +14031,37 @@ function mountPane(sid, pane) {
|
|
|
13906
14031
|
return key;
|
|
13907
14032
|
}
|
|
13908
14033
|
|
|
13909
|
-
|
|
14034
|
+
// The card in place of a URL pane that cannot be framed, or '' when it can.
|
|
14035
|
+
function paneBlockedHtml(pane) {
|
|
13910
14036
|
if (paneOwnOrigin(pane)) {
|
|
13911
|
-
|
|
13912
|
-
'beforeend',
|
|
13913
|
-
paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab'),
|
|
13914
|
-
);
|
|
13915
|
-
return;
|
|
14037
|
+
return paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab');
|
|
13916
14038
|
}
|
|
13917
|
-
if (
|
|
13918
|
-
|
|
13919
|
-
|
|
13920
|
-
|
|
13921
|
-
|
|
13922
|
-
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
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);
|
|
13926
14061
|
return;
|
|
13927
14062
|
}
|
|
13928
14063
|
if (pane.kind === 'url') {
|
|
13929
|
-
const frame =
|
|
13930
|
-
frame.className = 'pane-frame pane-url-frame modal-zoomable';
|
|
13931
|
-
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
13932
|
-
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
13933
|
-
frame.title = pane.title;
|
|
13934
|
-
frame.src = pane.target;
|
|
14064
|
+
const frame = paneUrlFrame(pane, 'pane-frame pane-url-frame modal-zoomable');
|
|
13935
14065
|
frame.addEventListener('load', () => {
|
|
13936
14066
|
view.classList.remove('keys-bridged');
|
|
13937
14067
|
frame.hostKeysSig = null;
|
|
@@ -13955,7 +14085,7 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
13955
14085
|
if (data?.kind === 'html') {
|
|
13956
14086
|
// A show card's own zoom follows the text zoom (showBridge), so its frame is not .modal-zoomable.
|
|
13957
14087
|
const [cls, doc] = pane.show
|
|
13958
|
-
? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y)]
|
|
14088
|
+
? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y, modalZoom)]
|
|
13959
14089
|
: ['pane-frame modal-zoomable themed-frame', themedSrcdoc(data.content)];
|
|
13960
14090
|
const frame = createPreviewFrame(cls, doc);
|
|
13961
14091
|
frame.title = pane.title;
|
|
@@ -14365,7 +14495,7 @@ async function reloadPane(id) {
|
|
|
14365
14495
|
await reloadPaneView(sid, pane, view);
|
|
14366
14496
|
} else {
|
|
14367
14497
|
unmountPane(sid, pane.id);
|
|
14368
|
-
|
|
14498
|
+
paneFraming.delete(pane.target);
|
|
14369
14499
|
syncPanes();
|
|
14370
14500
|
}
|
|
14371
14501
|
showToast('Pane reloaded', 'success');
|
|
@@ -14578,6 +14708,7 @@ function openPaneMenu(x, y, id) {
|
|
|
14578
14708
|
: paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
|
|
14579
14709
|
paneMenuItem('rename', 'Rename') +
|
|
14580
14710
|
paneMenuItem('reload', 'Reload') +
|
|
14711
|
+
(url ? paneMenuItem('dock', pane.dock ? 'Remove from the dock' : 'Dock beside the terminal') : '') +
|
|
14581
14712
|
paneGroupMenuItems(pane.id) +
|
|
14582
14713
|
paneMenuItem('close', 'Close pane'),
|
|
14583
14714
|
);
|
|
@@ -14589,6 +14720,20 @@ function paneIsLinked(pane) {
|
|
|
14589
14720
|
return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
|
|
14590
14721
|
}
|
|
14591
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
|
+
|
|
14592
14737
|
function togglePaneLink(id) {
|
|
14593
14738
|
const pane = paneById(id);
|
|
14594
14739
|
const sid = paneSessionId();
|
|
@@ -14620,6 +14765,7 @@ const PANE_MENU_ACTIONS = {
|
|
|
14620
14765
|
link: togglePaneLink,
|
|
14621
14766
|
rename: beginPaneRename,
|
|
14622
14767
|
reload: reloadPane,
|
|
14768
|
+
dock: togglePaneDock,
|
|
14623
14769
|
close: closePane,
|
|
14624
14770
|
group: (id, to) => groupPaneTab(id, to || null),
|
|
14625
14771
|
groupRename: beginPaneGroupRename,
|
|
@@ -15266,6 +15412,11 @@ function terminalShortcut(e, probe = false) {
|
|
|
15266
15412
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
15267
15413
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
15268
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
|
+
}
|
|
15269
15420
|
if (e.code !== 'Backquote' || e.metaKey) return null;
|
|
15270
15421
|
if (ctrlAlt) return toggleShowFocus;
|
|
15271
15422
|
if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
|
|
@@ -15309,14 +15460,29 @@ function toggleTerminalFocus() {
|
|
|
15309
15460
|
}
|
|
15310
15461
|
|
|
15311
15462
|
function toggleShowFocus() {
|
|
15312
|
-
if (isShowCardFocused()) {
|
|
15463
|
+
if (isShowCardFocused() || isDockFocused()) {
|
|
15313
15464
|
focusTerminalPane();
|
|
15314
15465
|
return;
|
|
15315
15466
|
}
|
|
15316
|
-
if (!wantsTerminal()
|
|
15317
|
-
|
|
15318
|
-
|
|
15319
|
-
|
|
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 }) });
|
|
15320
15486
|
renderShow();
|
|
15321
15487
|
}
|
|
15322
15488
|
focusShowBody();
|
|
@@ -15326,6 +15492,8 @@ function toggleShowFocus() {
|
|
|
15326
15492
|
function syncTerminal() {
|
|
15327
15493
|
const btn = document.getElementById('terminal-toggle');
|
|
15328
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();
|
|
15329
15497
|
const arrived = viewMode === 'session' ? currentSessionId : null;
|
|
15330
15498
|
const view = `${arrived}:${on}`;
|
|
15331
15499
|
// Only on a change of session or terminal state, so a panel opened by hand over the terminal stays.
|
|
@@ -15654,10 +15822,16 @@ const urlPaneFrames = () => [...paneViews.querySelectorAll('.pane-url-frame')];
|
|
|
15654
15822
|
window.addEventListener('message', (e) => {
|
|
15655
15823
|
const type = e.data?.type;
|
|
15656
15824
|
if (type !== 'hub:hostKey' && type !== 'hub:hostReady') return;
|
|
15657
|
-
const
|
|
15825
|
+
const dock = dockFrameEls().find((f) => f.contentWindow === e.source);
|
|
15826
|
+
const frame = dock || urlPaneFrames().find((f) => f.contentWindow === e.source);
|
|
15658
15827
|
if (!frame || URL.parse(frame.src)?.origin !== e.origin) return;
|
|
15659
|
-
if (type === 'hub:hostReady')
|
|
15660
|
-
|
|
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);
|
|
15661
15835
|
});
|
|
15662
15836
|
|
|
15663
15837
|
function pushTerminalClaims() {
|
|
@@ -15667,11 +15841,13 @@ function pushTerminalClaims() {
|
|
|
15667
15841
|
terminalClaimsQueued = false;
|
|
15668
15842
|
const bridged = bridgedFrameEls();
|
|
15669
15843
|
const urlFrames = urlPaneFrames();
|
|
15670
|
-
|
|
15844
|
+
const dockEls = dockFrameEls();
|
|
15845
|
+
if (!termFrame.inited && !kanbotState.inited && !bridged.length && !urlFrames.length && !dockEls.length) return;
|
|
15671
15846
|
const claims = keyClaims();
|
|
15672
15847
|
const base = bridgeClaims(claims);
|
|
15673
15848
|
sendBridgeClaimsAll(bridged, base);
|
|
15674
15849
|
sendHostKeys(urlFrames, base);
|
|
15850
|
+
sendHostKeys(dockEls, { keys: [...base.keys, ...DOCK_CLAIMS, ...claims.forward.flatMap(comboClaims)] });
|
|
15675
15851
|
const sig = JSON.stringify(claims);
|
|
15676
15852
|
sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
|
|
15677
15853
|
sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
|
|
@@ -16018,7 +16194,10 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
16018
16194
|
if (msg.t === 'ready') {
|
|
16019
16195
|
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
16020
16196
|
const spec = newSpecs.get(sessionId);
|
|
16021
|
-
if (spec)
|
|
16197
|
+
if (spec) {
|
|
16198
|
+
spec.prompted ||= !!spec.prompt;
|
|
16199
|
+
spec.prompt = null;
|
|
16200
|
+
}
|
|
16022
16201
|
clearTerminalSlow();
|
|
16023
16202
|
hideTerminalPrompt();
|
|
16024
16203
|
setTerminalStatus('');
|
|
@@ -16501,7 +16680,7 @@ function setAsk(a) {
|
|
|
16501
16680
|
if (a.content == null) {
|
|
16502
16681
|
body.innerHTML = '<p class="ask-missing">The card file is gone. Skip, so the agent stops waiting.</p>';
|
|
16503
16682
|
} else {
|
|
16504
|
-
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);
|
|
16505
16684
|
frame.addEventListener('load', () => {
|
|
16506
16685
|
if (askState.frame === frame && !askState.hidden) frame.focus();
|
|
16507
16686
|
});
|
|
@@ -16631,6 +16810,11 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
|
|
|
16631
16810
|
const SHOW_MIN_W = 280;
|
|
16632
16811
|
const SHOW_MIN_H = 120;
|
|
16633
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';
|
|
16634
16818
|
const SHOW_SANDBOX = 'allow-scripts allow-forms';
|
|
16635
16819
|
const SHOW_CSP =
|
|
16636
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'";
|
|
@@ -16684,6 +16868,9 @@ const SHOW_ICONS = {
|
|
|
16684
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"/>',
|
|
16685
16869
|
close: '<path d="M18 6L6 18M6 6l12 12"/>',
|
|
16686
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"/>',
|
|
16687
16874
|
};
|
|
16688
16875
|
|
|
16689
16876
|
// PTY id → terminal id, from the terminal list. A terminal started after the last list read is
|
|
@@ -16697,6 +16884,7 @@ const showState = {
|
|
|
16697
16884
|
closed: false,
|
|
16698
16885
|
expanded: false,
|
|
16699
16886
|
collapsed: false,
|
|
16887
|
+
docked: false,
|
|
16700
16888
|
marked: new Set(),
|
|
16701
16889
|
rendered: null,
|
|
16702
16890
|
frame: null,
|
|
@@ -16710,12 +16898,31 @@ const showState = {
|
|
|
16710
16898
|
path: null,
|
|
16711
16899
|
};
|
|
16712
16900
|
let showSize = store.readJson(SHOW_SIZE_KEY);
|
|
16713
|
-
|
|
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.
|
|
16714
16903
|
const showViews = store.readJson(SHOW_VIEW_KEY, {});
|
|
16715
16904
|
// Older boards stored the string 'collapsed' or 'expanded'.
|
|
16716
16905
|
for (const [tid, v] of Object.entries(showViews)) if (typeof v === 'string') showViews[tid] = { [v]: true };
|
|
16717
|
-
const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded'];
|
|
16906
|
+
const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded', 'docked'];
|
|
16718
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;
|
|
16719
16926
|
|
|
16720
16927
|
function showCardEl() {
|
|
16721
16928
|
return document.getElementById('show-card');
|
|
@@ -16742,7 +16949,12 @@ function focusShowBody() {
|
|
|
16742
16949
|
else showCardEl()?.querySelector('.show-body')?.focus();
|
|
16743
16950
|
}
|
|
16744
16951
|
|
|
16745
|
-
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' };
|
|
16953
|
+
|
|
16954
|
+
// Docked, the card is a dock item, so it shares the dock's expand with the add-ons.
|
|
16955
|
+
const showExpanded = () => (showState.docked ? dockExpanded : showState.expanded);
|
|
16956
|
+
|
|
16957
|
+
const expandTitle = (on, home) => (on ? `Back to the ${home} · Alt+Enter or Esc` : 'Fill the terminal · Alt+Enter');
|
|
16746
16958
|
|
|
16747
16959
|
// The body keeps focus, because a render replaces an HTML card's frame.
|
|
16748
16960
|
function showCardKey(e) {
|
|
@@ -16752,10 +16964,11 @@ function showCardKey(e) {
|
|
|
16752
16964
|
return true;
|
|
16753
16965
|
}
|
|
16754
16966
|
const combo = ClaudeHub.comboOf(e);
|
|
16755
|
-
|
|
16967
|
+
let cmd = SHOW_KEY_COMMANDS[combo];
|
|
16968
|
+
if (combo === 'Escape') cmd = showExpanded() ? 'expand' : 'collapse';
|
|
16756
16969
|
if (cmd) {
|
|
16757
16970
|
showCommand(cmd);
|
|
16758
|
-
if (cmd === 'expand') focusShowBody();
|
|
16971
|
+
if (cmd === 'expand' || cmd === 'dock') focusShowBody();
|
|
16759
16972
|
else focusTerminalPane();
|
|
16760
16973
|
return true;
|
|
16761
16974
|
}
|
|
@@ -16790,10 +17003,16 @@ function rememberTerminalIds(list) {
|
|
|
16790
17003
|
for (const t of list) if (t.terminalId) terminalIds.set(t.id, t.terminalId);
|
|
16791
17004
|
if (!terminalIds.size) return;
|
|
16792
17005
|
const live = new Set(terminalIds.values());
|
|
16793
|
-
|
|
17006
|
+
pruneTerminalMap(showViews, SHOW_VIEW_KEY, live);
|
|
17007
|
+
pruneTerminalMap(dockAddons, DOCK_ADDON_KEY, live);
|
|
17008
|
+
pruneTerminalMap(dockLast, DOCK_LAST_KEY, live);
|
|
17009
|
+
}
|
|
17010
|
+
|
|
17011
|
+
function pruneTerminalMap(map, key, live) {
|
|
17012
|
+
const gone = Object.keys(map).filter((tid) => !live.has(tid));
|
|
16794
17013
|
if (!gone.length) return;
|
|
16795
|
-
for (const tid of gone) delete
|
|
16796
|
-
store.writeJson(
|
|
17014
|
+
for (const tid of gone) delete map[tid];
|
|
17015
|
+
store.writeJson(key, map);
|
|
16797
17016
|
}
|
|
16798
17017
|
|
|
16799
17018
|
// A session resumed after its terminal ended runs under the same PTY id in a new terminal, with a
|
|
@@ -16901,16 +17120,24 @@ function onShowRemoved(d) {
|
|
|
16901
17120
|
if (dropShowPost(d.id)) renderShow();
|
|
16902
17121
|
}
|
|
16903
17122
|
|
|
17123
|
+
// `attr` names the data attribute that the header's click handler reads: show or dock.
|
|
17124
|
+
function showBtnHtml(attr, cmd, icon, title, disabled = false, pressed = null) {
|
|
17125
|
+
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>`;
|
|
17126
|
+
}
|
|
17127
|
+
|
|
16904
17128
|
function showHeaderHtml(p) {
|
|
16905
17129
|
const n = showState.posts.length;
|
|
16906
17130
|
const i = showState.idx;
|
|
16907
17131
|
const key = p.key ? `<span class="show-sep">·</span><span class="show-key">${escapeHtml(p.key)}</span>` : '';
|
|
16908
|
-
const btn = (
|
|
16909
|
-
|
|
16910
|
-
const
|
|
17132
|
+
const btn = (...args) => showBtnHtml('show', ...args);
|
|
17133
|
+
const { collapsed, docked } = showState;
|
|
17134
|
+
const expanded = showExpanded();
|
|
17135
|
+
const dockTitle = docked ? 'Back to the overlay · Alt+Shift+Enter' : 'Dock beside the terminal · Alt+Shift+Enter';
|
|
17136
|
+
const dock = btn('dock', 'dock', dockTitle, false, docked);
|
|
17137
|
+
const expand = btn('expand', expanded ? 'restore' : 'expand', expandTitle(expanded, docked ? 'dock' : 'card'));
|
|
16911
17138
|
return `<span class="show-nav"><span class="show-title" title="${escapeHtml(p.title)}">${escapeHtml(p.title)}</span>${key}</span>
|
|
16912
17139
|
<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>
|
|
16913
|
-
<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')}${
|
|
17140
|
+
<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>`;
|
|
16914
17141
|
}
|
|
16915
17142
|
|
|
16916
17143
|
function showTicksHtml() {
|
|
@@ -16933,6 +17160,10 @@ function renderShow({ flash = false } = {}) {
|
|
|
16933
17160
|
chip.hidden = !n || open;
|
|
16934
17161
|
chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
|
|
16935
17162
|
card.classList.toggle('open', open);
|
|
17163
|
+
const docked = open && showState.docked && !showState.collapsed;
|
|
17164
|
+
card.classList.toggle('docked', docked);
|
|
17165
|
+
if (flash && dockActivePane()) dockShowDot = true;
|
|
17166
|
+
renderDockRail();
|
|
16936
17167
|
if (!open) {
|
|
16937
17168
|
showState.rendered = null;
|
|
16938
17169
|
showState.frame = null;
|
|
@@ -16944,12 +17175,13 @@ function renderShow({ flash = false } = {}) {
|
|
|
16944
17175
|
}
|
|
16945
17176
|
const p = showState.posts[showState.idx];
|
|
16946
17177
|
showState.marked.delete(p.id);
|
|
16947
|
-
card.classList.toggle('expanded',
|
|
17178
|
+
card.classList.toggle('expanded', showExpanded() && !showState.collapsed);
|
|
16948
17179
|
card.classList.toggle('collapsed', showState.collapsed);
|
|
16949
17180
|
if (!card.firstChild) {
|
|
16950
17181
|
card.innerHTML = `<header class="show-head"></header><div class="show-ticks"></div><div class="show-body" tabindex="-1"></div>
|
|
16951
17182
|
<aside class="review-panel show-review" hidden></aside>
|
|
16952
|
-
<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
|
|
17183
|
+
<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-dock-edge" title="Resize · double-click to reset"></div>`;
|
|
16953
17185
|
}
|
|
16954
17186
|
card.querySelector('.show-head').innerHTML = showHeaderHtml(p);
|
|
16955
17187
|
card.querySelector('.show-ticks').innerHTML = showTicksHtml();
|
|
@@ -16987,10 +17219,10 @@ async function renderShowBody(p, samePost) {
|
|
|
16987
17219
|
const restoreY = samePost && showState.frame ? showState.frameY : 0;
|
|
16988
17220
|
showState.frameY = restoreY;
|
|
16989
17221
|
if (samePost && showState.frame?.isConnected) {
|
|
16990
|
-
showState.frame.srcdoc = showSrcdoc(data.content, restoreY);
|
|
17222
|
+
showState.frame.srcdoc = showSrcdoc(data.content, restoreY, showZoom);
|
|
16991
17223
|
} else {
|
|
16992
17224
|
showState.frameH = 0;
|
|
16993
|
-
body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0)));
|
|
17225
|
+
body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0, showZoom)));
|
|
16994
17226
|
}
|
|
16995
17227
|
refocus();
|
|
16996
17228
|
setShowReview(showReviewOpts(p, data, body, showState.frame));
|
|
@@ -17000,7 +17232,7 @@ async function renderShowBody(p, samePost) {
|
|
|
17000
17232
|
showState.frame = null;
|
|
17001
17233
|
const keepScroll = samePost ? body.scrollTop : 0;
|
|
17002
17234
|
const md = document.createElement('div');
|
|
17003
|
-
md.className = 'show-md
|
|
17235
|
+
md.className = 'show-md';
|
|
17004
17236
|
if (!data) md.innerHTML = '<p class="show-gone">This post is gone.</p>';
|
|
17005
17237
|
else if (data.waiting) md.innerHTML = '<p class="show-gone">Waiting for content.</p>';
|
|
17006
17238
|
else renderPreviewContent(md, data.file || p.file || '', data.content ?? '', data.kind);
|
|
@@ -17087,9 +17319,9 @@ function withHeadFirst(content, head) {
|
|
|
17087
17319
|
const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
|
|
17088
17320
|
|
|
17089
17321
|
// A whole document gets the tokens and base styles first in its head, so its own styles win.
|
|
17090
|
-
function showSrcdoc(content, restoreY, extraHead = '') {
|
|
17322
|
+
function showSrcdoc(content, restoreY, textZoom, extraHead = '') {
|
|
17091
17323
|
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>${extraHead}`;
|
|
17092
|
-
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${
|
|
17324
|
+
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${textZoom});</script>${REVIEW_BRIDGE_TAG}`;
|
|
17093
17325
|
if (!/^\s*(<!doctype|<html)/i.test(content)) {
|
|
17094
17326
|
return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
|
|
17095
17327
|
}
|
|
@@ -17192,11 +17424,11 @@ new MutationObserver(() => {
|
|
|
17192
17424
|
});
|
|
17193
17425
|
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme', 'style'] });
|
|
17194
17426
|
|
|
17427
|
+
const PANE_SHOW_FRAMES = '.show-pane-frame, .themed-frame, .ask-frame, .dock-addon-frame';
|
|
17428
|
+
|
|
17195
17429
|
// Builds the message only when a frame is there, because showTokens forces a style recalc.
|
|
17196
|
-
function postToShowFrames(makeMsg) {
|
|
17197
|
-
|
|
17198
|
-
(f) => f?.contentWindow,
|
|
17199
|
-
);
|
|
17430
|
+
function postToShowFrames(makeMsg, frames = [showState.frame, ...document.querySelectorAll(PANE_SHOW_FRAMES)]) {
|
|
17431
|
+
frames = [...frames].filter((f) => f?.contentWindow);
|
|
17200
17432
|
if (!frames.length) return;
|
|
17201
17433
|
const msg = makeMsg();
|
|
17202
17434
|
if (!msg) return;
|
|
@@ -17214,16 +17446,16 @@ function showClampSize(size, w, h) {
|
|
|
17214
17446
|
|
|
17215
17447
|
function layoutShow() {
|
|
17216
17448
|
const card = showCardEl();
|
|
17217
|
-
if (!card.classList.contains('open')) return;
|
|
17218
|
-
const
|
|
17219
|
-
const
|
|
17220
|
-
|
|
17221
|
-
if (!w || !h) return;
|
|
17449
|
+
if (!card.classList.contains('open') || card.classList.contains('under-addon')) return;
|
|
17450
|
+
const w = dockHostWidth();
|
|
17451
|
+
const h = document.getElementById('terminal-host').clientHeight;
|
|
17452
|
+
if (w <= 0 || !h) return;
|
|
17222
17453
|
const fit = showFitSize(w, h);
|
|
17223
17454
|
card.style.setProperty('--show-fit-w', `${fit.w}px`);
|
|
17224
17455
|
card.style.setProperty('--show-fit-h', `${fit.h}px`);
|
|
17225
17456
|
card.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
|
|
17226
|
-
const
|
|
17457
|
+
const docked = card.classList.contains('docked');
|
|
17458
|
+
const sized = !!showSize && !showState.expanded && !showState.collapsed && !docked;
|
|
17227
17459
|
card.classList.toggle('sized', sized);
|
|
17228
17460
|
if (sized) {
|
|
17229
17461
|
const s = showClampSize(showSize, w, h);
|
|
@@ -17232,7 +17464,7 @@ function layoutShow() {
|
|
|
17232
17464
|
}
|
|
17233
17465
|
const frame = showState.frame;
|
|
17234
17466
|
if (!frame) return;
|
|
17235
|
-
const peek = !
|
|
17467
|
+
const peek = !card.classList.contains('expanded') && !sized && !docked;
|
|
17236
17468
|
const chrome =
|
|
17237
17469
|
card.querySelector('.show-head').offsetHeight +
|
|
17238
17470
|
card.querySelector('.show-ticks').offsetHeight +
|
|
@@ -17242,6 +17474,460 @@ function layoutShow() {
|
|
|
17242
17474
|
frame.style.height = peek ? `${Math.min(showState.frameH || SHOW_MIN_H, fit.h - chrome)}px` : '';
|
|
17243
17475
|
}
|
|
17244
17476
|
|
|
17477
|
+
// A docked card gets 40% of the host until dragged, and always leaves the terminal SHOW_DOCK_TERM_MIN.
|
|
17478
|
+
function showDockWidth(want, w) {
|
|
17479
|
+
return Math.max(SHOW_MIN_W, Math.min(want ?? Math.round(w * 0.4), w - SHOW_DOCK_TERM_MIN));
|
|
17480
|
+
}
|
|
17481
|
+
|
|
17482
|
+
// The terminal pane holds the width, because the terminal frame and the terminal bar read it too.
|
|
17483
|
+
// Each change refits the terminal and resizes its PTY, so a drag sets it only at the end.
|
|
17484
|
+
function setShowDockWidth(px) {
|
|
17485
|
+
const pane = document.getElementById('terminal-pane');
|
|
17486
|
+
const value = `${px}px`;
|
|
17487
|
+
if (pane.style.getPropertyValue('--show-dock-w') !== value) pane.style.setProperty('--show-dock-w', value);
|
|
17488
|
+
}
|
|
17489
|
+
|
|
17490
|
+
// The host's width less the rail, which is 0 wide while hidden.
|
|
17491
|
+
function dockHostWidth() {
|
|
17492
|
+
return document.getElementById('terminal-host').clientWidth - document.getElementById('dock-rail').offsetWidth;
|
|
17493
|
+
}
|
|
17494
|
+
|
|
17495
|
+
function fitDockWidth() {
|
|
17496
|
+
if (!document.getElementById('terminal-pane').classList.contains('show-docked')) return;
|
|
17497
|
+
const w = dockHostWidth();
|
|
17498
|
+
if (w > 0) setShowDockWidth(showDockWidth(showDockW, w));
|
|
17499
|
+
}
|
|
17500
|
+
|
|
17501
|
+
// A terminal with no posts has no session from the show list yet, so its PTY id stands in,
|
|
17502
|
+
// which is the session it started.
|
|
17503
|
+
function dockSessionId() {
|
|
17504
|
+
return showState.terminalId ? (showState.sessionId ?? currentSessionId) : null;
|
|
17505
|
+
}
|
|
17506
|
+
|
|
17507
|
+
function dockPanes(sid) {
|
|
17508
|
+
return sid ? paneLayout(sid).panes.filter((p) => p.dock) : [];
|
|
17509
|
+
}
|
|
17510
|
+
|
|
17511
|
+
function fillAddonUrl(url, values) {
|
|
17512
|
+
return url.replace(/\{([^{}]*)\}/g, (m, k) => (values[k] == null ? m : encodeURIComponent(values[k])));
|
|
17513
|
+
}
|
|
17514
|
+
|
|
17515
|
+
function addonValues(sid) {
|
|
17516
|
+
const s = sessions.find((x) => x.id === sid);
|
|
17517
|
+
const cwd = s?.cwd || s?.project || newSpecs.get(sid)?.cwd || '';
|
|
17518
|
+
return { sessionId: sid, cwd, project: s?.project || cwd };
|
|
17519
|
+
}
|
|
17520
|
+
|
|
17521
|
+
// The rail's items after the card: global add-ons, the session's, then its docked URL panes.
|
|
17522
|
+
// The same filled URL shows once. Until the list for a new session arrives, the global
|
|
17523
|
+
// entries of the last one stand, so their frames are kept when the URL stays the same.
|
|
17524
|
+
function dockItems(sid = dockSid) {
|
|
17525
|
+
if (!sid) return [];
|
|
17526
|
+
const list = dockAddonList;
|
|
17527
|
+
const own = list?.sid === sid ? list : null;
|
|
17528
|
+
const items = [];
|
|
17529
|
+
const seen = new Set();
|
|
17530
|
+
const push = (item) => {
|
|
17531
|
+
if (seen.has(item.target)) return;
|
|
17532
|
+
seen.add(item.target);
|
|
17533
|
+
items.push(item);
|
|
17534
|
+
};
|
|
17535
|
+
if (list && (list.global.length || own?.session.length)) {
|
|
17536
|
+
const values = addonValues(sid);
|
|
17537
|
+
const entries = [...list.global.map((a) => [a, true]), ...(own ? own.session.map((a) => [a, false]) : [])];
|
|
17538
|
+
for (const [a, global] of entries) {
|
|
17539
|
+
const target = fillAddonUrl(a.url, { ...a.params, ...own?.params[a.id], ...values });
|
|
17540
|
+
push({ id: a.id, kind: 'url', title: a.title, icon: a.icon, target, addon: true, global, run: !!a.run });
|
|
17541
|
+
}
|
|
17542
|
+
}
|
|
17543
|
+
for (const p of dockPanes(sid)) push(p);
|
|
17544
|
+
return items;
|
|
17545
|
+
}
|
|
17546
|
+
|
|
17547
|
+
async function fetchDockAddons(sid) {
|
|
17548
|
+
const list = await getJson(`${apiPath`/api/addons`}?session=${encodeURIComponent(sid)}`);
|
|
17549
|
+
if (!list || sid !== dockSid) return;
|
|
17550
|
+
dockAddonList = { sid, ...list };
|
|
17551
|
+
renderDockRail();
|
|
17552
|
+
}
|
|
17553
|
+
|
|
17554
|
+
function dockActivePane(items = dockItems()) {
|
|
17555
|
+
const id = showState.terminalId && dockAddons[showState.terminalId];
|
|
17556
|
+
return (id && items.find((p) => p.id === id)) || null;
|
|
17557
|
+
}
|
|
17558
|
+
|
|
17559
|
+
function dropDockView(id) {
|
|
17560
|
+
dockFrames.get(id)?.remove();
|
|
17561
|
+
dockFrames.delete(id);
|
|
17562
|
+
clearTimeout(dockHideTimers.get(id));
|
|
17563
|
+
dockHideTimers.delete(id);
|
|
17564
|
+
}
|
|
17565
|
+
|
|
17566
|
+
function dockShowOn(active) {
|
|
17567
|
+
return !active && showState.posts.length > 0 && !showState.closed;
|
|
17568
|
+
}
|
|
17569
|
+
|
|
17570
|
+
function dockOnScreen(active = dockActivePane()) {
|
|
17571
|
+
return active ? active.id : dockShowOn(active) ? 'show' : null;
|
|
17572
|
+
}
|
|
17573
|
+
|
|
17574
|
+
function setDockAddon(id) {
|
|
17575
|
+
const tid = showState.terminalId;
|
|
17576
|
+
if (!tid || (dockAddons[tid] ?? null) === id) return;
|
|
17577
|
+
if (id) dockAddons[tid] = id;
|
|
17578
|
+
else delete dockAddons[tid];
|
|
17579
|
+
store.writeJson(DOCK_ADDON_KEY, dockAddons);
|
|
17580
|
+
}
|
|
17581
|
+
|
|
17582
|
+
function dockRailButton(id, label, title, on, dot) {
|
|
17583
|
+
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>`;
|
|
17584
|
+
}
|
|
17585
|
+
|
|
17586
|
+
// Runs from every renderShow, so it does no work past a class toggle until a session has add-ons.
|
|
17587
|
+
function renderDockRail() {
|
|
17588
|
+
const sid = dockSessionId();
|
|
17589
|
+
if (sid !== dockSid) {
|
|
17590
|
+
dockSid = sid;
|
|
17591
|
+
if (sid && !paneLayouts.has(sid)) fetchPaneLayout(sid);
|
|
17592
|
+
if (sid) fetchDockAddons(sid);
|
|
17593
|
+
}
|
|
17594
|
+
const addons = dockItems(sid);
|
|
17595
|
+
for (const [id, view] of dockFrames) {
|
|
17596
|
+
if (!addons.some((p) => p.id === id && p.target === view.dataset.target)) dropDockView(id);
|
|
17597
|
+
}
|
|
17598
|
+
const on = addons.length > 0;
|
|
17599
|
+
const termPane = document.getElementById('terminal-pane');
|
|
17600
|
+
const rail = document.getElementById('dock-rail');
|
|
17601
|
+
const card = showCardEl();
|
|
17602
|
+
const active = dockActivePane(addons);
|
|
17603
|
+
const onScreen = dockOnScreen(active);
|
|
17604
|
+
const tid = showState.terminalId;
|
|
17605
|
+
if (tid && onScreen && dockLast[tid] !== onScreen) {
|
|
17606
|
+
dockLast[tid] = onScreen;
|
|
17607
|
+
store.writeJson(DOCK_LAST_KEY, dockLast);
|
|
17608
|
+
}
|
|
17609
|
+
termPane.classList.toggle('dock-rail-on', on);
|
|
17610
|
+
rail.hidden = !on;
|
|
17611
|
+
card.classList.toggle('under-addon', !!active);
|
|
17612
|
+
const docked = !!active || card.classList.contains('docked');
|
|
17613
|
+
termPane.classList.toggle('show-docked', docked);
|
|
17614
|
+
if (on) document.getElementById('show-reopen').hidden = true;
|
|
17615
|
+
if (!active) dockShowDot = false;
|
|
17616
|
+
let html = '';
|
|
17617
|
+
if (on) {
|
|
17618
|
+
const n = showState.posts.length;
|
|
17619
|
+
if (n) html += dockRailButton('show', showIcon('card'), `Show · ${n}`, onScreen === 'show', dockShowDot);
|
|
17620
|
+
for (const p of addons) {
|
|
17621
|
+
html += dockRailButton(
|
|
17622
|
+
p.id,
|
|
17623
|
+
escapeHtml(p.icon || [...p.title][0]?.toUpperCase() || '?'),
|
|
17624
|
+
p.title,
|
|
17625
|
+
p === active,
|
|
17626
|
+
false,
|
|
17627
|
+
);
|
|
17628
|
+
}
|
|
17629
|
+
}
|
|
17630
|
+
if (html !== dockRailHtml) {
|
|
17631
|
+
dockRailHtml = html;
|
|
17632
|
+
rail.innerHTML = html;
|
|
17633
|
+
}
|
|
17634
|
+
const claimed = dockRailItems(addons).length > 1;
|
|
17635
|
+
if (claimed !== dockRailClaimed) {
|
|
17636
|
+
dockRailClaimed = claimed;
|
|
17637
|
+
pushTerminalClaims();
|
|
17638
|
+
}
|
|
17639
|
+
renderDockAddon(active);
|
|
17640
|
+
if (docked && !dockObserver) {
|
|
17641
|
+
dockObserver = new ResizeObserver(fitDockWidth);
|
|
17642
|
+
dockObserver.observe(document.getElementById('terminal-host'));
|
|
17643
|
+
} else if (!docked) {
|
|
17644
|
+
dockObserver?.disconnect();
|
|
17645
|
+
dockObserver = null;
|
|
17646
|
+
}
|
|
17647
|
+
fitDockWidth();
|
|
17648
|
+
}
|
|
17649
|
+
|
|
17650
|
+
function renderDockAddon(pane) {
|
|
17651
|
+
const panel = document.getElementById('dock-addon');
|
|
17652
|
+
panel.hidden = !pane;
|
|
17653
|
+
if (pane) {
|
|
17654
|
+
if (!panel.firstChild) {
|
|
17655
|
+
panel.innerHTML = `<header class="show-head dock-addon-head" tabindex="-1"><span class="show-nav"><span class="show-title"></span></span>
|
|
17656
|
+
<span class="show-actions">${showBtnHtml('dock', 'open', 'external', 'Open in a new tab')}
|
|
17657
|
+
${showBtnHtml('dock', 'expand', 'expand', 'Fill the terminal · Alt+Enter')}
|
|
17658
|
+
${showBtnHtml('dock', 'reload', 'reload', 'Reload')}
|
|
17659
|
+
${showBtnHtml('dock', 'remove', 'clear', 'Remove from the rail')}
|
|
17660
|
+
${showBtnHtml('dock', 'close', 'close', 'Hide the dock · Alt+W')}</span></header>
|
|
17661
|
+
<div class="dock-addon-body"></div><div class="show-dock-edge" title="Resize · double-click to reset"></div>`;
|
|
17662
|
+
}
|
|
17663
|
+
const title = panel.querySelector('.show-title');
|
|
17664
|
+
title.textContent = pane.title;
|
|
17665
|
+
title.title = pane.global ? `${pane.title} · every session` : pane.title;
|
|
17666
|
+
const open = panel.querySelector('[data-dock="open"]');
|
|
17667
|
+
open.title = `Open ${pane.target} in a new tab`;
|
|
17668
|
+
open.setAttribute('aria-label', open.title);
|
|
17669
|
+
panel.classList.toggle('expanded', dockExpanded);
|
|
17670
|
+
panel.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
|
|
17671
|
+
const expand = panel.querySelector('[data-dock="expand"]');
|
|
17672
|
+
const label = expandTitle(dockExpanded, 'dock');
|
|
17673
|
+
if (expand.title !== label) {
|
|
17674
|
+
expand.innerHTML = showIcon(dockExpanded ? 'restore' : 'expand');
|
|
17675
|
+
expand.title = label;
|
|
17676
|
+
expand.setAttribute('aria-label', label);
|
|
17677
|
+
}
|
|
17678
|
+
if (!dockFrames.has(pane.id)) {
|
|
17679
|
+
const view = createDockView(pane);
|
|
17680
|
+
panel.querySelector('.dock-addon-body').appendChild(view);
|
|
17681
|
+
dockFrames.set(pane.id, view);
|
|
17682
|
+
}
|
|
17683
|
+
}
|
|
17684
|
+
for (const [id, view] of dockFrames) {
|
|
17685
|
+
view.hidden = id !== pane?.id;
|
|
17686
|
+
if (!view.hidden) {
|
|
17687
|
+
clearTimeout(dockHideTimers.get(id));
|
|
17688
|
+
dockHideTimers.delete(id);
|
|
17689
|
+
} else if (!dockHideTimers.has(id))
|
|
17690
|
+
dockHideTimers.set(
|
|
17691
|
+
id,
|
|
17692
|
+
setTimeout(() => dropDockView(id), DOCK_UNLOAD_MS),
|
|
17693
|
+
);
|
|
17694
|
+
}
|
|
17695
|
+
syncDockFocus();
|
|
17696
|
+
}
|
|
17697
|
+
|
|
17698
|
+
function dockCardView(pane, html) {
|
|
17699
|
+
const view = document.createElement('div');
|
|
17700
|
+
view.className = 'dock-addon-view';
|
|
17701
|
+
view.dataset.target = pane.target;
|
|
17702
|
+
view.innerHTML = html;
|
|
17703
|
+
return view;
|
|
17704
|
+
}
|
|
17705
|
+
|
|
17706
|
+
function createDockView(pane) {
|
|
17707
|
+
const blocked = paneBlockedHtml(pane);
|
|
17708
|
+
if (blocked) return dockCardView(pane, blocked);
|
|
17709
|
+
if (pane.run && !dockAddonUp.has(pane.id)) return startDockAddon(pane);
|
|
17710
|
+
const frame = paneUrlFrame(pane, 'dock-addon-frame');
|
|
17711
|
+
frame.dataset.target = pane.target;
|
|
17712
|
+
frame.addEventListener('load', () => {
|
|
17713
|
+
postToShowFrames(() => ({ type: `${SHOW_MSG}tokens`, tokens: showTokens() }), [frame]);
|
|
17714
|
+
frame.hostKeysSig = null;
|
|
17715
|
+
pushTerminalClaims();
|
|
17716
|
+
});
|
|
17717
|
+
if (pane.addon || paneFraming.get(pane.target)) return frame;
|
|
17718
|
+
const sid = dockSid;
|
|
17719
|
+
probePaneFraming(sid, pane).then((frameable) => {
|
|
17720
|
+
if (frameable !== false || dockFrames.get(pane.id) !== frame) return;
|
|
17721
|
+
dropDockView(pane.id);
|
|
17722
|
+
if (sid === dockSid) renderDockRail();
|
|
17723
|
+
});
|
|
17724
|
+
return frame;
|
|
17725
|
+
}
|
|
17726
|
+
|
|
17727
|
+
// The board starts the page's server when its port does not answer (lib/addon-runner.js);
|
|
17728
|
+
// the frame comes once it does. Reload asks again.
|
|
17729
|
+
function startDockAddon(pane) {
|
|
17730
|
+
const view = dockCardView(
|
|
17731
|
+
pane,
|
|
17732
|
+
`<div class="pane-card"><div class="pane-card-box"><p>Starting ${escapeHtml(pane.title)}…</p></div></div>`,
|
|
17733
|
+
);
|
|
17734
|
+
paneRequest('POST', apiPath`/api/addons/${pane.id}/start`).then((r) => {
|
|
17735
|
+
if (dockFrames.get(pane.id) !== view) return;
|
|
17736
|
+
if (r.state === 'up' || r.state === 'none') {
|
|
17737
|
+
dockAddonUp.add(pane.id);
|
|
17738
|
+
dropDockView(pane.id);
|
|
17739
|
+
renderDockRail();
|
|
17740
|
+
return;
|
|
17741
|
+
}
|
|
17742
|
+
const log = r.log ? `<p>Log: <code>${escapeHtml(r.log)}</code></p>` : '';
|
|
17743
|
+
view.innerHTML = paneCardHtml(`${pane.title} did not start`, r.error || 'No answer', 'Retry', {
|
|
17744
|
+
attr: 'data-dock="reload"',
|
|
17745
|
+
extra: log,
|
|
17746
|
+
});
|
|
17747
|
+
});
|
|
17748
|
+
return view;
|
|
17749
|
+
}
|
|
17750
|
+
|
|
17751
|
+
async function removeDockItem(item) {
|
|
17752
|
+
if (!item.addon) return setPaneDock(dockSid, item.id, false);
|
|
17753
|
+
if (item.global) {
|
|
17754
|
+
const ok = await confirmModal({
|
|
17755
|
+
title: 'Remove this add-on from every session?',
|
|
17756
|
+
message: 'It was added with --global, so it is on the rail of each session.',
|
|
17757
|
+
chip: item.title,
|
|
17758
|
+
okLabel: 'Remove',
|
|
17759
|
+
});
|
|
17760
|
+
if (!ok) return;
|
|
17761
|
+
}
|
|
17762
|
+
const r = await paneRequest('DELETE', `${apiPath`/api/addons/${item.id}`}?session=${encodeURIComponent(dockSid)}`);
|
|
17763
|
+
if (r.error) showToast(r.error, 'error');
|
|
17764
|
+
}
|
|
17765
|
+
|
|
17766
|
+
// Page-wide, like the session log's full width. The panel covers the terminal above its prompt
|
|
17767
|
+
// rows, so the PTY keeps its size.
|
|
17768
|
+
function setDockExpanded(on) {
|
|
17769
|
+
dockExpanded = on;
|
|
17770
|
+
store.setItem(DOCK_EXPANDED_KEY, on ? '1' : '');
|
|
17771
|
+
renderShow();
|
|
17772
|
+
}
|
|
17773
|
+
|
|
17774
|
+
// Alt+Enter toggles the full width and Esc goes back; Esc in the dock focuses the terminal.
|
|
17775
|
+
function dockHeadKey(e) {
|
|
17776
|
+
const combo = ClaudeHub.comboOf(e);
|
|
17777
|
+
if (combo !== 'alt+w' && combo !== 'alt+Enter' && combo !== 'Escape') return false;
|
|
17778
|
+
const pane = dockActivePane();
|
|
17779
|
+
if (!pane) return false;
|
|
17780
|
+
if (combo === 'alt+w') {
|
|
17781
|
+
dockRailCommand(pane.id);
|
|
17782
|
+
focusTerminalPane();
|
|
17783
|
+
} else if (combo === 'alt+Enter') setDockExpanded(!dockExpanded);
|
|
17784
|
+
else if (combo === 'Escape' && dockExpanded) setDockExpanded(false);
|
|
17785
|
+
else if (combo === 'Escape') focusTerminalPane();
|
|
17786
|
+
else return false;
|
|
17787
|
+
return true;
|
|
17788
|
+
}
|
|
17789
|
+
|
|
17790
|
+
// Focus moving from the terminal's frame into a dock page leaves this document's activeElement on the
|
|
17791
|
+
// terminal frame and fires no event here, so the page posts its own focus (skills/addon/references/build.md).
|
|
17792
|
+
let dockFocusFrame = null;
|
|
17793
|
+
|
|
17794
|
+
const dockFrameFocused = () => !!dockFocusFrame?.isConnected && !dockFocusFrame.hidden;
|
|
17795
|
+
|
|
17796
|
+
function isDockFocused() {
|
|
17797
|
+
return dockFrameFocused() || !!document.activeElement?.closest('#dock-addon');
|
|
17798
|
+
}
|
|
17799
|
+
|
|
17800
|
+
function setDockFrameFocused(frame, on) {
|
|
17801
|
+
if (on) dockFocusFrame = frame;
|
|
17802
|
+
else if (dockFocusFrame === frame) dockFocusFrame = null;
|
|
17803
|
+
syncDockFocus();
|
|
17804
|
+
}
|
|
17805
|
+
|
|
17806
|
+
function syncDockFocus() {
|
|
17807
|
+
document.getElementById('dock-addon').classList.toggle('focused', dockFrameFocused());
|
|
17808
|
+
}
|
|
17809
|
+
|
|
17810
|
+
const dockFrameEls = () => [...dockFrames.values()].filter((v) => v.tagName === 'IFRAME');
|
|
17811
|
+
|
|
17812
|
+
window.addEventListener('message', (e) => {
|
|
17813
|
+
if (e.data?.type !== `${SHOW_MSG}focus`) return;
|
|
17814
|
+
const frame = dockFrameEls().find((f) => f.contentWindow === e.source);
|
|
17815
|
+
if (frame) setDockFrameFocused(frame, e.data.on === true);
|
|
17816
|
+
});
|
|
17817
|
+
|
|
17818
|
+
// A dock page gets the pane keys plus these (hub protocol §5.2). A page with no hub SDK cannot
|
|
17819
|
+
// forward the hub's own combos, so they go as claims too.
|
|
17820
|
+
const DOCK_CLAIMS = [{ altKey: true, key: 'Enter', code: '' }];
|
|
17821
|
+
|
|
17822
|
+
function comboClaims(combo) {
|
|
17823
|
+
const parts = combo.split('+');
|
|
17824
|
+
const key = parts.pop() || '+';
|
|
17825
|
+
const mods = Object.fromEntries(parts.filter(Boolean).map((m) => [`${m}Key`, true]));
|
|
17826
|
+
const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[1-9]$/.test(key) ? `Digit${key}` : '';
|
|
17827
|
+
return [{ ...mods, key, code: '' }, ...(code ? [{ ...mods, key: 'Unidentified', code }] : [])];
|
|
17828
|
+
}
|
|
17829
|
+
|
|
17830
|
+
// Focus goes to the header, not the page: a frame on another origin would keep the keys.
|
|
17831
|
+
function focusDockHead() {
|
|
17832
|
+
document.querySelector('#dock-addon .dock-addon-head')?.focus();
|
|
17833
|
+
}
|
|
17834
|
+
|
|
17835
|
+
// Show and each add-on take the dock in turn; the one on screen hides it.
|
|
17836
|
+
function dockRailCommand(id) {
|
|
17837
|
+
const active = dockActivePane();
|
|
17838
|
+
if (id === 'show') {
|
|
17839
|
+
const showOn = dockShowOn(active);
|
|
17840
|
+
setDockAddon(null);
|
|
17841
|
+
if (showOn) showState.closed = true;
|
|
17842
|
+
else Object.assign(showState, { closed: false, collapsed: false });
|
|
17843
|
+
} else if (active?.id === id) {
|
|
17844
|
+
setDockAddon(null);
|
|
17845
|
+
if (showState.docked) showState.closed = true;
|
|
17846
|
+
} else setDockAddon(id);
|
|
17847
|
+
renderShow();
|
|
17848
|
+
}
|
|
17849
|
+
|
|
17850
|
+
function dockRailItems(items = dockItems()) {
|
|
17851
|
+
const ids = items.map((p) => p.id);
|
|
17852
|
+
return ids.length && showState.posts.length ? ['show', ...ids] : ids;
|
|
17853
|
+
}
|
|
17854
|
+
|
|
17855
|
+
// With nothing in the dock, Down starts at the first item and Up at the last.
|
|
17856
|
+
function stepDockRail(dir) {
|
|
17857
|
+
const items = dockRailItems();
|
|
17858
|
+
const cur = dockOnScreen();
|
|
17859
|
+
const i = items.indexOf(cur);
|
|
17860
|
+
const next = items[i < 0 ? (dir > 0 ? 0 : items.length - 1) : (i + dir + items.length) % items.length];
|
|
17861
|
+
if (!next || next === cur) return;
|
|
17862
|
+
dockRailCommand(next);
|
|
17863
|
+
if (next === 'show') focusShowBody();
|
|
17864
|
+
else focusDockHead();
|
|
17865
|
+
}
|
|
17866
|
+
|
|
17867
|
+
// The panel follows the pointer over the terminal; the terminal takes the new width on release.
|
|
17868
|
+
function startDockDrag(e, panel) {
|
|
17869
|
+
const edge = e.target.closest('.show-dock-edge');
|
|
17870
|
+
if (!edge || e.button !== 0) return;
|
|
17871
|
+
e.preventDefault();
|
|
17872
|
+
edge.setPointerCapture(e.pointerId);
|
|
17873
|
+
const hostW = dockHostWidth();
|
|
17874
|
+
const start = { x: e.clientX, w: panel.offsetWidth };
|
|
17875
|
+
let w = start.w;
|
|
17876
|
+
let raf = 0;
|
|
17877
|
+
document.body.classList.add('show-resizing');
|
|
17878
|
+
const move = (ev) => {
|
|
17879
|
+
w = showDockWidth(Math.round(start.w + start.x - ev.clientX), hostW);
|
|
17880
|
+
raf ||= requestAnimationFrame(() => {
|
|
17881
|
+
raf = 0;
|
|
17882
|
+
panel.style.setProperty('--show-dock-drag', `${w}px`);
|
|
17883
|
+
});
|
|
17884
|
+
};
|
|
17885
|
+
const up = () => {
|
|
17886
|
+
cancelAnimationFrame(raf);
|
|
17887
|
+
edge.removeEventListener('pointermove', move);
|
|
17888
|
+
edge.removeEventListener('pointerup', up);
|
|
17889
|
+
edge.removeEventListener('pointercancel', up);
|
|
17890
|
+
document.body.classList.remove('show-resizing');
|
|
17891
|
+
panel.style.removeProperty('--show-dock-drag');
|
|
17892
|
+
showDockW = w;
|
|
17893
|
+
store.writeJson(SHOW_DOCK_KEY, showDockW);
|
|
17894
|
+
fitDockWidth();
|
|
17895
|
+
};
|
|
17896
|
+
edge.addEventListener('pointermove', move);
|
|
17897
|
+
edge.addEventListener('pointerup', up);
|
|
17898
|
+
edge.addEventListener('pointercancel', up);
|
|
17899
|
+
}
|
|
17900
|
+
|
|
17901
|
+
function resetDockWidth() {
|
|
17902
|
+
showDockW = null;
|
|
17903
|
+
store.removeItem(SHOW_DOCK_KEY);
|
|
17904
|
+
fitDockWidth();
|
|
17905
|
+
}
|
|
17906
|
+
|
|
17907
|
+
function initDockRail() {
|
|
17908
|
+
document.getElementById('dock-rail').addEventListener('click', (e) => {
|
|
17909
|
+
const b = e.target.closest('[data-rail]');
|
|
17910
|
+
if (b) dockRailCommand(b.dataset.rail);
|
|
17911
|
+
});
|
|
17912
|
+
const panel = document.getElementById('dock-addon');
|
|
17913
|
+
panel.addEventListener('click', (e) => {
|
|
17914
|
+
const pane = dockActivePane();
|
|
17915
|
+
if (!pane) return;
|
|
17916
|
+
if (e.target.closest('[data-dock="close"]')) dockRailCommand(pane.id);
|
|
17917
|
+
else if (e.target.closest('[data-dock="expand"]')) setDockExpanded(!dockExpanded);
|
|
17918
|
+
else if (e.target.closest('[data-dock="remove"]')) removeDockItem(pane);
|
|
17919
|
+
else if (e.target.closest('[data-dock="reload"]')) {
|
|
17920
|
+
dockAddonUp.delete(pane.id);
|
|
17921
|
+
dropDockView(pane.id);
|
|
17922
|
+
renderDockRail();
|
|
17923
|
+
} else if (e.target.closest('[data-dock="open"], [data-pane-open]')) hub.openExternal(pane.target);
|
|
17924
|
+
});
|
|
17925
|
+
panel.addEventListener('pointerdown', (e) => startDockDrag(e, panel));
|
|
17926
|
+
panel.addEventListener('dblclick', (e) => {
|
|
17927
|
+
if (e.target.closest('.show-dock-edge')) resetDockWidth();
|
|
17928
|
+
});
|
|
17929
|
+
}
|
|
17930
|
+
|
|
17245
17931
|
async function openShowInPane() {
|
|
17246
17932
|
const sid = showState.sessionId;
|
|
17247
17933
|
const target = showState.path;
|
|
@@ -17275,9 +17961,13 @@ function showCommand(cmd) {
|
|
|
17275
17961
|
showState.idx = i;
|
|
17276
17962
|
showState.collapsed = false;
|
|
17277
17963
|
} else if (cmd === 'collapse') showState.collapsed = !showState.collapsed;
|
|
17278
|
-
else if (cmd === '
|
|
17279
|
-
showState.
|
|
17964
|
+
else if (cmd === 'dock') {
|
|
17965
|
+
showState.docked = !showState.docked;
|
|
17280
17966
|
showState.collapsed = false;
|
|
17967
|
+
} else if (cmd === 'expand') {
|
|
17968
|
+
showState.collapsed = false;
|
|
17969
|
+
if (showState.docked) return setDockExpanded(!dockExpanded);
|
|
17970
|
+
showState.expanded = !showState.expanded;
|
|
17281
17971
|
} else if (cmd === 'close') showState.closed = true;
|
|
17282
17972
|
else if (cmd === 'remove') {
|
|
17283
17973
|
const id = showState.posts[showState.idx]?.id;
|
|
@@ -17361,7 +18051,12 @@ function initShowCard() {
|
|
|
17361
18051
|
grip.addEventListener('pointerup', up);
|
|
17362
18052
|
grip.addEventListener('pointercancel', up);
|
|
17363
18053
|
});
|
|
18054
|
+
card.addEventListener('pointerdown', (e) => startDockDrag(e, card));
|
|
17364
18055
|
card.addEventListener('dblclick', (e) => {
|
|
18056
|
+
if (e.target.closest('.show-dock-edge')) {
|
|
18057
|
+
resetDockWidth();
|
|
18058
|
+
return;
|
|
18059
|
+
}
|
|
17365
18060
|
if (!e.target.closest('.show-grip')) return;
|
|
17366
18061
|
showSize = null;
|
|
17367
18062
|
store.removeItem(SHOW_SIZE_KEY);
|
|
@@ -17400,6 +18095,7 @@ const PLACEHOLDER_HINTS = {
|
|
|
17400
18095
|
pick: 'pick a session to resume',
|
|
17401
18096
|
dispatch: 'starting',
|
|
17402
18097
|
new: 'waiting for your first message',
|
|
18098
|
+
prompted: 'starting',
|
|
17403
18099
|
edit: 'press Enter in the terminal to start',
|
|
17404
18100
|
};
|
|
17405
18101
|
|
|
@@ -17414,8 +18110,9 @@ function placeholderSession(id, spec) {
|
|
|
17414
18110
|
placeholder: true,
|
|
17415
18111
|
mode: spec.mode,
|
|
17416
18112
|
edit: !!spec.edit,
|
|
18113
|
+
prompted: !!(spec.prompted || spec.prompt),
|
|
17417
18114
|
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
17418
|
-
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
18115
|
+
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null, child: !!spec.child } : undefined,
|
|
17419
18116
|
project: spec.cwd,
|
|
17420
18117
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
17421
18118
|
modifiedAt: new Date(spec.startedAt).toISOString(),
|
|
@@ -17464,6 +18161,7 @@ async function loadDispatches() {
|
|
|
17464
18161
|
worktree: r.worktree,
|
|
17465
18162
|
group: r.group,
|
|
17466
18163
|
parent: r.parent,
|
|
18164
|
+
child: r.child,
|
|
17467
18165
|
startedAt: r.startedAt,
|
|
17468
18166
|
});
|
|
17469
18167
|
}
|
|
@@ -17507,13 +18205,26 @@ function renderPlaceholderCard(session, depth, hostProject) {
|
|
|
17507
18205
|
${
|
|
17508
18206
|
trustWaiting.has(session.id)
|
|
17509
18207
|
? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
|
|
17510
|
-
: `<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>`
|
|
17511
18209
|
}
|
|
17512
18210
|
</button>
|
|
17513
18211
|
`;
|
|
17514
18212
|
}
|
|
17515
18213
|
|
|
17516
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
|
+
|
|
17517
18228
|
async function restorePendingSessions() {
|
|
17518
18229
|
document.getElementById('new-session-btn').hidden = !terminalAvailable();
|
|
17519
18230
|
const terminals = terminalAvailable() ? loadTerminals() : null;
|
|
@@ -17521,15 +18232,7 @@ async function restorePendingSessions() {
|
|
|
17521
18232
|
try {
|
|
17522
18233
|
for (const t of terminals ? await terminals : []) {
|
|
17523
18234
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
17524
|
-
newSpecs.set(t.id,
|
|
17525
|
-
cwd: t.cwd,
|
|
17526
|
-
name: t.name,
|
|
17527
|
-
worktree: t.worktree,
|
|
17528
|
-
mode: t.mode,
|
|
17529
|
-
edit: t.edit,
|
|
17530
|
-
forkOf: t.forkOf,
|
|
17531
|
-
startedAt: t.startedAt,
|
|
17532
|
-
});
|
|
18235
|
+
newSpecs.set(t.id, specFromTerminal(t));
|
|
17533
18236
|
}
|
|
17534
18237
|
} catch (_) {}
|
|
17535
18238
|
// Sessions that were already running keep the collapse state saved before the reload.
|
|
@@ -18134,10 +18837,12 @@ initSessionGroupsDnd();
|
|
|
18134
18837
|
initSessionPicker();
|
|
18135
18838
|
initProjectPicker();
|
|
18136
18839
|
initShowCard();
|
|
18840
|
+
initDockRail();
|
|
18137
18841
|
initAsk();
|
|
18138
18842
|
for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
|
|
18139
18843
|
initSidebarResize();
|
|
18140
18844
|
applyModalZoom();
|
|
18845
|
+
applyShowZoom();
|
|
18141
18846
|
loadModalFullscreen();
|
|
18142
18847
|
initModalResize();
|
|
18143
18848
|
loadPanelWidths();
|
|
@@ -18429,6 +19134,7 @@ hub.onActive((active) => {
|
|
|
18429
19134
|
});
|
|
18430
19135
|
hub.onStatus(pushTerminalClaims);
|
|
18431
19136
|
hub.onSettings(applyHubSettings);
|
|
19137
|
+
hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session));
|
|
18432
19138
|
|
|
18433
19139
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
18434
19140
|
let publishTimer = 0;
|