dsh-split-view 0.2.4 → 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) | 动作 |
@@ -65,6 +67,8 @@ dsh web # 重启生效
65
67
 
66
68
  分屏能力整体暴露成了一个 Cordis 服务:面板操作、面板×会话操作(加载指定会话、发消息、改名、换模型、取消回合)、查询与订阅。面向集成方的服务 API 见 [api.md](./api.md)。
67
69
 
70
+ 如果你要实现类似于 Claude Code 的 agent teams的插件,可以通过本插件提供的 api 来实现等同于 [agent teams 的 Split-pane 模式](https://code.claude.com/docs/zh-CN/agent-teams#choose-a-display-mode)的分屏显示功能。
71
+
68
72
  ## 协议
69
73
 
70
74
  MIT © dsh-split-view contributors
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
@@ -53,13 +55,26 @@ window.__ModuleLoader__.load({
53
55
  var exports = module.exports;
54
56
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
55
57
  const React = require("react");
56
- const runtime = require("@deepseek-ai/dsh-client-runtime/client");
58
+ // DSH ≥0.1.2-alpha.1 将 dsh-client-runtime 拆为 modules/store/locale 三包并把
59
+ // 平台模块表冻结为新 seed 名(defineStore/createSnapshotStore 归 dsh-client-store);
60
+ // stable ≤0.1.1-rc.x 只提供旧 seed。双 seed 回退让一份 build 兼容两代宿主
61
+ //(同款手法见 dsh-dream-skin 9.16.0;上游 issue #41 修 master 破 stable、#43 跟进)。
62
+ let runtime = null;
63
+ for (const seed of ["@deepseek-ai/dsh-client-store", "@deepseek-ai/dsh-client-runtime/client"]) {
64
+ try {
65
+ runtime = require(seed);
66
+ break;
67
+ } catch {
68
+ runtime = null;
69
+ }
70
+ }
71
+ if (!runtime) throw new Error("dsh-split-view: 无可用 store seed(dsh-client-store 与 dsh-client-runtime/client 均缺失)");
57
72
 
58
73
  //#region constants
59
74
  const params = new URLSearchParams(window.location.search);
60
75
  /** Pane identity: present means this page load is a split pane, not the splitter. */
61
76
  const PANE_ID = params.get("dshPane");
62
- /** Session this pane must show (forced at boot through sessions.open). */
77
+ /** Session this pane must show (pinned at boot through the session nav). */
63
78
  const SEED_SESSION = params.get("dshSession");
64
79
  /** Fresh pane with an inherited workspace: create/connect its workspace session. */
65
80
  const INHERIT_WORKSPACE = params.get("dshWorkspace");
@@ -436,7 +451,7 @@ function collectSyncedStorage() {
436
451
  const SETTINGS_NS = "settings.splitView";
437
452
  // Keep in sync with the "version" field in package.json — the client bundle
438
453
  // has no runtime version source (boot manifest entries carry no version).
439
- const PLUGIN_VERSION = "0.2.4";
454
+ const PLUGIN_VERSION = "0.2.6";
440
455
  const ACTION_IDS = ["split-right", "split-down", "close", "maximize"];
441
456
  const DEFAULT_SHORTCUTS = Object.freeze({
442
457
  "split-right": "Mod+Shift+D",
@@ -674,6 +689,140 @@ function collectSyncedStorage() {
674
689
  //#endregion
675
690
 
676
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
+
677
826
  function applyPane(ctx) {
678
827
  // Prefs mirror of the top window (the source of truth — see the
679
828
  // preferences region header). Same-origin panes share the top's
@@ -698,6 +847,9 @@ function collectSyncedStorage() {
698
847
  }
699
848
  };
700
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);
701
853
  ctx.effect(() => {
702
854
  const sessions = ctx.sessions;
703
855
  // Session seeding is gated on the list baseline: SessionManager.select
@@ -711,7 +863,7 @@ function collectSyncedStorage() {
711
863
  const opts = INHERIT_WORKSPACE ? { workspaceId: INHERIT_WORKSPACE } : { cwd: INHERIT_CWD };
712
864
  const fallback = () => {
713
865
  try {
714
- sessions.clear();
866
+ nav.clear();
715
867
  } catch {
716
868
  // empty-state fallback is best-effort
717
869
  }
@@ -728,7 +880,7 @@ function collectSyncedStorage() {
728
880
  }
729
881
  if (target !== void 0) {
730
882
  try {
731
- sessions.open(target);
883
+ nav.open(target);
732
884
  return;
733
885
  } catch {
734
886
  // fall through to the empty state
@@ -743,32 +895,68 @@ function collectSyncedStorage() {
743
895
  const trySeed = () => {
744
896
  if (seeded) return;
745
897
  if (sessions.list.getSnapshot().phase === "pending") return;
746
- 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;
747
905
  if (SEED_SESSION) {
748
906
  try {
749
- sessions.open(SEED_SESSION);
907
+ nav.open(SEED_SESSION);
750
908
  } catch (error) {
909
+ // Genuinely gone (deleted/archived elsewhere) — give up
910
+ // gracefully and let the pane fall back.
911
+ seeded = true;
751
912
  console.warn("dsh-split-view: pane session seed unavailable, pane falls back", error);
913
+ return;
752
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;
753
921
  return;
754
922
  }
755
923
  if (INHERIT_WORKSPACE || INHERIT_CWD) {
924
+ seeded = true;
756
925
  seedInherited();
757
926
  return;
758
927
  }
759
928
  if (WANT_NEW) {
760
929
  try {
761
- sessions.clear();
930
+ nav.clear();
762
931
  } catch {
763
932
  // empty-state fallback is best-effort
764
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
+ }
765
947
  }
766
948
  };
767
949
  trySeed();
768
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));
769
957
  const report = () => {
770
958
  const listSnap = sessions.list.getSnapshot();
771
- const current = listSnap.current;
959
+ const current = nav.currentOf(listSnap);
772
960
  const summary = current === null || current === void 0 ? void 0 : listSnap.byId[current];
773
961
  postToParent({
774
962
  kind: "session",
@@ -798,6 +986,7 @@ function collectSyncedStorage() {
798
986
  document.addEventListener("keydown", onKey, true);
799
987
  return () => {
800
988
  offSeed();
989
+ for (const timer of seedTimers) clearTimeout(timer);
801
990
  offList();
802
991
  document.removeEventListener("mousedown", onActivity, true);
803
992
  window.removeEventListener("focus", onActivity);
@@ -960,7 +1149,7 @@ function collectSyncedStorage() {
960
1149
  const target = data.sessionId;
961
1150
  const openNow = () => {
962
1151
  try {
963
- ctx.sessions.open(target);
1152
+ nav.open(target);
964
1153
  return true;
965
1154
  } catch {
966
1155
  return false;
@@ -975,7 +1164,7 @@ function collectSyncedStorage() {
975
1164
  if (ctx.sessions.list.getSnapshot().phase === "pending") return;
976
1165
  off();
977
1166
  try {
978
- ctx.sessions.open(target);
1167
+ nav.open(target);
979
1168
  } catch {
980
1169
  // genuinely gone (deleted elsewhere) — keep the current session
981
1170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-split-view",
3
- "version": "0.2.4",
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
  }