@deepseek-ai/dsh-client-ui-layout 0.1.5-alpha.1 → 0.1.5-alpha.2

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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
5
- README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa
6
- README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93
5
+ README.md: 4fe19fb9ec0a549dc76ca311d0564cbe86c9f5f9
6
+ README.zh.md: 7a61e7808ffb3d81214388a2792a901ced8d9564
package/README.md CHANGED
@@ -25,7 +25,9 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a
25
25
  <a id="use-this-package"></a>
26
26
  ## Use this package
27
27
 
28
- The root slot composes the sidebar, conversation, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen.
28
+ The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen.
29
+
30
+ Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition.
29
31
 
30
32
  ### Theme presentation
31
33
 
@@ -39,7 +41,9 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
39
41
  <details>
40
42
  <summary>Implementation internals — click to expand</summary>
41
43
 
42
- One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
44
+ `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts.
45
+
46
+ One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
43
47
 
44
48
  </details>
45
49
 
@@ -51,7 +55,7 @@ One registration declares four child slots and binds `ctx.layout` methods `toggl
51
55
  Read these pages when the layout surface is not enough. They move from the frame to the columns it renders and the theme it presents.
52
56
 
53
57
  - [ui-sidebar](../ui-sidebar/README.md) — occupies the `sidebar` column and its seats.
