dsh-split-view 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/api.md +1 -1
- package/lib/client.js +189 -13
- package/package.json +6 -6
package/README.en.md
CHANGED
|
@@ -37,6 +37,8 @@ Install from source for local development: `dsh plugin --profile web add -w /pat
|
|
|
37
37
|
|
|
38
38
|
Uninstall with `dsh plugin --profile web remove dsh-split-view`, restart, and the window is back to normal. If the plugin itself breaks, boot stops with an error instead of a silent blank page, so uninstalling is also the emergency recovery path.
|
|
39
39
|
|
|
40
|
+
**Compatibility**: dsh `0.1.0-rc.6` through `0.2.x` — one build spans both host generations. dsh 0.2.0 added a peer-dependency gate, so on 0.2.5 or earlier the boot log shows `skipping profile bundle "dsh-split-view"` and no split buttons appear. Upgrade to 0.2.6 or later.
|
|
41
|
+
|
|
40
42
|
## Default shortcuts (customizable in Settings)
|
|
41
43
|
|
|
42
44
|
| Key (⌘ on macOS, Ctrl on Windows/Linux) | Action |
|
package/README.md
CHANGED
|
@@ -37,6 +37,8 @@ dsh web # 重启生效
|
|
|
37
37
|
|
|
38
38
|
卸载用 `dsh plugin --profile web remove dsh-split-view`,重启恢复单窗口。插件本身如果出问题,boot 会停下来报错,不会静默白屏,所以卸载也就是紧急恢复的办法。
|
|
39
39
|
|
|
40
|
+
**兼容范围**:dsh `0.1.0-rc.6` 起至 `0.2.x`,一份构建同时适配两代宿主。dsh 0.2.0 起有 peer 门禁,若装的是 0.2.5 及更早版本,启动日志会出现 `skipping profile bundle "dsh-split-view"`、界面上看不到分屏按钮——升级到 0.2.6 及以上即可。
|
|
41
|
+
|
|
40
42
|
## 默认快捷键(支持在设置中自定义)
|
|
41
43
|
|
|
42
44
|
| 键(macOS ⌘,Windows/Linux Ctrl) | 动作 |
|
package/api.md
CHANGED
|
@@ -165,7 +165,7 @@ await sv.applyLayout(lay.tree)
|
|
|
165
165
|
await sv.open('session-…', { pane: paneId })
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
活面板**热切换**:顶层通过 postMessage
|
|
168
|
+
活面板**热切换**:顶层通过 postMessage 通知面板自己导航到目标会话(dsh 0.1.x 走 `sessions.open`,0.2.x 起走 `ctx.uiWorkspace.openSession`),不重载 iframe,面板内的客户端状态保留。休眠面板更新启动种子,下次唤醒时生效。会话不在列表里会抛错(列表基线未就绪时跳过校验,由面板侧兜底)。
|
|
169
169
|
|
|
170
170
|
### 新建会话
|
|
171
171
|
|
package/lib/client.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
// lowest renders) and draws a split container. Each cell is an iframe
|
|
12
12
|
// loading the full DSH client at /?dshPane=<id>[&dshSession=<sid>|&dshNew=1].
|
|
13
13
|
// - Pane window (has ?dshPane): the pane agent pins the client to its assigned
|
|
14
|
-
// session
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// session at boot (or drops it into the no-session state for a fresh pane),
|
|
15
|
+
// reports selection changes and focus to the parent, and forwards the split
|
|
16
|
+
// keyboard shortcuts as postMessage commands. Both the pinning and the
|
|
17
|
+
// read-back go through createSessionNav, which spans the dsh 0.2.0
|
|
18
|
+
// sessions-API break (open/clear/list.current replaced by mainView retention).
|
|
17
19
|
// - Preferences (shortcuts, focus highlight color, title-bar visibility) are
|
|
18
20
|
// owned by the top window (it draws everything they control) and persisted
|
|
19
21
|
// in its localStorage; panes hold a mirror synced over postMessage and edit
|
|
@@ -72,7 +74,7 @@ window.__ModuleLoader__.load({
|
|
|
72
74
|
const params = new URLSearchParams(window.location.search);
|
|
73
75
|
/** Pane identity: present means this page load is a split pane, not the splitter. */
|
|
74
76
|
const PANE_ID = params.get("dshPane");
|
|
75
|
-
/** Session this pane must show (
|
|
77
|
+
/** Session this pane must show (pinned at boot through the session nav). */
|
|
76
78
|
const SEED_SESSION = params.get("dshSession");
|
|
77
79
|
/** Fresh pane with an inherited workspace: create/connect its workspace session. */
|
|
78
80
|
const INHERIT_WORKSPACE = params.get("dshWorkspace");
|
|
@@ -449,7 +451,7 @@ function collectSyncedStorage() {
|
|
|
449
451
|
const SETTINGS_NS = "settings.splitView";
|
|
450
452
|
// Keep in sync with the "version" field in package.json — the client bundle
|
|
451
453
|
// has no runtime version source (boot manifest entries carry no version).
|
|
452
|
-
const PLUGIN_VERSION = "0.2.
|
|
454
|
+
const PLUGIN_VERSION = "0.2.6";
|
|
453
455
|
const ACTION_IDS = ["split-right", "split-down", "close", "maximize"];
|
|
454
456
|
const DEFAULT_SHORTCUTS = Object.freeze({
|
|
455
457
|
"split-right": "Mod+Shift+D",
|
|
@@ -687,6 +689,140 @@ function collectSyncedStorage() {
|
|
|
687
689
|
//#endregion
|
|
688
690
|
|
|
689
691
|
//#region pane agent (runs inside ?dshPane loads)
|
|
692
|
+
/** Pane session navigation across the dsh 0.2.0 sessions-API break.
|
|
693
|
+
*
|
|
694
|
+
* 0.1.x: `sessions.open(id)` selects the current session, `sessions.clear()`
|
|
695
|
+
* drops back to the no-session view, and the selection reads back as
|
|
696
|
+
* `list.getSnapshot().current`.
|
|
697
|
+
*
|
|
698
|
+
* 0.2.0 removed all three and moved navigation into `ctx.uiWorkspace`,
|
|
699
|
+
* whose `replaceMain()` owns the single `mainView` retention reference.
|
|
700
|
+
* That reference is the de-facto "current session" signal: a dozen call
|
|
701
|
+
* sites across ui-workspace / ui-layout / ui-session / ui-cordis /
|
|
702
|
+
* ui-open-in-app / ui-settings-general / ui-agent-preset derive it with
|
|
703
|
+
* `Object.values(list.byId).find((row) => (row.retainedBy.mainView ?? 0) > 0)`.
|
|
704
|
+
*
|
|
705
|
+
* Retaining `mainView` ourselves — the obvious literal reading of the new
|
|
706
|
+
* `sessions.retain(target, { source })` API — is wrong: the service does not
|
|
707
|
+
* know about our reference and never releases it, so it outlives every
|
|
708
|
+
* navigation and a second session accumulates a reference of its own. The
|
|
709
|
+
* derivation above then resolves to whichever row comes first, and the pane
|
|
710
|
+
* reports a session it is not showing (measured on 0.2.0-rc.2: our boot-seed
|
|
711
|
+
* reference stayed on session A while the pane displayed B). Going through
|
|
712
|
+
* `uiWorkspace` keeps the invariant and also updates `selection`, the
|
|
713
|
+
* service's real source of truth.
|
|
714
|
+
*
|
|
715
|
+
* Access is through `ctx.get()`, which bypasses the cordis inject guard.
|
|
716
|
+
* That is deliberate: declaring `"uiWorkspace"` in the fiber's inject list
|
|
717
|
+
* would block the fiber forever on dsh 0.1.0-rc.6, where the service does
|
|
718
|
+
* not exist yet, silently disabling the plugin for that whole range.
|
|
719
|
+
*
|
|
720
|
+
* The lookup is lazy and memoized. `applyPane` runs while the client tree
|
|
721
|
+
* is still being mounted, and `ctx.get("uiWorkspace")` is undefined that
|
|
722
|
+
* early — resolving it once at construction made every later navigation
|
|
723
|
+
* throw "session navigation unavailable" while the service was in fact
|
|
724
|
+
* reachable moments afterwards (measured on 0.2.0-rc.2).
|
|
725
|
+
*
|
|
726
|
+
* @param ctx - the pane client's cordis context.
|
|
727
|
+
* @returns the pane's navigation handle; generation decided once, at construction. */
|
|
728
|
+
function createSessionNav(ctx) {
|
|
729
|
+
const sessions = ctx.sessions;
|
|
730
|
+
// 0.1.x has sessions.open/clear; 0.2.x has neither. `sessions` is a
|
|
731
|
+
// declared fiber inject, so it is settled by the time applyPane runs.
|
|
732
|
+
const legacy = typeof sessions.open === "function";
|
|
733
|
+
let workspace = void 0;
|
|
734
|
+
let warned = false;
|
|
735
|
+
const navigation = () => {
|
|
736
|
+
if (workspace === void 0 || workspace === null) {
|
|
737
|
+
try {
|
|
738
|
+
// Absent (not throwing) on hosts without the service.
|
|
739
|
+
if (typeof ctx.get === "function") workspace = ctx.get("uiWorkspace");
|
|
740
|
+
} catch {
|
|
741
|
+
workspace = void 0;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return workspace && typeof workspace.openSession === "function" ? workspace : void 0;
|
|
745
|
+
};
|
|
746
|
+
const unavailable = () => {
|
|
747
|
+
if (!warned) {
|
|
748
|
+
// Once, not per call: the pane agent retries on a bounded schedule.
|
|
749
|
+
warned = true;
|
|
750
|
+
console.warn("dsh-split-view: ctx.uiWorkspace is unavailable on this host; panes cannot pin a session");
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
return {
|
|
754
|
+
/** Whether this host's navigation entry point is reachable right now.
|
|
755
|
+
* The service mounts after the client tree comes up, so a caller
|
|
756
|
+
* gating a one-shot boot action asks first instead of discovering
|
|
757
|
+
* by throwing — and can retry rather than latch a failure. */
|
|
758
|
+
ready() {
|
|
759
|
+
return legacy || navigation() !== void 0;
|
|
760
|
+
},
|
|
761
|
+
/** Point the pane at `id`; throws while the id is not listed yet. */
|
|
762
|
+
open(id) {
|
|
763
|
+
if (legacy) {
|
|
764
|
+
sessions.open(id);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const ws = navigation();
|
|
768
|
+
if (ws === void 0) {
|
|
769
|
+
unavailable();
|
|
770
|
+
throw new Error("dsh-split-view: session navigation unavailable on this host");
|
|
771
|
+
}
|
|
772
|
+
// Same failure mode 0.1.x `open()` had — an id absent from the
|
|
773
|
+
// list baseline throws synchronously — so callers keep their
|
|
774
|
+
// retry-once-the-list-resolves logic unchanged.
|
|
775
|
+
ws.openSession(id);
|
|
776
|
+
},
|
|
777
|
+
/** Fresh-start the pane: no previously shown session.
|
|
778
|
+
*
|
|
779
|
+
* 0.1.x reaches the strict no-session view through `sessions.clear()`.
|
|
780
|
+
* 0.2.x has no boot-time no-session state at all: `watchNavigation()`
|
|
781
|
+
* treats a missing main reference as "restore the persisted
|
|
782
|
+
* selection", so `clearMain()` during boot is provably overwritten by
|
|
783
|
+
* the host's async `restoreSelection` (measured — the pane came back
|
|
784
|
+
* up on the persisted session every time). `startSession()` is the
|
|
785
|
+
* official fresh-start action instead: it reuses or creates the
|
|
786
|
+
* workspace's blank session and sets the main reference, which makes
|
|
787
|
+
* `watchNavigation` skip the restore. It degrades to `clearMain()`
|
|
788
|
+
* itself when the host has no workspace, so the strict empty state is
|
|
789
|
+
* still what a workspace-less pane gets. */
|
|
790
|
+
clear() {
|
|
791
|
+
if (legacy) {
|
|
792
|
+
sessions.clear();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const ws = navigation();
|
|
796
|
+
if (ws === void 0) {
|
|
797
|
+
unavailable();
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (typeof ws.startSession === "function") ws.startSession();
|
|
801
|
+
else if (typeof ws.clearMain === "function") ws.clearMain();
|
|
802
|
+
},
|
|
803
|
+
/** The session this pane shows, or undefined for the empty state.
|
|
804
|
+
* `list.current` on 0.1.x; the `mainView` derivation the official UI
|
|
805
|
+
* itself uses on 0.2.x, where `SessionListState` has no `current`
|
|
806
|
+
* and the counts live on `SessionSummary.retainedBy` (absent in
|
|
807
|
+
* 0.1.x, so the scan is a no-op there and the two paths cannot
|
|
808
|
+
* disagree). Retention changes propagate through the list store —
|
|
809
|
+
* `dsh-client-ui-cordis` reads this same shape as a reactive
|
|
810
|
+
* `useSessions` selector — so the caller's list subscription is
|
|
811
|
+
* sufficient; no separate retainInfo watch is needed.
|
|
812
|
+
* @param snap - a `sessions.list` snapshot. */
|
|
813
|
+
currentOf(snap) {
|
|
814
|
+
if (snap.current !== void 0 && snap.current !== null) return snap.current;
|
|
815
|
+
const rows = Object.values(snap.byId);
|
|
816
|
+
for (let i = 0; i < rows.length; i++) {
|
|
817
|
+
const row = rows[i];
|
|
818
|
+
const retainedBy = row ? row.retainedBy : void 0;
|
|
819
|
+
if (retainedBy && (retainedBy.mainView ?? 0) > 0) return row.id;
|
|
820
|
+
}
|
|
821
|
+
return void 0;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
690
826
|
function applyPane(ctx) {
|
|
691
827
|
// Prefs mirror of the top window (the source of truth — see the
|
|
692
828
|
// preferences region header). Same-origin panes share the top's
|
|
@@ -711,6 +847,9 @@ function collectSyncedStorage() {
|
|
|
711
847
|
}
|
|
712
848
|
};
|
|
713
849
|
applySettings(ctx, prefsChannel);
|
|
850
|
+
// Pane-scoped, shared by both effects below so every navigation in this
|
|
851
|
+
// pane goes through the same host-generation decision.
|
|
852
|
+
const nav = createSessionNav(ctx);
|
|
714
853
|
ctx.effect(() => {
|
|
715
854
|
const sessions = ctx.sessions;
|
|
716
855
|
// Session seeding is gated on the list baseline: SessionManager.select
|
|
@@ -724,7 +863,7 @@ function collectSyncedStorage() {
|
|
|
724
863
|
const opts = INHERIT_WORKSPACE ? { workspaceId: INHERIT_WORKSPACE } : { cwd: INHERIT_CWD };
|
|
725
864
|
const fallback = () => {
|
|
726
865
|
try {
|
|
727
|
-
|
|
866
|
+
nav.clear();
|
|
728
867
|
} catch {
|
|
729
868
|
// empty-state fallback is best-effort
|
|
730
869
|
}
|
|
@@ -741,7 +880,7 @@ function collectSyncedStorage() {
|
|
|
741
880
|
}
|
|
742
881
|
if (target !== void 0) {
|
|
743
882
|
try {
|
|
744
|
-
|
|
883
|
+
nav.open(target);
|
|
745
884
|
return;
|
|
746
885
|
} catch {
|
|
747
886
|
// fall through to the empty state
|
|
@@ -756,32 +895,68 @@ function collectSyncedStorage() {
|
|
|
756
895
|
const trySeed = () => {
|
|
757
896
|
if (seeded) return;
|
|
758
897
|
if (sessions.list.getSnapshot().phase === "pending") return;
|
|
759
|
-
|
|
898
|
+
// The host mounts the navigation service after the client tree
|
|
899
|
+
// comes up, and the list baseline can resolve first. Navigating
|
|
900
|
+
// that early fails against a service that becomes reachable
|
|
901
|
+
// moments later, so do not latch the attempt — the deferred
|
|
902
|
+
// passes below retry it (the same bounded idiom the theme
|
|
903
|
+
// presenter uses for events that race its listener).
|
|
904
|
+
if (!nav.ready()) return;
|
|
760
905
|
if (SEED_SESSION) {
|
|
761
906
|
try {
|
|
762
|
-
|
|
907
|
+
nav.open(SEED_SESSION);
|
|
763
908
|
} catch (error) {
|
|
909
|
+
// Genuinely gone (deleted/archived elsewhere) — give up
|
|
910
|
+
// gracefully and let the pane fall back.
|
|
911
|
+
seeded = true;
|
|
764
912
|
console.warn("dsh-split-view: pane session seed unavailable, pane falls back", error);
|
|
913
|
+
return;
|
|
765
914
|
}
|
|
915
|
+
// Latch only on the observed outcome. The host restores the
|
|
916
|
+
// persisted selection asynchronously (`restoreSelection`, which
|
|
917
|
+
// reuses a blank session in the same workspace) and can land
|
|
918
|
+
// after us, silently overwriting the seed; the passes below
|
|
919
|
+
// re-assert until the pane really shows it.
|
|
920
|
+
if (nav.currentOf(sessions.list.getSnapshot()) === SEED_SESSION) seeded = true;
|
|
766
921
|
return;
|
|
767
922
|
}
|
|
768
923
|
if (INHERIT_WORKSPACE || INHERIT_CWD) {
|
|
924
|
+
seeded = true;
|
|
769
925
|
seedInherited();
|
|
770
926
|
return;
|
|
771
927
|
}
|
|
772
928
|
if (WANT_NEW) {
|
|
773
929
|
try {
|
|
774
|
-
|
|
930
|
+
nav.clear();
|
|
775
931
|
} catch {
|
|
776
932
|
// empty-state fallback is best-effort
|
|
777
933
|
}
|
|
934
|
+
// Latch on the observed outcome rather than on having called:
|
|
935
|
+
// the host's async restore can still put the persisted session
|
|
936
|
+
// back, and a "fresh pane" showing someone's previous
|
|
937
|
+
// conversation is exactly the failure worth re-asserting. 0.1.x
|
|
938
|
+
// lands on no session, 0.2.x on the workspace's blank session —
|
|
939
|
+
// both are a fresh start, a restored non-blank one is not.
|
|
940
|
+
const freshSnap = sessions.list.getSnapshot();
|
|
941
|
+
const shown = nav.currentOf(freshSnap);
|
|
942
|
+
if (shown === void 0 || shown === null) seeded = true;
|
|
943
|
+
else {
|
|
944
|
+
const row = freshSnap.byId[shown];
|
|
945
|
+
if (row && row.blank) seeded = true;
|
|
946
|
+
}
|
|
778
947
|
}
|
|
779
948
|
};
|
|
780
949
|
trySeed();
|
|
781
950
|
const offSeed = seeded ? () => {} : sessions.list.subscribe(trySeed);
|
|
951
|
+
// Deferred passes: the list subscription only re-fires on list changes,
|
|
952
|
+
// which is not guaranteed once the baseline has resolved — so neither a
|
|
953
|
+
// late-mounting navigation service nor a host restore that overwrote us
|
|
954
|
+
// would ever be retried. Bounded, and every pass lands inside the pane's
|
|
955
|
+
// boot window, so re-asserting cannot fight a deliberate user navigation.
|
|
956
|
+
const seedTimers = seeded ? [] : [200, 500, 1000, 2000, 5000].map((delay) => setTimeout(trySeed, delay));
|
|
782
957
|
const report = () => {
|
|
783
958
|
const listSnap = sessions.list.getSnapshot();
|
|
784
|
-
const current = listSnap
|
|
959
|
+
const current = nav.currentOf(listSnap);
|
|
785
960
|
const summary = current === null || current === void 0 ? void 0 : listSnap.byId[current];
|
|
786
961
|
postToParent({
|
|
787
962
|
kind: "session",
|
|
@@ -811,6 +986,7 @@ function collectSyncedStorage() {
|
|
|
811
986
|
document.addEventListener("keydown", onKey, true);
|
|
812
987
|
return () => {
|
|
813
988
|
offSeed();
|
|
989
|
+
for (const timer of seedTimers) clearTimeout(timer);
|
|
814
990
|
offList();
|
|
815
991
|
document.removeEventListener("mousedown", onActivity, true);
|
|
816
992
|
window.removeEventListener("focus", onActivity);
|
|
@@ -973,7 +1149,7 @@ function collectSyncedStorage() {
|
|
|
973
1149
|
const target = data.sessionId;
|
|
974
1150
|
const openNow = () => {
|
|
975
1151
|
try {
|
|
976
|
-
|
|
1152
|
+
nav.open(target);
|
|
977
1153
|
return true;
|
|
978
1154
|
} catch {
|
|
979
1155
|
return false;
|
|
@@ -988,7 +1164,7 @@ function collectSyncedStorage() {
|
|
|
988
1164
|
if (ctx.sessions.list.getSnapshot().phase === "pending") return;
|
|
989
1165
|
off();
|
|
990
1166
|
try {
|
|
991
|
-
|
|
1167
|
+
nav.open(target);
|
|
992
1168
|
} catch {
|
|
993
1169
|
// genuinely gone (deleted elsewhere) — keep the current session
|
|
994
1170
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-split-view",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Multi-session split view for the DeepSeek Harness web GUI: the top window shadows the root slot with a resizable split container; each pane is an iframe running a full DSH client pinned to its own session, with per-pane title bars, LRU hibernation and a dual-loopback origin pool.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,10 +65,10 @@
|
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"react": "^18.2.0",
|
|
67
67
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
68
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
69
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-theme": "^0.1.0-rc.6",
|
|
71
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-settings-general": "^0.1.0-rc.6"
|
|
68
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6 || ^0.2.0-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-theme": "^0.1.0-rc.6 || ^0.2.0-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6 || ^0.2.0-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-settings-general": "^0.1.0-rc.6 || ^0.2.0-rc.2"
|
|
73
73
|
}
|
|
74
74
|
}
|