dsh-split-view 0.2.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/lib/client.js ADDED
@@ -0,0 +1,2817 @@
1
+ // dsh-split-view — browser half (client plugin bundle).
2
+ //
3
+ // Loaded by dsh-client-modules at /plugins/dsh-split-view/client.js and
4
+ // executed through the vendored cordis Loader's lazy-CJS module table
5
+ // (window.__ModuleLoader__.load). Same bundle shape as dsh-dream-skin and
6
+ // the shipped ui-* packages: plain CJS factory, require() answered from the
7
+ // shell module table (platform seed words + registered client bundles).
8
+ //
9
+ // Architecture:
10
+ // - Top window (no ?dshPane parameter): shadows the 'root' slot (priority -1,
11
+ // lowest renders) and draws a split container. Each cell is an iframe
12
+ // loading the full DSH client at /?dshPane=<id>[&dshSession=<sid>|&dshNew=1].
13
+ // - Pane window (has ?dshPane): the pane agent pins the client to its assigned
14
+ // session via ctx.sessions.open() at boot (or ctx.sessions.clear() for a
15
+ // fresh pane), reports selection changes and focus to the parent, and
16
+ // forwards the split keyboard shortcuts as postMessage commands.
17
+ // - Preferences (shortcuts, focus highlight color, title-bar visibility) are
18
+ // owned by the top window (it draws everything they control) and persisted
19
+ // in its localStorage; panes hold a mirror synced over postMessage and edit
20
+ // through the plugin's settings section, which the shell only renders inside
21
+ // panes (the top window shadows the AppFrame and has no settings modal).
22
+ //
23
+ // Why iframes: the DSH client runtime is a per-page singleton (module table,
24
+ // cordis tree, renderer BindingContext all single-instance), so N concurrent
25
+ // sessions need N full clients. The server sends no X-Frame-Options/CSP
26
+ // frame-ancestors, the client has no frame-hostile code, and the current
27
+ // session is memory-resident after boot (localStorage is a boot seed only,
28
+ // with no storage-event sync), so same-origin iframe clients stay isolated.
29
+
30
+ // One-time key migration for the dsh-multi-view -> dsh-split-view rename.
31
+ // `dsh-multi:*` was the live namespace before the rename; any `dsh-split:*`
32
+ // values still present predate it, so the migrated values win. Runs at bundle
33
+ // load (top level), before the factory reads any key, and is idempotent: once
34
+ // no `dsh-multi:*` keys remain it does nothing.
35
+ (() => {
36
+ try {
37
+ const moved = [];
38
+ for (let i = 0; i < localStorage.length; i++) {
39
+ const key = localStorage.key(i);
40
+ if (key && key.indexOf("dsh-multi:") === 0) moved.push(key);
41
+ }
42
+ for (const key of moved) {
43
+ localStorage.setItem("dsh-split:" + key.slice("dsh-multi:".length), localStorage.getItem(key));
44
+ localStorage.removeItem(key);
45
+ }
46
+ } catch (e) { /* storage unavailable (pre-boot); defaults apply */ }
47
+ })();
48
+
49
+ window.__ModuleLoader__.load({
50
+ id: "dsh-split-view",
51
+ factory: (require) => {
52
+ var module = { exports: {} };
53
+ var exports = module.exports;
54
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
55
+ const React = require("react");
56
+ const runtime = require("@deepseek-ai/dsh-client-runtime/client");
57
+
58
+ //#region constants
59
+ const params = new URLSearchParams(window.location.search);
60
+ /** Pane identity: present means this page load is a split pane, not the splitter. */
61
+ const PANE_ID = params.get("dshPane");
62
+ /** Session this pane must show (forced at boot through sessions.open). */
63
+ const SEED_SESSION = params.get("dshSession");
64
+ /** Fresh pane with an inherited workspace: create/connect its workspace session. */
65
+ const INHERIT_WORKSPACE = params.get("dshWorkspace");
66
+ /** Fresh pane with an inherited working directory (no workspace). */
67
+ const INHERIT_CWD = params.get("dshCwd");
68
+ /** Fresh pane: boot into the no-session empty state instead of the persisted seed. */
69
+ const WANT_NEW = params.get("dshNew") === "1";
70
+ /** localStorage key holding the persisted split layout. */
71
+ const LAYOUT_KEY = "dsh-split:layout";
72
+ /** postMessage envelope type shared by pane agents and the splitter. */
73
+ const MSG_TYPE = "dsh-split";
74
+ /** Smallest fraction a divider may leave on either side. */
75
+ const MIN_FRACTION = 0.06;
76
+ /** Veil fallback: show the pane even if its ready message never arrives. */
77
+ const VEIL_TIMEOUT_MS = 4000;
78
+ /** Max concurrently live panes. Each pane is a full DSH client holding 2
79
+ * WebSockets; the browser's per-origin connection budget sustains ~5 live
80
+ * clients, and the 6th boot hangs at "Loading plugins…" until a client is
81
+ * freed (reproduced 100%; freeing one revives it instantly). 127.0.0.1
82
+ * and localhost are DISTINCT origins backed by the same loopback server,
83
+ * and a client on origin B boots fine while origin A is saturated
84
+ * (verified) — so panes alternate origins: 4 on the top window's origin
85
+ * (top itself is a client there) + 5 on the alternate origin = 9 live.
86
+ * Beyond the caps the least-recently-used pane hibernates (iframe
87
+ * unloaded, connections freed) and wakes on click. */
88
+ const MAX_LIVE_PANES_MULTI = 9;
89
+ const MAX_LIVE_PANES_SINGLE = 4;
90
+ /** Per-origin live-pane caps, indexed like the pool: the top window's own
91
+ * origin hosts one extra client (the top window), so it gets one less. */
92
+ const ORIGIN_CAPS_MULTI = [4, 5];
93
+ const ORIGIN_CAPS_SINGLE = [4];
94
+ function originPool() {
95
+ const loc = window.location;
96
+ const port = loc.port ? ":" + loc.port : "";
97
+ if (loc.hostname === "127.0.0.1") return [loc.origin, loc.protocol + "//localhost" + port];
98
+ if (loc.hostname === "localhost") return [loc.origin, loc.protocol + "//127.0.0.1" + port];
99
+ return [loc.origin];
100
+ }
101
+ function pickOrigin(draft, pool, caps) {
102
+ if (pool.length < 2) return 0;
103
+ const counts = new Array(pool.length).fill(0);
104
+ collectLeaves(draft.tree, []).forEach((l) => {
105
+ // Skip the focused leaf — it is the new pane being placed (still
106
+ // without an origin) and must not count against the fill order.
107
+ if (l.id === draft.focused) return;
108
+ counts[typeof l.origin === "number" && l.origin < pool.length ? l.origin : 0]++;
109
+ });
110
+ // Fill the first origin (the top's own) up to its cap before using
111
+ // the alternate one. Same-origin panes share localStorage with the
112
+ // top and skip the cross-origin sync machinery entirely, so the
113
+ // common 2-4 pane case never touches it. Once every origin is at
114
+ // cap, fall back to the least-loaded (the extra pane goes dormant
115
+ // through the LRU live set).
116
+ for (let i = 0; i < pool.length; i++) {
117
+ if (counts[i] < caps[i]) return i;
118
+ }
119
+ let best = 0;
120
+ for (let i = 1; i < pool.length; i++) {
121
+ if (counts[i] < counts[best]) best = i;
122
+ }
123
+ return best;
124
+ }
125
+ /** localStorage keys that must NOT cross the pane↔top boundary: the
126
+ * per-client current session, and the plugin's own keys (they already have
127
+ * dedicated channels). Everything else — dream-skin, trajectory toggles, any
128
+ * third-party plugin's config — syncs generically. */
129
+ const STORAGE_SYNC_DENY = new Set(["dsh.sessions.current"]);
130
+ const STORAGE_SYNC_DENY_PREFIXES = ["dsh-split:", "dsh-multi:"];
131
+ function shouldSyncKey(key) {
132
+ if (!key) return false;
133
+ if (STORAGE_SYNC_DENY.has(key)) return false;
134
+ return !STORAGE_SYNC_DENY_PREFIXES.some((p) => key.startsWith(p));
135
+ }
136
+ function collectSyncedStorage() {
137
+ const values = {};
138
+ try {
139
+ for (let i = 0; i < window.localStorage.length; i++) {
140
+ const key = window.localStorage.key(i);
141
+ if (shouldSyncKey(key)) {
142
+ const v = window.localStorage.getItem(key);
143
+ if (v !== null) values[key] = v;
144
+ }
145
+ }
146
+ } catch {
147
+ // storage unavailable — nothing to sync
148
+ }
149
+ return values;
150
+ }
151
+ /** The theme service restores its built-in preference from the host
152
+ * settings document asynchronously; that adoption can land AFTER a
153
+ * skin plugin (dream-skin etc.) restored its custom skin at boot and
154
+ * reset the theme to the default. Re-apply the persisted skin while the
155
+ * boot settles: through the theme service (so other plugins follow
156
+ * theme/change) and directly onto the DOM (the layout presenter can
157
+ * miss boot-time events — its listener registers during its own apply). */
158
+ function reapplyBootSkin(ctx) {
159
+ const applySnapshot = () => {
160
+ try {
161
+ const snapshot = ctx.theme.getTheme();
162
+ const active = snapshot.active;
163
+ if (!active) return;
164
+ const scheme = active.colorScheme;
165
+ document.documentElement.style.colorScheme = scheme;
166
+ const body = document.body;
167
+ if (!body) return;
168
+ if (scheme === "dark") body.setAttribute("data-ds-dark-theme", "");
169
+ else body.removeAttribute("data-ds-dark-theme");
170
+ for (const [name, value] of Object.entries(active.tokens)) body.style.setProperty(name, value);
171
+ } catch {
172
+ // best-effort — never break the boot
173
+ }
174
+ };
175
+ const reconcile = () => {
176
+ try {
177
+ const skinId = window.localStorage.getItem("dsh-dream-skin:skin");
178
+ if (typeof skinId === "string" && skinId.length > 0) {
179
+ if (skinId !== ctx.theme.getTheme().preference) ctx.theme.setTheme(skinId);
180
+ applySnapshot();
181
+ }
182
+ } catch {
183
+ // best-effort
184
+ }
185
+ };
186
+ const deadline = Date.now() + 15000;
187
+ ctx.on("theme/change", () => {
188
+ if (Date.now() > deadline) return;
189
+ reconcile();
190
+ });
191
+ // Deferred passes: events racing ahead of the presenter's listener
192
+ // registration get lost, so reconcile again after the boot settles.
193
+ for (const delay of [2000, 5000, 10000]) setTimeout(reconcile, delay);
194
+ }
195
+ //#endregion
196
+
197
+ const newId = () => "p" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
198
+
199
+ /** Panes may live on a different origin than their parent (origin pool),
200
+ * so target the parent's origin derived from the referrer; loopback-only
201
+ * chatter, "*" fallback when the referrer is unavailable. */
202
+ const PARENT_ORIGIN = (() => {
203
+ try {
204
+ if (document.referrer) return new URL(document.referrer).origin;
205
+ } catch {
206
+ // fall through to the wildcard
207
+ }
208
+ return "*";
209
+ })();
210
+
211
+ function postToParent(data) {
212
+ try {
213
+ if (window.parent && window.parent !== window) {
214
+ window.parent.postMessage(Object.assign({ type: MSG_TYPE }, data), PARENT_ORIGIN);
215
+ }
216
+ } catch {
217
+ // pane-to-parent chatter must never break the page
218
+ }
219
+ }
220
+
221
+ //#region layout tree (pure draft mutators for the snapshot store)
222
+ function freshLeaf() {
223
+ return { kind: "leaf", id: newId(), session: null };
224
+ }
225
+ function defaultLayout() {
226
+ const leaf = freshLeaf();
227
+ return { tree: leaf, focused: leaf.id, maximized: null };
228
+ }
229
+ function findNode(node, id) {
230
+ if (node.id === id) return node;
231
+ if (node.kind !== "branch") return null;
232
+ for (const child of node.children) {
233
+ const hit = findNode(child, id);
234
+ if (hit) return hit;
235
+ }
236
+ return null;
237
+ }
238
+ function findParent(node, id) {
239
+ if (node.kind !== "branch") return null;
240
+ for (let i = 0; i < node.children.length; i++) {
241
+ if (node.children[i].id === id) return { parent: node, index: i };
242
+ const hit = findParent(node.children[i], id);
243
+ if (hit) return hit;
244
+ }
245
+ return null;
246
+ }
247
+ function firstLeafId(node) {
248
+ if (node.kind === "leaf") return node.id;
249
+ return firstLeafId(node.children[0]);
250
+ }
251
+ function lastLeafId(node) {
252
+ if (node.kind === "leaf") return node.id;
253
+ return lastLeafId(node.children[node.children.length - 1]);
254
+ }
255
+ /** The pane spatially adjacent to a leaf within its own split group — the
256
+ * sibling it was split from / that was split from it. Prefers the NEXT
257
+ * sibling (the one created by splitting this pane), falling back to the
258
+ * previous. When the chosen sibling is itself a nested group, return the
259
+ * leaf nearest the closing pane (first leaf of the next sibling, last
260
+ * leaf of the previous). Returns null for an only child. */
261
+ function neighborLeafId(parent, index) {
262
+ const n = parent.children.length;
263
+ if (index + 1 < n) return firstLeafId(parent.children[index + 1]);
264
+ if (index - 1 >= 0) return lastLeafId(parent.children[index - 1]);
265
+ return null;
266
+ }
267
+ function replaceNode(draft, id, replacement) {
268
+ const hit = findParent(draft.tree, id);
269
+ if (!hit) return false;
270
+ hit.parent.children[hit.index] = replacement;
271
+ return true;
272
+ }
273
+ function splitLeaf(draft, leafId, dir, inherit) {
274
+ const leaf = findNode(draft.tree, leafId);
275
+ if (!leaf || leaf.kind !== "leaf") return;
276
+ // The new pane starts a FRESH session but inherits the split pane's
277
+ // workspace (or working directory) — ghostty-style: no manual
278
+ // workspace/mode re-selection, without duplicating the conversation.
279
+ const newLeaf = {
280
+ kind: "leaf",
281
+ id: newId(),
282
+ // A pre-created session (from the public `split()` service) pins the
283
+ // pane straight to it; otherwise the pane creates its own.
284
+ session: inherit && inherit.session ? inherit.session : null,
285
+ ...(inherit && inherit.workspace ? { workspace: inherit.workspace } : {}),
286
+ ...(inherit && inherit.cwd ? { cwd: inherit.cwd } : {})
287
+ };
288
+ const branch = {
289
+ kind: "branch",
290
+ id: newId(),
291
+ // The layout engine speaks "h"/"v"; normalize caller-friendly names.
292
+ dir: dir === "right" ? "h" : dir === "down" ? "v" : dir,
293
+ fractions: [0.5, 0.5],
294
+ children: [leaf, newLeaf]
295
+ };
296
+ if (draft.tree.id === leafId) draft.tree = branch;
297
+ else if (!replaceNode(draft, leafId, branch)) return;
298
+ draft.focused = branch.children[1].id;
299
+ }
300
+ /** Non-empty string or null — the shared option-field normalizer. */
301
+ const str = (v) => (typeof v === "string" && v.length > 0 ? v : null);
302
+
303
+ /** Normalize the model option: "provider/model" shorthand or
304
+ * {provider, model, reasoningEffort?}. Anything else → null. */
305
+ function normalizeModel(raw) {
306
+ if (raw && typeof raw === "object") {
307
+ const provider = str(raw.provider);
308
+ const modelId = str(raw.model);
309
+ if (provider === null || modelId === null) return null;
310
+ const effort = str(raw.reasoningEffort);
311
+ return { provider, model: modelId, ...(effort !== null ? { reasoningEffort: effort } : {}) };
312
+ }
313
+ if (typeof raw === "string" && raw.length > 0) {
314
+ const slash = raw.indexOf("/");
315
+ if (slash > 0) return { provider: raw.slice(0, slash), model: raw.slice(slash + 1) };
316
+ }
317
+ return null;
318
+ }
319
+ /** Normalize the public service's session-bearing option payloads
320
+ * (split / newSession) into canonical nullable fields; omitted or
321
+ * unknown options fall back to null (the default). */
322
+ function normalizeSplitOptions(options) {
323
+ const o = options && typeof options === "object" ? options : {};
324
+ const direction = o.direction === "down" ? "down" : "right";
325
+ const workspaceId = str(o.workspaceId);
326
+ const cwd = str(o.cwd);
327
+ const prompt = str(o.prompt);
328
+ const title = str(o.title);
329
+ const mode = str(o.mode);
330
+ const session = str(o.session);
331
+ /** Pane the action anchors to (defaults to the focused pane). */
332
+ const pane = str(o.pane);
333
+ return { direction, workspaceId, cwd, prompt, title, mode, model: normalizeModel(o.model), session, pane };
334
+ }
335
+ function closeLeaf(draft, leafId) {
336
+ if (draft.tree.kind === "leaf") {
337
+ if (draft.tree.id !== leafId) return;
338
+ const leaf = freshLeaf();
339
+ draft.tree = leaf;
340
+ draft.focused = leaf.id;
341
+ draft.maximized = null;
342
+ return;
343
+ }
344
+ const hit = findParent(draft.tree, leafId);
345
+ if (!hit) return;
346
+ const { parent, index } = hit;
347
+ // Resolve the successor BEFORE splicing: the adjacent sibling in the
348
+ // pane's own split group (the pane it was split from / that was split
349
+ // from it), never the layout's first pane.
350
+ const successor = neighborLeafId(parent, index);
351
+ parent.children.splice(index, 1);
352
+ parent.fractions.splice(index, 1);
353
+ if (parent.children.length === 1) {
354
+ // pull the surviving sibling up into the vacated branch slot
355
+ const only = parent.children[0];
356
+ if (draft.tree.id === parent.id) draft.tree = only;
357
+ else replaceNode(draft, parent.id, only);
358
+ } else {
359
+ const sum = parent.fractions.reduce((a, b) => a + b, 0) || 1;
360
+ parent.fractions = parent.fractions.map((f) => f / sum);
361
+ }
362
+ if (draft.focused === leafId) draft.focused = successor ?? firstLeafId(draft.tree);
363
+ if (draft.maximized === leafId) draft.maximized = null;
364
+ }
365
+ function setLeafSession(draft, leafId, sessionId) {
366
+ const leaf = findNode(draft.tree, leafId);
367
+ if (leaf && leaf.kind === "leaf") leaf.session = sessionId;
368
+ }
369
+ function nudgeFractions(draft, branchId, index, delta) {
370
+ const branch = findNode(draft.tree, branchId);
371
+ if (!branch || branch.kind !== "branch") return;
372
+ if (index < 0 || index + 1 >= branch.fractions.length) return;
373
+ const a = branch.fractions[index];
374
+ const b = branch.fractions[index + 1];
375
+ const clamped = Math.max(MIN_FRACTION - a, Math.min(delta, b - MIN_FRACTION));
376
+ branch.fractions[index] = a + clamped;
377
+ branch.fractions[index + 1] = b - clamped;
378
+ }
379
+ /** Validate + normalize an externally supplied layout tree (the public
380
+ * applyLayout escape hatch): leaves and branches only, unique ids
381
+ * (regenerated when absent or colliding), branch dir folded to h/v,
382
+ * fractions positive and renormalized (even split when missing or
383
+ * malformed). Throws a dsh-split-view error on any malformed node. */
384
+ function normalizeLayoutTree(raw) {
385
+ const seen = new Set();
386
+ const takeId = (node) => {
387
+ let id = typeof node.id === "string" && node.id.length > 0 ? node.id : newId();
388
+ if (seen.has(id)) id = newId();
389
+ seen.add(id);
390
+ return id;
391
+ };
392
+ const norm = (node) => {
393
+ if (!node || typeof node !== "object") throw new Error("dsh-split-view: layout nodes must be objects");
394
+ if (node.kind === "leaf" || node.kind === "pane") {
395
+ const leaf = { kind: "leaf", id: takeId(node), session: str(node.session) };
396
+ if (typeof node.origin === "number" && isFinite(node.origin) && node.origin >= 0) leaf.origin = Math.floor(node.origin);
397
+ return leaf;
398
+ }
399
+ if (node.kind === "branch" || node.kind === "split" || node.kind === "group") {
400
+ if (!Array.isArray(node.children) || node.children.length < 2) throw new Error("dsh-split-view: a branch needs at least 2 children");
401
+ const children = node.children.map(norm);
402
+ const dir = node.dir === "v" || node.dir === "down" || node.dir === "up" ? "v" : "h";
403
+ let fractions = Array.isArray(node.fractions) && node.fractions.length === children.length && node.fractions.every((f) => typeof f === "number" && isFinite(f) && f > 0)
404
+ ? node.fractions.slice()
405
+ : children.map(() => 1 / children.length);
406
+ const sum = fractions.reduce((a, b) => a + b, 0) || 1;
407
+ fractions = fractions.map((f) => f / sum);
408
+ return { kind: "branch", id: takeId(node), dir, fractions, children };
409
+ }
410
+ throw new Error("dsh-split-view: unknown layout node kind: " + String(node.kind));
411
+ };
412
+ return norm(raw);
413
+ }
414
+ //#endregion
415
+
416
+ //#region preferences (shortcuts, focus color, title bars)
417
+ // User preferences. The TOP WINDOW is the source of truth — it draws the
418
+ // title bars, the focus highlight and the splitter the shortcuts drive —
419
+ // and persists them in its own localStorage (PREFS_KEY). The settings UI
420
+ // only ever opens inside a pane (the top shadows the AppFrame, so its
421
+ // settings modal is unreachable), so panes hold a mirror synced over
422
+ // postMessage: prefs-pull at boot, prefs-push broadcast on every change,
423
+ // prefs-set carries edits back to the top.
424
+ //
425
+ // Shortcut defaults are the browser-safe mnemonic bindings picked in
426
+ // earlier rounds: ⌘/Ctrl alone collides with browser chrome (⌘W close
427
+ // tab, ⌘D bookmark, ⌘V paste…); ⌘⇧Q locked the browser and ⌘⇧W is
428
+ // taken by Feishu's global hotkey — X reads as ✕. Mod+Shift+letter is
429
+ // the space a page can actually capture. ⌘⇧Enter keeps maximize/restore.
430
+ /** localStorage key holding user preferences (persisted by the top window). */
431
+ const PREFS_KEY = "dsh-split:prefs";
432
+ /** Locale namespace of the settings section and its rows. */
433
+ const SETTINGS_NS = "settings.splitView";
434
+ const ACTION_IDS = ["split-right", "split-down", "close", "maximize"];
435
+ const DEFAULT_SHORTCUTS = Object.freeze({
436
+ "split-right": "Mod+Shift+H",
437
+ "split-down": "Mod+Shift+V",
438
+ "close": "Mod+Shift+X",
439
+ "maximize": "Mod+Shift+Enter"
440
+ });
441
+ const DEFAULT_PREFS = Object.freeze({
442
+ shortcuts: DEFAULT_SHORTCUTS,
443
+ /** Hex color (always stored lowercase), or "theme" to follow the
444
+ * current skin's brand color. */
445
+ focusColor: "#4d6bfe",
446
+ showTitleBar: true
447
+ });
448
+ const FOCUS_COLOR_PRESETS = ["#4D6BFE", "#4098ff", "#2dd4bf", "#34d399", "#a78bfa", "#e879f9", "#fb923c", "#f87171"];
449
+ const IS_MAC = typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/.test(((typeof navigator.platform === "string" ? navigator.platform : "") + " " + navigator.userAgent));
450
+ /** Key values that never end a combo — pressing them means "still chording". */
451
+ const MODIFIER_KEYS = new Set(["meta", "control", "shift", "alt", "capslock", "fn", "fnlock", "numlock", "scrolllock"]);
452
+
453
+ function normEventKey(event) {
454
+ if (typeof event.key !== "string" || event.key.length === 0) return null;
455
+ if (event.key === " ") return "space";
456
+ return event.key.toLowerCase();
457
+ }
458
+
459
+ /** Parse a serialized combo ("Mod+Shift+H"). Mod (matched as ⌘ OR Ctrl on
460
+ * every platform — the same unification the original fixed map used) is
461
+ * mandatory; returns {shift, alt, key} or null. */
462
+ function parseCombo(text) {
463
+ if (typeof text !== "string") return null;
464
+ const parts = text.split("+").map((p) => p.trim()).filter((p) => p.length > 0);
465
+ let mod = false;
466
+ const parsed = { shift: false, alt: false, key: null };
467
+ for (const part of parts) {
468
+ const token = part.toLowerCase();
469
+ if (token === "mod" || token === "cmd" || token === "ctrl" || token === "cmdorctrl") mod = true;
470
+ else if (token === "shift") parsed.shift = true;
471
+ else if (token === "alt" || token === "option") parsed.alt = true;
472
+ else if (parsed.key === null && !MODIFIER_KEYS.has(token)) parsed.key = token;
473
+ else return null;
474
+ }
475
+ return mod && parsed.key !== null ? parsed : null;
476
+ }
477
+
478
+ function comboToText(parsed) {
479
+ const parts = ["Mod"];
480
+ if (parsed.shift) parts.push("Shift");
481
+ if (parsed.alt) parts.push("Alt");
482
+ const key = parsed.key;
483
+ parts.push(key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1));
484
+ return parts.join("+");
485
+ }
486
+
487
+ function combosEqual(a, b) {
488
+ return a !== null && b !== null && a.shift === b.shift && a.alt === b.alt && a.key === b.key;
489
+ }
490
+
491
+ /** Which split command a key event carries under the given shortcut map
492
+ * (null when it matches no binding). */
493
+ function commandOfEvent(event, shortcuts) {
494
+ if (!(event.metaKey || event.ctrlKey)) return null;
495
+ const key = normEventKey(event);
496
+ if (key === null || MODIFIER_KEYS.has(key)) return null;
497
+ const map = shortcuts || DEFAULT_SHORTCUTS;
498
+ for (const cmd of ACTION_IDS) {
499
+ const combo = parseCombo(map[cmd]);
500
+ if (combo && combo.shift === event.shiftKey && combo.alt === event.altKey && combo.key === key) return cmd;
501
+ }
502
+ return null;
503
+ }
504
+
505
+ /** Platform-aware label for tooltips and the settings row (⌘⇧H / Ctrl+Shift+H). */
506
+ function comboLabel(text) {
507
+ const parsed = parseCombo(text);
508
+ if (!parsed) return "—";
509
+ const key = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key.charAt(0).toUpperCase() + parsed.key.slice(1);
510
+ if (IS_MAC) return "⌘" + (parsed.shift ? "⇧" : "") + (parsed.alt ? "⌥" : "") + key;
511
+ return "Ctrl+" + (parsed.shift ? "Shift+" : "") + (parsed.alt ? "Alt+" : "") + key;
512
+ }
513
+
514
+ /** Recording-time validation. Returns a locale error key (resolved under
515
+ * "shortcuts.*" by the caller) or null when the combo is safe to bind. */
516
+ function validateRecorded(parsed, map, actionId) {
517
+ // Bare ⌘/Ctrl+letter is browser-reserved territory (⌘W closes the
518
+ // tab, ⌘D bookmarks…) — the page never sees those events.
519
+ if (parsed.key.length === 1 && !parsed.shift && !parsed.alt) return "err.letter";
520
+ // Combinations with a painful history or a browser/system reservation
521
+ // (⌘⇧Q locked the browser, ⌘⇧W closes all windows / is Feishu's
522
+ // global hotkey, ⌘⇧T reopens tabs, ⌘⇧N opens private windows).
523
+ if (parsed.shift && !parsed.alt && (parsed.key === "w" || parsed.key === "q" || parsed.key === "t" || parsed.key === "n")) return "err.danger";
524
+ for (const other of ACTION_IDS) {
525
+ if (other === actionId) continue;
526
+ if (combosEqual(parseCombo(map[other]), parsed)) return "err.dup";
527
+ }
528
+ return null;
529
+ }
530
+
531
+ /** Merge anything storage (or a message) produced back into a guaranteed
532
+ * full, internally consistent prefs object. */
533
+ function normalizePrefs(raw) {
534
+ const source = raw && typeof raw === "object" ? raw : {};
535
+ const rawShortcuts = source.shortcuts && typeof source.shortcuts === "object" ? source.shortcuts : {};
536
+ const shortcuts = {};
537
+ const claimed = [];
538
+ for (const cmd of ACTION_IDS) {
539
+ let parsed = parseCombo(rawShortcuts[cmd]);
540
+ if (!parsed || claimed.some((c) => combosEqual(c, parsed))) parsed = parseCombo(DEFAULT_SHORTCUTS[cmd]);
541
+ shortcuts[cmd] = comboToText(parsed);
542
+ claimed.push(parsed);
543
+ }
544
+ let focusColor = DEFAULT_PREFS.focusColor;
545
+ if (source.focusColor === "theme") focusColor = "theme";
546
+ else if (typeof source.focusColor === "string" && /^#[0-9a-fA-F]{6}$/.test(source.focusColor)) focusColor = source.focusColor.toLowerCase();
547
+ return {
548
+ shortcuts,
549
+ focusColor,
550
+ showTitleBar: source.showTitleBar === undefined ? DEFAULT_PREFS.showTitleBar : !!source.showTitleBar
551
+ };
552
+ }
553
+
554
+ /** Apply a partial settings edit. Returns the merged prefs or null when
555
+ * nothing in the patch was valid — the top window is authoritative, so
556
+ * an all-invalid patch silently keeps the current state. */
557
+ function patchPrefs(current, patch) {
558
+ if (!patch || typeof patch !== "object") return null;
559
+ const next = {
560
+ shortcuts: Object.assign({}, current.shortcuts),
561
+ focusColor: current.focusColor,
562
+ showTitleBar: current.showTitleBar
563
+ };
564
+ let changed = false;
565
+ if (patch.shortcuts && typeof patch.shortcuts === "object") {
566
+ for (const cmd of ACTION_IDS) {
567
+ const text = patch.shortcuts[cmd];
568
+ if (text === undefined) continue;
569
+ const parsed = parseCombo(text);
570
+ if (!parsed) continue;
571
+ const collides = ACTION_IDS.some((other) => other !== cmd && combosEqual(parseCombo(next.shortcuts[other]), parsed));
572
+ if (collides) continue;
573
+ const nextText = comboToText(parsed);
574
+ if (next.shortcuts[cmd] !== nextText) {
575
+ next.shortcuts[cmd] = nextText;
576
+ changed = true;
577
+ }
578
+ }
579
+ }
580
+ if (patch.focusColor !== undefined) {
581
+ const value = patch.focusColor;
582
+ const valid = value === "theme" || (typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value));
583
+ if (valid) {
584
+ const norm = value === "theme" ? "theme" : value.toLowerCase();
585
+ if (norm !== next.focusColor) {
586
+ next.focusColor = norm;
587
+ changed = true;
588
+ }
589
+ }
590
+ }
591
+ if (patch.showTitleBar !== undefined) {
592
+ const on = !!patch.showTitleBar;
593
+ if (on !== next.showTitleBar) {
594
+ next.showTitleBar = on;
595
+ changed = true;
596
+ }
597
+ }
598
+ return changed ? next : null;
599
+ }
600
+ //#endregion
601
+
602
+ //#region settings locale
603
+ const zh = {
604
+ "nav": "分屏",
605
+ "shortcuts.title": "快捷键",
606
+ "shortcuts.split-right": "向右分屏",
607
+ "shortcuts.split-down": "向下分屏",
608
+ "shortcuts.close": "关闭面板",
609
+ "shortcuts.maximize": "最大化 / 还原",
610
+ "shortcuts.record": "按新的组合键…",
611
+ "shortcuts.recordHint": "按 Esc 取消",
612
+ "shortcuts.reset": "恢复默认",
613
+ "shortcuts.hint": "组合键需包含 ⌘/Ctrl;单个字母或数字需搭配 Shift 或 Alt,否则会被浏览器保留。个别组合可能仍被浏览器或系统全局热键占用,遇到无效换一个即可。",
614
+ "shortcuts.err.noMod": "组合键需包含 ⌘ 或 Ctrl",
615
+ "shortcuts.err.letter": "字母/数字键需搭配 Shift 或 Alt,否则会被浏览器保留",
616
+ "shortcuts.err.danger": "该组合被浏览器或系统保留(例如会关掉所有窗口),请换一个",
617
+ "shortcuts.err.dup": "该组合已被其他动作使用",
618
+ "color.title": "选中高亮色",
619
+ "color.theme": "跟随主题",
620
+ "color.pick": "自定义…",
621
+ "color.hint": "聚焦面板的描边和标题栏染色。「跟随主题」随当前皮肤的品牌色变化,换皮肤后自动跟着变。",
622
+ "titlebar.title": "标题栏",
623
+ "titlebar.show": "显示面板标题栏",
624
+ "titlebar.hint": "标题栏承载会话标题(点击标题文字可重命名;新会话显示文件夹名、不可重命名)、复制会话信息按钮和分屏、最大化、刷新、关闭五个按钮,双击标题栏空白处可放大/还原;隐藏后请用快捷键操作,点面板任意位置仍可聚焦。",
625
+ "reset.title": "恢复默认",
626
+ "reset.button": "恢复默认设置",
627
+ "reset.hint": "把快捷键、选中高亮色、标题栏开关全部还原为默认值。",
628
+ "reset.confirm": "确定把分屏设置全部恢复为默认值吗?"
629
+ };
630
+
631
+ /** English dictionary, checked complete against the zh key set. */
632
+ const en = {
633
+ "nav": "Split View",
634
+ "shortcuts.title": "Keyboard Shortcuts",
635
+ "shortcuts.split-right": "Split right",
636
+ "shortcuts.split-down": "Split down",
637
+ "shortcuts.close": "Close pane",
638
+ "shortcuts.maximize": "Maximize / restore",
639
+ "shortcuts.record": "Press a new combo…",
640
+ "shortcuts.recordHint": "Esc to cancel",
641
+ "shortcuts.reset": "Reset to defaults",
642
+ "shortcuts.hint": "Combos need ⌘/Ctrl; a bare letter or digit also needs Shift or Alt, otherwise the browser keeps it. A few combos may still be taken by the browser or global hotkeys — pick another if one does nothing.",
643
+ "shortcuts.err.noMod": "The combo needs ⌘ or Ctrl",
644
+ "shortcuts.err.letter": "Letters/digits need Shift or Alt, otherwise the browser keeps them",
645
+ "shortcuts.err.danger": "That combo is reserved by the browser or the OS (it would e.g. close all windows) — pick another",
646
+ "shortcuts.err.dup": "That combo is already bound to another action",
647
+ "color.title": "Focus Highlight Color",
648
+ "color.theme": "Follow theme",
649
+ "color.pick": "Custom…",
650
+ "color.hint": "The outline of the focused pane and its title-bar tint. “Follow theme” tracks the current skin's brand color.",
651
+ "titlebar.title": "Title Bar",
652
+ "titlebar.show": "Show pane title bars",
653
+ "titlebar.hint": "The title bar carries the session title (click the title text to rename; blank new sessions show the folder name and cannot be renamed), a copy-session-info button and the split / maximize / reload / close buttons. Double-click the title bar to maximize/restore. With it hidden, use the keyboard shortcuts instead; clicking a pane still focuses it.",
654
+ "reset.title": "Restore Defaults",
655
+ "reset.button": "Restore default settings",
656
+ "reset.hint": "Reset shortcuts, focus highlight color and the title-bar switch back to their defaults.",
657
+ "reset.confirm": "Restore all split-view settings to their defaults?"
658
+ };
659
+ //#endregion
660
+
661
+ //#region pane agent (runs inside ?dshPane loads)
662
+ function applyPane(ctx) {
663
+ // Prefs mirror of the top window (the source of truth — see the
664
+ // preferences region header). Same-origin panes share the top's
665
+ // localStorage and can seed directly; cross-origin panes stay on
666
+ // defaults until the prefs-pull below is answered.
667
+ const prefs = runtime.createSnapshotStore(normalizePrefs({}), {});
668
+ try {
669
+ const raw = window.localStorage.getItem(PREFS_KEY);
670
+ if (raw !== null) prefs.set(normalizePrefs(JSON.parse(raw)));
671
+ } catch {
672
+ // storage unavailable or corrupt — defaults apply
673
+ }
674
+ const prefsChannel = {
675
+ getPrefs: () => prefs.getSnapshot(),
676
+ subscribe: (fn) => prefs.subscribe(fn),
677
+ savePrefs: (patch) => {
678
+ // Optimistic local echo keeps the row snappy; the top window
679
+ // stays authoritative and broadcasts the canonical state back.
680
+ const next = patchPrefs(prefs.getSnapshot(), patch);
681
+ if (next) prefs.set(next);
682
+ postToParent({ kind: "prefs-set", pane: PANE_ID, patch });
683
+ }
684
+ };
685
+ applySettings(ctx, prefsChannel);
686
+ ctx.effect(() => {
687
+ const sessions = ctx.sessions;
688
+ // Session seeding is gated on the list baseline: SessionManager.select
689
+ // validates the id against the listed summaries and throws "unknown
690
+ // session" while the session.list RPC is still in flight (observed in
691
+ // the wild). Try once the list resolves; a still-unknown id then means
692
+ // the session is genuinely gone (deleted/archived elsewhere) — give up
693
+ // gracefully and let the pane fall back to adoption/empty state.
694
+ let seeded = false;
695
+ const seedInherited = () => {
696
+ const opts = INHERIT_WORKSPACE ? { workspaceId: INHERIT_WORKSPACE } : { cwd: INHERIT_CWD };
697
+ const fallback = () => {
698
+ try {
699
+ sessions.clear();
700
+ } catch {
701
+ // empty-state fallback is best-effort
702
+ }
703
+ };
704
+ try {
705
+ Promise.resolve(sessions.create(opts)).then((result) => {
706
+ let target = result && result.ok && result.value ? result.value.sessionId : void 0;
707
+ if (target === void 0 && INHERIT_WORKSPACE) {
708
+ // create may report "workspace already attached" and the
709
+ // runtime upserts that workspace's blank session — find it.
710
+ const listSnap = sessions.list.getSnapshot();
711
+ const blank = listSnap.ids.map((id) => listSnap.byId[id]).find((s) => s && s.blank);
712
+ if (blank) target = blank.id;
713
+ }
714
+ if (target !== void 0) {
715
+ try {
716
+ sessions.open(target);
717
+ return;
718
+ } catch {
719
+ // fall through to the empty state
720
+ }
721
+ }
722
+ fallback();
723
+ }).catch(fallback);
724
+ } catch {
725
+ fallback();
726
+ }
727
+ };
728
+ const trySeed = () => {
729
+ if (seeded) return;
730
+ if (sessions.list.getSnapshot().phase === "pending") return;
731
+ seeded = true;
732
+ if (SEED_SESSION) {
733
+ try {
734
+ sessions.open(SEED_SESSION);
735
+ } catch (error) {
736
+ console.warn("dsh-split-view: pane session seed unavailable, pane falls back", error);
737
+ }
738
+ return;
739
+ }
740
+ if (INHERIT_WORKSPACE || INHERIT_CWD) {
741
+ seedInherited();
742
+ return;
743
+ }
744
+ if (WANT_NEW) {
745
+ try {
746
+ sessions.clear();
747
+ } catch {
748
+ // empty-state fallback is best-effort
749
+ }
750
+ }
751
+ };
752
+ trySeed();
753
+ const offSeed = seeded ? () => {} : sessions.list.subscribe(trySeed);
754
+ const report = () => {
755
+ const listSnap = sessions.list.getSnapshot();
756
+ const current = listSnap.current;
757
+ const summary = current === null || current === void 0 ? void 0 : listSnap.byId[current];
758
+ postToParent({
759
+ kind: "session",
760
+ pane: PANE_ID,
761
+ sessionId: current ?? null,
762
+ title: summary ? (summary.displayTitle ?? summary.title ?? null) : null,
763
+ // Blank sessions (empty log) display the workspace folder name
764
+ // as their title and must NOT be renamable from the title bar.
765
+ blank: summary ? !!summary.blank : true,
766
+ // Durable title (absent while the display title is the derived
767
+ // folder-name fallback) — seeds the rename editor.
768
+ realTitle: summary && typeof summary.title === "string" ? summary.title : null
769
+ });
770
+ };
771
+ report();
772
+ const offList = sessions.list.subscribe(report);
773
+ const onActivity = () => postToParent({ kind: "focus", pane: PANE_ID });
774
+ const onKey = (event) => {
775
+ const cmd = commandOfEvent(event, prefs.getSnapshot().shortcuts);
776
+ if (cmd === null) return;
777
+ event.preventDefault();
778
+ event.stopPropagation();
779
+ postToParent({ kind: "cmd", cmd, pane: PANE_ID });
780
+ };
781
+ document.addEventListener("mousedown", onActivity, true);
782
+ window.addEventListener("focus", onActivity);
783
+ document.addEventListener("keydown", onKey, true);
784
+ return () => {
785
+ offSeed();
786
+ offList();
787
+ document.removeEventListener("mousedown", onActivity, true);
788
+ window.removeEventListener("focus", onActivity);
789
+ document.removeEventListener("keydown", onKey, true);
790
+ };
791
+ }, "split-view: pane agent");
792
+ reapplyBootSkin(ctx);
793
+ ctx.effect(() => {
794
+ // Cross-origin storage sync: a pane on the alternate loopback origin
795
+ // has its own storage-partitioned localStorage, so client-side
796
+ // preferences (dream-skin skin/wallpaper, trajectory toggles, any
797
+ // plugin's config) do not carry over. Pull the top's snapshot at
798
+ // boot, then forward every later localStorage write back to the top
799
+ // so it stays the source of truth. Same-origin panes share storage
800
+ // and skip this entirely.
801
+ let parentOrigin = null;
802
+ try {
803
+ if (document.referrer) parentOrigin = new URL(document.referrer).origin;
804
+ } catch {
805
+ // unparseable referrer — nothing to sync against
806
+ }
807
+ if (parentOrigin === null || parentOrigin === window.location.origin) return;
808
+ // True once the boot pull has landed. Writes before that are boot
809
+ // restores, not user intent, and must not overwrite the top.
810
+ let synced = false;
811
+ // Retry the boot pull a few times: if the first pull is lost (the top
812
+ // side booting slow or a transient message drop), synced would stay
813
+ // false forever and every later write would silently never forward.
814
+ let pullTries = 0;
815
+ let pullTimer = null;
816
+ const sendPull = () => {
817
+ if (synced || pullTries >= 5) return;
818
+ pullTries += 1;
819
+ postToParent({ kind: "storage-pull", pane: PANE_ID });
820
+ pullTimer = setTimeout(sendPull, 2000);
821
+ };
822
+ const onStoragePush = (event) => {
823
+ if (event.origin !== parentOrigin) return;
824
+ const data = event.data;
825
+ if (!data || data.type !== MSG_TYPE || data.kind !== "storage-push" || !data.values) return;
826
+ window.removeEventListener("message", onStoragePush);
827
+ try {
828
+ for (const key of Object.keys(data.values)) {
829
+ if (!shouldSyncKey(key)) continue;
830
+ try {
831
+ window.localStorage.setItem(key, data.values[key]);
832
+ } catch {
833
+ // quota — skip this key
834
+ }
835
+ }
836
+ // Drop synced keys the top no longer has.
837
+ const drop = [];
838
+ for (let i = 0; i < window.localStorage.length; i++) {
839
+ const key = window.localStorage.key(i);
840
+ if (shouldSyncKey(key) && !(key in data.values)) drop.push(key);
841
+ }
842
+ for (const key of drop) {
843
+ try {
844
+ window.localStorage.removeItem(key);
845
+ } catch {
846
+ // best-effort
847
+ }
848
+ }
849
+ } catch {
850
+ // storage unavailable — the values cannot apply
851
+ }
852
+ // Re-apply the theme preference so theme-listening plugins
853
+ // (dream-skin etc.) pick up the freshly written keys.
854
+ try {
855
+ const skinId = data.values["dsh-dream-skin:skin"];
856
+ if (typeof skinId === "string" && skinId.length > 0 && skinId !== ctx.theme.getTheme().preference) ctx.theme.setTheme(skinId);
857
+ const dispose = ctx.theme.overrideTokens("dsh-split-view:storage-sync", {});
858
+ dispose();
859
+ } catch {
860
+ // theme re-apply is best-effort
861
+ }
862
+ if (pullTimer !== null) clearTimeout(pullTimer);
863
+ synced = true;
864
+ };
865
+ window.addEventListener("message", onStoragePush);
866
+ sendPull();
867
+ // Intercept this pane's own localStorage writes and forward them to
868
+ // the top (which mirrors them and serves them back on the next
869
+ // reload). Only window.localStorage writes cross the boundary.
870
+ const forwardWrite = (key, value) => {
871
+ if (!synced || !shouldSyncKey(key)) return;
872
+ postToParent({ kind: "storage-set", pane: PANE_ID, key, value });
873
+ };
874
+ const origSet = Storage.prototype.setItem;
875
+ const origRemove = Storage.prototype.removeItem;
876
+ const origClear = Storage.prototype.clear;
877
+ const patchedSet = function (key, value) {
878
+ origSet.call(this, key, value);
879
+ if (this === window.localStorage) forwardWrite(key, String(value));
880
+ };
881
+ const patchedRemove = function (key) {
882
+ origRemove.call(this, key);
883
+ if (this === window.localStorage) forwardWrite(key, null);
884
+ };
885
+ const patchedClear = function () {
886
+ const keys = [];
887
+ for (let i = 0; i < this.length; i++) {
888
+ const k = this.key(i);
889
+ if (shouldSyncKey(k)) keys.push(k);
890
+ }
891
+ origClear.call(this);
892
+ if (this === window.localStorage) {
893
+ for (const k of keys) forwardWrite(k, null);
894
+ }
895
+ };
896
+ Storage.prototype.setItem = patchedSet;
897
+ Storage.prototype.removeItem = patchedRemove;
898
+ Storage.prototype.clear = patchedClear;
899
+ return () => {
900
+ if (pullTimer !== null) clearTimeout(pullTimer);
901
+ window.removeEventListener("message", onStoragePush);
902
+ if (Storage.prototype.setItem === patchedSet) Storage.prototype.setItem = origSet;
903
+ if (Storage.prototype.removeItem === patchedRemove) Storage.prototype.removeItem = origRemove;
904
+ if (Storage.prototype.clear === patchedClear) Storage.prototype.clear = origClear;
905
+ };
906
+ }, "split-view: cross-origin storage sync");
907
+ ctx.effect(() => {
908
+ // Prefs sync with the top window: pull once at boot, then follow
909
+ // prefs-push broadcasts (every top-side change, including edits
910
+ // made in another pane's settings modal). Works for same- and
911
+ // cross-origin panes alike — the channel is postMessage, not
912
+ // storage events.
913
+ let parentOrigin = null;
914
+ try {
915
+ if (document.referrer) parentOrigin = new URL(document.referrer).origin;
916
+ } catch {
917
+ // unparseable referrer — accept any origin (loopback-only server)
918
+ }
919
+ const onPrefs = (event) => {
920
+ if (parentOrigin !== null && event.origin !== parentOrigin) return;
921
+ const data = event.data;
922
+ if (!data || data.type !== MSG_TYPE || data.kind !== "prefs-push" || !data.prefs) return;
923
+ prefs.set(normalizePrefs(data.prefs));
924
+ };
925
+ window.addEventListener("message", onPrefs);
926
+ postToParent({ kind: "prefs-pull", pane: PANE_ID });
927
+ return () => window.removeEventListener("message", onPrefs);
928
+ }, "split-view: prefs sync");
929
+ ctx.effect(() => {
930
+ // Top→pane control channel (public service `open` / `reload`):
931
+ // swap the shown session or reload the pane live, without
932
+ // touching the boot URL. Origin-gated like the other top
933
+ // broadcasts (referrer-derived parent origin).
934
+ let parentOrigin = null;
935
+ try {
936
+ if (document.referrer) parentOrigin = new URL(document.referrer).origin;
937
+ } catch {
938
+ // unparseable referrer — accept any origin (loopback-only server)
939
+ }
940
+ const onControl = (event) => {
941
+ if (parentOrigin !== null && event.origin !== parentOrigin) return;
942
+ const data = event.data;
943
+ if (!data || data.type !== MSG_TYPE) return;
944
+ if (data.kind === "open-session" && typeof data.sessionId === "string") {
945
+ const target = data.sessionId;
946
+ const openNow = () => {
947
+ try {
948
+ ctx.sessions.open(target);
949
+ return true;
950
+ } catch {
951
+ return false;
952
+ }
953
+ };
954
+ if (openNow()) return;
955
+ // select validates against the list baseline; while the
956
+ // list RPC is in flight every id fails, so retry once it
957
+ // resolves (same gate as the boot seeding).
958
+ if (ctx.sessions.list.getSnapshot().phase !== "pending") return;
959
+ const off = ctx.sessions.list.subscribe(() => {
960
+ if (ctx.sessions.list.getSnapshot().phase === "pending") return;
961
+ off();
962
+ try {
963
+ ctx.sessions.open(target);
964
+ } catch {
965
+ // genuinely gone (deleted elsewhere) — keep the current session
966
+ }
967
+ });
968
+ } else if (data.kind === "reload") {
969
+ window.location.reload();
970
+ }
971
+ };
972
+ window.addEventListener("message", onControl);
973
+ return () => window.removeEventListener("message", onControl);
974
+ }, "split-view: top control channel");
975
+ }
976
+ //#endregion
977
+
978
+ //#region settings UI (settings.section + rows)
979
+ // Follows the dsh-dream-skin pattern: one settings.section registration
980
+ // (a category in the settings left nav) hosting rows through its own child
981
+ // slot. The section only ever becomes visible inside panes — the top
982
+ // window shadows the AppFrame, so its settings modal never opens — but it
983
+ // registers in both modes; the row API object decides where edits land
984
+ // (top: the authoritative persisted store; pane: the synced mirror plus a
985
+ // prefs-set message to the top).
986
+ const settingsStyles = {
987
+ section: { display: "flex", flexDirection: "column", width: "100%" },
988
+ group: {
989
+ borderBottom: "1px solid var(--dsw-alias-border-l2)",
990
+ display: "flex",
991
+ flexDirection: "column",
992
+ gap: "10px",
993
+ padding: "16px 0"
994
+ },
995
+ title: { color: "var(--dsw-alias-label-primary)", fontSize: "14px", fontWeight: 400, lineHeight: "22px" },
996
+ hint: { color: "var(--dsw-alias-label-tertiary)", fontSize: "12px", lineHeight: "18px" },
997
+ error: { color: "var(--dsw-alias-state-error-primary,#e5484d)", fontSize: "12px", lineHeight: "18px" },
998
+ actionRow: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" },
999
+ kvRow: { display: "flex", alignItems: "center", gap: "10px" },
1000
+ kvLabel: { color: "var(--dsw-alias-label-secondary)", fontSize: "13px", lineHeight: "20px" },
1001
+ button: {
1002
+ height: "32px",
1003
+ padding: "0 14px",
1004
+ borderRadius: "8px",
1005
+ border: "1px solid var(--dsw-alias-border-l2)",
1006
+ background: "var(--dsw-alias-button-elevated-fill)",
1007
+ color: "var(--dsw-alias-label-primary)",
1008
+ cursor: "pointer",
1009
+ fontSize: "13px",
1010
+ font: "inherit",
1011
+ boxSizing: "border-box"
1012
+ },
1013
+ buttonDanger: { color: "var(--dsw-alias-state-error-primary,#e5484d)" },
1014
+ comboChip: {
1015
+ height: "28px",
1016
+ minWidth: "110px",
1017
+ padding: "0 10px",
1018
+ borderRadius: "7px",
1019
+ border: "1px solid var(--dsw-alias-border-l2)",
1020
+ background: "var(--dsw-alias-bg-layer-1)",
1021
+ color: "var(--dsw-alias-label-primary)",
1022
+ cursor: "pointer",
1023
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
1024
+ fontSize: "12px",
1025
+ display: "inline-flex",
1026
+ alignItems: "center",
1027
+ justifyContent: "center",
1028
+ boxSizing: "border-box"
1029
+ },
1030
+ comboChipRecording: {
1031
+ borderColor: "var(--dsw-alias-brand-primary,#4D6BFE)",
1032
+ color: "var(--dsw-alias-brand-primary,#4D6BFE)",
1033
+ background: "color-mix(in srgb, var(--dsw-alias-brand-primary,#4D6BFE) 10%, transparent)"
1034
+ },
1035
+ chipBtn: {
1036
+ height: "28px",
1037
+ padding: "0 12px",
1038
+ borderRadius: "7px",
1039
+ border: "1px solid var(--dsw-alias-border-l2)",
1040
+ background: "transparent",
1041
+ color: "var(--dsw-alias-label-secondary)",
1042
+ cursor: "pointer",
1043
+ fontSize: "12px",
1044
+ font: "inherit",
1045
+ boxSizing: "border-box"
1046
+ },
1047
+ chipBtnActive: {
1048
+ color: "var(--dsw-alias-brand-primary,#4D6BFE)",
1049
+ borderColor: "var(--dsw-alias-brand-primary,#4D6BFE)",
1050
+ background: "color-mix(in srgb, var(--dsw-alias-brand-primary,#4D6BFE) 10%, transparent)"
1051
+ },
1052
+ mono: { color: "var(--dsw-alias-label-secondary)", fontSize: "13px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" },
1053
+ swatch: {
1054
+ width: "24px",
1055
+ height: "24px",
1056
+ borderRadius: "50%",
1057
+ border: "1px solid rgba(128,128,128,0.4)",
1058
+ cursor: "pointer",
1059
+ padding: 0,
1060
+ boxSizing: "border-box",
1061
+ flex: "none"
1062
+ },
1063
+ swatchActive: { outline: "2px solid var(--dsw-alias-label-primary)", outlineOffset: "1px" },
1064
+ switchTrack: {
1065
+ width: "36px",
1066
+ height: "22px",
1067
+ borderRadius: "11px",
1068
+ border: "1px solid var(--dsw-alias-border-l2)",
1069
+ background: "var(--dsw-alias-bg-layer-1)",
1070
+ cursor: "pointer",
1071
+ position: "relative",
1072
+ padding: 0,
1073
+ flex: "none",
1074
+ transition: "background .15s ease, border-color .15s ease"
1075
+ },
1076
+ switchTrackOn: { background: "var(--dsw-alias-brand-primary,#4D6BFE)", borderColor: "var(--dsw-alias-brand-primary,#4D6BFE)" },
1077
+ switchKnob: {
1078
+ position: "absolute",
1079
+ top: "2px",
1080
+ left: "2px",
1081
+ width: "16px",
1082
+ height: "16px",
1083
+ borderRadius: "50%",
1084
+ background: "#fff",
1085
+ boxShadow: "0 1px 2px rgba(0,0,0,.25)",
1086
+ transition: "left .15s ease",
1087
+ display: "block"
1088
+ },
1089
+ switchKnobOn: { left: "16px" }
1090
+ };
1091
+
1092
+ /** Row store: one shared prefs snapshot for every row (the handle's
1093
+ * identity keys the instance, so all rows read the same state). */
1094
+ function createPrefsRowStore() {
1095
+ return runtime.defineStore({
1096
+ init: () => ({ prefs: normalizePrefs({}), revision: -1 }),
1097
+ actions: {
1098
+ sync: (d, prefs, revision) => {
1099
+ if (revision <= d.revision) return;
1100
+ d.prefs = prefs;
1101
+ d.revision = revision;
1102
+ }
1103
+ }
1104
+ });
1105
+ }
1106
+
1107
+ function SplitViewSection({ renderSlot }) {
1108
+ return React.createElement("div", { style: settingsStyles.section }, renderSlot("settings.splitView.item", {}));
1109
+ }
1110
+
1111
+ /** Shortcut row: one recording chip per action. Click a chip, press the
1112
+ * new combination (Esc cancels); reserved/duplicate combos are rejected
1113
+ * with an inline error instead of binding. */
1114
+ function ShortcutsRow({ t, useStore, setShortcut }) {
1115
+ const prefs = useStore((s) => s.prefs);
1116
+ const [recording, setRecording] = React.useState(null);
1117
+ const [error, setError] = React.useState(null);
1118
+ const prefsRef = React.useRef(prefs);
1119
+ prefsRef.current = prefs;
1120
+ React.useEffect(() => {
1121
+ if (recording === null) return;
1122
+ const onKey = (event) => {
1123
+ event.preventDefault();
1124
+ event.stopPropagation();
1125
+ const key = normEventKey(event);
1126
+ if (key === "escape") {
1127
+ setRecording(null);
1128
+ setError(null);
1129
+ return;
1130
+ }
1131
+ if (key === null || MODIFIER_KEYS.has(key)) return;
1132
+ if (!(event.metaKey || event.ctrlKey)) {
1133
+ setError(t("shortcuts.err.noMod"));
1134
+ return;
1135
+ }
1136
+ const parsed = { shift: event.shiftKey, alt: event.altKey, key };
1137
+ const errKey = validateRecorded(parsed, prefsRef.current.shortcuts, recording);
1138
+ if (errKey !== null) {
1139
+ setError(t("shortcuts." + errKey));
1140
+ return;
1141
+ }
1142
+ setShortcut(recording, comboToText(parsed));
1143
+ setRecording(null);
1144
+ setError(null);
1145
+ };
1146
+ window.addEventListener("keydown", onKey, true);
1147
+ return () => window.removeEventListener("keydown", onKey, true);
1148
+ }, [recording]);
1149
+ return React.createElement("div", { style: settingsStyles.group },
1150
+ React.createElement("div", { style: settingsStyles.title }, t("shortcuts.title")),
1151
+ ACTION_IDS.map((cmd) => React.createElement("div", { style: settingsStyles.kvRow, key: cmd },
1152
+ React.createElement("span", { style: Object.assign({}, settingsStyles.kvLabel, { width: "110px", flex: "none" }) }, t("shortcuts." + cmd)),
1153
+ React.createElement("button", {
1154
+ type: "button",
1155
+ style: recording === cmd
1156
+ ? Object.assign({}, settingsStyles.comboChip, settingsStyles.comboChipRecording)
1157
+ : settingsStyles.comboChip,
1158
+ title: recording === cmd ? t("shortcuts.recordHint") : undefined,
1159
+ "aria-pressed": recording === cmd ? "true" : undefined,
1160
+ onClick: () => {
1161
+ setError(null);
1162
+ setRecording(recording === cmd ? null : cmd);
1163
+ }
1164
+ }, recording === cmd ? t("shortcuts.record") : comboLabel(prefs.shortcuts[cmd]))
1165
+ )),
1166
+ React.createElement("div", { style: settingsStyles.actionRow },
1167
+ error !== null ? React.createElement("span", { style: settingsStyles.error }, error) : null,
1168
+ recording !== null ? React.createElement("span", { style: settingsStyles.hint }, t("shortcuts.recordHint")) : null
1169
+ ),
1170
+ React.createElement("div", { style: settingsStyles.hint }, t("shortcuts.hint"))
1171
+ );
1172
+ }
1173
+
1174
+ /** Focus highlight color row: "follow theme", preset swatches, custom picker. */
1175
+ function FocusColorRow({ t, useStore, setFocusColor }) {
1176
+ const prefs = useStore((s) => s.prefs);
1177
+ const color = prefs.focusColor;
1178
+ const pickerRef = React.useRef(null);
1179
+ const active = color === "theme" ? null : color;
1180
+ return React.createElement("div", { style: settingsStyles.group },
1181
+ React.createElement("div", { style: settingsStyles.title }, t("color.title")),
1182
+ React.createElement("div", { style: settingsStyles.actionRow },
1183
+ React.createElement("button", {
1184
+ type: "button",
1185
+ style: color === "theme"
1186
+ ? Object.assign({}, settingsStyles.chipBtn, settingsStyles.chipBtnActive)
1187
+ : settingsStyles.chipBtn,
1188
+ "aria-pressed": color === "theme" ? "true" : "false",
1189
+ onClick: () => setFocusColor("theme")
1190
+ }, t("color.theme")),
1191
+ FOCUS_COLOR_PRESETS.map((hex) => React.createElement("button", {
1192
+ key: hex,
1193
+ type: "button",
1194
+ title: hex,
1195
+ "aria-pressed": color === hex.toLowerCase() ? "true" : "false",
1196
+ style: Object.assign({}, settingsStyles.swatch, { background: hex }, color === hex.toLowerCase() ? settingsStyles.swatchActive : null),
1197
+ onClick: () => setFocusColor(hex)
1198
+ })),
1199
+ React.createElement("button", {
1200
+ type: "button",
1201
+ style: settingsStyles.button,
1202
+ onClick: () => { if (pickerRef.current) pickerRef.current.click(); }
1203
+ }, t("color.pick")),
1204
+ React.createElement("input", {
1205
+ ref: pickerRef,
1206
+ type: "color",
1207
+ value: active || "#4d6bfe",
1208
+ style: { display: "none" },
1209
+ onChange: (event) => setFocusColor(event.target.value)
1210
+ }),
1211
+ React.createElement("span", { style: settingsStyles.mono }, color === "theme" ? t("color.theme") : color)
1212
+ ),
1213
+ React.createElement("div", { style: settingsStyles.hint }, t("color.hint"))
1214
+ );
1215
+ }
1216
+
1217
+ /** Title bar visibility row. */
1218
+ function TitleBarRow({ t, useStore, setShowTitleBar }) {
1219
+ const prefs = useStore((s) => s.prefs);
1220
+ const show = prefs.showTitleBar;
1221
+ return React.createElement("div", { style: settingsStyles.group },
1222
+ React.createElement("div", { style: settingsStyles.title }, t("titlebar.title")),
1223
+ React.createElement("div", { style: settingsStyles.actionRow },
1224
+ React.createElement("button", {
1225
+ type: "button",
1226
+ role: "switch",
1227
+ "aria-checked": show ? "true" : "false",
1228
+ style: Object.assign({}, settingsStyles.switchTrack, show ? settingsStyles.switchTrackOn : null),
1229
+ onClick: () => setShowTitleBar(!show)
1230
+ }, React.createElement("span", {
1231
+ style: Object.assign({}, settingsStyles.switchKnob, show ? settingsStyles.switchKnobOn : null)
1232
+ })),
1233
+ React.createElement("span", { style: settingsStyles.kvLabel }, t("titlebar.show"))
1234
+ ),
1235
+ React.createElement("div", { style: settingsStyles.hint }, t("titlebar.hint"))
1236
+ );
1237
+ }
1238
+
1239
+ /** Restore-defaults row: one click (with confirm) back to factory state. */
1240
+ function ResetRow({ t, resetAll, useStore }) {
1241
+ const prefs = useStore((s) => s.prefs);
1242
+ const dirty = JSON.stringify(prefs) !== JSON.stringify(normalizePrefs({}));
1243
+ return React.createElement("div", { style: settingsStyles.group },
1244
+ React.createElement("div", { style: settingsStyles.title }, t("reset.title")),
1245
+ React.createElement("div", { style: settingsStyles.actionRow },
1246
+ React.createElement("button", {
1247
+ type: "button",
1248
+ style: Object.assign({}, settingsStyles.button, dirty ? settingsStyles.buttonDanger : { opacity: 0.55 }),
1249
+ onClick: () => {
1250
+ if (!dirty) return;
1251
+ let ok = true;
1252
+ try {
1253
+ ok = window.confirm(t("reset.confirm"));
1254
+ } catch {
1255
+ // confirm unavailable — reset directly
1256
+ }
1257
+ if (ok) resetAll();
1258
+ }
1259
+ }, t("reset.button"))
1260
+ ),
1261
+ React.createElement("div", { style: settingsStyles.hint }, t("reset.hint"))
1262
+ );
1263
+ }
1264
+
1265
+ /**
1266
+ * Register the settings section + rows.
1267
+ * @param ctx - client cordis context.
1268
+ * @param api - prefs channel: getPrefs(), subscribe(fn), savePrefs(patch).
1269
+ */
1270
+ function applySettings(ctx, api) {
1271
+ ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), "dsh-split-view: settings dictionaries");
1272
+
1273
+ const rowStore = createPrefsRowStore();
1274
+ const rowActions = new Set();
1275
+ let revision = 0;
1276
+ const syncRows = () => {
1277
+ revision += 1;
1278
+ const prefs = api.getPrefs();
1279
+ for (const actions of rowActions) actions.sync(prefs, revision);
1280
+ };
1281
+ ctx.effect(() => api.subscribe(syncRows), "dsh-split-view: settings row sync");
1282
+ const bindRow = (actions) => {
1283
+ rowActions.add(actions);
1284
+ syncRows();
1285
+ return {
1286
+ setShortcut: (cmd, combo) => {
1287
+ const patch = { shortcuts: {} };
1288
+ patch.shortcuts[cmd] = combo;
1289
+ api.savePrefs(patch);
1290
+ },
1291
+ setFocusColor: (value) => api.savePrefs({ focusColor: value }),
1292
+ setShowTitleBar: (on) => api.savePrefs({ showTitleBar: !!on }),
1293
+ resetAll: () => api.savePrefs({
1294
+ shortcuts: Object.assign({}, DEFAULT_SHORTCUTS),
1295
+ focusColor: DEFAULT_PREFS.focusColor,
1296
+ showTitleBar: DEFAULT_PREFS.showTitleBar
1297
+ })
1298
+ };
1299
+ };
1300
+
1301
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
1302
+ name: "settings.section",
1303
+ id: "split-view",
1304
+ order: 30,
1305
+ label: () => ctx.locale.bind(SETTINGS_NS)("nav"),
1306
+ locale: SETTINGS_NS,
1307
+ children: { "settings.splitView.item": {
1308
+ kind: "list",
1309
+ scope: "root"
1310
+ } }
1311
+ }, SplitViewSection));
1312
+
1313
+ ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
1314
+ name: "settings.splitView.item",
1315
+ id: "split-view-shortcuts",
1316
+ order: 10,
1317
+ store: rowStore,
1318
+ locale: SETTINGS_NS,
1319
+ inject: bindRow
1320
+ }, ShortcutsRow));
1321
+
1322
+ ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
1323
+ name: "settings.splitView.item",
1324
+ id: "split-view-focus-color",
1325
+ order: 20,
1326
+ store: rowStore,
1327
+ locale: SETTINGS_NS,
1328
+ inject: bindRow
1329
+ }, FocusColorRow));
1330
+
1331
+ ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
1332
+ name: "settings.splitView.item",
1333
+ id: "split-view-title-bar",
1334
+ order: 30,
1335
+ store: rowStore,
1336
+ locale: SETTINGS_NS,
1337
+ inject: bindRow
1338
+ }, TitleBarRow));
1339
+
1340
+ ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
1341
+ name: "settings.splitView.item",
1342
+ id: "split-view-reset",
1343
+ order: 90,
1344
+ store: rowStore,
1345
+ locale: SETTINGS_NS,
1346
+ inject: bindRow
1347
+ }, ResetRow));
1348
+
1349
+ // The shell's settings nav hard-codes the per-section icon by entry id
1350
+ // (models / agent-presets / plugins get distinct icons, everything else
1351
+ // falls back to the generic gear) and the settings.section contract
1352
+ // carries no icon option — so this section would draw the gear like any
1353
+ // unknown page. Patch the rendered nav instead: hide the gear on our
1354
+ // label-matched cell and append a split-view icon (additive only — the
1355
+ // React-managed nodes stay in place, so reconciliation never trips).
1356
+ // Runs per pane window: the top window shadows the AppFrame, so the
1357
+ // settings dialog only ever mounts here.
1358
+ ctx.effect(() => {
1359
+ const NAV_ICON = '<svg class="dsh-split-nav-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.75" width="12.5" height="10.5" rx="1.75"></rect><line x1="8" y1="2.75" x2="8" y2="13.25"></line><line x1="8" y1="8" x2="14.25" y2="8"></line></svg>';
1360
+ const labels = new Set([zh.nav, en.nav]);
1361
+ const style = document.createElement("style");
1362
+ style.setAttribute("data-plugin", "dsh-split-view-settings-icon");
1363
+ style.textContent = ".dsh-split-nav-icon{order:-1;flex:none;}";
1364
+ document.head.append(style);
1365
+ let queued = false;
1366
+ const patch = () => {
1367
+ queued = false;
1368
+ const cells = document.querySelectorAll('div[role="dialog"] nav button[type="button"]');
1369
+ for (const cell of cells) {
1370
+ const span = cell.querySelector("span");
1371
+ if (!span || !labels.has(span.textContent.trim())) continue;
1372
+ for (const gear of cell.querySelectorAll("svg:not(.dsh-split-nav-icon)")) gear.style.display = "none";
1373
+ if (cell.querySelector(".dsh-split-nav-icon") === null) cell.insertAdjacentHTML("beforeend", NAV_ICON);
1374
+ }
1375
+ };
1376
+ const schedule = () => {
1377
+ if (queued) return;
1378
+ queued = true;
1379
+ requestAnimationFrame(patch);
1380
+ };
1381
+ schedule();
1382
+ const observer = new MutationObserver(schedule);
1383
+ observer.observe(document.body, { childList: true, subtree: true });
1384
+ return () => {
1385
+ observer.disconnect();
1386
+ style.remove();
1387
+ };
1388
+ }, "dsh-split-view: settings nav icon");
1389
+ }
1390
+ //#endregion
1391
+
1392
+ //#region top window (the splitter)
1393
+ function useSnap(store, selector) {
1394
+ const sel = selector ?? ((value) => value);
1395
+ return React.useSyncExternalStore(store.subscribe, () => sel(store.getSnapshot()));
1396
+ }
1397
+
1398
+ function Divider({ dir, rect, usable, hidden, onNudge }) {
1399
+ const onPointerDown = (event) => {
1400
+ event.preventDefault();
1401
+ const el = event.currentTarget;
1402
+ if (usable <= 0) return;
1403
+ let last = dir === "h" ? event.clientX : event.clientY;
1404
+ // Direct tracking while dragging: kill the layout transitions so
1405
+ // panes follow the pointer 1:1 instead of lagging behind it.
1406
+ document.body.classList.add("dsh-split-dragging");
1407
+ try {
1408
+ el.setPointerCapture(event.pointerId);
1409
+ } catch {
1410
+ // capture is a nicety; dragging still works without it
1411
+ }
1412
+ const move = (ev) => {
1413
+ const pos = dir === "h" ? ev.clientX : ev.clientY;
1414
+ const delta = (pos - last) / usable;
1415
+ last = pos;
1416
+ if (delta !== 0) onNudge(delta);
1417
+ };
1418
+ const up = () => {
1419
+ el.removeEventListener("pointermove", move);
1420
+ el.removeEventListener("pointerup", up);
1421
+ el.removeEventListener("pointercancel", up);
1422
+ document.body.classList.remove("dsh-split-dragging");
1423
+ };
1424
+ el.addEventListener("pointermove", move);
1425
+ el.addEventListener("pointerup", up);
1426
+ el.addEventListener("pointercancel", up);
1427
+ };
1428
+ return React.createElement("div", {
1429
+ className: "dsh-split-divider " + (dir === "h" ? "dsh-split-divider-v" : "dsh-split-divider-h") + (hidden ? " dsh-split-divider-hidden" : ""),
1430
+ style: { left: rect.x, top: rect.y, width: rect.w, height: rect.h },
1431
+ onPointerDown
1432
+ });
1433
+ }
1434
+
1435
+ // Boot gate: each pane boot bursts ~40 bundle fetches plus 2 WebSocket
1436
+ // handshakes at the origin. Simultaneous pane boots stampede shared
1437
+ // connection resources and intermittently hang boots in constrained
1438
+ // environments (observed: random stuck panes in a busy Chrome profile,
1439
+ // never reproducible in a clean Chromium). At most MAX_CONCURRENT_BOOT
1440
+ // iframes may load at once; a Pane acquires a slot before mounting its
1441
+ // iframe src and releases it when the agent reports ready, the fallback
1442
+ // fires, or the pane unmounts.
1443
+ const MAX_CONCURRENT_BOOT = 2;
1444
+ const bootGate = (() => {
1445
+ let active = 0;
1446
+ const waiters = [];
1447
+ const grant = () => {
1448
+ while (active < MAX_CONCURRENT_BOOT && waiters.length > 0) {
1449
+ active++;
1450
+ waiters.shift()();
1451
+ }
1452
+ };
1453
+ return {
1454
+ acquire(cb) {
1455
+ if (active < MAX_CONCURRENT_BOOT) {
1456
+ active++;
1457
+ cb();
1458
+ } else waiters.push(cb);
1459
+ },
1460
+ release() {
1461
+ if (active > 0) active--;
1462
+ grant();
1463
+ }
1464
+ };
1465
+ })();
1466
+
1467
+ function Pane({ leaf, focused, ready, rect, hidden, dormant, onWake, base, title, info, maximized, actions, labels }) {
1468
+ const iframeRef = React.useRef(null);
1469
+ // The iframe URL is frozen per boot cycle: rebuilt when a boot starts
1470
+ // (so a woken pane re-enters the session it reported before
1471
+ // hibernation instead of re-running its birth URL), and never touched
1472
+ // while the iframe is live — a changed src would reload the pane.
1473
+ const srcRef = React.useRef(null);
1474
+ const srcCycleRef = React.useRef(-1);
1475
+ const [bootCycle, setBootCycle] = React.useState(0);
1476
+ const [booting, setBooting] = React.useState(false);
1477
+ const [fallback, setFallback] = React.useState(false);
1478
+ // Token-based gate release: each mount cycle owns one token, so an
1479
+ // unmount/remount cycle (React StrictMode double-invoke, key churn)
1480
+ // can neither leak a boot slot nor double-free one — the released
1481
+ // flag lives on the per-cycle token, and cleanup only frees a slot
1482
+ // the cycle actually consumed.
1483
+ const tokenRef = React.useRef(null);
1484
+ const releaseGate = () => {
1485
+ const token = tokenRef.current;
1486
+ if (token && token.acquired && !token.released) {
1487
+ token.released = true;
1488
+ bootGate.release();
1489
+ }
1490
+ };
1491
+ React.useEffect(() => {
1492
+ // Dormant panes hold no iframe: the effect's cleanup already freed
1493
+ // the boot slot, and unmounting the iframe closed its WebSockets —
1494
+ // that is exactly what hibernation is for.
1495
+ if (dormant) {
1496
+ setBooting(false);
1497
+ return;
1498
+ }
1499
+ setFallback(false);
1500
+ const token = { acquired: false, released: false };
1501
+ tokenRef.current = token;
1502
+ let cancelled = false;
1503
+ bootGate.acquire(() => {
1504
+ if (cancelled || token.released) {
1505
+ bootGate.release();
1506
+ return;
1507
+ }
1508
+ token.acquired = true;
1509
+ setBootCycle((c) => c + 1);
1510
+ setBooting(true);
1511
+ });
1512
+ return () => {
1513
+ cancelled = true;
1514
+ if (token.acquired && !token.released) {
1515
+ token.released = true;
1516
+ bootGate.release();
1517
+ }
1518
+ };
1519
+ }, [dormant]);
1520
+ // Ready report frees the gate immediately; otherwise the fallback
1521
+ // (started when the iframe actually begins loading) frees it and shows
1522
+ // the stale chip so a hung boot is visible and reloadable instead of
1523
+ // an unexplained spinner.
1524
+ React.useEffect(() => {
1525
+ if (ready) releaseGate();
1526
+ }, [ready]);
1527
+ React.useEffect(() => {
1528
+ if (!booting) return;
1529
+ const t = setTimeout(() => {
1530
+ setFallback(true);
1531
+ releaseGate();
1532
+ }, VEIL_TIMEOUT_MS);
1533
+ return () => clearTimeout(t);
1534
+ }, [booting]);
1535
+ // Pull the browser focus into the pane on boot/ready edges only.
1536
+ // Deliberately NOT keyed on `focused`: when the user clicks into a
1537
+ // not-yet-focused pane, the browser hands focus to the clicked
1538
+ // element (an input, say) by itself — a parent-side frame.focus()
1539
+ // fired on the same focused flip steals it back to the iframe
1540
+ // document body, so the first click never reaches the input and a
1541
+ // second click is needed. Layout actions that still need a focus
1542
+ // transfer (split/wake boots land here via booting/ready; the
1543
+ // close-survivor is handled explicitly by actions.close).
1544
+ React.useEffect(() => {
1545
+ if (!focused || dormant) return;
1546
+ const frame = iframeRef.current;
1547
+ if (!frame) return;
1548
+ try {
1549
+ frame.focus();
1550
+ if (frame.contentWindow) frame.contentWindow.focus();
1551
+ } catch {
1552
+ // focus is best-effort
1553
+ }
1554
+ }, [booting, ready]);
1555
+ const stale = fallback && !ready;
1556
+ const reloadPane = () => {
1557
+ const frame = iframeRef.current;
1558
+ if (!frame) return;
1559
+ setFallback(false);
1560
+ try {
1561
+ frame.contentWindow.location.reload();
1562
+ } catch {
1563
+ frame.src = srcRef.current;
1564
+ }
1565
+ };
1566
+ //#region title-bar interactions (maximize on dblclick, session-id copy, inline rename)
1567
+ const sessionId = typeof leaf.session === "string" && leaf.session.length > 0 ? leaf.session : null;
1568
+ // Renamable only once the session has content: blank sessions display
1569
+ // the workspace folder name as a placeholder title, which must not be
1570
+ // edited (it re-derives from the workspace).
1571
+ const canEdit = sessionId !== null && info !== null && info.blank === false;
1572
+ const realTitle = info !== null && typeof info.realTitle === "string" ? info.realTitle : null;
1573
+ const [editing, setEditing] = React.useState(false);
1574
+ const [draft, setDraft] = React.useState("");
1575
+ const [copied, setCopied] = React.useState(false);
1576
+ // Single-click opens the rename editor only after the double-click
1577
+ // window passes — a double-click on the title bar maximizes instead
1578
+ // and cancels the pending editor. Any follow-up mousedown anywhere
1579
+ // cancels it too, so "click title, then click into the pane" never
1580
+ // pops the editor 250ms later.
1581
+ const editTimer = React.useRef(null);
1582
+ const editCancelRef = React.useRef(null);
1583
+ const clearEditTimer = () => {
1584
+ if (editTimer.current !== null) {
1585
+ clearTimeout(editTimer.current);
1586
+ editTimer.current = null;
1587
+ }
1588
+ if (editCancelRef.current !== null) {
1589
+ window.removeEventListener("mousedown", editCancelRef.current, true);
1590
+ editCancelRef.current = null;
1591
+ }
1592
+ };
1593
+ React.useEffect(() => clearEditTimer, []);
1594
+ React.useEffect(() => {
1595
+ if (!copied) return;
1596
+ const t = setTimeout(() => setCopied(false), 1200);
1597
+ return () => clearTimeout(t);
1598
+ }, [copied]);
1599
+ // A pane can change identity (session replaced) or become un-editable
1600
+ // while the editor is open — drop the editor instead of renaming the
1601
+ // wrong session.
1602
+ React.useEffect(() => {
1603
+ if (editing && !canEdit) {
1604
+ clearEditTimer();
1605
+ setEditing(false);
1606
+ }
1607
+ }, [editing, canEdit]);
1608
+ const onTitleClick = () => {
1609
+ if (!canEdit || editing) return;
1610
+ clearEditTimer();
1611
+ editTimer.current = setTimeout(() => {
1612
+ editTimer.current = null;
1613
+ // Focus already moved into a pane iframe while waiting — the
1614
+ // original click was not an edit intent.
1615
+ const active = document.activeElement;
1616
+ if (active && active.tagName === "IFRAME") {
1617
+ clearEditTimer();
1618
+ return;
1619
+ }
1620
+ setDraft(realTitle ?? "");
1621
+ setEditing(true);
1622
+ }, 250);
1623
+ editCancelRef.current = () => clearEditTimer();
1624
+ window.addEventListener("mousedown", editCancelRef.current, true);
1625
+ };
1626
+ const exitEdit = () => {
1627
+ clearEditTimer();
1628
+ setEditing(false);
1629
+ };
1630
+ const commitEdit = () => {
1631
+ clearEditTimer();
1632
+ setEditing(false);
1633
+ const next = draft.trim();
1634
+ if (sessionId === null || next.length === 0) return;
1635
+ if (next === (realTitle ?? "")) return;
1636
+ actions.rename(leaf.id, next);
1637
+ };
1638
+ const onTitleBarDoubleClick = (event) => {
1639
+ clearEditTimer();
1640
+ const target = event.target;
1641
+ // Interactive islands keep their own double-click semantics: the
1642
+ // action buttons and the rename editor (word-selection in the input).
1643
+ if (target && target.closest && target.closest(".dsh-split-tb-actions, .dsh-split-title-edit")) return;
1644
+ actions.maximize(leaf.id);
1645
+ };
1646
+ const copySessionId = () => {
1647
+ if (sessionId === null) return;
1648
+ const text = "会话 id: " + sessionId + "\n会话标题: " + (typeof title === "string" ? title : "");
1649
+ const done = () => setCopied(true);
1650
+ const fallback = () => {
1651
+ try {
1652
+ const ta = document.createElement("textarea");
1653
+ ta.value = text;
1654
+ ta.setAttribute("readonly", "");
1655
+ ta.style.position = "fixed";
1656
+ ta.style.left = "-9999px";
1657
+ document.body.appendChild(ta);
1658
+ ta.select();
1659
+ document.execCommand("copy");
1660
+ ta.remove();
1661
+ done();
1662
+ } catch {
1663
+ // clipboard unavailable — no feedback
1664
+ }
1665
+ };
1666
+ try {
1667
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1668
+ navigator.clipboard.writeText(text).then(done, fallback);
1669
+ } else fallback();
1670
+ } catch {
1671
+ fallback();
1672
+ }
1673
+ };
1674
+ //#endregion
1675
+ // Geometry comes from the layout engine; hidden panes (maximized
1676
+ // away) keep their home rect and fade out, so restore is instant,
1677
+ // never reloads, and animates smoothly.
1678
+ const paneStyle = rect
1679
+ ? { left: rect.x, top: rect.y, width: rect.w, height: rect.h }
1680
+ : { display: "none" };
1681
+ if (booting && srcCycleRef.current !== bootCycle) {
1682
+ srcCycleRef.current = bootCycle;
1683
+ const q = new URLSearchParams();
1684
+ q.set("dshPane", leaf.id);
1685
+ if (leaf.session) q.set("dshSession", leaf.session);
1686
+ else if (leaf.workspace) q.set("dshWorkspace", leaf.workspace);
1687
+ else if (leaf.cwd) q.set("dshCwd", leaf.cwd);
1688
+ else q.set("dshNew", "1");
1689
+ srcRef.current = base + "/?" + q.toString();
1690
+ }
1691
+ return React.createElement("div", {
1692
+ className: "dsh-split-pane" + (focused ? " dsh-split-pane-focused" : "") + (hidden ? " dsh-split-pane-hidden" : ""),
1693
+ style: paneStyle,
1694
+ "data-leaf": leaf.id
1695
+ },
1696
+ React.createElement("div", {
1697
+ className: "dsh-split-titlebar" + (focused ? " dsh-split-titlebar-focused" : ""),
1698
+ onMouseDown: () => actions.focus(leaf.id),
1699
+ onDoubleClick: onTitleBarDoubleClick
1700
+ },
1701
+ editing
1702
+ ? React.createElement("span", { className: "dsh-split-title-edit" },
1703
+ React.createElement("input", {
1704
+ className: "dsh-split-title-input",
1705
+ value: draft,
1706
+ autoFocus: true,
1707
+ placeholder: "输入会话名称…",
1708
+ onFocus: (e) => e.target.select(),
1709
+ onChange: (e) => setDraft(e.target.value),
1710
+ onKeyDown: (e) => {
1711
+ if (e.key === "Enter") {
1712
+ e.preventDefault();
1713
+ commitEdit();
1714
+ } else if (e.key === "Escape") {
1715
+ e.preventDefault();
1716
+ exitEdit();
1717
+ }
1718
+ },
1719
+ onBlur: () => exitEdit()
1720
+ }),
1721
+ React.createElement("button", {
1722
+ className: "dsh-split-tb-btn dsh-split-title-confirm",
1723
+ title: "确认重命名(Enter)",
1724
+ // keep the input focused so its blur (cancel) never races the click
1725
+ onMouseDown: (e) => e.preventDefault(),
1726
+ onClick: (e) => {
1727
+ e.stopPropagation();
1728
+ commitEdit();
1729
+ }
1730
+ }, iconCheck()))
1731
+ : React.createElement("span", {
1732
+ className: "dsh-split-title" + (canEdit ? " dsh-split-title-editable" : ""),
1733
+ onClick: onTitleClick,
1734
+ title: canEdit ? (typeof title === "string" && title.length > 0 ? title + " · 点击重命名" : "点击重命名") : undefined
1735
+ }, title || (dormant ? "已休眠" : "…")),
1736
+ !editing && sessionId !== null ? React.createElement("button", {
1737
+ className: "dsh-split-tb-btn" + (copied ? " dsh-split-tb-btn-ok" : ""),
1738
+ title: copied ? "已复制会话信息" : "复制会话信息",
1739
+ onClick: (e) => {
1740
+ e.stopPropagation();
1741
+ copySessionId();
1742
+ }
1743
+ }, copied ? iconCheck() : iconCopy()) : null,
1744
+ React.createElement("span", { className: "dsh-split-tb-actions" },
1745
+ tbButton(iconSplitRight(), "向右分屏(" + labels.splitRight + ")", () => actions.split(leaf.id, "right")),
1746
+ tbButton(iconSplitDown(), "向下分屏(" + labels.splitDown + ")", () => actions.split(leaf.id, "down")),
1747
+ tbButton(maximized ? iconShrink() : iconExpand(), maximized ? "还原(" + labels.maximize + ")" : "放大(" + labels.maximize + ")", () => actions.maximize(leaf.id)),
1748
+ tbButton(iconRefresh(), dormant ? "唤醒该面板" : "刷新该面板", dormant ? onWake : reloadPane),
1749
+ tbButton(iconClose(), "关闭面板(" + labels.close + ")", () => actions.close(leaf.id), true))),
1750
+ React.createElement("div", { className: "dsh-split-pane-body" },
1751
+ dormant ? React.createElement("button", {
1752
+ className: "dsh-split-dormant",
1753
+ title: "该面板已休眠以释放连接资源,点击唤醒",
1754
+ onClick: onWake
1755
+ },
1756
+ React.createElement("div", { className: "dsh-split-dormant-icon" }, "😴"),
1757
+ React.createElement("div", null, "已休眠 · 点击唤醒")) : React.createElement(React.Fragment, null,
1758
+ !ready ? React.createElement("div", { className: "dsh-split-veil" },
1759
+ React.createElement("div", { className: "dsh-split-spinner" }),
1760
+ React.createElement("div", { className: "dsh-split-veil-hint" }, booting ? "启动中…" : "等待启动…")
1761
+ ) : null,
1762
+ stale ? React.createElement("button", {
1763
+ className: "dsh-split-stale",
1764
+ title: "该面板未在预期时间内就绪,点击重载",
1765
+ onClick: reloadPane
1766
+ }, "⚠ 未就绪 · 重载") : null,
1767
+ booting ? React.createElement("iframe", {
1768
+ ref: iframeRef,
1769
+ className: "dsh-split-iframe",
1770
+ src: srcRef.current,
1771
+ title: "session pane " + leaf.id
1772
+ }) : null)));
1773
+ }
1774
+ /** Small icon button for the per-pane title bar. */
1775
+ function tbButton(icon, title, onClick, danger) {
1776
+ return React.createElement("button", {
1777
+ className: "dsh-split-tb-btn" + (danger ? " dsh-split-tb-btn-danger" : ""),
1778
+ title,
1779
+ onClick
1780
+ }, icon);
1781
+ }
1782
+ /** Crisp stroke icons on a 16-grid, inheriting currentColor. */
1783
+ function svgIcon() {
1784
+ const children = Array.prototype.slice.call(arguments);
1785
+ return React.createElement.apply(React, ["svg", {
1786
+ width: "13",
1787
+ height: "13",
1788
+ viewBox: "0 0 16 16",
1789
+ fill: "none",
1790
+ stroke: "currentColor",
1791
+ strokeWidth: "1.4",
1792
+ strokeLinecap: "round",
1793
+ strokeLinejoin: "round",
1794
+ "aria-hidden": "true"
1795
+ }].concat(children));
1796
+ }
1797
+ const iconSplitRight = () => svgIcon(
1798
+ React.createElement("rect", { x: "1.75", y: "3", width: "12.5", height: "10", rx: "1.5" }),
1799
+ React.createElement("path", { d: "M8 3 V13" }),
1800
+ React.createElement("path", { d: "M9.9 8 H12.3" }),
1801
+ React.createElement("path", { d: "M11.2 6.9 L12.3 8 L11.2 9.1" })
1802
+ );
1803
+ const iconSplitDown = () => svgIcon(
1804
+ React.createElement("rect", { x: "1.75", y: "3", width: "12.5", height: "10", rx: "1.5" }),
1805
+ React.createElement("path", { d: "M1.75 8 H14.25" }),
1806
+ React.createElement("path", { d: "M8 9.3 V11.7" }),
1807
+ React.createElement("path", { d: "M6.9 10.6 L8 11.7 L9.1 10.6" })
1808
+ );
1809
+ /** Maximize = video-player fullscreen mark: diagonal two-way corner arrows. */
1810
+ const iconExpand = () => svgIcon(
1811
+ React.createElement("polyline", { points: "10 2 14 2 14 6" }),
1812
+ React.createElement("polyline", { points: "6 14 2 14 2 10" }),
1813
+ React.createElement("line", { x1: "14", y1: "2", x2: "9.3", y2: "6.7" }),
1814
+ React.createElement("line", { x1: "2", y1: "14", x2: "6.7", y2: "9.3" })
1815
+ );
1816
+ const iconShrink = () => svgIcon(
1817
+ React.createElement("polyline", { points: "2.7 9.3 6.7 9.3 6.7 13.3" }),
1818
+ React.createElement("polyline", { points: "13.3 6.7 9.3 6.7 9.3 2.7" }),
1819
+ React.createElement("line", { x1: "9.3", y1: "6.7", x2: "14", y2: "2" }),
1820
+ React.createElement("line", { x1: "2", y1: "14", x2: "6.7", y2: "9.3" })
1821
+ );
1822
+ const iconRefresh = () => svgIcon(
1823
+ React.createElement("polyline", { points: "15.3 2.7 15.3 6.7 11.3 6.7" }),
1824
+ React.createElement("path", { d: "M13.66 10a6 6 0 1 1-1.41-6.24L15.33 6.67" })
1825
+ );
1826
+ const iconClose = () => svgIcon(
1827
+ React.createElement("line", { x1: "4.5", y1: "4.5", x2: "11.5", y2: "11.5" }),
1828
+ React.createElement("line", { x1: "11.5", y1: "4.5", x2: "4.5", y2: "11.5" })
1829
+ );
1830
+ const iconCopy = () => svgIcon(
1831
+ React.createElement("rect", { x: "5.5", y: "5.5", width: "8.5", height: "8.5", rx: "1.5" }),
1832
+ React.createElement("path", { d: "M10.5 3.25 V3 A1.25 1.25 0 0 0 9.25 1.75 H3 A1.25 1.25 0 0 0 1.75 3 V9.25 A1.25 1.25 0 0 0 3 10.5 H3.25" })
1833
+ );
1834
+ const iconCheck = () => svgIcon(
1835
+ React.createElement("polyline", { points: "2.75 8.5 6.25 12 13.25 4.25" })
1836
+ );
1837
+
1838
+ //#region layout geometry
1839
+ const DIVIDER_PX = 5;
1840
+
1841
+ function collectLeaves(node, out) {
1842
+ if (node.kind === "leaf") {
1843
+ out.push(node);
1844
+ return out;
1845
+ }
1846
+ node.children.forEach((child) => collectLeaves(child, out));
1847
+ return out;
1848
+ }
1849
+
1850
+ /** The LRU live set with PER-ORIGIN caps, shared by the renderer and
1851
+ * the public service (both must agree on which panes are dormant).
1852
+ * Focused/maximized lead the candidate order so they win a slot first;
1853
+ * the rest follow focus recency, then tree order. A candidate whose
1854
+ * origin is already at cap is skipped (it hibernates), so no origin
1855
+ * ever exceeds the browser's live-client budget. */
1856
+ function computeLiveSet(snap, order, pool, caps, maxLive) {
1857
+ const allLeaves = collectLeaves(snap.tree, []);
1858
+ const leafById = new Map(allLeaves.map((l) => [l.id, l]));
1859
+ const candidates = [];
1860
+ if (snap.focused) candidates.push(snap.focused);
1861
+ if (snap.maximized) candidates.push(snap.maximized);
1862
+ for (let i = order.length - 1; i >= 0; i--) candidates.push(order[i]);
1863
+ for (const l of allLeaves) candidates.push(l.id);
1864
+ const set = new Set();
1865
+ const perOrigin = new Array(pool.length).fill(0);
1866
+ for (const id of candidates) {
1867
+ if (set.size >= maxLive) break;
1868
+ if (set.has(id)) continue;
1869
+ const leaf = leafById.get(id);
1870
+ if (!leaf) continue;
1871
+ const o = typeof leaf.origin === "number" && leaf.origin < pool.length ? leaf.origin : 0;
1872
+ if (perOrigin[o] >= caps[o]) continue;
1873
+ set.add(id);
1874
+ perOrigin[o]++;
1875
+ }
1876
+ return set;
1877
+ }
1878
+
1879
+ // Geometry engine: pixel rects for every leaf and divider from the layout
1880
+ // tree. Panes and dividers render as absolutely-positioned siblings under
1881
+ // one stable container, keyed by leaf id at a constant tree position, so
1882
+ // structural changes (split/close/maximize) only move rects — React never
1883
+ // remounts a Pane and its iframe survives: split/close resize instead of
1884
+ // reloading, and maximize restore is instant.
1885
+ function computeLayout(node, x, y, w, h, out) {
1886
+ if (w <= 0 || h <= 0) return;
1887
+ if (node.kind === "leaf") {
1888
+ out.panes.push({ id: node.id, x, y, w, h });
1889
+ return;
1890
+ }
1891
+ const horizontal = node.dir === "h";
1892
+ const span = horizontal ? w : h;
1893
+ const usable = Math.max(0, span - (node.children.length - 1) * DIVIDER_PX);
1894
+ let offset = 0;
1895
+ node.children.forEach((child, i) => {
1896
+ if (i > 0) {
1897
+ const pos = offset - DIVIDER_PX;
1898
+ out.dividers.push(horizontal
1899
+ ? { key: node.id + "-d" + i, dir: node.dir, branchId: node.id, index: i - 1, usable, x: x + pos, y, w: DIVIDER_PX, h }
1900
+ : { key: node.id + "-d" + i, dir: node.dir, branchId: node.id, index: i - 1, usable, x, y: y + pos, w, h: DIVIDER_PX });
1901
+ }
1902
+ const size = Math.max(0, usable * node.fractions[i]);
1903
+ if (horizontal) computeLayout(child, x + offset, y, size, h, out);
1904
+ else computeLayout(child, x, y + offset, w, size, out);
1905
+ offset += size + DIVIDER_PX;
1906
+ });
1907
+ }
1908
+ //#endregion
1909
+
1910
+ function SplitterRoot({ layout, panesReady, liveOrder, actions, handleCommand, pool, caps, maxLive, paneTitles, paneInfo, prefs }) {
1911
+ const snap = useSnap(layout);
1912
+ const readyMap = useSnap(panesReady);
1913
+ const order = useSnap(liveOrder);
1914
+ const titles = useSnap(paneTitles);
1915
+ const infoMap = useSnap(paneInfo);
1916
+ const prefsSnap = useSnap(prefs);
1917
+ // Preferences → render state: the focus color rides one CSS custom
1918
+ // property (the ::after outline and the title-bar tint read it);
1919
+ // "theme" delegates to the skin's brand token.
1920
+ const focusVar = prefsSnap.focusColor === "theme" ? "var(--dsw-alias-brand-primary,#4D6BFE)" : prefsSnap.focusColor;
1921
+ const labels = {
1922
+ splitRight: comboLabel(prefsSnap.shortcuts["split-right"]),
1923
+ splitDown: comboLabel(prefsSnap.shortcuts["split-down"]),
1924
+ close: comboLabel(prefsSnap.shortcuts["close"]),
1925
+ maximize: comboLabel(prefsSnap.shortcuts["maximize"])
1926
+ };
1927
+ const bodyRef = React.useRef(null);
1928
+ const [size, setSize] = React.useState(null);
1929
+ React.useEffect(() => {
1930
+ const el = bodyRef.current;
1931
+ if (!el) return;
1932
+ const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight });
1933
+ measure();
1934
+ const observer = new ResizeObserver(measure);
1935
+ observer.observe(el);
1936
+ return () => observer.disconnect();
1937
+ }, []);
1938
+ const maximizedTarget = snap.maximized ? findNode(snap.tree, snap.maximized) : null;
1939
+ // Geometry: home rects come from the full tree so panes hidden by
1940
+ // maximize keep their place (fade out/in instead of popping); the
1941
+ // maximized pane gets the whole body rect. Everything animates via
1942
+ // CSS transitions; divider drags disable them for direct tracking.
1943
+ const home = { panes: [], dividers: [] };
1944
+ if (size) computeLayout(snap.tree, 0, 0, size.w, size.h, home);
1945
+ const rectById = {};
1946
+ home.panes.forEach((p) => {
1947
+ rectById[p.id] = p;
1948
+ });
1949
+ const maximizedRect = size && maximizedTarget ? { x: 0, y: 0, w: size.w, h: size.h } : null;
1950
+ const allLeaves = collectLeaves(snap.tree, []);
1951
+ // Which panes are live vs hibernating (shared with the public
1952
+ // service — one computation, no drift).
1953
+ const liveSet = computeLiveSet(snap, order, pool, caps, maxLive);
1954
+ return React.createElement("div", {
1955
+ className: "dsh-split-root" + (prefsSnap.showTitleBar ? "" : " dsh-split-no-titlebar"),
1956
+ style: { "--dsh-split-focus": focusVar }
1957
+ },
1958
+ React.createElement("div", { className: "dsh-split-body", ref: bodyRef },
1959
+ allLeaves.map((leaf) => React.createElement(Pane, {
1960
+ key: leaf.id,
1961
+ leaf: leaf,
1962
+ focused: snap.focused === leaf.id,
1963
+ ready: readyMap[leaf.id] === true,
1964
+ rect: maximizedRect && snap.maximized === leaf.id ? maximizedRect : rectById[leaf.id] ?? null,
1965
+ hidden: maximizedTarget !== null && snap.maximized !== leaf.id,
1966
+ dormant: !liveSet.has(leaf.id),
1967
+ onWake: () => actions.wake(leaf.id),
1968
+ base: pool[typeof leaf.origin === "number" && leaf.origin < pool.length ? leaf.origin : 0],
1969
+ title: titles[leaf.id] ?? null,
1970
+ info: infoMap[leaf.id] ?? null,
1971
+ maximized: snap.maximized === leaf.id,
1972
+ actions: actions,
1973
+ labels: labels
1974
+ })),
1975
+ home.dividers.map((d) => React.createElement(Divider, {
1976
+ key: d.key,
1977
+ dir: d.dir,
1978
+ rect: d,
1979
+ usable: d.usable,
1980
+ hidden: maximizedTarget !== null,
1981
+ onNudge: (delta) => actions.nudge(d.branchId, d.index, delta)
1982
+ }))));
1983
+ }
1984
+
1985
+ const CSS = `
1986
+ .dsh-split-root{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base,#fff);}
1987
+ .dsh-split-body{flex:1 1 auto;min-height:0;position:relative;overflow:hidden;}
1988
+ .dsh-split-pane{position:absolute;display:flex;flex-direction:column;transition:left .18s cubic-bezier(.4,0,.2,1),top .18s cubic-bezier(.4,0,.2,1),width .18s cubic-bezier(.4,0,.2,1),height .18s cubic-bezier(.4,0,.2,1),opacity .16s ease;}
1989
+ .dsh-split-pane-hidden{opacity:0;pointer-events:none;}
1990
+ .dsh-split-pane-focused::after{content:"";position:absolute;inset:0;border:2px solid var(--dsh-split-focus,#4D6BFE);pointer-events:none;z-index:12;}
1991
+ .dsh-split-no-titlebar .dsh-split-titlebar{display:none;}
1992
+ .dsh-split-titlebar{flex:0 0 34px;display:flex;align-items:center;gap:8px;padding:0 6px 0 10px;background:linear-gradient(180deg,var(--dsw-alias-bg-layer-2,#f7f7f9),var(--dsw-alias-bg-layer-1,#f1f1f4));border-bottom:1px solid var(--dsw-alias-border-l1,#e2e2e8);user-select:none;cursor:default;min-width:0;}
1993
+ .dsh-split-titlebar-focused{background:linear-gradient(180deg,color-mix(in srgb,var(--dsh-split-focus,#4D6BFE) 13%,var(--dsw-alias-bg-layer-2,#f7f7f9)),color-mix(in srgb,var(--dsh-split-focus,#4D6BFE) 8%,var(--dsw-alias-bg-layer-1,#f1f1f4)));border-bottom-color:color-mix(in srgb,var(--dsh-split-focus,#4D6BFE) 35%,var(--dsw-alias-border-l1,#e2e2e8));}
1994
+ .dsh-split-title{flex:0 1 auto;min-width:88px;max-width:60%;font:500 12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.01em;color:var(--dsw-alias-label-secondary,#6b6b72);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
1995
+ .dsh-split-titlebar-focused .dsh-split-title{color:var(--dsh-split-focus,#4D6BFE);font-weight:600;}
1996
+ .dsh-split-title-editable{cursor:text;}
1997
+ .dsh-split-title-editable:hover{color:var(--dsw-alias-label-primary,#3c3c43);}
1998
+ .dsh-split-titlebar-focused .dsh-split-title-editable:hover{color:var(--dsh-split-focus,#4D6BFE);}
1999
+ .dsh-split-title-edit{flex:1 1 auto;min-width:0;position:relative;display:inline-flex;align-items:center;}
2000
+ .dsh-split-title-input{flex:1 1 auto;min-width:0;width:100%;height:22px;padding:0 26px 0 7px;border-radius:6px;border:1px solid var(--dsh-split-focus,#4D6BFE);background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#2b2b31);font:500 12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;box-sizing:border-box;}
2001
+ .dsh-split-tb-btn.dsh-split-title-confirm{position:absolute;right:2px;top:50%;transform:translateY(-50%);width:20px;height:20px;color:var(--dsh-split-focus,#4D6BFE);}
2002
+ .dsh-split-tb-btn.dsh-split-tb-btn-ok,.dsh-split-tb-btn.dsh-split-tb-btn-ok:hover{color:#2fa36b;background:color-mix(in srgb,#2fa36b 12%,transparent);}
2003
+ .dsh-split-tb-actions{flex:0 0 auto;margin-left:auto;display:flex;align-items:center;gap:1px;padding-left:6px;border-left:1px solid var(--dsw-alias-border-l1,#e5e5ea);}
2004
+ .dsh-split-tb-btn{border:0;background:transparent;width:24px;height:24px;border-radius:6px;color:var(--dsw-alias-label-secondary,#6b6b72);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;flex:0 0 auto;transition:background .12s ease,color .12s ease,transform .05s ease;}
2005
+ .dsh-split-tb-btn svg{display:block;}
2006
+ .dsh-split-tb-btn:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4D6BFE) 12%,transparent);color:var(--dsw-alias-brand-primary,#4D6BFE);}
2007
+ .dsh-split-tb-btn:active{transform:scale(.9);}
2008
+ .dsh-split-tb-btn-danger:hover{background:color-mix(in srgb,#e5484d 13%,transparent);color:#e5484d;}
2009
+ .dsh-split-pane-body{flex:1 1 auto;position:relative;min-height:0;min-width:0;}
2010
+ .dsh-split-iframe{position:absolute;inset:0;border:0;width:100%;height:100%;display:block;background:var(--dsw-alias-bg-base,#fff);}
2011
+ .dsh-split-divider{position:absolute;background:transparent;z-index:5;transition:left .18s cubic-bezier(.4,0,.2,1),top .18s cubic-bezier(.4,0,.2,1),width .18s cubic-bezier(.4,0,.2,1),height .18s cubic-bezier(.4,0,.2,1),opacity .16s ease,background .12s;}
2012
+ .dsh-split-divider-hidden{opacity:0;pointer-events:none;}
2013
+ .dsh-split-dragging .dsh-split-pane,.dsh-split-dragging .dsh-split-divider{transition:none;}
2014
+ .dsh-split-divider::before{content:"";position:absolute;pointer-events:none;}
2015
+ .dsh-split-divider-v{cursor:col-resize;}
2016
+ .dsh-split-divider-v::before{top:0;bottom:0;left:2px;width:1px;background:var(--dsw-alias-border-l1,#e5e5e5);}
2017
+ .dsh-split-divider-h{cursor:row-resize;}
2018
+ .dsh-split-divider-h::before{left:0;right:0;top:2px;height:1px;background:var(--dsw-alias-border-l1,#e5e5e5);}
2019
+ .dsh-split-divider:hover,.dsh-split-divider:active{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4098ff) 18%,transparent);}
2020
+ .dsh-split-divider:hover::before,.dsh-split-divider:active::before{background:var(--dsw-alias-brand-primary,#4098ff);}
2021
+ .dsh-split-stale{position:absolute;top:6px;right:6px;z-index:3;font:11px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:var(--dsw-alias-label-primary,#333);background:var(--dsw-alias-bg-layer-1,#fafafa);border:1px solid var(--dsw-alias-border-l2,#ccc);border-radius:6px;padding:3px 8px;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.15);}
2022
+ .dsh-split-stale:hover{border-color:var(--dsw-alias-brand-primary,#4098ff);color:var(--dsw-alias-brand-primary,#4098ff);}
2023
+ .dsh-split-dormant{position:absolute;inset:0;border:0;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;background:var(--dsw-alias-bg-layer-1,#fafafa);color:var(--dsw-alias-label-secondary,#888);font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;transition:background .15s,color .15s;}
2024
+ .dsh-split-dormant:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4D6BFE) 7%,var(--dsw-alias-bg-layer-1,#fafafa));color:var(--dsw-alias-brand-primary,#4D6BFE);}
2025
+ .dsh-split-dormant-icon{font-size:22px;line-height:1;}
2026
+ .dsh-split-veil{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;background:var(--dsw-alias-bg-base,#fff);z-index:2;}
2027
+ .dsh-split-veil-hint{font-size:12px;color:var(--dsw-alias-text-l3,#999);user-select:none;}
2028
+ .dsh-split-spinner{width:22px;height:22px;border-radius:50%;border:3px solid var(--dsw-alias-border-l2,#ddd);border-top-color:var(--dsw-alias-brand-primary,#4098ff);animation:dsh-split-spin .9s linear infinite;}
2029
+ @keyframes dsh-split-spin{to{transform:rotate(360deg)}}
2030
+ `;
2031
+
2032
+ function applyTop(ctx) {
2033
+ const layout = runtime.createSnapshotStore(defaultLayout(), { persist: { name: LAYOUT_KEY } });
2034
+ // User preferences — the top window is the source of truth (it draws
2035
+ // everything the prefs control). The store rehydrates from
2036
+ // localStorage; normalize immediately so a partial/corrupt shape from
2037
+ // an older version can never reach the renderer or the key handler.
2038
+ const prefs = runtime.createSnapshotStore(normalizePrefs({}), { persist: { name: PREFS_KEY } });
2039
+ prefs.set(normalizePrefs(prefs.getSnapshot()));
2040
+ // Every pane WindowProxy ever heard from (any message registers it)
2041
+ // receives prefs broadcasts; dead frames no-op inside the try/catch.
2042
+ // Entries accumulate across pane reload cycles — cheap, and a stale
2043
+ // WindowProxy just swallows its postMessage.
2044
+ const panePeers = new Map();
2045
+ // Pane address book for the public service's top→pane control
2046
+ // messages (open-session / reload): leafId -> {source, origin},
2047
+ // refreshed by every well-formed pane message the bus receives.
2048
+ const paneWindows = new Map();
2049
+ const postToPane = (leafId, data) => {
2050
+ const win = paneWindows.get(leafId);
2051
+ if (!win) return false;
2052
+ try {
2053
+ win.source.postMessage(Object.assign({ type: MSG_TYPE }, data), win.origin);
2054
+ return true;
2055
+ } catch {
2056
+ // pane went away mid-flight — the layout-side update stands
2057
+ return false;
2058
+ }
2059
+ };
2060
+ prefs.subscribe(() => {
2061
+ const snap = prefs.getSnapshot();
2062
+ for (const peer of panePeers) {
2063
+ try {
2064
+ peer[0].postMessage({ type: MSG_TYPE, kind: "prefs-push", prefs: snap }, peer[1]);
2065
+ } catch {
2066
+ // pane is gone — nothing to sync
2067
+ }
2068
+ }
2069
+ });
2070
+ // Settings rows registered at the top talk straight to this store
2071
+ // (the top's settings modal is unreachable today — no AppFrame — but
2072
+ // keep the path honest in case the root shadow ever changes).
2073
+ applySettings(ctx, {
2074
+ getPrefs: () => prefs.getSnapshot(),
2075
+ subscribe: (fn) => prefs.subscribe(fn),
2076
+ savePrefs: (patch) => {
2077
+ const next = patchPrefs(prefs.getSnapshot(), patch);
2078
+ if (next) prefs.set(next);
2079
+ }
2080
+ });
2081
+ const panesReady = runtime.createSnapshotStore({});
2082
+ // Origin pool: panes alternate between the two loopback origins so
2083
+ // more clients fit inside the browser's per-origin budget.
2084
+ const pool = originPool();
2085
+ const caps = pool.length > 1 ? ORIGIN_CAPS_MULTI : ORIGIN_CAPS_SINGLE;
2086
+ const maxLive = pool.length > 1 ? MAX_LIVE_PANES_MULTI : MAX_LIVE_PANES_SINGLE;
2087
+ const allowedOrigins = new Set(pool);
2088
+ // Per-pane session titles reported by the pane agents (transient UI
2089
+ // state — never persisted with the layout).
2090
+ const paneTitles = runtime.createSnapshotStore({});
2091
+ // Per-pane session facts reported alongside the title: blank (empty-log
2092
+ // sessions show the folder name and are not renamable) and realTitle
2093
+ // (the durable title that seeds the rename editor).
2094
+ const paneInfo = runtime.createSnapshotStore({});
2095
+ // leafId -> {until, title}. While set, inbound pane title reports are
2096
+ // ignored so a stale in-flight report cannot overwrite a rename the top
2097
+ // window just applied; a report matching the rename (or the deadline)
2098
+ // lifts the suppression.
2099
+ const titleSuppress = new Map();
2100
+ // Focus-recency order for the LRU live set (most recent last).
2101
+ const liveOrder = runtime.createSnapshotStore([]);
2102
+ const touchLive = (leafId) => liveOrder.update((d) => {
2103
+ const i = d.indexOf(leafId);
2104
+ if (i >= 0) d.splice(i, 1);
2105
+ d.push(leafId);
2106
+ });
2107
+ /** Transfer the browser focus to a pane's iframe (DOM lookup). Only
2108
+ * for layout actions the top window itself initiates — never for user
2109
+ * clicks, which the browser focuses naturally. */
2110
+ const focusPane = (leafId) => {
2111
+ if (!leafId) return;
2112
+ const frame = document.querySelector('.dsh-split-pane[data-leaf="' + leafId + '"] iframe');
2113
+ if (!frame) return;
2114
+ try {
2115
+ frame.focus();
2116
+ if (frame.contentWindow) frame.contentWindow.focus();
2117
+ } catch {
2118
+ // focus is best-effort
2119
+ }
2120
+ };
2121
+ const actions = {
2122
+ split: (leafId, dir) => {
2123
+ // The new pane gets a FRESH session in the split pane's workspace
2124
+ // (or cwd): resolve the source's binding from the live list.
2125
+ const snapNow = layout.getSnapshot();
2126
+ const leaf = findNode(snapNow.tree, leafId);
2127
+ let inherit = null;
2128
+ if (leaf && leaf.kind === "leaf" && leaf.session) {
2129
+ const summary = ctx.sessions.list.getSnapshot().byId[leaf.session];
2130
+ if (summary && summary.cwd) inherit = { cwd: summary.cwd };
2131
+ const workspaces = ctx.get("workspaces");
2132
+ if (workspaces) {
2133
+ try {
2134
+ const items = workspaces.list.getSnapshot().items;
2135
+ const ws = items.find((w) => Array.isArray(w.sessionIds) && w.sessionIds.includes(leaf.session));
2136
+ if (ws && ws.workspaceId) inherit = { workspace: ws.workspaceId };
2137
+ } catch {
2138
+ // cwd inheritance is the fallback anyway
2139
+ }
2140
+ }
2141
+ }
2142
+ layout.update((d) => {
2143
+ splitLeaf(d, leafId, dir, inherit);
2144
+ // Give the new pane the least-loaded origin so the live
2145
+ // clients spread across the pool's per-origin budgets.
2146
+ const newLeaf = findNode(d.tree, d.focused);
2147
+ if (newLeaf && newLeaf.kind === "leaf") newLeaf.origin = pickOrigin(d, pool, caps);
2148
+ });
2149
+ touchLive(layout.getSnapshot().focused);
2150
+ },
2151
+ close: (leafId) => {
2152
+ // The survivor only needs a programmatic focus transfer when
2153
+ // the closed pane was the focused one (its iframe just went
2154
+ // away, taking the browser focus with it).
2155
+ const wasFocused = layout.getSnapshot().focused === leafId;
2156
+ layout.update((d) => closeLeaf(d, leafId));
2157
+ liveOrder.update((d) => {
2158
+ const i = d.indexOf(leafId);
2159
+ if (i >= 0) d.splice(i, 1);
2160
+ });
2161
+ if (wasFocused) focusPane(layout.getSnapshot().focused);
2162
+ },
2163
+ wake: (leafId) => {
2164
+ panesReady.update((d) => {
2165
+ d[leafId] = false;
2166
+ });
2167
+ touchLive(leafId);
2168
+ layout.update((d) => {
2169
+ d.focused = leafId;
2170
+ });
2171
+ },
2172
+ focus: (leafId) => layout.update((d) => {
2173
+ d.focused = leafId;
2174
+ }),
2175
+ session: (leafId, sessionId) => layout.update((d) => setLeafSession(d, leafId, sessionId)),
2176
+ nudge: (branchId, index, delta) => layout.update((d) => nudgeFractions(d, branchId, index, delta)),
2177
+ maximize: (leafId) => layout.update((d) => {
2178
+ d.maximized = d.maximized === leafId ? null : leafId;
2179
+ }),
2180
+ // Rename the pane's session from the title bar. The top window runs a
2181
+ // full client of its own, so it resolves the session binding directly
2182
+ // and issues the rename RPC; the pane re-reports the new title once
2183
+ // the backend settles it. Blank sessions (workspace-folder placeholder
2184
+ // title) are refused here as well as in the editor UI.
2185
+ rename: (leafId, nextTitle) => {
2186
+ const snapNow = layout.getSnapshot();
2187
+ const leaf = findNode(snapNow.tree, leafId);
2188
+ if (!leaf || leaf.kind !== "leaf" || !leaf.session) return;
2189
+ const info = paneInfo.getSnapshot()[leafId];
2190
+ if (info && info.blank) return;
2191
+ let session = null;
2192
+ try {
2193
+ const binding = ctx.sessions.binding(leaf.session);
2194
+ session = binding && binding.session;
2195
+ } catch {
2196
+ session = null;
2197
+ }
2198
+ if (!session) return;
2199
+ const prevTitle = paneTitles.getSnapshot()[leafId] ?? null;
2200
+ paneTitles.update((d) => {
2201
+ d[leafId] = nextTitle;
2202
+ });
2203
+ titleSuppress.set(leafId, { until: Date.now() + 5000, title: nextTitle });
2204
+ Promise.resolve(session.rename(nextTitle)).then((result) => {
2205
+ if (!result || result.ok !== true) {
2206
+ titleSuppress.delete(leafId);
2207
+ paneTitles.update((d) => {
2208
+ d[leafId] = prevTitle;
2209
+ });
2210
+ }
2211
+ }).catch(() => {
2212
+ titleSuppress.delete(leafId);
2213
+ paneTitles.update((d) => {
2214
+ d[leafId] = prevTitle;
2215
+ });
2216
+ });
2217
+ }
2218
+ };
2219
+ // ── Public service ────────────────────────────────────────────────
2220
+ // Other plugins reach this through `inject: ["dsh-split-view"]` or
2221
+ // `ctx.get("dsh-split-view")`. It is provided ONLY in the top window
2222
+ // (panes run their own cordis trees), and shaped like this:
2223
+ //
2224
+ // - Pane addressing is one optional id everywhere: omitted/null
2225
+ // targets the focused pane; unknown ids throw. Payload verbs take
2226
+ // the target through `{pane}`; target-only verbs take the paneId
2227
+ // positionally.
2228
+ // - Session verbs resolve the pane's session and execute it in the
2229
+ // top window's own client (sessions.binding → SessionFace). The
2230
+ // session is backend-hosted — the pane is just its display — so
2231
+ // the pane picks the turn up through the host stream, dormant
2232
+ // panes included. Only "which session a pane shows" (open /
2233
+ // newSession / reload) crosses the postMessage boundary.
2234
+ // - Mutators are async, queries are sync snapshots, subscribe()
2235
+ // streams the merged pane state, and failures throw
2236
+ // Error("dsh-split-view: …").
2237
+ const fail = (message) => {
2238
+ throw new Error("dsh-split-view: " + message);
2239
+ };
2240
+ /** Resolve an optional pane target: absent → focused pane; explicit
2241
+ * ids must name a leaf and are validated loud. */
2242
+ const resolvePane = (paneId) => {
2243
+ const snap = layout.getSnapshot();
2244
+ const id = paneId === void 0 || paneId === null ? snap.focused : paneId;
2245
+ const node = typeof id === "string" && id.length > 0 ? findNode(snap.tree, id) : null;
2246
+ if (!node || node.kind !== "leaf") fail("no such pane: " + String(id));
2247
+ return id;
2248
+ };
2249
+ const paneOfOptions = (options) => (options && typeof options === "object" ? options.pane : void 0);
2250
+ /** The session the pane currently shows (its last report), or a loud
2251
+ * error when the pane has none yet. */
2252
+ const paneSession = (leafId) => {
2253
+ const leaf = findNode(layout.getSnapshot().tree, leafId);
2254
+ const sid = leaf && typeof leaf.session === "string" && leaf.session.length > 0 ? leaf.session : null;
2255
+ if (sid === null) fail("the pane has no session yet (it may still be booting — pass prompt to split(), or retry once the pane reports its session)");
2256
+ return sid;
2257
+ };
2258
+ const bindSession = (sessionId) => {
2259
+ let binding = null;
2260
+ try {
2261
+ binding = ctx.sessions.binding(sessionId);
2262
+ } catch {
2263
+ binding = null;
2264
+ }
2265
+ if (!binding || !binding.session) fail("cannot bind session " + sessionId);
2266
+ return binding.session;
2267
+ };
2268
+ const bindPaneSession = (leafId) => {
2269
+ const sid = paneSession(leafId);
2270
+ return { sid, session: bindSession(sid) };
2271
+ };
2272
+ const liveSetOf = () => computeLiveSet(layout.getSnapshot(), liveOrder.getSnapshot(), pool, caps, maxLive);
2273
+ /** Public pane descriptors: one entry per leaf, render-state included. */
2274
+ const describePanes = () => {
2275
+ const snap = layout.getSnapshot();
2276
+ const live = computeLiveSet(snap, liveOrder.getSnapshot(), pool, caps, maxLive);
2277
+ const titles = paneTitles.getSnapshot();
2278
+ const readyMap = panesReady.getSnapshot();
2279
+ return collectLeaves(snap.tree, []).map((leaf) => ({
2280
+ id: leaf.id,
2281
+ session: typeof leaf.session === "string" ? leaf.session : null,
2282
+ title: titles[leaf.id] ?? null,
2283
+ focused: snap.focused === leaf.id,
2284
+ maximized: snap.maximized === leaf.id,
2285
+ ready: readyMap[leaf.id] === true,
2286
+ dormant: !live.has(leaf.id)
2287
+ }));
2288
+ };
2289
+ const assertRpc = (result, what) => {
2290
+ if (result && result.ok === false) {
2291
+ const e = result.error;
2292
+ fail(what + " failed: " + (e ? (e.message || e.code) : "unknown"));
2293
+ }
2294
+ return result;
2295
+ };
2296
+ /** Run the mode/model/title/prompt configuration pass on an already
2297
+ * created session (the fixed order split() has always used).
2298
+ * `strict` decides whether an unresolvable binding throws or is
2299
+ * skipped — split() keeps its historic tolerance, newSession() does
2300
+ * not. */
2301
+ const configureSession = async (sessionId, opts, strict) => {
2302
+ let binding = null;
2303
+ try {
2304
+ binding = ctx.sessions.binding(sessionId);
2305
+ } catch {
2306
+ binding = null;
2307
+ }
2308
+ const session = binding && binding.session;
2309
+ if (!session) {
2310
+ if (strict) fail("cannot bind session " + sessionId);
2311
+ return;
2312
+ }
2313
+ if (opts.mode !== null) {
2314
+ const res = await session.api.agentPresets.select({ sessionId, agentPreset: opts.mode });
2315
+ if (res && res.result && res.result.ok === false) {
2316
+ const e = res.result.error;
2317
+ fail("preset select failed: " + (e ? (e.message || e.code) : "unknown"));
2318
+ }
2319
+ }
2320
+ if (opts.model !== null) {
2321
+ const res = await session.api.sessions.selectModel({
2322
+ sessionId,
2323
+ provider: opts.model.provider,
2324
+ model: opts.model.model,
2325
+ ...(opts.model.reasoningEffort !== void 0 ? { reasoningEffort: opts.model.reasoningEffort } : {})
2326
+ });
2327
+ if (res && res.result && res.result.ok === false) {
2328
+ const e = res.result.error;
2329
+ fail("model select failed: " + (e ? (e.message || e.code) : "unknown"));
2330
+ }
2331
+ }
2332
+ if (opts.title !== null) await session.rename(opts.title);
2333
+ if (opts.prompt !== null) assertRpc(await session.prompt([{ type: "text", text: opts.prompt }], "queue"), "prompt");
2334
+ };
2335
+ const createConfiguredSession = async (opts, strict) => {
2336
+ const createOpts = opts.workspaceId !== null ? { workspaceId: opts.workspaceId } : opts.cwd !== null ? { cwd: opts.cwd } : {};
2337
+ const sessionId = await ctx.sessions.create(createOpts);
2338
+ await configureSession(sessionId, opts, strict);
2339
+ return sessionId;
2340
+ };
2341
+ /** Point a pane at a session: update the layout seed (persistence +
2342
+ * dormant-boot truth), then swap the shown session live when the
2343
+ * pane is up, or pull it into the live set when it is hibernating. */
2344
+ const repointPane = (leafId, sessionId) => {
2345
+ layout.update((d) => {
2346
+ const leaf = findNode(d.tree, leafId);
2347
+ if (leaf && leaf.kind === "leaf") {
2348
+ leaf.session = sessionId;
2349
+ delete leaf.workspace;
2350
+ delete leaf.cwd;
2351
+ }
2352
+ });
2353
+ if (liveSetOf().has(leafId)) postToPane(leafId, { kind: "open-session", sessionId });
2354
+ else touchLive(leafId);
2355
+ };
2356
+ /**
2357
+ * Split a pane and open the new pane in a fresh session (or pin an
2358
+ * existing one via options.session). Every option is optional and
2359
+ * defaults to a plain new session in the default working directory.
2360
+ * @param {object} [options]
2361
+ * @param {string} [options.pane] pane to split from (defaults to the focused pane)
2362
+ * @param {"right"|"down"} [options.direction="right"]
2363
+ * @param {string} [options.prompt] first user message (sent as turn one)
2364
+ * @param {string} [options.title] session title (renamed after create)
2365
+ * @param {string|{provider:string,model:string,reasoningEffort?:string}} [options.model]
2366
+ * @param {string} [options.workspaceId]
2367
+ * @param {string} [options.cwd]
2368
+ * @param {string} [options.mode] agent-preset id
2369
+ * @param {string} [options.session] pin an EXISTING session into the new pane (skips create/configure)
2370
+ * @returns {Promise<{paneId: string|null, leafId: string|null, sessionId: string|null}>}
2371
+ */
2372
+ const splitImpl = async (options) => {
2373
+ const opts = normalizeSplitOptions(options);
2374
+ const snap = layout.getSnapshot();
2375
+ let target;
2376
+ if (opts.pane !== null) {
2377
+ const anchor = findNode(snap.tree, opts.pane);
2378
+ if (!anchor || anchor.kind !== "leaf") fail("no such pane: " + opts.pane);
2379
+ target = opts.pane;
2380
+ } else {
2381
+ target = snap.focused;
2382
+ }
2383
+ if (!target) fail("no focused pane to split");
2384
+ let sessionId = null;
2385
+ let seed = null;
2386
+ if (opts.session !== null) {
2387
+ // Pin an existing session into the new pane (e.g. an
2388
+ // agent-teams member child session): no create/configure,
2389
+ // the pane seeds straight from the given session id.
2390
+ sessionId = opts.session;
2391
+ seed = { session: opts.session };
2392
+ }
2393
+ const wantsCustom = sessionId === null && (opts.workspaceId !== null || opts.cwd !== null || opts.prompt !== null || opts.title !== null || opts.model !== null || opts.mode !== null);
2394
+ if (wantsCustom) {
2395
+ sessionId = await createConfiguredSession(opts, false);
2396
+ seed = { session: sessionId };
2397
+ }
2398
+ let newLeafId = null;
2399
+ layout.update((d) => {
2400
+ splitLeaf(d, target, opts.direction, seed);
2401
+ const newLeaf = findNode(d.tree, d.focused);
2402
+ if (newLeaf && newLeaf.kind === "leaf") {
2403
+ newLeaf.origin = pickOrigin(d, pool, caps);
2404
+ newLeafId = newLeaf.id;
2405
+ }
2406
+ });
2407
+ touchLive(layout.getSnapshot().focused);
2408
+ // `leafId` keeps the pre-rename field for existing callers.
2409
+ return { paneId: newLeafId, leafId: newLeafId, sessionId };
2410
+ };
2411
+ const api = {
2412
+ /** Service-surface generation: feature-detect new members with this. */
2413
+ version: 2,
2414
+
2415
+ // ── Introspection (sync snapshots; tolerant — unknown ids → null) ──
2416
+ /** Every pane, tree order: {id, session, title, focused, maximized, ready, dormant}. */
2417
+ panes: () => describePanes(),
2418
+ /** One pane's descriptor (focused pane when omitted), or null. */
2419
+ pane: (paneId) => {
2420
+ const snap = layout.getSnapshot();
2421
+ const id = paneId === void 0 || paneId === null ? snap.focused : paneId;
2422
+ if (typeof id !== "string" || !findNode(snap.tree, id)) return null;
2423
+ return describePanes().find((p) => p.id === id) ?? null;
2424
+ },
2425
+ /** The focused pane id, or null. */
2426
+ focused: () => layout.getSnapshot().focused ?? null,
2427
+ /** Deep copy of the raw layout state {tree, focused, maximized} —
2428
+ * the input shape applyLayout() accepts. */
2429
+ layout: () => JSON.parse(JSON.stringify(layout.getSnapshot())),
2430
+
2431
+ // ── Pane lifecycle (paneId optional → focused pane) ──
2432
+ split: splitImpl,
2433
+ /** Close a pane; the adjacent sibling inherits the space and focus. */
2434
+ close: async (paneId) => {
2435
+ const id = resolvePane(paneId);
2436
+ actions.close(id);
2437
+ return { closed: id, focused: layout.getSnapshot().focused ?? null };
2438
+ },
2439
+ /** Focus a pane (layout focus + browser focus transfer). */
2440
+ focus: async (paneId) => {
2441
+ const id = resolvePane(paneId);
2442
+ actions.focus(id);
2443
+ touchLive(id);
2444
+ focusPane(id);
2445
+ return { focused: id };
2446
+ },
2447
+ /** Maximize a pane: maximize(paneId?, true/false) sets explicitly,
2448
+ * maximize(paneId?) toggles. */
2449
+ maximize: async (paneId, on) => {
2450
+ const id = resolvePane(paneId);
2451
+ layout.update((d) => {
2452
+ if (on === true) d.maximized = id;
2453
+ else if (on === false) d.maximized = null;
2454
+ else d.maximized = d.maximized === id ? null : id;
2455
+ });
2456
+ return { maximized: layout.getSnapshot().maximized ?? null };
2457
+ },
2458
+ /** Restore from maximize. */
2459
+ restore: async () => {
2460
+ layout.update((d) => {
2461
+ d.maximized = null;
2462
+ });
2463
+ return { maximized: null };
2464
+ },
2465
+ /** Wake a hibernating pane (boots its iframe back into the live set). */
2466
+ wake: async (paneId) => {
2467
+ const id = resolvePane(paneId);
2468
+ actions.wake(id);
2469
+ return { paneId: id };
2470
+ },
2471
+ /** Reload a pane's client (live panes reload in place; hibernating
2472
+ * ones wake, which boots them). */
2473
+ reload: async (paneId) => {
2474
+ const id = resolvePane(paneId);
2475
+ if (!liveSetOf().has(id) || !postToPane(id, { kind: "reload" })) actions.wake(id);
2476
+ return { paneId: id };
2477
+ },
2478
+ /** Replace the whole layout tree (validated): the escape hatch
2479
+ * for moves, resizes and rearrangements the verb API does not
2480
+ * cover. Reused leaf ids keep their panes (iframes survive);
2481
+ * new ids boot fresh panes. See layout() for the shape. */
2482
+ applyLayout: async (tree) => {
2483
+ const norm = normalizeLayoutTree(tree);
2484
+ layout.update((d) => {
2485
+ d.tree = norm;
2486
+ d.focused = firstLeafId(norm);
2487
+ d.maximized = null;
2488
+ for (const leaf of collectLeaves(d.tree, [])) {
2489
+ if (!(typeof leaf.origin === "number" && leaf.origin < pool.length)) leaf.origin = pickOrigin(d, pool, caps);
2490
+ }
2491
+ });
2492
+ return { panes: describePanes() };
2493
+ },
2494
+
2495
+ // ── Pane × session ──
2496
+ /**
2497
+ * Show an existing session in a pane — live swap when the pane is
2498
+ * up, boot seed when it is hibernating.
2499
+ * @param {string} sessionId
2500
+ * @param {{pane?: string}} [options]
2501
+ */
2502
+ open: async (sessionId, options) => {
2503
+ if (typeof sessionId !== "string" || sessionId.length === 0) fail("open needs a session id");
2504
+ const id = resolvePane(paneOfOptions(options));
2505
+ const listSnap = ctx.sessions.list.getSnapshot();
2506
+ // Skip validation while the list baseline is in flight: the
2507
+ // pane agent gates its seed the same way and falls back
2508
+ // gracefully.
2509
+ if (listSnap.phase !== "pending" && !listSnap.byId[sessionId]) fail("unknown session: " + sessionId);
2510
+ repointPane(id, sessionId);
2511
+ return { paneId: id, sessionId };
2512
+ },
2513
+ /**
2514
+ * Create a fresh session and show it in a pane (replaces the
2515
+ * pane's current session). Same session options as split().
2516
+ * @param {{pane?: string, workspaceId?: string, cwd?: string, title?: string, model?: string|object, mode?: string, prompt?: string}} [options]
2517
+ */
2518
+ newSession: async (options) => {
2519
+ const o = options && typeof options === "object" ? options : {};
2520
+ const id = resolvePane(o.pane);
2521
+ const opts = normalizeSplitOptions(o);
2522
+ const sessionId = await createConfiguredSession(opts, true);
2523
+ repointPane(id, sessionId);
2524
+ return { paneId: id, sessionId };
2525
+ },
2526
+ /**
2527
+ * Send a user message to the session a pane shows. Executes in
2528
+ * the top window's client — the pane renders the turn through
2529
+ * the host stream, whether live or hibernating.
2530
+ * @param {string|Array<{type: string, text?: string}>} content text or prompt content parts
2531
+ * @param {{pane?: string, mode?: "queue"|"steer"}} [options] queue (default) appends a turn; steer interrupts the running one
2532
+ */
2533
+ send: async (content, options) => {
2534
+ const id = resolvePane(paneOfOptions(options));
2535
+ const blocks = typeof content === "string"
2536
+ ? (content.length > 0 ? [{ type: "text", text: content }] : null)
2537
+ : (Array.isArray(content) && content.length > 0 ? content : null);
2538
+ if (blocks === null) fail("send needs a non-empty string or content-part array");
2539
+ const mode = options && options.mode === "steer" ? "steer" : "queue";
2540
+ const { sid, session } = bindPaneSession(id);
2541
+ assertRpc(await session.prompt(blocks, mode), "prompt");
2542
+ return { paneId: id, sessionId: sid };
2543
+ },
2544
+ /** Rename the session a pane shows (blank sessions are refused,
2545
+ * like the title-bar editor). */
2546
+ rename: async (title, options) => {
2547
+ const id = resolvePane(paneOfOptions(options));
2548
+ const next = typeof title === "string" ? title.trim() : "";
2549
+ if (next.length === 0) fail("rename needs a non-empty title");
2550
+ const sid = paneSession(id);
2551
+ const info = paneInfo.getSnapshot()[id];
2552
+ if (info && info.blank) fail("blank sessions are not renamable (their title is the workspace folder name)");
2553
+ bindSession(sid);
2554
+ actions.rename(id, next);
2555
+ return { paneId: id, sessionId: sid, title: next };
2556
+ },
2557
+ /** Change the model and/or agent preset of the session a pane
2558
+ * shows. @param {{model?: string|object, mode?: string}} patch */
2559
+ configure: async (patch, options) => {
2560
+ const id = resolvePane(paneOfOptions(options));
2561
+ const p = patch && typeof patch === "object" ? patch : {};
2562
+ const mode = str(p.mode);
2563
+ const model = normalizeModel(p.model);
2564
+ if (mode === null && model === null) fail("configure needs a mode and/or a model");
2565
+ const { sid, session } = bindPaneSession(id);
2566
+ if (mode !== null) {
2567
+ const res = await session.api.agentPresets.select({ sessionId: sid, agentPreset: mode });
2568
+ if (res && res.result && res.result.ok === false) {
2569
+ const e = res.result.error;
2570
+ fail("preset select failed: " + (e ? (e.message || e.code) : "unknown"));
2571
+ }
2572
+ }
2573
+ if (model !== null) {
2574
+ const res = await session.api.sessions.selectModel({
2575
+ sessionId: sid,
2576
+ provider: model.provider,
2577
+ model: model.model,
2578
+ ...(model.reasoningEffort !== void 0 ? { reasoningEffort: model.reasoningEffort } : {})
2579
+ });
2580
+ if (res && res.result && res.result.ok === false) {
2581
+ const e = res.result.error;
2582
+ fail("model select failed: " + (e ? (e.message || e.code) : "unknown"));
2583
+ }
2584
+ }
2585
+ return { paneId: id, sessionId: sid };
2586
+ },
2587
+ /** Cancel the running turn of the session a pane shows. */
2588
+ cancel: async (paneId) => {
2589
+ const id = resolvePane(paneId);
2590
+ const { sid, session } = bindPaneSession(id);
2591
+ assertRpc(await session.cancel(), "cancel");
2592
+ return { paneId: id, sessionId: sid };
2593
+ },
2594
+
2595
+ // ── Observation ──
2596
+ /**
2597
+ * Subscribe to pane-state changes (structure, focus, sessions,
2598
+ * titles, ready/dormant). The listener receives the same shape
2599
+ * panes() describes, wrapped as {panes, focused, maximized};
2600
+ * duplicate states are coalesced. Returns the unsubscribe fn.
2601
+ */
2602
+ subscribe: (listener) => {
2603
+ if (typeof listener !== "function") fail("subscribe needs a listener function");
2604
+ let last = null;
2605
+ const emit = () => {
2606
+ const snap = layout.getSnapshot();
2607
+ const state = {
2608
+ panes: describePanes(),
2609
+ focused: snap.focused ?? null,
2610
+ maximized: snap.maximized ?? null
2611
+ };
2612
+ const key = JSON.stringify(state);
2613
+ if (key === last) return;
2614
+ last = key;
2615
+ try {
2616
+ listener(state);
2617
+ } catch {
2618
+ // listener errors must not break the store bus
2619
+ }
2620
+ };
2621
+ const offs = [
2622
+ layout.subscribe(emit),
2623
+ panesReady.subscribe(emit),
2624
+ liveOrder.subscribe(emit),
2625
+ paneTitles.subscribe(emit)
2626
+ ];
2627
+ return () => {
2628
+ for (const off of offs) {
2629
+ try {
2630
+ off();
2631
+ } catch {
2632
+ // already disposed
2633
+ }
2634
+ }
2635
+ };
2636
+ }
2637
+ };
2638
+ ctx.provide("dsh-split-view", api);
2639
+ // Dev hook: with localStorage `dsh-split:debug` set, mirror the
2640
+ // service on window.__dshSplitView so the API can be exercised from
2641
+ // devtools. Off by default — other plugins keep using ctx.get/inject.
2642
+ try {
2643
+ if (window.localStorage.getItem("dsh-split:debug")) window.__dshSplitView = api;
2644
+ } catch {
2645
+ // storage unavailable — the hook stays off
2646
+ }
2647
+ // TEMP(agent-team-demo): auto-pin agent-teams member sessions into panes
2648
+ // on top-level boot. Remove once the dedicated dsh-agent-team plugin lands.
2649
+ (() => {
2650
+ if (window.__dshMemberDemoScheduled) return;
2651
+ window.__dshMemberDemoScheduled = true;
2652
+ const KEY = "dsh-split:memberPanes";
2653
+ const TRACE = "dsh-split:demoTrace";
2654
+ const trace = (m) => { try { localStorage.setItem(TRACE, (localStorage.getItem(TRACE) || "").slice(-400) + "\n" + new Date().toISOString().slice(11,19) + " " + m); } catch (e) {} };
2655
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2656
+ const run = async () => {
2657
+ try {
2658
+ trace("start iframes=" + document.querySelectorAll("iframe").length);
2659
+ const res = await fetch("/plugins/dsh-agent-teams/state", { cache: "no-store" });
2660
+ if (!res.ok) { trace("state route not ok: " + res.status); return; }
2661
+ const data = await res.json();
2662
+ const teams = (data && data.teams) || [];
2663
+ trace("fetched teams=" + teams.length);
2664
+ const pinned = new Set(JSON.parse(localStorage.getItem(KEY) || "[]"));
2665
+ let changed = false;
2666
+ for (const team of teams) {
2667
+ for (const m of team.members || []) {
2668
+ if (!m || !m.id || pinned.has(m.id)) continue;
2669
+ trace("split before " + m.id.slice(0, 8));
2670
+ try {
2671
+ const out = await splitImpl({ session: m.id, direction: "right" });
2672
+ trace("split after leaf=" + (out && out.leafId));
2673
+ if (out && out.leafId) { pinned.add(m.id); changed = true; await sleep(1200); }
2674
+ } catch (e) { trace("split err: " + (e && e.message)); }
2675
+ }
2676
+ }
2677
+ if (changed) localStorage.setItem(KEY, JSON.stringify([...pinned]));
2678
+ trace("done iframes=" + document.querySelectorAll("iframe").length);
2679
+ } catch (e) { trace("outer err: " + (e && e.message)); }
2680
+ };
2681
+ // TEMP(agent-team-demo): 6s delay lets boot/layout restore settle first.
2682
+ setTimeout(run, 6000);
2683
+ })();
2684
+ const handleCommand = (cmd, paneId) => {
2685
+ const snap = layout.getSnapshot();
2686
+ const target = paneId && findNode(snap.tree, paneId) ? paneId : snap.focused;
2687
+ if (!target) return;
2688
+ if (cmd === "split-right") actions.split(target, "h");
2689
+ else if (cmd === "split-down") actions.split(target, "v");
2690
+ else if (cmd === "close") actions.close(target);
2691
+ else if (cmd === "maximize") actions.maximize(target);
2692
+ };
2693
+ ctx.effect(() => {
2694
+ const onMessage = (event) => {
2695
+ // Panes may live on the alternate loopback origin (origin pool).
2696
+ if (!allowedOrigins.has(event.origin)) return;
2697
+ const data = event.data;
2698
+ if (!data || data.type !== MSG_TYPE || typeof data.pane !== "string") return;
2699
+ // Any well-formed pane message registers the sender as a prefs
2700
+ // broadcast peer (window-level, cross-origin safe).
2701
+ panePeers.set(event.source, event.origin);
2702
+ // …and refreshes the pane address book (top→pane control channel).
2703
+ paneWindows.set(data.pane, { source: event.source, origin: event.origin });
2704
+ if (data.kind === "session") {
2705
+ actions.session(data.pane, typeof data.sessionId === "string" ? data.sessionId : null);
2706
+ paneInfo.update((d) => {
2707
+ d[data.pane] = {
2708
+ blank: data.blank === true,
2709
+ realTitle: typeof data.realTitle === "string" ? data.realTitle : null
2710
+ };
2711
+ });
2712
+ // A rename just applied at the top suppresses stale title reports
2713
+ // until the pane catches up (its report matches the new title)
2714
+ // or the suppression window expires.
2715
+ const sup = titleSuppress.get(data.pane);
2716
+ if (sup !== void 0) {
2717
+ if ((typeof data.title === "string" && data.title === sup.title) || Date.now() >= sup.until) titleSuppress.delete(data.pane);
2718
+ }
2719
+ if (!titleSuppress.has(data.pane)) {
2720
+ paneTitles.update((d) => {
2721
+ d[data.pane] = typeof data.title === "string" && data.title.length > 0 ? data.title : null;
2722
+ });
2723
+ }
2724
+ panesReady.update((d) => {
2725
+ d[data.pane] = true;
2726
+ });
2727
+ touchLive(data.pane);
2728
+ } else if (data.kind === "focus") {
2729
+ actions.focus(data.pane);
2730
+ panesReady.update((d) => {
2731
+ d[data.pane] = true;
2732
+ });
2733
+ touchLive(data.pane);
2734
+ } else if (data.kind === "cmd" && typeof data.cmd === "string") {
2735
+ handleCommand(data.cmd, data.pane);
2736
+ } else if (data.kind === "prefs-pull") {
2737
+ // Pane boot asking for the current preferences.
2738
+ try {
2739
+ event.source.postMessage({ type: MSG_TYPE, kind: "prefs-push", prefs: prefs.getSnapshot() }, event.origin);
2740
+ } catch {
2741
+ // pane may already be gone
2742
+ }
2743
+ } else if (data.kind === "prefs-set" && data.patch && typeof data.patch === "object") {
2744
+ // Settings edit from a pane's settings modal — validate and
2745
+ // merge here (the top is authoritative); the store's
2746
+ // subscriber broadcasts the canonical state to every pane.
2747
+ const next = patchPrefs(prefs.getSnapshot(), data.patch);
2748
+ if (next) prefs.set(next);
2749
+ } else if (data.kind === "storage-pull") {
2750
+ // Cross-origin pane asking for the top's client-side config
2751
+ // snapshot — answer with every non-per-client key.
2752
+ try {
2753
+ event.source.postMessage({ type: MSG_TYPE, kind: "storage-push", values: collectSyncedStorage() }, event.origin);
2754
+ } catch {
2755
+ // pane may already be gone
2756
+ }
2757
+ } else if (data.kind === "storage-set" && typeof data.key === "string") {
2758
+ // A cross-origin pane wrote to its localStorage — mirror the
2759
+ // write in the top so it survives the next reload.
2760
+ if (!shouldSyncKey(data.key)) return;
2761
+ try {
2762
+ if (data.value === null || data.value === void 0) window.localStorage.removeItem(data.key);
2763
+ else window.localStorage.setItem(data.key, String(data.value));
2764
+ } catch {
2765
+ // storage unavailable — the write cannot persist
2766
+ }
2767
+ }
2768
+ };
2769
+ const onKey = (event) => {
2770
+ const cmd = commandOfEvent(event, prefs.getSnapshot().shortcuts);
2771
+ if (cmd === null) return;
2772
+ event.preventDefault();
2773
+ handleCommand(cmd, null);
2774
+ };
2775
+ const style = document.createElement("style");
2776
+ style.setAttribute("data-plugin", "dsh-split-view");
2777
+ style.textContent = CSS;
2778
+ document.head.append(style);
2779
+ window.addEventListener("message", onMessage);
2780
+ window.addEventListener("keydown", onKey);
2781
+ return () => {
2782
+ window.removeEventListener("message", onMessage);
2783
+ window.removeEventListener("keydown", onKey);
2784
+ style.remove();
2785
+ };
2786
+ }, "split-view: top bus + styles");
2787
+ // Shadow the AppFrame root entry (priority 0) — lowest priority renders.
2788
+ // The AppFrame subtree (sidebar/conversation/details/shell.overlay) stays
2789
+ // registered but unrendered; every pane iframe runs its own full AppFrame.
2790
+ reapplyBootSkin(ctx);
2791
+ ctx.slots.inject("root", () => ctx.slots.register({
2792
+ name: "root",
2793
+ priority: -1
2794
+ }, () => React.createElement(SplitterRoot, { layout, panesReady, liveOrder, actions, handleCommand, pool, caps, maxLive, paneTitles, paneInfo, prefs })));
2795
+ }
2796
+ //#endregion
2797
+
2798
+ /** Cordis fiber inject — slots for the root shadow and the settings
2799
+ * section, sessions for pane seeding, theme for the cross-origin settings
2800
+ * re-apply, locale for the settings rows' dictionaries. */
2801
+ const inject = ["slots", "sessions", "theme", "locale"];
2802
+ /**
2803
+ * Client plugin body: exactly one of the two modes runs per page load,
2804
+ * selected by the ?dshPane parameter.
2805
+ * @param ctx - client root context.
2806
+ */
2807
+ function apply(ctx) {
2808
+ if (PANE_ID) applyPane(ctx);
2809
+ else applyTop(ctx);
2810
+ }
2811
+ exports.apply = apply;
2812
+ exports.inject = inject;
2813
+ return module.exports;
2814
+ }
2815
+ });
2816
+
2817
+ //# sourceMappingURL=client.js.map