54
- - [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` column.
58
+ - [ui-conversation](../ui-conversation/README.md) — occupies the `main` key `conversation`.
55
59
  - [ui-sidebar-right](../ui-sidebar-right/README.md) — occupies the `rightbar` column with one docking surface per session.
56
60
  - [ui-theme](../ui-theme/README.md) — the theme seam whose resolved snapshots the presenter consumes.
57
61
  - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots.
package/README.zh.md CHANGED
@@ -25,7 +25,9 @@ kind: "package-reference"
25
25
  <a id="use-this-package"></a>
26
26
  ## 使用本包
27
27
 
28
- 本插件在 root slot 组合侧栏、会话与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。
28
+ 本插件在 root slot 组合侧栏、中央内容与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。
29
+
30
+ 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
29
31
 
30
32
  ### 主题呈现
31
33
 
@@ -39,7 +41,9 @@ kind: "package-reference"
39
41
  <details>
40
42
  <summary>实现细节——点击展开</summary>
41
43
 
42
- 一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
44
+ `selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费者在提交导航或搬移草稿前检查 signal。
45
+
46
+ 一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
43
47
 
44
48
  </details>
45
49
 
@@ -51,7 +55,7 @@ kind: "package-reference"
51
55
  当布局面不够用时阅读以下页面。它们从框架进入它所渲染的栏与它所呈现的主题。
52
56
 
53
57
  - [ui-sidebar](../ui-sidebar/README.zh.md)——占据 `sidebar` 栏及其座位。
54
- - [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 栏。
58
+ - [ui-conversation](../ui-conversation/README.zh.md)——占据 `main` 中的 `conversation` key。
55
59
  - [ui-sidebar-right](../ui-sidebar-right/README.zh.md)——以每会话一个停靠面占据 `rightbar` 栏。
56
60
  - [ui-theme](../ui-theme/README.zh.md)——呈现器消费其解析快照的主题 seam。
57
61
  - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册槽位。
package/lib/client.js CHANGED
@@ -45,13 +45,19 @@ window.__ModuleLoader__.load({
45
45
  }
46
46
  //#endregion
47
47
  //#region lib/types/client/DocumentTitle.js
48
+ /** Browser title selection follows the active main panel without subscribing the frame. */
48
49
  /**
49
50
  * Project the selected durable session title into the browser title and
50
51
  * restore the build-selected product title when unmounted.
51
52
  * @param props - Selected session title projection.
52
53
  * @returns No rendered content.
53
54
  */
54
- function DocumentTitle({ title, productTitle }) {
55
+ function DocumentTitle({ useSessions, usePanelInfo, productTitle }) {
56
+ const showSessionTitle = usePanelInfo((info) => info.activePanelId === null);
57
+ const title = useSessions((state) => {
58
+ const current = state.current;
59
+ return !showSessionTitle || current === void 0 ? void 0 : state.byId[current]?.title;
60
+ });
55
61
  (0, react.useEffect)(() => {
56
62
  document.title = title === void 0 ? productTitle : `${title} — ${productTitle}`;
57
63
  return () => {
@@ -86,10 +92,9 @@ window.__ModuleLoader__.load({
86
92
  * shell renders only 'root'). Owns the grid tracks (sidebar | center |
87
93
  * rightbar), the drag handles (pointer capture + rAF throttle), the column
88
94
  * solve (columns.ts), and the child-slot render decisions: the sidebar slot
89
- * renders HERE with live parameters from that solve, and the session-aware
90
- * occupants render in fixed column positions; the strict right-column entry
91
- * gates itself on current-session availability while the session-maybe
92
- * conversation retains identity.
95
+ * receives live parameters from that solve. The root-scoped main slot selects
96
+ * the Conversation or a global panel. Each column occupant owns its Session
97
+ * binding and reports the geometry it needs.
93
98
  *
94
99
  * The right column is a track, not a box: its occupant draws its panel anchored
95
100
  * to the frame's right edge at the resolved normal width, and the
@@ -105,6 +110,10 @@ window.__ModuleLoader__.load({
105
110
  children: props.children
106
111
  });
107
112
  }
113
+ /** Subscribe to the main key without subscribing the column frame to each panel id. */
114
+ function MainPanel({ usePanelInfo, renderSlot }) {
115
+ return renderSlot("main", {}, { entryKey: usePanelInfo((info) => info.activePanelId) ?? "conversation" });
116
+ }
108
117
  /**
109
118
  * Right column grid item. Zero-width unless the occupant asked for a track; the
110
119
  * occupant's panel is positioned against the column's right edge, which never
@@ -192,14 +201,10 @@ window.__ModuleLoader__.load({
192
201
  });
193
202
  }
194
203
  /** The three-column frame (see module doc). */
195
- function AppFrame({ useStore, useSessions, actions, renderSlot, SessionProvider, t }) {
196
- const panels = useStore((s) => s);
197
- const documentTitle = useSessions((s) => {
198
- const current = s.current;
199
- return current === void 0 ? void 0 : s.byId[current]?.title;
200
- });
204
+ function AppFrame({ useStore, useSessions, usePanelInfo, actions, renderSlot, t }) {
205
+ const layoutInfo = useStore((state) => state.layoutInfo);
201
206
  const frameRef = (0, react.useRef)(null);
202
- const viewport = panels.viewportWidth;
207
+ const viewport = layoutInfo.viewportWidth;
203
208
  (0, react.useLayoutEffect)(() => {
204
209
  const el = frameRef.current;
205
210
  /* v8 ignore next -- the ref is always attached by effect time: the frame div renders unconditionally. */
@@ -226,11 +231,11 @@ window.__ModuleLoader__.load({
226
231
  };
227
232
  }, [actions]);
228
233
  const narrow = viewport < SIDEBAR_AUTO_COLLAPSE;
229
- const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0;
230
- const sidebarPreference = sidebarCollapsed ? 0 : panels.sidebar === 0 ? 280 : panels.sidebar;
231
- const rightbarPreference = panels.rightbar ?? viewport * .45;
232
- const normal = computeColumns(viewport, !panels.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference);
233
- const cols = computeColumns(viewport, sidebarPreference, panels.rightbarTrack ? rightbarPreference : 0);
234
+ const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0;
235
+ const sidebarPreference = sidebarCollapsed ? 0 : layoutInfo.sidebar === 0 ? 280 : layoutInfo.sidebar;
236
+ const rightbarPreference = layoutInfo.rightbar ?? viewport * .45;
237
+ const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference);
238
+ const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0);
234
239
  const colsRef = (0, react.useRef)(cols);
235
240
  colsRef.current = cols;
236
241
  const rightbarWidth = (0, react.useRef)(normal.rightbar);
@@ -255,36 +260,48 @@ window.__ModuleLoader__.load({
255
260
  const onRightbarDrag = (0, react.useCallback)((dx) => {
256
261
  actions.setRightbar(rightbarBase.current - dx);
257
262
  }, [actions]);
263
+ const productTitle = "DeepSeek Harness";
264
+ const sidebar = (0, react.useMemo)(() => renderSlot("sidebar", {
265
+ collapsed: sidebarCollapsed,
266
+ width: cols.sidebar
267
+ }), [
268
+ renderSlot,
269
+ sidebarCollapsed,
270
+ cols.sidebar
271
+ ]);
272
+ const main = (0, react.useMemo)(() => (0, react_jsx_runtime.jsx)(MainPanel, {
273
+ usePanelInfo,
274
+ renderSlot
275
+ }), [usePanelInfo, renderSlot]);
276
+ const overlays = (0, react.useMemo)(() => renderSlot("shell.overlay", {}), [renderSlot]);
258
277
  return (0, react_jsx_runtime.jsxs)("div", {
259
278
  ref: frameRef,
260
279
  className: AppFrame_module_css_default.frame,
261
280
  style: { gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.rightbar}px` },
262
281
  "data-sidebar-collapsed": sidebarCollapsed || void 0,
263
282
  "data-rightbar-collapsed": cols.rightbar === 0 || void 0,
264
- "data-rightbar-fullscreen": panels.rightbarFullscreen || void 0,
265
- "data-rightbar-instant": panels.rightbarInstant || void 0,
283
+ "data-rightbar-fullscreen": layoutInfo.rightbarFullscreen || void 0,
284
+ "data-rightbar-instant": layoutInfo.rightbarInstant || void 0,
266
285
  "data-dragging": dragging || void 0,
267
286
  children: [
268
287
  (0, react_jsx_runtime.jsx)(DocumentTitle, {
269
- productTitle: "DeepSeek Harness",
270
- ...documentTitle === void 0 ? {} : { title: documentTitle }
288
+ productTitle,
289
+ useSessions,
290
+ usePanelInfo
271
291
  }),
272
292
  (0, react_jsx_runtime.jsx)("div", {
273
293
  className: AppFrame_module_css_default.sidebarCol,
274
- children: renderSlot("sidebar", {
275
- collapsed: sidebarCollapsed,
276
- width: cols.sidebar
277
- })
294
+ children: sidebar
278
295
  }),
279
- (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(CenterColumn, { children: renderSlot("conversation", {}) }), (0, react_jsx_runtime.jsx)(RightbarColumn, { children: (0, react_jsx_runtime.jsx)(SessionProvider, { children: renderSlot("rightbar", {
296
+ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(CenterColumn, { children: main }), (0, react_jsx_runtime.jsx)(RightbarColumn, { children: renderSlot("rightbar", {
280
297
  width: normal.rightbar,
281
298
  viewportWidth: viewport,
282
299
  canShow: normal.rightbar > 0
283
- }) }) })] }),
300
+ }) })] }),
284
301
  (0, react_jsx_runtime.jsx)("div", {
285
302
  className: AppFrame_module_css_default.overlayLayer,
286
303
  "data-shell-overlay": true,
287
- children: renderSlot("shell.overlay", {})
304
+ children: overlays
288
305
  }),
289
306
  !sidebarCollapsed && (0, react_jsx_runtime.jsx)(DragHandle, {
290
307
  side: "sidebar",
@@ -293,7 +310,7 @@ window.__ModuleLoader__.load({
293
310
  onDrag: onSidebarDrag,
294
311
  onEnd: onDragEnd
295
312
  }),
296
- panels.rightbarShown && !panels.rightbarFullscreen && normal.rightbar > 0 && (0, react_jsx_runtime.jsx)(DragHandle, {
313
+ layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (0, react_jsx_runtime.jsx)(DragHandle, {
297
314
  side: "rightbar",
298
315
  left: viewport - normal.rightbar,
299
316
  onStart: onRightbarStart,
@@ -321,48 +338,57 @@ window.__ModuleLoader__.load({
321
338
  function createLayoutStore() {
322
339
  return (0, _deepseek_ai_dsh_client_store.defineStore)({
323
340
  init: () => ({
324
- sidebar: 280,
325
- viewportWidth: window.innerWidth,
326
- narrowExpanded: false,
327
- rightbar: null,
328
- rightbarShown: false,
329
- rightbarTrack: false,
330
- rightbarFullscreen: false,
331
- rightbarInstant: false
341
+ panelInfo: { activePanelId: null },
342
+ layoutInfo: {
343
+ sidebar: 280,
344
+ viewportWidth: window.innerWidth,
345
+ narrowExpanded: false,
346
+ rightbar: null,
347
+ rightbarShown: false,
348
+ rightbarTrack: false,
349
+ rightbarFullscreen: false,
350
+ rightbarInstant: false
351
+ }
332
352
  }),
333
353
  actions: {
354
+ selectPanel: (d, panelId) => {
355
+ d.panelInfo.activePanelId = panelId;
356
+ },
357
+ retainMainPanels: (d, panelIds) => {
358
+ if (d.panelInfo.activePanelId !== null && !panelIds.includes(d.panelInfo.activePanelId)) d.panelInfo.activePanelId = null;
359
+ },
334
360
  setSidebar: (d, px) => {
335
- d.rightbarInstant = false;
336
- d.sidebar = clampWidth(px, 264, 420);
361
+ d.layoutInfo.rightbarInstant = false;
362
+ d.layoutInfo.sidebar = clampWidth(px, 264, 420);
337
363
  },
338
364
  toggleSidebar: (d) => {
339
- d.rightbarInstant = false;
340
- if (d.viewportWidth < 1024) d.narrowExpanded = !d.narrowExpanded;
341
- else d.sidebar = d.sidebar === 0 ? 280 : 0;
365
+ d.layoutInfo.rightbarInstant = false;
366
+ if (d.layoutInfo.viewportWidth < 1024) d.layoutInfo.narrowExpanded = !d.layoutInfo.narrowExpanded;
367
+ else d.layoutInfo.sidebar = d.layoutInfo.sidebar === 0 ? 280 : 0;
342
368
  },
343
369
  setViewportWidth: (d, width) => {
344
- if (d.viewportWidth === width) return;
345
- d.rightbarInstant = false;
346
- if (d.viewportWidth < 1024 !== width < 1024) d.narrowExpanded = false;
347
- d.viewportWidth = width;
370
+ if (d.layoutInfo.viewportWidth === width) return;
371
+ d.layoutInfo.rightbarInstant = false;
372
+ if (d.layoutInfo.viewportWidth < 1024 !== width < 1024) d.layoutInfo.narrowExpanded = false;
373
+ d.layoutInfo.viewportWidth = width;
348
374
  },
349
375
  setRightbar: (d, px) => {
350
- d.rightbarInstant = false;
351
- d.rightbar = clampWidth(px, 300, Math.max(300, d.viewportWidth * RIGHTBAR_MAX_RATIO));
376
+ d.layoutInfo.rightbarInstant = false;
377
+ d.layoutInfo.rightbar = clampWidth(px, 300, Math.max(300, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO));
352
378
  },
353
379
  openRightbar: (d, track, fullscreen) => {
354
- if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) d.rightbarInstant = d.rightbarFullscreen && !fullscreen;
355
- if (!d.rightbarShown && d.viewportWidth < 1024) d.narrowExpanded = false;
356
- d.rightbar ??= Math.max(300, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO));
357
- d.rightbarShown = true;
358
- d.rightbarTrack = track;
359
- d.rightbarFullscreen = fullscreen;
380
+ if (!d.layoutInfo.rightbarShown || d.layoutInfo.rightbarTrack !== track || d.layoutInfo.rightbarFullscreen !== fullscreen) d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen && !fullscreen;
381
+ if (!d.layoutInfo.rightbarShown && d.layoutInfo.viewportWidth < 1024) d.layoutInfo.narrowExpanded = false;
382
+ d.layoutInfo.rightbar ??= Math.max(300, Math.round(d.layoutInfo.viewportWidth * RIGHTBAR_DEFAULT_RATIO));
383
+ d.layoutInfo.rightbarShown = true;
384
+ d.layoutInfo.rightbarTrack = track;
385
+ d.layoutInfo.rightbarFullscreen = fullscreen;
360
386
  },
361
387
  closeRightbar: (d) => {
362
- if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen;
363
- d.rightbarShown = false;
364
- d.rightbarTrack = false;
365
- d.rightbarFullscreen = false;
388
+ if (d.layoutInfo.rightbarShown) d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen;
389
+ d.layoutInfo.rightbarShown = false;
390
+ d.layoutInfo.rightbarTrack = false;
391
+ d.layoutInfo.rightbarFullscreen = false;
366
392
  }
367
393
  }
368
394
  });
@@ -371,32 +397,44 @@ window.__ModuleLoader__.load({
371
397
  //#region lib/types/client/service.js
372
398
  /** Cross-plugin panel-action face (ctx.layout). */
373
399
  var LayoutController = class {
374
- #panels;
400
+ panels;
401
+ hasMainPanel;
402
+ navigation = new AbortController();
375
403
  /**
376
- * Adopt the root entry's bound store actions. Called from the root
377
- * registration's inject hook (a sanctioned assembly side effect), so the
378
- * face is live from the entry's first render; on entry re-register the
379
- * fresh actions overwrite the stale set.
380
- * @param actions - bound actions of the entry's layout store instance.
404
+ * @param panels - actions of the instance shared with the root entry.
405
+ * @param hasMainPanel - checks the live main-slot registry for a panel id.
381
406
  */
382
- attachPanels(actions) {
383
- this.#panels = actions;
407
+ constructor(panels, hasMainPanel) {
408
+ this.panels = panels;
409
+ this.hasMainPanel = hasMainPanel;
410
+ }
411
+ /** Select a global panel or return to the Conversation. */
412
+ selectPanel(panelId) {
413
+ if (panelId !== null && !this.hasMainPanel(panelId)) throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`);
414
+ this.navigation.abort();
415
+ this.panels.selectPanel(panelId);
416
+ }
417
+ /** @returns the new pending navigation's cancellation signal. */
418
+ beginNavigation() {
419
+ this.navigation.abort();
420
+ this.navigation = new AbortController();
421
+ return this.navigation.signal;
422
+ }
423
+ /** Invalidate pending navigations when the layout owner is unloaded. */
424
+ dispose() {
425
+ this.navigation.abort();
384
426
  }
385
427
  /** Toggle the sidebar panel (closed ⟷ contract default width). */
386
428
  toggleSidebar() {
387
- this.#require().toggleSidebar();
429
+ this.panels.toggleSidebar();
388
430
  }
389
431
  /** Report the right panel's track and fullscreen presentation. */
390
432
  openRightbar(track, fullscreen) {
391
- this.#require().openRightbar(track, fullscreen);
433
+ this.panels.openRightbar(track, fullscreen);
392
434
  }
393
435
  /** Report the right panel as hidden: no track, no handle. */
394
436
  closeRightbar() {
395
- this.#require().closeRightbar();
396
- }
397
- #require() {
398
- if (this.#panels === void 0) throw new Error("layout: panel actions not wired (root entry not mounted)");
399
- return this.#panels;
437
+ this.panels.closeRightbar();
400
438
  }
401
439
  };
402
440
  //#endregion
@@ -463,12 +501,25 @@ window.__ModuleLoader__.load({
463
501
  /**
464
502
  * Client plugin body: provide ctx.layout, then one register() call — AppFrame
465
503
  * into 'root' with the four child-slot declarations, the layout store seat,
466
- * and the inject hook that hands the store's bound actions to the service.
504
+ * and the shared root instance supplying commands and the panel-info source.
467
505
  * @param ctx - client root context.
468
506
  */
469
507
  function apply(ctx) {
470
- const layout = new LayoutController();
471
508
  ctx.effect(() => {
509
+ const handle = createLayoutStore();
510
+ const instance = handle.create();
511
+ const store = {
512
+ ...handle,
513
+ create: () => instance
514
+ };
515
+ const layout = new LayoutController(instance.actions, (id) => ctx.slots.entries("main").some((entry) => entry.options.key === id));
516
+ const retainMainPanels = () => {
517
+ instance.actions.retainMainPanels(ctx.slots.entries("main").flatMap((entry) => entry.options.key === void 0 ? [] : [entry.options.key]));
518
+ };
519
+ const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo: {
520
+ getSnapshot: () => instance.getSnapshot().panelInfo,
521
+ subscribe: (listener) => instance.subscribe(listener)
522
+ } } });
472
523
  const disposeService = ctx.reflect.provide("layout", layout);
473
524
  const disposeRegistration = ctx.slots.register({
474
525
  name: "root",
@@ -478,27 +529,28 @@ window.__ModuleLoader__.load({
478
529
  kind: "single",
479
530
  scope: "root"
480
531
  },
481
- "conversation": {
482
- kind: "single",
483
- scope: "session-maybe"
532
+ "main": {
533
+ kind: "keyed",
534
+ scope: "root"
484
535
  },
485
536
  "rightbar": {
486
537
  kind: "single",
487
- scope: "session"
538
+ scope: "root"
488
539
  },
489
540
  "shell.overlay": {
490
541
  kind: "list",
491
542
  scope: "root"
492
543
  }
493
544
  },
494
- store: createLayoutStore,
495
- inject: (actions) => {
496
- layout.attachPanels(actions);
497
- return {};
498
- }
545
+ store
499
546
  }, AppFrame);
547
+ const disposePanels = ctx.slots.subscribe("main", retainMainPanels);
548
+ retainMainPanels();
500
549
  return () => {
550
+ layout.dispose();
551
+ disposePanels();
501
552
  disposeRegistration();
553
+ disposePanelInfo();
502
554
  disposeService();
503
555
  };
504
556
  }, "ui-layout: service + root registration");
@@ -1,7 +1,7 @@
1
1
  import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { createLayoutStore } from './stores.ts';
3
3
  /** Full composed props: runtime share + child-slot render share + store share. */
4
- export type AppFrameProps = PropsRuntime<'root'> & PropsRenderSlots<'sidebar' | 'conversation' | 'rightbar' | 'shell.overlay'> & PropsStore<ReturnType<typeof createLayoutStore>> & PropsLocale<'common'>;
4
+ export type AppFrameProps = PropsRuntime<'root'> & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay'> & PropsStore<ReturnType<typeof createLayoutStore>> & PropsLocale<'common'>;
5
5
  /** The three-column frame (see module doc). */
6
- export declare function AppFrame({ useStore, useSessions, actions, renderSlot, SessionProvider, t, }: AppFrameProps): import("react").JSX.Element;
6
+ export declare function AppFrame({ useStore, useSessions, usePanelInfo, actions, renderSlot, t, }: AppFrameProps): import("react").JSX.Element;
7
7
  //# sourceMappingURL=AppFrame.d.ts.map
@@ -1,15 +1,14 @@
1
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
1
2
  /** Props for the browser title projection. */
2
- export interface DocumentTitleProps {
3
- /** Durable title of the selected session, or undefined for the product title. */
4
- title?: string;
3
+ export type DocumentTitleProps = Pick<PropsRuntime<'root'>, 'useSessions' | 'usePanelInfo'> & {
5
4
  /** Build-configured or localized product title. */
6
5
  productTitle: string;
7
- }
6
+ };
8
7
  /**
9
8
  * Project the selected durable session title into the browser title and
10
9
  * restore the build-selected product title when unmounted.
11
10
  * @param props - Selected session title projection.
12
11
  * @returns No rendered content.
13
12
  */
14
- export declare function DocumentTitle({ title, productTitle }: DocumentTitleProps): null;
13
+ export declare function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null;
15
14
  //# sourceMappingURL=DocumentTitle.d.ts.map
@@ -3,13 +3,17 @@
3
3
  * the runtime's built-in 'root' slot and, in the same breath, declares the
4
4
  * four child slots (declaration = exclusive render authority), seats the
5
5
  * layout store (panel geometry), and wires the panel-action service face.
6
- * ctx.layout is the cross-plugin panel-action contract; navigation state lives
7
- * with the runtime sessions service. A second effect seats the theme
6
+ * ctx.layout selects the main panel and controls column geometry; Session
7
+ * selection belongs to the Session Controller. A second effect seats the theme
8
8
  * presenter, which projects ctx.theme snapshots onto document.body.
9
9
  */
10
10
  import type { Context as ClientContext } from '@deepseek-ai/cordis';
11
+ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots';
12
+ import type { PanelInfo } from './service.ts';
11
13
  export { LayoutController } from './service.ts';
12
- export type { ILayout } from './service.ts';
14
+ export type { ILayout, MainPanelId, PanelInfo } from './service.ts';
15
+ /** Selector hook over root-scoped panel selection. */
16
+ export type UsePanelInfo = SnapshotSelectorHook<PanelInfo>;
13
17
  declare module '@deepseek-ai/cordis' {
14
18
  interface Context {
15
19
  /** The outward face only; the concrete service stays inside this plugin. */
@@ -17,6 +21,10 @@ declare module '@deepseek-ai/cordis' {
17
21
  }
18
22
  }
19
23
  declare module '@deepseek-ai/dsh-client-ui-slots' {
24
+ interface GlobalStandardProps {
25
+ /** Subscribe to the selected main panel independently of parent renders. */
26
+ usePanelInfo: UsePanelInfo;
27
+ }
20
28
  interface SlotMap {
21
29
  /**
22
30
  * The whole left column. OCCUPIED by ui-sidebar's SidebarRoot, which
@@ -34,21 +42,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
34
42
  owner: SidebarOwnerProps;
35
43
  };
36
44
  /**
37
- * The whole center column, across both the no-session hero and a live
38
- * conversation. OCCUPIED by ui-conversation's ConversationRoot, which
39
- * declares the session body, composer, and input seats inside it —
40
- * registering here replaces the entire conversation surface (and removes
41
- * every seat it declares) rather than adding to it.
42
- *
43
- * Current-session-optional: the occupant owns both states without
44
- * changing its React identity, so it keeps its own state across a session
45
- * switch. It receives no owner props; session facts arrive through the
46
- * framework hooks of the `session-maybe` scope.
45
+ * Central panel selected by sidebar entry id. The reserved `conversation`
46
+ * key hosts the Conversation; other keys receive no Session binding.
47
47
  */
48
- 'conversation': {
49
- kind: 'single';
50
- scope: 'session-maybe';
51
- owner: ConvOwnerProps;
48
+ 'main': {
49
+ kind: 'keyed';
50
+ scope: 'root';
52
51
  };
53
52
  /**
54
53
  * The right column: a track the centre makes room for, or nothing. OCCUPIED
@@ -60,12 +59,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
60
59
  * occupant's own recorded business — it reports the composition of its
61
60
  * expanded and presentation state through `ctx.layout`, and the frame sizes
62
61
  * the track and places the resize handle from that. The expand control is
63
- * not this column's: it is a button in the conversation header. With no
64
- * current session nothing is mounted here.
62
+ * not this column's: it is a button in the conversation header. The root
63
+ * occupant decides when to render its Session-bound content.
65
64
  */
66
65
  'rightbar': {
67
66
  kind: 'single';
68
- scope: 'session';
67
+ scope: 'root';
69
68
  owner: RightbarOwnerProps;
70
69
  };
71
70
  /**
@@ -91,9 +90,6 @@ export interface SidebarOwnerProps {
91
90
  /** Rendered column width in px (SIDEBAR_COLLAPSED when collapsed). */
92
91
  width: number;
93
92
  }
94
- /** Conversation owner share: business state and actions belong to the registrant. */
95
- export interface ConvOwnerProps {
96
- }
97
93
  /** Right column owner share: resolved normal geometry and opening eligibility. */
98
94
  export interface RightbarOwnerProps {
99
95
  /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */
@@ -111,7 +107,7 @@ export declare const inject: string[];
111
107
  /**
112
108
  * Client plugin body: provide ctx.layout, then one register() call — AppFrame
113
109
  * into 'root' with the four child-slot declarations, the layout store seat,
114
- * and the inject hook that hands the store's bound actions to the service.
110
+ * and the shared root instance supplying commands and the panel-info source.
115
111
  * @param ctx - client root context.
116
112
  */
117
113
  export declare function apply(ctx: ClientContext): void;
@@ -1,24 +1,38 @@
1
1
  /**
2
2
  * LayoutController: the cross-plugin panel-action face behind ctx.layout.
3
- * Panel geometry itself lives in the root entry's layout store (stores.ts);
3
+ * Panel geometry and main-panel selection live in the root layout store;
4
4
  * the current-session selection lives with the runtime sessions service, and
5
5
  * the per-session active view dissolved into ui-conversation's session store
6
6
  * (its only consumer). What remains here is the contract other plugins'
7
- * apply worlds reach for panel transitions (sidebar toggle from ui-sidebar,
7
+ * apply worlds reach for panel transitions (main-panel selection and sidebar toggle,
8
8
  * right-panel show/hide from ui-sidebar-right) — writes stay inside the
9
- * store's declared action set, delivered as the registration's bound actions.
9
+ * store's declared action set, shared with the root registration.
10
10
  */
11
11
  import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots';
12
+ import type { Branded } from '@deepseek-ai/dsh-brand';
12
13
  import type { createLayoutStore } from './stores.ts';
14
+ /** Identity shared by a sidebar panel entry and its main-slot occupant. */
15
+ export type MainPanelId = Branded<'MainPanelId'>;
16
+ /** Root-scoped navigation state exposed to panel-aware components. */
17
+ export interface PanelInfo {
18
+ /** Selected global panel; null displays the current Conversation. */
19
+ readonly activePanelId: MainPanelId | null;
20
+ }
13
21
  /** The layout store's bound action set (framework-baked, draft params peeled). */
14
22
  export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>;
15
- /**
16
- * The outward layout face (`ctx.layout`): the panel transitions other
17
- * plugins may trigger — and exactly what a test fake must supply. The
18
- * attachPanels wiring hook stays on the concrete class (root-entry assembly
19
- * only).
20
- */
23
+ /** Panel navigation and geometry actions exposed through ctx.layout. */
21
24
  export interface ILayout {
25
+ /**
26
+ * Select a global central panel without changing the current Session.
27
+ * @param panelId - registered main key, or null to show the Conversation.
28
+ * @throws if the selected main key is not registered; preserves the current selection.
29
+ */
30
+ selectPanel(panelId: MainPanelId | null): void;
31
+ /**
32
+ * Start an asynchronous navigation, superseding any earlier pending navigation.
33
+ * @returns a signal aborted by the next navigation or layout disposal; check it before committing UI state.
34
+ */
35
+ beginNavigation(): AbortSignal;
22
36
  /** Toggle the sidebar panel (closed ⟷ contract default width). */
23
37
  toggleSidebar(): void;
24
38
  /**
@@ -34,15 +48,20 @@ export interface ILayout {
34
48
  }
35
49
  /** Cross-plugin panel-action face (ctx.layout). */
36
50
  export declare class LayoutController implements ILayout {
37
- #private;
51
+ private readonly panels;
52
+ private readonly hasMainPanel;
53
+ private navigation;
38
54
  /**
39
- * Adopt the root entry's bound store actions. Called from the root
40
- * registration's inject hook (a sanctioned assembly side effect), so the
41
- * face is live from the entry's first render; on entry re-register the
42
- * fresh actions overwrite the stale set.
43
- * @param actions - bound actions of the entry's layout store instance.
55
+ * @param panels - actions of the instance shared with the root entry.
56
+ * @param hasMainPanel - checks the live main-slot registry for a panel id.
44
57
  */
45
- attachPanels(actions: PanelActions): void;
58
+ constructor(panels: PanelActions, hasMainPanel: (id: MainPanelId) => boolean);
59
+ /** Select a global panel or return to the Conversation. */
60
+ selectPanel(panelId: MainPanelId | null): void;
61
+ /** @returns the new pending navigation's cancellation signal. */
62
+ beginNavigation(): AbortSignal;
63
+ /** Invalidate pending navigations when the layout owner is unloaded. */
64
+ dispose(): void;
46
65
  /** Toggle the sidebar panel (closed ⟷ contract default width). */
47
66
  toggleSidebar(): void;
48
67
  /** Report the right panel's track and fullscreen presentation. */
@@ -3,11 +3,19 @@
3
3
  * The registration supplies a fresh store and binds its actions to ctx.layout.
4
4
  */
5
5
  import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-store';
6
+ import type { MainPanelId } from './service.ts';
6
7
  /**
7
8
  * Transient layout preferences. Responsive concessions never rewrite widths;
8
9
  * the right panel's expanded state belongs to its occupant.
9
10
  */
10
11
  type LayoutState = {
12
+ panelInfo: {
13
+ /** Null selects the Conversation; global panels keep the current Session intact. */
14
+ activePanelId: MainPanelId | null;
15
+ };
16
+ layoutInfo: LayoutInfo;
17
+ };
18
+ type LayoutInfo = {
11
19
  sidebar: number;
12
20
  /** Last positive frame measurement; window width bootstraps the first render. */
13
21
  viewportWidth: number;
@@ -41,6 +49,8 @@ type LayoutState = {
41
49
  * return type); drift fails assignability at the defineStore call.
42
50
  */
43
51
  type LayoutActions = {
52
+ selectPanel: (draft: LayoutState, panelId: MainPanelId | null) => void;
53
+ retainMainPanels: (draft: LayoutState, panelIds: readonly string[]) => void;
44
54
  setSidebar: (draft: LayoutState, px: number) => void;
45
55
  toggleSidebar: (draft: LayoutState) => void;
46
56
  setViewportWidth: (draft: LayoutState, width: number) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-layout",
3
3
  "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)",
4
- "version": "0.1.5-alpha.1",
4
+ "version": "0.1.5-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -43,12 +43,13 @@
43
43
  "devDependencies": {
44
44
  "@types/react": "~18.3.1",
45
45
  "react": "^18.2.0",
46
- "@deepseek-ai/dsh-client-locale": "^0.1.5-alpha.1",
47
- "@deepseek-ai/dsh-client-store": "^0.1.5-alpha.1",
48
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-alpha.1",
49
- "@deepseek-ai/dsh-client-ui-session": "^0.1.5-alpha.1",
50
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-alpha.1",
51
- "@deepseek-ai/dsh-client-ui-theme": "^0.1.5-alpha.1",
46
+ "@deepseek-ai/dsh-brand": "^0.1.5-alpha.2",
47
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-alpha.2",
48
+ "@deepseek-ai/dsh-client-store": "^0.1.5-alpha.2",
49
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-alpha.2",
50
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.5-alpha.2",
51
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-alpha.2",
52
+ "@deepseek-ai/dsh-client-ui-theme": "^0.1.5-alpha.2",
52
53
  "@deepseek-ai/cordis": "^4.0.2"
53
54
  },
54
55
  "files": [