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 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 通知面板自己执行 `sessions.open`,不重载 iframe,面板内的客户端状态保留。休眠面板更新启动种子,下次唤醒时生效。会话不在列表里会抛错(列表基线未就绪时跳过校验,由面板侧兜底)。
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 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.
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 (forced at boot through sessions.open). */
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.4";
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
- sessions.clear();
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
- sessions.open(target);
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
- seeded = true;
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
- sessions.open(SEED_SESSION);
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
- sessions.clear();
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.current;
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
- ctx.sessions.open(target);
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
- ctx.sessions.open(target);
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.5",
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
  }