diff --git a/README.md b/README.md
index beb2c00..34ca46b 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,32 @@
# @local/dsh-chat-split
-DSH plugin for multi-chat split screen with responsive grid wrapping, per-chat sidebars, and customizable layout rules.
+Split view for DeepSeek Harness: several chats open at once in the main area.
## Features
-- **Responsive Grid Layout**: Places chats side-by-side; when the container width falls below the readability threshold, chats automatically wrap vertically into a grid of rows.
-- **Configurable Minimum Height**: Windows enforce a minimum height (default `396px`, configurable in Settings) to guarantee conversation readability.
-- **Settings with Interactive Preview**: Dedicated Settings section with an interactive resizable preview box and chat-count selector showing real-time reflow behavior.
-- **Per-Chat Sidebar**: Every split window features its own toggleable mini-sidebar listing sessions, letting you switch individual windows or jump to conversations.
-- **Active Split Blue Glow Highlight**: When selecting a chat that is already open in another split window while split view is active, that window pulses with a brief blue glow highlight.
-- **Include / Exclude Controls**: Add or remove any chat directly from the session menu (`...`) in the workspace sidebar.
-- **Window Top Bar**: Every window includes controls to move position in the grid (`◀` and `▶`) or close (`✕`) the window.
+- **Side by side, then a grid.** Chats sit in one row while each still gets the *readability width* (default 420 px). When the window is too narrow for that, they wrap into a grid of rows and columns.
+- **Minimum height.** Every window is at least **396 px** tall (configurable). If the rows don't fit, the grid scrolls instead of squashing the chats.
+- **A full chat in every window.** Each window renders the real conversation (messages, composer, tools), the same embedded view DSH uses for sub-agent chats.
+- **Own chat list per window.** The `☰` button opens that window's own sidebar: filter and switch which chat the window shows. A blue dot marks chats already open in another window, and a green dot marks running ones.
+- **Top bar per window.** Title, grid position (row · column), move up / left / right / down, and close.
+- **Blue glow on re-selection.** Selecting a chat that is already open (from a window's list, *+ Add chat*, the "…" menu, or the main sidebar) while split view is showing flashes that window with a short blue glow. Choosing it in the main sidebar keeps you in split view.
+- **Include / exclude.** Every chat's "…" menu in the sidebar has *Include in split view* / *Exclude from split view*.
+- **Settings with a live preview.** *Settings → Chat split* has sliders for minimum height, readability width, gap and window chat-list width, plus a scaled preview of the split view built from your real chats. Drag the preview's width/height or change the number of chats to see where it wraps.
-## Usage
+## Use
-1. Open **Split view** from the sidebar panellist icon (`⊞`).
-2. Use "+ Add open chats" or click the `...` menu on any session in the workspace sidebar and choose **Include in split view**.
-3. In any split window, use `◀ Sidebar` to view available sessions and switch chats.
-4. Customize minimum height and readability width threshold in **Settings → Chat split**.
+1. Click **Split view** in the sidebar's panel list (the two-pane icon).
+2. Add chats with **+ Add chat**, the suggestions on the empty page, or *Include in split view* in a chat's "…" menu.
+3. Rearrange with the arrows in each window's top bar; close with `✕`.
+
+The list of open chats and the settings are stored in the browser (`localStorage`).
+
+## Development
+
+`client.js` is the whole plugin (`index.js` is an empty host half).
+
+```bash
+node --test test/*.test.mjs # headless: registrations, rendering, layout maths, main-sidebar glow
+```
+
+The test loads `client.js` with real React (`REACT_DIR=
`) against fake DSH services, and skips when React isn't found.
diff --git a/client.js b/client.js
index 747cf2b..376aa30 100644
--- a/client.js
+++ b/client.js
@@ -1,1071 +1,478 @@
/**
* @local/dsh-chat-split — browser side.
*
- * Provides a split-screen multi-chat environment:
- * - Arranges chats side-by-side or wraps them vertically in a responsive grid.
- * - Minimum height of 396px per chat window, configurable in Settings.
- * - Settings section includes an interactive preview showing how real layout reflows.
- * - Each chat has its own sidebar inside its window with session selection and quick switching.
- * - Brief blue glow highlight when selecting an already-open chat while split mode is active.
- * - Include/exclude chats to/from split view directly from the session menu.
- * - Top bar on each split window with position rearrangement in grid and window closing.
+ * Split view: several conversations side by side in one main panel.
+ * - Windows sit in one row while each still gets `minWidth` px (readability); otherwise
+ * they wrap into a grid. Each window is at least `minHeight` px tall (default 396).
+ * - Every window has its own top bar (move up/left/right/down, sidebar toggle, close) and
+ * its own chat sidebar to switch what that window shows.
+ * - Selecting a chat that is already open (from a window sidebar or the main sidebar)
+ * while split view is showing flashes that window with a short blue glow.
+ * - "Include in / Exclude from split view" in every session's "..." menu.
+ * - Settings → Chat split: sizes plus an interactive preview built from your real chats.
*/
window.__ModuleLoader__.load({
id: '@local/dsh-chat-split',
factory(require) {
const React = require('react');
+ const P = require('@deepseek-ai/dsh-client-ui-primitives') || {};
const h = React.createElement;
const { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } = React;
const PANEL_ID = 'dsh-chat-split';
+ const CHILD_SLOT = 'dsh-chat-split.window';
const SETTINGS_KEY = 'dsh.chat-split.settings.v1';
const SESSIONS_KEY = 'dsh.chat-split.sessions.v1';
- const ACTIVE_KEY = 'dsh.chat-split.active.v1';
+ const DEFAULTS = { minHeight: 396, minWidth: 420, gap: 8, sidebarWidth: 180, sidebarOpen: true };
+ const GLOW_MS = 1400;
- // ---- Settings & State Store ------------------------------------------
- const defaultSettings = {
- minHeight: 396,
- minWidth: 420,
- gap: 10,
+ // ------------------------------------------------------------------ store
+ const read = (key, fallback) => {
+ try { const v = JSON.parse(localStorage.getItem(key)); return v ?? fallback; } catch { return fallback; }
};
+ const write = (key, v) => { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* quota */ } };
- function loadJson(key, fallback) {
- try {
- const raw = localStorage.getItem(key);
- if (!raw) return fallback;
- return { ...fallback, ...JSON.parse(raw) };
- } catch {
- return fallback;
- }
- }
-
- function saveJson(key, val) {
- try {
- localStorage.setItem(key, JSON.stringify(val));
- } catch {}
- }
-
- const listeners = new Set();
- function notify() {
- listeners.forEach((fn) => {
- try { fn(); } catch {}
- });
- }
-
- let currentSettings = loadJson(SETTINGS_KEY, defaultSettings);
- let splitSessionIds = (() => {
- try {
- const raw = localStorage.getItem(SESSIONS_KEY);
- return raw ? JSON.parse(raw) : [];
- } catch {
- return [];
- }
- })();
- let isSplitActive = localStorage.getItem(ACTIVE_KEY) === 'true';
- let glowingSessionId = null;
- let glowTimer = null;
-
+ let state = {
+ settings: { ...DEFAULTS, ...read(SETTINGS_KEY, {}) },
+ ids: (read(SESSIONS_KEY, []) || []).filter((x) => typeof x === 'string'),
+ glow: null, // { id, n }
+ };
+ const subs = new Set();
+ const set = (patch) => { state = { ...state, ...patch }; subs.forEach((f) => { try { f(); } catch { /* ignore */ } }); };
const store = {
- getSettings() {
- return currentSettings;
+ get: () => state,
+ subscribe: (f) => { subs.add(f); return () => subs.delete(f); },
+ setSettings(p) { const s = { ...state.settings, ...p }; write(SETTINGS_KEY, s); set({ settings: s }); },
+ setIds(ids) { const u = [...new Set(ids)]; write(SESSIONS_KEY, u); set({ ids: u }); },
+ has: (id) => state.ids.includes(id),
+ add(id) { if (!store.has(id)) store.setIds([...state.ids, id]); },
+ remove(id) { store.setIds(state.ids.filter((x) => x !== id)); },
+ replace(oldId, newId) { store.setIds(state.ids.map((x) => (x === oldId ? newId : x))); },
+ move(id, dir) {
+ const ids = [...state.ids]; const i = ids.indexOf(id); const j = i + dir;
+ if (i < 0 || j < 0 || j >= ids.length) return;
+ ids.splice(i, 1); ids.splice(j, 0, id); store.setIds(ids);
},
- updateSettings(patch) {
- currentSettings = { ...currentSettings, ...patch };
- saveJson(SETTINGS_KEY, currentSettings);
- notify();
+ glow(id) {
+ const n = (state.glow?.n || 0) + 1;
+ set({ glow: { id, n } });
+ setTimeout(() => { if (state.glow?.n === n) set({ glow: null }); }, GLOW_MS);
},
- getSessions() {
- return splitSessionIds;
- },
- setSessions(ids) {
- splitSessionIds = ids;
- saveJson(SESSIONS_KEY, ids);
- notify();
- },
- addSession(id) {
- if (!splitSessionIds.includes(id)) {
- splitSessionIds = [...splitSessionIds, id];
- saveJson(SESSIONS_KEY, splitSessionIds);
- notify();
- }
- },
- removeSession(id) {
- splitSessionIds = splitSessionIds.filter((x) => x !== id);
- saveJson(SESSIONS_KEY, splitSessionIds);
- notify();
- },
- moveSession(index, direction) {
- const targetIndex = index + direction;
- if (targetIndex < 0 || targetIndex >= splitSessionIds.length) return;
- const copy = [...splitSessionIds];
- const [removed] = copy.splice(index, 1);
- copy.splice(targetIndex, 0, removed);
- splitSessionIds = copy;
- saveJson(SESSIONS_KEY, copy);
- notify();
- },
- isSplitActive() {
- return isSplitActive;
- },
- setSplitActive(active) {
- isSplitActive = !!active;
- localStorage.setItem(ACTIVE_KEY, isSplitActive ? 'true' : 'false');
- notify();
- },
- getGlowingSessionId() {
- return glowingSessionId;
- },
- triggerGlow(sessionId) {
- if (!isSplitActive) return;
- glowingSessionId = sessionId;
- if (glowTimer) clearTimeout(glowTimer);
- notify();
- glowTimer = setTimeout(() => {
- glowingSessionId = null;
- notify();
- }, 1600);
- },
- subscribe(fn) {
- listeners.add(fn);
- return () => listeners.delete(fn);
- }
};
+ const useStore = (sel) => { const get = () => sel(store.get()); return useSyncExternalStore(store.subscribe, get, get); };
- // ---- CSS Styles ------------------------------------------------------
- function installStyles() {
- if (document.getElementById('dsh-chat-split-styles')) return;
- const el = document.createElement('style');
- el.id = 'dsh-chat-split-styles';
- el.textContent = `
- @keyframes dsh-split-blue-glow {
- 0% { box-shadow: 0 0 0 3px #1f6feb, 0 0 20px 6px rgba(31, 111, 235, 0.65); border-color: #58a6ff; }
- 60% { box-shadow: 0 0 0 3px #1f6feb, 0 0 16px 4px rgba(31, 111, 235, 0.45); border-color: #58a6ff; }
- 100% { box-shadow: none; border-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12)); }
- }
- .dsh-split-container {
- display: flex;
- flex-direction: column;
- height: 100%;
- width: 100%;
- background: var(--dsw-alias-bg-base, #0d1117);
- color: var(--dsw-alias-label-primary, #e6edf3);
- overflow: hidden;
- font-family: var(--dsw-font-family, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
- }
- .dsh-split-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 16px;
- border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
- background: var(--dsw-alias-bg-l1, #161b22);
- flex-shrink: 0;
- gap: 12px;
- }
- .dsh-split-header-left {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .dsh-split-header-title {
- font-size: 14px;
- font-weight: 600;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .dsh-split-badge {
- background: rgba(31, 111, 235, 0.2);
- color: #58a6ff;
- border: 1px solid rgba(56, 139, 253, 0.3);
- padding: 2px 7px;
- border-radius: 12px;
- font-size: 11px;
- font-weight: 500;
- }
- .dsh-split-grid {
- display: grid;
- flex: 1;
- padding: 10px;
- overflow: auto;
- box-sizing: border-box;
- align-content: start;
- }
- .dsh-split-window {
- display: flex;
- flex-direction: column;
- background: var(--dsw-alias-bg-l1, #161b22);
- border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
- border-radius: 8px;
- overflow: hidden;
- box-sizing: border-box;
- position: relative;
- transition: border-color 0.2s, box-shadow 0.2s;
- }
- .dsh-split-window.glow {
- animation: dsh-split-blue-glow 1.5s ease-out;
- border-color: #58a6ff !important;
- z-index: 5;
- }
- .dsh-split-win-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 6px 10px;
- background: var(--dsw-alias-bg-l2, #21262d);
- border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
- font-size: 12px;
- font-weight: 500;
- user-select: none;
- flex-shrink: 0;
- gap: 8px;
- }
- .dsh-split-win-title {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-weight: 600;
- flex: 1;
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .dsh-split-win-controls {
- display: flex;
- align-items: center;
- gap: 4px;
- }
- .dsh-split-win-btn {
- border: none;
- background: transparent;
- color: var(--dsw-alias-label-secondary, #8b949e);
- cursor: pointer;
- padding: 3px 6px;
- border-radius: 4px;
- font-size: 12px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
- transition: background 0.15s, color 0.15s;
- }
- .dsh-split-win-btn:hover:not(:disabled) {
- background: var(--dsw-alias-bg-l3, rgba(255, 255, 255, 0.1));
- color: var(--dsw-alias-label-primary, #ffffff);
- }
- .dsh-split-win-btn:disabled {
- opacity: 0.35;
- cursor: default;
- }
- .dsh-split-win-body {
- display: flex;
- flex: 1;
- min-height: 0;
- position: relative;
- overflow: hidden;
- }
- .dsh-split-sidebar {
- width: 170px;
- flex-shrink: 0;
- border-right: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
- background: var(--dsw-alias-bg-l2, #1c2128);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- transition: width 0.2s;
- }
- .dsh-split-sidebar.collapsed {
- width: 0;
- border-right: none;
- }
- .dsh-split-sidebar-head {
- padding: 6px 8px;
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- color: var(--dsw-alias-label-secondary, #8b949e);
- border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- .dsh-split-sidebar-list {
- flex: 1;
- overflow-y: auto;
- padding: 4px 0;
- margin: 0;
- list-style: none;
- }
- .dsh-split-sidebar-item {
- display: flex;
- align-items: center;
- padding: 6px 8px;
- gap: 6px;
- font-size: 11px;
- cursor: pointer;
- color: var(--dsw-alias-label-primary, #e6edf3);
- border-left: 2px solid transparent;
- transition: background 0.15s;
- }
- .dsh-split-sidebar-item:hover {
- background: var(--dsw-alias-bg-l3, rgba(255, 255, 255, 0.06));
- }
- .dsh-split-sidebar-item.current {
- background: rgba(31, 111, 235, 0.15);
- border-left-color: #58a6ff;
- font-weight: 600;
- }
- .dsh-split-sidebar-item.open-elsewhere {
- opacity: 0.75;
- }
- .dsh-split-sidebar-item-title {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .dsh-split-conversation-pane {
- flex: 1;
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- position: relative;
- overflow: hidden;
- }
- .dsh-split-empty {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- text-align: center;
- color: var(--dsw-alias-label-secondary, #8b949e);
- padding: 24px;
- }
- .dsh-split-empty h3 {
- margin: 0 0 8px 0;
- color: var(--dsw-alias-label-primary, #ffffff);
- font-size: 16px;
- }
- .dsh-split-btn {
- border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.18));
- background: var(--dsw-alias-bg-l2, #21262d);
- color: var(--dsw-alias-label-primary, #ffffff);
- padding: 5px 12px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- transition: all 0.15s;
- }
- .dsh-split-btn:hover {
- background: var(--dsw-alias-bg-l3, #30363d);
- border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.3));
- }
- .dsh-split-btn-primary {
- background: #238636;
- border-color: rgba(240, 246, 252, 0.1);
- }
- .dsh-split-btn-primary:hover {
- background: #2ea043;
- }
- .dsh-split-preview-box {
- border: 1px dashed var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.25));
- border-radius: 8px;
- padding: 12px;
- background: var(--dsw-alias-bg-base, #0d1117);
- position: relative;
- box-sizing: border-box;
- margin-top: 10px;
- }
- .dsh-split-mock-win {
- background: var(--dsw-alias-bg-l1, #161b22);
- border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.15));
- border-radius: 6px;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- font-size: 11px;
- }
- .dsh-split-mock-bar {
- background: var(--dsw-alias-bg-l2, #21262d);
- padding: 4px 8px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
- font-weight: 600;
- }
- .dsh-split-mock-body {
- flex: 1;
- display: flex;
- }
- .dsh-split-mock-side {
- width: 50px;
- background: var(--dsw-alias-bg-l2, #1c2128);
- border-right: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
- padding: 4px;
- }
- .dsh-split-mock-chat {
- flex: 1;
- padding: 8px;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- background: rgba(0,0,0,0.2);
- }
- `;
+ /** Services captured in apply(). */
+ let sessionsSvc = null;
+ let layoutSvc = null;
+ const EMPTY_LIST = { ids: [], byId: {}, phase: 'pending' };
+ const noopSub = () => () => {};
+ function useSessionList() {
+ const src = sessionsSvc?.list;
+ const get = () => (src ? src.getSnapshot() : EMPTY_LIST);
+ return useSyncExternalStore(src ? src.subscribe.bind(src) : noopSub, get, get);
+ }
+ /** Top-level, non-blank chats, most recent first. */
+ function chatIds(list) {
+ return list.ids
+ .filter((id) => { const s = list.byId[id]; return s && !s.blank && !s.parentId && s.origin !== 'subagent'; })
+ .sort((a, b) => (list.byId[b].updatedAt || 0) - (list.byId[a].updatedAt || 0));
+ }
+ const titleOf = (list, id) => list.byId[id]?.displayTitle || list.byId[id]?.title || 'Untitled chat';
+ const isSplitShowing = () => layoutSvc?.panelInfo?.getSnapshot().activePanelId === PANEL_ID;
+ const openSplit = () => { try { layoutSvc?.selectPanel(PANEL_ID); } catch { /* not registered yet */ } };
+
+ // ------------------------------------------------------------------ css
+ const CSS = `
+@keyframes dcs-glow{0%{box-shadow:0 0 0 2px #3b82f6,0 0 22px 6px rgba(59,130,246,.65)}70%{box-shadow:0 0 0 2px #3b82f6,0 0 14px 3px rgba(59,130,246,.35)}100%{box-shadow:0 0 0 0 rgba(59,130,246,0)}}
+.dcs-root{display:flex;flex-direction:column;height:100%;width:100%;min-width:0;min-height:0;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);font-size:13px}
+.dcs-head{position:relative;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none}
+.dcs-head-title{font-weight:600;font-size:14px}
+.dcs-head-meta{color:var(--dsw-alias-label-tertiary);font-size:12px}
+.dcs-sp{flex:1}
+.dcs-btn{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-l1);color:inherit;border-radius:7px;padding:4px 10px;font:inherit;font-size:12px;cursor:pointer}
+.dcs-btn:hover{background:var(--dsw-alias-bg-l2)}
+.dcs-grid{flex:1;min-height:0;overflow:auto;display:grid;padding:8px;box-sizing:border-box}
+.dcs-win{position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-base)}
+.dcs-win.glow{animation:dcs-glow ${GLOW_MS}ms ease-out;border-color:#3b82f6}
+.dcs-bar{display:flex;align-items:center;gap:2px;height:32px;padding:0 6px;background:var(--dsw-alias-bg-l1);border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;user-select:none}
+.dcs-bar-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:12px;margin-left:4px}
+.dcs-bar-pos{font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 4px}
+.dcs-ib{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:0;border-radius:6px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:13px;line-height:1;padding:0}
+.dcs-ib:hover:not(:disabled){background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary)}
+.dcs-ib:disabled{opacity:.3;cursor:default}
+.dcs-ib.on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-l2)}
+.dcs-body{flex:1;display:flex;min-height:0}
+.dcs-side{flex:none;display:flex;flex-direction:column;border-right:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-l1);min-height:0}
+.dcs-side-head{padding:6px 8px 4px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase;letter-spacing:.04em}
+.dcs-side-filter{margin:0 6px 4px;padding:4px 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-alias-bg-base);color:inherit;font:inherit;font-size:12px}
+.dcs-side-list{flex:1;overflow:auto;padding:0 4px 6px}
+.dcs-item{display:flex;align-items:center;gap:6px;width:100%;border:0;background:none;color:inherit;text-align:left;font:inherit;font-size:12px;padding:5px 6px;border-radius:6px;cursor:pointer}
+.dcs-item:hover{background:var(--dsw-alias-bg-l2)}
+.dcs-item.cur{background:var(--dsw-alias-bg-l2);font-weight:600}
+.dcs-item-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.dcs-dot{width:6px;height:6px;border-radius:50%;flex:none;background:transparent}
+.dcs-dot.open{background:#3b82f6}
+.dcs-dot.run{background:#22c55e}
+.dcs-conv{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative;overflow:hidden}
+.dcs-conv > *{flex:1;min-height:0}
+.dcs-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;text-align:center;color:var(--dsw-alias-label-secondary)}
+.dcs-chips{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;max-width:640px}
+.dcs-msg{padding:16px;color:var(--dsw-alias-label-tertiary)}
+.dcs-pop{position:absolute;z-index:20;top:40px;right:12px;width:300px;max-height:360px;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-l1));box-shadow:0 8px 28px rgba(0,0,0,.35)}
+.dcs-set{max-width:860px}
+.dcs-set h3{margin:0 0 4px;font-size:16px}
+.dcs-set p{margin:0 0 14px;color:var(--dsw-alias-label-secondary)}
+.dcs-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px 18px;margin-bottom:18px}
+.dcs-f label{display:flex;justify-content:space-between;font-weight:600;font-size:12px;margin-bottom:4px}
+.dcs-f input[type=range]{width:100%}
+.dcs-f small{display:block;color:var(--dsw-alias-label-tertiary);font-size:11px;margin-top:2px}
+.dcs-prev-ctl{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-bottom:8px;font-size:12px}
+.dcs-prev-ctl input[type=range]{flex:1;min-width:140px}
+.dcs-prev{border:1px dashed var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-base)}
+.dcs-prev-scale{transform-origin:0 0;pointer-events:none}
+.dcs-mock{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-base)}
+.dcs-mline{height:10px;border-radius:5px;background:var(--dsw-alias-bg-l2);margin:8px 12px}
+.dcs-mline.me{margin-left:40%;background:rgba(59,130,246,.28)}
+.dcs-minput{margin:auto 12px 12px;height:34px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px}
+`;
+ function installCss() {
+ const el = document.createElement('style'); el.id = 'dcs-css'; el.textContent = CSS;
document.head.appendChild(el);
+ return () => el.remove();
}
- // ---- Child Slot Occupant: Split Window Content -----------------------
- function FixedChatConversationView(props) {
- return h(React.Fragment, null, props.renderSlot('conversation.session', { view: 'chat' }));
- }
+ /** Columns that fit `width` while each keeps at least minWidth (never more than n). */
+ const columnsFor = (width, n, s) => Math.max(1, Math.min(n || 1, Math.floor((width + s.gap) / (s.minWidth + s.gap))));
- function SplitWindowConversationPanel({ sessionId, useSession, useConversation, useSessions, renderFactorySlot }) {
- const session = useSession ? useSession((v) => v) : null;
- const conversation = useConversation ? useConversation((v) => v) : null;
- const summaryBlank = useSessions ? useSessions((s) => s.byId?.[sessionId]?.blank) : false;
-
- const shellPhase = conversation && conversation.activeTargets && conversation.activeTargets.size > 0 ||
- (session && !session.blank && !session.awaitingFirstTurn) ||
- (session && session.running)
- ? 'active'
- : session && session.promptAttempted
- ? 'engaging'
- : 'blank';
-
- const parentAvailabilityPending = session?.subagent?.address?.mode === 'continuable' && session?.subagent?.parentAvailable === void 0;
- const settling = shellPhase === 'blank' && session?.openState === 'loading' && summaryBlank !== true || parentAvailabilityPending;
- const hero = shellPhase === 'blank' && (session?.openState === 'open' || summaryBlank === true);
-
- if (typeof renderFactorySlot !== 'function') {
- return h('div', { style: { padding: 16, color: '#888' } }, `Session ${sessionId}`);
- }
+ // ------------------------------------------------------------------ embedded conversation
+ function FixedChatView(props) { return h(React.Fragment, null, props.renderSlot('conversation.session', { view: 'chat' })); }
+ /** Occupant of the per-window session-scope slot (same shape as ui-subagent's embedded chat). */
+ function WindowConversation({ sessionId, useSession, useConversation, useSessions, renderFactorySlot }) {
+ const session = useSession((v) => v);
+ const active = useConversation((v) => v).activeTargets.size > 0;
+ const summaryBlank = useSessions((s) => s.byId[sessionId]?.blank);
+ const shell = active || (!session.blank && !session.awaitingFirstTurn) || session.running ? 'active' : session.promptAttempted ? 'engaging' : 'blank';
+ const settling = shell === 'blank' && session.openState === 'loading' && summaryBlank !== true;
+ const hero = shell === 'blank' && (session.openState === 'open' || summaryBlank === true);
return renderFactorySlot('conversation.content', {
- variant: 'embedded',
- phase: settling ? 'settling' : hero ? 'hero' : 'active',
- hero: !!hero,
- }, {
- slots: { views: FixedChatConversationView }
- });
+ variant: 'embedded', phase: settling ? 'settling' : hero ? 'hero' : 'active', hero,
+ }, { slots: { views: FixedChatView } });
}
- // ---- Single Split Window Frame ---------------------------------------
- function SplitWindowFrame({
- sessionId,
- index,
- total,
- ctx,
- settings,
- onClose,
- onMove,
- onSelectSession,
- isGlowing,
- renderSlot,
- SessionProvider,
- availableSessions,
- }) {
- const [sidebarOpen, setSidebarOpen] = useState(true);
- const [sessionRef, setSessionRef] = useState(null);
- const [loadError, setLoadError] = useState(null);
-
- const title = availableSessions?.byId?.[sessionId]?.displayTitle ||
- availableSessions?.byId?.[sessionId]?.title ||
- sessionId ||
- 'Untitled chat';
-
+ /** Retains the window's session for as long as the window shows it. */
+ function useRetained(sessionId) {
+ const [ref, setRef] = useState({ id: null, reference: null, error: null });
useEffect(() => {
- if (!ctx?.sessions || !sessionId) return;
- const ctrl = new AbortController();
- let ref = null;
+ if (!sessionsSvc || !sessionId) return undefined;
+ let reference = null;
try {
- ref = ctx.sessions.retain(sessionId, { source: 'chatSplit', signal: ctrl.signal });
- setSessionRef(ref);
- setLoadError(null);
- } catch (err) {
- setLoadError(err.message || String(err));
- setSessionRef(null);
+ reference = sessionsSvc.retain(sessionId, { source: 'chatSplit' });
+ setRef({ id: sessionId, reference, error: null });
+ } catch (e) {
+ setRef({ id: sessionId, reference: null, error: String(e?.message || e) });
}
-
- return () => {
- ctrl.abort();
- if (ref) {
- try { ref.release(); } catch {}
- }
- };
- }, [ctx, sessionId]);
-
- const sessionIdsList = useMemo(() => {
- if (!availableSessions?.ids) return [];
- return availableSessions.ids.filter((id) => !availableSessions.byId?.[id]?.blank);
- }, [availableSessions]);
-
- return h('div', {
- className: `dsh-split-window ${isGlowing ? 'glow' : ''}`,
- style: { minHeight: `${settings.minHeight}px` }
- },
- // Top bar
- h('div', { className: 'dsh-split-win-bar' },
- h('div', { className: 'dsh-split-win-title', title },
- h('span', { style: { color: '#58a6ff', fontSize: 13 } }, '💬'),
- h('span', null, title)
- ),
- h('div', { className: 'dsh-split-win-controls' },
- // Toggle per-chat sidebar
- h('button', {
- className: 'dsh-split-win-btn',
- onClick: () => setSidebarOpen((v) => !v),
- title: sidebarOpen ? "Collapse chat's sidebar" : "Open chat's sidebar"
- }, sidebarOpen ? '◀ Sidebar' : '▶ Sidebar'),
- // Move left / up in grid
- h('button', {
- className: 'dsh-split-win-btn',
- disabled: index === 0,
- onClick: () => onMove(index, -1),
- title: 'Move earlier in grid'
- }, '◀'),
- // Move right / down in grid
- h('button', {
- className: 'dsh-split-win-btn',
- disabled: index === total - 1,
- onClick: () => onMove(index, 1),
- title: 'Move later in grid'
- }, '▶'),
- // Close window
- h('button', {
- className: 'dsh-split-win-btn',
- onClick: () => onClose(sessionId),
- title: 'Close window'
- }, '✕')
- )
- ),
- // Window body: per-chat sidebar + conversation
- h('div', { className: 'dsh-split-win-body' },
- // Per-chat sidebar (session list for this window)
- h('div', { className: `dsh-split-sidebar ${sidebarOpen ? '' : 'collapsed'}` },
- h('div', { className: 'dsh-split-sidebar-head' },
- h('span', null, 'Chats'),
- h('span', { style: { opacity: 0.7 } }, `${sessionIdsList.length}`)
- ),
- h('ul', { className: 'dsh-split-sidebar-list' },
- sessionIdsList.map((id) => {
- const isCurrent = id === sessionId;
- const openElsewhere = !isCurrent && store.getSessions().includes(id);
- const sTitle = availableSessions.byId[id]?.displayTitle || availableSessions.byId[id]?.title || id;
- return h('li', {
- key: id,
- className: `dsh-split-sidebar-item ${isCurrent ? 'current' : ''} ${openElsewhere ? 'open-elsewhere' : ''}`,
- onClick: () => {
- if (isCurrent) return;
- if (openElsewhere) {
- // Selecting an already open chat triggers glow highlight
- store.triggerGlow(id);
- } else {
- onSelectSession(index, id);
- }
- },
- title: openElsewhere ? `${sTitle} (already open in another split window)` : sTitle
- },
- h('span', {
- style: {
- width: 6,
- height: 6,
- borderRadius: '50%',
- flexShrink: 0,
- background: isCurrent ? '#58a6ff' : openElsewhere ? '#d29922' : '#8b949e'
- }
- }),
- h('span', { className: 'dsh-split-sidebar-item-title' }, sTitle)
- );
- })
- )
- ),
- // Conversation pane
- h('div', { className: 'dsh-split-conversation-pane' },
- loadError
- ? h('div', { style: { padding: 16, color: '#f85149', fontSize: 13 } }, `Failed to load conversation: ${loadError}`)
- : sessionRef && SessionProvider && typeof renderSlot === 'function'
- ? h(SessionProvider, { session: sessionRef },
- renderSlot('dsh-chat-split.window', { sessionId })
- )
- : h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888' } },
- 'Loading chat...'
- )
- )
- )
- );
+ return () => { try { reference?.release(); } catch { /* ignore */ } };
+ }, [sessionId]);
+ return ref.id === sessionId ? ref : { reference: null, error: null };
}
- // ---- Main Panel Component --------------------------------------------
- function SplitPanel(props) {
- const { ctx, renderSlot, SessionProvider } = props;
- const [settings, setSettings] = useState(() => store.getSettings());
- const [sessions, setSessions] = useState(() => store.getSessions());
- const [glowingSessionId, setGlowingSessionId] = useState(() => store.getGlowingSessionId());
- const [availableSessions, setAvailableSessions] = useState(() => ctx?.sessions?.list?.getSnapshot?.() || { ids: [], byId: {} });
- const gridRef = useRef(null);
- const [containerWidth, setContainerWidth] = useState(1000);
+ // ------------------------------------------------------------------ chat list (window sidebar + add popover)
+ function ChatList({ list, currentId, onPick, autoFocus }) {
+ const [q, setQ] = useState('');
+ const openIds = useStore((s) => s.ids);
+ const ids = useMemo(() => {
+ const all = chatIds(list);
+ const needle = q.trim().toLowerCase();
+ return needle ? all.filter((id) => titleOf(list, id).toLowerCase().includes(needle)) : all;
+ }, [list, q]);
+ return h(React.Fragment, null,
+ h('input', { className: 'dcs-side-filter', placeholder: 'Filter chats…', value: q, autoFocus, onChange: (e) => setQ(e.target.value) }),
+ h('div', { className: 'dcs-side-list', role: 'listbox' },
+ ids.length === 0 && h('div', { className: 'dcs-msg' }, list.phase === 'ready' ? 'No chats' : 'Loading…'),
+ ids.map((id) => {
+ const open = openIds.includes(id);
+ return h('button', {
+ key: id, type: 'button', role: 'option', 'aria-selected': id === currentId,
+ className: `dcs-item${id === currentId ? ' cur' : ''}`,
+ title: open && id !== currentId ? `${titleOf(list, id)} — already open in split view` : titleOf(list, id),
+ onClick: () => onPick(id),
+ },
+ h('span', { className: `dcs-dot${list.byId[id]?.running ? ' run' : open ? ' open' : ''}` }),
+ h('span', { className: 'dcs-item-t' }, titleOf(list, id)));
+ })));
+ }
- useEffect(() => {
- installStyles();
- store.setSplitActive(true);
- return () => {
- store.setSplitActive(false);
- };
+ // ------------------------------------------------------------------ one split window
+ function SplitWindow({ id, index, total, cols, list, settings, glowKey, renderSlot, SessionProvider }) {
+ const [sideOpen, setSideOpen] = useState(settings.sidebarOpen);
+ const { reference, error } = useRetained(id);
+ const pick = (next) => {
+ if (store.has(next)) { store.glow(next); return; } // already open (here or elsewhere) → highlight it
+ store.replace(id, next);
+ };
+ const move = (steps) => { for (let i = 0; i < Math.abs(steps); i++) store.move(id, Math.sign(steps)); };
+ const row = Math.floor(index / cols) + 1;
+ const col = (index % cols) + 1;
+ return h('section', {
+ key: glowKey || 0, // restart the CSS animation on every glow
+ className: `dcs-win${glowKey ? ' glow' : ''}`, 'data-session-id': id,
+ },
+ h('header', { className: 'dcs-bar' },
+ h('button', { className: `dcs-ib${sideOpen ? ' on' : ''}`, title: sideOpen ? 'Hide this window\'s chat list' : 'Show this window\'s chat list', onClick: () => setSideOpen(!sideOpen) }, '☰'),
+ h('span', { className: 'dcs-bar-title', title: titleOf(list, id) }, titleOf(list, id)),
+ h('span', { className: 'dcs-bar-pos', title: 'Position in the grid (row · column)' }, `${row}·${col}`),
+ cols < total && h('button', { className: 'dcs-ib', title: 'Move up a row', disabled: index - cols < 0, onClick: () => move(-cols) }, '↑'),
+ h('button', { className: 'dcs-ib', title: 'Move left', disabled: index === 0, onClick: () => move(-1) }, '←'),
+ h('button', { className: 'dcs-ib', title: 'Move right', disabled: index === total - 1, onClick: () => move(1) }, '→'),
+ cols < total && h('button', { className: 'dcs-ib', title: 'Move down a row', disabled: index + cols > total - 1, onClick: () => move(cols) }, '↓'),
+ h('button', { className: 'dcs-ib', title: 'Close window', onClick: () => store.remove(id) }, '✕')),
+ h('div', { className: 'dcs-body' },
+ sideOpen && h('aside', { className: 'dcs-side', style: { width: settings.sidebarWidth } },
+ h('div', { className: 'dcs-side-head' }, 'Chats'),
+ h(ChatList, { list, currentId: id, onPick: pick })),
+ h('div', { className: 'dcs-conv' },
+ error ? h('div', { className: 'dcs-msg' }, `Cannot open this chat: ${error}`)
+ : reference ? h(SessionProvider, { session: reference }, renderSlot(CHILD_SLOT, {}))
+ : h('div', { className: 'dcs-msg' }, 'Opening…'))));
+ }
+
+ // ------------------------------------------------------------------ main panel
+ function useWidth() {
+ const [w, setW] = useState(0);
+ const ro = useRef(null);
+ const ref = useCallback((el) => {
+ ro.current?.disconnect(); ro.current = null;
+ if (!el) return;
+ setW(el.clientWidth);
+ ro.current = new ResizeObserver(([e]) => setW(e.contentRect.width));
+ ro.current.observe(el);
}, []);
+ return [ref, w];
+ }
- useEffect(() => {
- return store.subscribe(() => {
- setSettings(store.getSettings());
- setSessions(store.getSessions());
- setGlowingSessionId(store.getGlowingSessionId());
- });
- }, []);
+ function AddChat({ list, onClose }) {
+ return h('div', { className: 'dcs-pop', onKeyDown: (e) => { if (e.key === 'Escape') onClose(); } },
+ h('div', { className: 'dcs-side-head' }, 'Add chat to split view'),
+ h(ChatList, { list, currentId: null, autoFocus: true, onPick: (id) => { if (store.has(id)) store.glow(id); else store.add(id); onClose(); } }));
+ }
- // Subscribe to session list changes
- useEffect(() => {
- if (!ctx?.sessions?.list) return;
- const sub = ctx.sessions.list.subscribe?.(() => {
- setAvailableSessions(ctx.sessions.list.getSnapshot());
- });
- return () => {
- if (typeof sub === 'function') sub();
- };
- }, [ctx]);
+ function SplitPanel({ renderSlot, SessionProvider }) {
+ const ids = useStore((s) => s.ids);
+ const settings = useStore((s) => s.settings);
+ const glow = useStore((s) => s.glow);
+ const list = useSessionList();
+ const [ref, width] = useWidth();
+ const [adding, setAdding] = useState(false);
- // Measure container width for responsive column calculation
+ // Drop chats that no longer exist (deleted/archived) once the list is loaded.
useEffect(() => {
- const el = gridRef.current;
- if (!el || typeof ResizeObserver === 'undefined') return;
- const ro = new ResizeObserver((entries) => {
- for (const entry of entries) {
- setContainerWidth(entry.contentRect.width);
- }
- });
- ro.observe(el);
+ if (list.phase !== 'ready') return;
+ const gone = ids.filter((id) => !list.byId[id]);
+ if (gone.length) store.setIds(ids.filter((id) => list.byId[id]));
+ }, [list, ids]);
+
+ const cols = columnsFor(width - 16, ids.length, settings);
+ const rows = Math.max(1, Math.ceil(ids.length / cols));
+ const recent = chatIds(list).filter((id) => !ids.includes(id)).slice(0, 8);
+
+ return h('div', { className: 'dcs-root', 'data-dsh-chat-split': '' },
+ h('div', { className: 'dcs-head' },
+ h('span', { className: 'dcs-head-title' }, 'Split view'),
+ h('span', { className: 'dcs-head-meta' }, ids.length ? `${ids.length} chat${ids.length > 1 ? 's' : ''} · ${cols} × ${rows}` : ''),
+ h('span', { className: 'dcs-sp' }),
+ h('button', { className: 'dcs-btn', onClick: () => setAdding(!adding) }, '+ Add chat'),
+ ids.length > 0 && h('button', { className: 'dcs-btn', onClick: () => store.setIds([]) }, 'Close all'),
+ adding && h(AddChat, { list, onClose: () => setAdding(false) })),
+ ids.length === 0
+ ? h('div', { className: 'dcs-empty' },
+ h('strong', null, 'No chats in split view'),
+ h('span', null, 'Use "+ Add chat", or "Include in split view" in a chat\'s "…" menu in the sidebar.'),
+ h('div', { className: 'dcs-chips' }, recent.map((id) => h('button', { key: id, className: 'dcs-btn', onClick: () => store.add(id) }, `+ ${titleOf(list, id)}`))))
+ : h('div', {
+ ref, className: 'dcs-grid',
+ style: {
+ gap: settings.gap,
+ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
+ gridAutoRows: `minmax(${settings.minHeight}px, 1fr)`,
+ },
+ }, ids.map((id, i) => h(SplitWindow, {
+ key: id, id, index: i, total: ids.length, cols, list, settings,
+ glowKey: glow?.id === id ? glow.n : 0, renderSlot, SessionProvider,
+ }))));
+ }
+
+ // ------------------------------------------------------------------ sidebar "…" menu item
+ function IncludeMenuItem({ sessionId, useMenuOpenState }) {
+ const [, setMenuOpen] = useMenuOpenState();
+ const included = useStore((s) => s.ids.includes(sessionId));
+ const onSelect = () => {
+ setMenuOpen(false);
+ if (included) { store.remove(sessionId); return; }
+ store.add(sessionId);
+ if (isSplitShowing()) store.glow(sessionId);
+ };
+ const label = included ? 'Exclude from split view' : 'Include in split view';
+ const icon = h(PanelIcon, { size: 16 });
+ if (P.MenuItemButton) return h(P.MenuItemButton, { icon, onSelect }, label);
+ return h('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label);
+ }
+
+ function PanelIcon({ size = 16 }) {
+ return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round', 'aria-hidden': true },
+ h('rect', { x: 1.5, y: 2, width: 13, height: 12, rx: 1.6 }),
+ h('path', { d: 'M8 2v12M1.5 8h6.5' }));
+ }
+
+ // ------------------------------------------------------------------ settings + live preview
+ function Slider({ label, value, min, max, step = 1, unit = 'px', hint, onChange }) {
+ return h('div', { className: 'dcs-f' },
+ h('label', null, h('span', null, label), h('span', null, `${value}${unit}`)),
+ h('input', { type: 'range', min, max, step, value, onChange: (e) => onChange(Number(e.target.value)) }),
+ hint && h('small', null, hint));
+ }
+
+ /** Scaled replica of the split panel at a chosen screen size, using your real chat titles. */
+ function Preview({ settings, list }) {
+ const box = useRef(null);
+ const [boxW, setBoxW] = useState(760);
+ useEffect(() => {
+ const el = box.current; if (!el) return undefined;
+ setBoxW(el.clientWidth);
+ const ro = new ResizeObserver(([e]) => setBoxW(e.contentRect.width)); ro.observe(el);
return () => ro.disconnect();
}, []);
-
- // Calculate columns based on width and readability threshold (minWidth)
- const columnCount = useMemo(() => {
- const minW = Math.max(280, settings.minWidth || 420);
- const gap = settings.gap || 10;
- const availableW = containerWidth - 20; // 10px padding each side
- const cols = Math.floor((availableW + gap) / (minW + gap));
- return Math.max(1, cols);
- }, [containerWidth, settings.minWidth, settings.gap]);
-
- const handleClose = useCallback((id) => {
- store.removeSession(id);
- }, []);
-
- const handleMove = useCallback((idx, dir) => {
- store.moveSession(idx, dir);
- }, []);
-
- const handleSelectSession = useCallback((windowIndex, newSessionId) => {
- const copy = [...store.getSessions()];
- copy[windowIndex] = newSessionId;
- store.setSessions(copy);
- }, []);
-
- const handleAddAllKnown = useCallback(() => {
- const ids = availableSessions.ids.filter((id) => !availableSessions.byId[id]?.blank);
- store.setSessions(ids.slice(0, 6)); // take up to 6
- }, [availableSessions]);
-
- return h('div', { className: 'dsh-split-container' },
- // Main header
- h('div', { className: 'dsh-split-header' },
- h('div', { className: 'dsh-split-header-left' },
- h('span', { className: 'dsh-split-header-title' },
- h('span', null, '⊞ Chat Split View'),
- h('span', { className: 'dsh-split-badge' }, `${sessions.length} chats`),
- h('span', { style: { fontSize: 11, color: '#8b949e' } },
- `${columnCount} column${columnCount > 1 ? 's' : ''} (min-height: ${settings.minHeight}px)`
- )
- )
- ),
- h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
- sessions.length === 0 && h('button', {
- className: 'dsh-split-btn dsh-split-btn-primary',
- onClick: handleAddAllKnown
- }, '+ Add open chats'),
- sessions.length > 0 && h('button', {
- className: 'dsh-split-btn',
- onClick: () => store.setSessions([])
- }, 'Clear all')
- )
- ),
- // Grid or Empty State
- sessions.length === 0
- ? h('div', { className: 'dsh-split-empty' },
- h('h3', null, 'No chats in split view'),
- h('p', { style: { maxWidth: 440, marginBottom: 16 } },
- 'Add conversations to split view using the "..." menu on any session in the sidebar, or pick from recent chats below:'
- ),
- h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center', maxWidth: 600 } },
- availableSessions.ids.filter((id) => !availableSessions.byId[id]?.blank).slice(0, 8).map((id) => {
- const sTitle = availableSessions.byId[id]?.displayTitle || availableSessions.byId[id]?.title || id;
- return h('button', {
- key: id,
- className: 'dsh-split-btn',
- onClick: () => store.addSession(id)
- }, `+ ${sTitle}`);
- })
- )
- )
- : h('div', {
- ref: gridRef,
- className: 'dsh-split-grid',
- style: {
- gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
- gap: `${settings.gap || 10}px`,
- }
- },
- sessions.map((id, index) =>
- h(SplitWindowFrame, {
- key: `${id}-${index}`,
- sessionId: id,
- index,
- total: sessions.length,
- ctx,
- settings,
- onClose: handleClose,
- onMove: handleMove,
- onSelectSession: handleSelectSession,
- isGlowing: glowingSessionId === id,
- renderSlot,
- SessionProvider,
- availableSessions,
- })
- )
- )
- );
+ const screen = () => ({ w: Math.max(480, window.innerWidth - 300), h: Math.max(420, window.innerHeight - 60) });
+ const [vw, setVw] = useState(() => screen().w);
+ const [vh, setVh] = useState(() => screen().h);
+ const openIds = useStore((s) => s.ids);
+ const defaultN = Math.min(9, Math.max(2, openIds.length || 3));
+ const [n, setN] = useState(defaultN);
+ const pool = [...openIds, ...chatIds(list).filter((id) => !openIds.includes(id))];
+ const titles = pool.slice(0, n).map((id) => titleOf(list, id));
+ while (titles.length < n) titles.push(`Chat ${titles.length + 1}`);
+ const cols = columnsFor(vw - 16, n, settings);
+ const rows = Math.ceil(n / cols);
+ const headH = 41; const pad = 16;
+ const rowH = Math.max(settings.minHeight, (vh - headH - pad - (rows - 1) * settings.gap) / rows);
+ const fullH = headH + pad + rows * rowH + (rows - 1) * settings.gap;
+ const shownH = Math.min(fullH, vh);
+ const scale = Math.min(1, boxW / vw);
+ return h('div', null,
+ h('div', { className: 'dcs-prev-ctl' },
+ h('span', null, `Width ${vw}px`),
+ h('input', { type: 'range', min: 480, max: 3840, step: 10, value: vw, onChange: (e) => setVw(Number(e.target.value)) }),
+ h('span', null, `Height ${vh}px`),
+ h('input', { type: 'range', min: 400, max: 2160, step: 10, value: vh, onChange: (e) => setVh(Number(e.target.value)) }),
+ h('label', null, 'Chats ', h('select', { value: n, onChange: (e) => setN(Number(e.target.value)) }, [1, 2, 3, 4, 5, 6, 7, 8, 9].map((k) => h('option', { key: k, value: k }, k)))),
+ h('button', { className: 'dcs-btn', onClick: () => { const s = screen(); setVw(s.w); setVh(s.h); setN(defaultN); } }, 'Fit my screen')),
+ h('div', { style: { fontSize: 12, marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } },
+ `${cols} column${cols > 1 ? 's' : ''} × ${rows} row${rows > 1 ? 's' : ''}`,
+ cols < n ? ' — too narrow for all chats side by side, so they wrap into a grid' : ' — all chats side by side',
+ fullH > vh + 1 ? ` · rows keep their ${settings.minHeight}px minimum, so the grid scrolls` : ''),
+ h('div', { ref: box, className: 'dcs-prev', style: { height: shownH * scale } },
+ h('div', { className: 'dcs-prev-scale', style: { width: vw, height: shownH, transform: `scale(${scale})`, overflow: 'hidden' } },
+ h('div', { className: 'dcs-head' }, h('span', { className: 'dcs-head-title' }, 'Split view'), h('span', { className: 'dcs-head-meta' }, `${n} chats · ${cols} × ${rows}`)),
+ h('div', { style: { display: 'grid', padding: 8, gap: settings.gap, gridTemplateColumns: `repeat(${cols}, minmax(0,1fr))`, gridAutoRows: `${rowH}px` } },
+ titles.map((t, i) => h('div', { key: i, className: 'dcs-mock' },
+ h('div', { className: 'dcs-bar' },
+ h('span', { className: 'dcs-ib on' }, '☰'),
+ h('span', { className: 'dcs-bar-title' }, t),
+ h('span', { className: 'dcs-bar-pos' }, `${Math.floor(i / cols) + 1}·${(i % cols) + 1}`),
+ h('span', { className: 'dcs-ib' }, '←'), h('span', { className: 'dcs-ib' }, '→'), h('span', { className: 'dcs-ib' }, '✕')),
+ h('div', { className: 'dcs-body' },
+ settings.sidebarOpen && h('div', { className: 'dcs-side', style: { width: settings.sidebarWidth } },
+ h('div', { className: 'dcs-side-head' }, 'Chats'),
+ pool.slice(0, 6).map((id, k) => h('div', { key: k, className: `dcs-item${k === i ? ' cur' : ''}` }, h('span', { className: 'dcs-item-t' }, titleOf(list, id))))),
+ h('div', { className: 'dcs-conv' },
+ h('div', { style: { display: 'flex', flexDirection: 'column', flex: 1 } },
+ h('div', { className: 'dcs-mline', style: { width: '55%' } }),
+ h('div', { className: 'dcs-mline me' }),
+ h('div', { className: 'dcs-mline', style: { width: '75%' } }),
+ h('div', { className: 'dcs-mline', style: { width: '62%' } }),
+ h('div', { className: 'dcs-minput' }))))))))));
}
- // ---- Settings Panel with Interactive Preview -------------------------
function SettingsPanel() {
- const [settings, setSettings] = useState(() => store.getSettings());
- const [previewWidth, setPreviewWidth] = useState(720);
- const [mockChatCount, setMockChatCount] = useState(3);
-
- const handleChange = (key, val) => {
- const num = parseInt(val, 10);
- if (!isNaN(num)) {
- const patch = { [key]: num };
- setSettings((prev) => ({ ...prev, ...patch }));
- store.updateSettings(patch);
- }
- };
-
- // Preview columns calculation
- const minW = Math.max(200, settings.minWidth || 420);
- const gap = settings.gap || 10;
- const previewCols = Math.max(1, Math.floor((previewWidth + gap) / (minW + gap)));
-
- return h('div', { style: { padding: '8px 4px', maxWidth: 760 } },
- h('h3', { style: { margin: '0 0 12px 0', fontSize: 16 } }, 'Chat Split Settings'),
- h('p', { style: { opacity: 0.8, fontSize: 13, marginBottom: 16 } },
- 'Configure multi-chat split view dimensions, wrapping rules, and interactive preview.'
- ),
-
- // Controls
- h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 20 } },
- // Minimum Height
- h('div', null,
- h('label', { style: { display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 6 } }, 'Minimum Height (px)'),
- h('input', {
- type: 'number',
- min: 250,
- max: 900,
- step: 10,
- value: settings.minHeight,
- onChange: (e) => handleChange('minHeight', e.target.value),
- style: {
- width: '100%',
- padding: '6px 8px',
- borderRadius: 6,
- background: 'var(--dsw-alias-bg-l1, #161b22)',
- border: '1px solid var(--dsw-alias-border-l1, #30363d)',
- color: 'inherit',
- fontSize: 13
- }
- }),
- h('div', { style: { fontSize: 11, opacity: 0.6, marginTop: 4 } }, 'Default: 396px. Ensures conversation readability vertically.')
- ),
- // Minimum Width (Readability Threshold)
- h('div', null,
- h('label', { style: { display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 6 } }, 'Readability Width Threshold (px)'),
- h('input', {
- type: 'number',
- min: 280,
- max: 800,
- step: 10,
- value: settings.minWidth,
- onChange: (e) => handleChange('minWidth', e.target.value),
- style: {
- width: '100%',
- padding: '6px 8px',
- borderRadius: 6,
- background: 'var(--dsw-alias-bg-l1, #161b22)',
- border: '1px solid var(--dsw-alias-border-l1, #30363d)',
- color: 'inherit',
- fontSize: 13
- }
- }),
- h('div', { style: { fontSize: 11, opacity: 0.6, marginTop: 4 } }, 'When viewport cannot fit another column at this width, chats wrap vertically into rows.')
- ),
- // Grid Gap
- h('div', null,
- h('label', { style: { display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 6 } }, 'Grid Gap (px)'),
- h('input', {
- type: 'number',
- min: 4,
- max: 30,
- value: settings.gap,
- onChange: (e) => handleChange('gap', e.target.value),
- style: {
- width: '100%',
- padding: '6px 8px',
- borderRadius: 6,
- background: 'var(--dsw-alias-bg-l1, #161b22)',
- border: '1px solid var(--dsw-alias-border-l1, #30363d)',
- color: 'inherit',
- fontSize: 13
- }
- }),
- h('div', { style: { fontSize: 11, opacity: 0.6, marginTop: 4 } }, 'Spacing between split windows in grid.')
- )
- ),
-
- // Interactive Preview Section
- h('div', { style: { marginTop: 24 } },
- h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
- h('h4', { style: { margin: 0, fontSize: 14 } }, 'Interactive Layout Preview'),
- h('div', { style: { fontSize: 12, display: 'flex', gap: 12, alignItems: 'center' } },
- h('span', { style: { color: '#58a6ff', fontWeight: 600 } },
- `${previewCols} column${previewCols > 1 ? 's' : ''} at ${previewWidth}px width`
- ),
- h('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
- 'Chats:',
- h('select', {
- value: mockChatCount,
- onChange: (e) => setMockChatCount(parseInt(e.target.value, 10)),
- style: { background: 'var(--dsw-alias-bg-l1, #161b22)', border: '1px solid var(--dsw-alias-border-l1, #30363d)', color: 'inherit', padding: '2px 4px', borderRadius: 4 }
- },
- [1, 2, 3, 4, 5, 6].map((n) => h('option', { key: n, value: n }, `${n}`))
- )
- )
- )
- ),
- h('div', { style: { marginBottom: 8 } },
- h('label', { style: { display: 'block', fontSize: 11, opacity: 0.7, marginBottom: 4 } }, 'Drag slider to test viewport resizing & wrapping:'),
- h('input', {
- type: 'range',
- min: 360,
- max: 1200,
- value: previewWidth,
- onChange: (e) => setPreviewWidth(parseInt(e.target.value, 10)),
- style: { width: '100%', cursor: 'pointer' }
- })
- ),
- // Resizable preview box
- h('div', {
- className: 'dsh-split-preview-box',
- style: { width: `${previewWidth}px`, maxWidth: '100%', overflow: 'hidden' }
- },
- h('div', {
- style: {
- display: 'grid',
- gridTemplateColumns: `repeat(${previewCols}, minmax(0, 1fr))`,
- gap: `${settings.gap}px`,
- transition: 'all 0.2s ease'
- }
- },
- Array.from({ length: mockChatCount }).map((_, i) =>
- h('div', {
- key: i,
- className: 'dsh-split-mock-win',
- style: { height: `${Math.round(settings.minHeight * 0.55)}px` }
- },
- h('div', { className: 'dsh-split-mock-bar' },
- h('span', null, `Chat ${i + 1}`),
- h('span', { style: { opacity: 0.6 } }, '◀ ▶ ✕')
- ),
- h('div', { className: 'dsh-split-mock-body' },
- h('div', { className: 'dsh-split-mock-side' },
- h('div', { style: { height: 6, background: '#58a6ff', borderRadius: 3, marginBottom: 4 } }),
- h('div', { style: { height: 6, background: 'rgba(255,255,255,0.1)', borderRadius: 3, marginBottom: 4 } }),
- h('div', { style: { height: 6, background: 'rgba(255,255,255,0.1)', borderRadius: 3 } })
- ),
- h('div', { className: 'dsh-split-mock-chat' },
- h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
- h('div', { style: { height: 12, width: '70%', background: 'rgba(255,255,255,0.08)', borderRadius: 4 } }),
- h('div', { style: { height: 18, width: '90%', background: 'rgba(31,111,235,0.15)', borderRadius: 4, alignSelf: 'flex-end' } })
- ),
- h('div', { style: { height: 16, background: 'rgba(255,255,255,0.05)', borderRadius: 4, border: '1px solid rgba(255,255,255,0.1)' } })
- )
- )
- )
- )
- )
- )
- )
- );
+ const s = useStore((st) => st.settings);
+ const list = useSessionList();
+ return h('div', { className: 'dcs-set' },
+ h('h3', null, 'Chat split'),
+ h('p', null, 'Open several chats at once from "Split view" in the sidebar. Chats sit side by side while each keeps the readability width; otherwise they wrap into a grid.'),
+ h('div', { className: 'dcs-fields' },
+ h(Slider, { label: 'Minimum window height', value: s.minHeight, min: 240, max: 900, step: 4, hint: 'Default 396px. Rows never get shorter; the grid scrolls instead.', onChange: (v) => store.setSettings({ minHeight: v }) }),
+ h(Slider, { label: 'Readability width', value: s.minWidth, min: 300, max: 900, step: 10, hint: 'Narrowest a chat may get before windows wrap to the next row.', onChange: (v) => store.setSettings({ minWidth: v }) }),
+ h(Slider, { label: 'Gap between windows', value: s.gap, min: 0, max: 24, onChange: (v) => store.setSettings({ gap: v }) }),
+ h(Slider, { label: 'Window chat-list width', value: s.sidebarWidth, min: 120, max: 320, step: 4, onChange: (v) => store.setSettings({ sidebarWidth: v }) }),
+ h('div', { className: 'dcs-f' },
+ h('label', null, h('span', null, 'Window chat list open by default'),
+ h('input', { type: 'checkbox', checked: !!s.sidebarOpen, onChange: (e) => store.setSettings({ sidebarOpen: e.target.checked }) }))),
+ h('div', { className: 'dcs-f' }, h('button', { className: 'dcs-btn', onClick: () => store.setSettings({ ...DEFAULTS }) }, 'Reset to defaults'))),
+ h('h3', { style: { fontSize: 14, margin: '6px 0 8px' } }, 'Preview'),
+ h(Preview, { settings: s, list }));
}
- // ---- Sidebar Panel List Icon -----------------------------------------
- function SplitPanelIcon({ size }) {
- return h('svg', {
- 'data-dsh-panel-entry': PANEL_ID,
- viewBox: '0 0 16 16',
- width: size || 16,
- height: size || 16,
- fill: 'none',
- stroke: 'currentColor',
- strokeWidth: 1.3,
- strokeLinecap: 'round',
- strokeLinejoin: 'round',
- 'aria-hidden': true,
- },
- h('rect', { x: 1.5, y: 1.5, width: 13, height: 13, rx: 1.5 }),
- h('line', { x1: 8, y1: 1.5, x2: 8, y2: 14.5 }),
- h('line', { x1: 1.5, y1: 8, x2: 8, y2: 8 })
- );
+ // ------------------------------------------------------------------ main-sidebar selection → glow
+ /**
+ * The stock sidebar opens a chat by retaining it as `mainView` and then leaving any panel
+ * (selectPanel(null)). If that happens while split view is showing and the chat is already
+ * in the split, go straight back to split view and glow its window.
+ */
+ function watchMainSelection(ctx) {
+ const byId = () => sessionsSvc.list.getSnapshot().byId;
+ const signature = () => { const b = byId(); return Object.keys(b).filter((id) => b[id].retainedBy?.mainView).map((id) => `${id}:${b[id].retainedBy.mainView}`).join(','); };
+ const mainId = () => { const b = byId(); return Object.keys(b).find((id) => b[id].retainedBy?.mainView); };
+ let sig = signature();
+ let changedAt = 0;
+ let prevPanel = layoutSvc.panelInfo.getSnapshot().activePanelId;
+ const offList = sessionsSvc.list.subscribe(() => {
+ const next = signature();
+ if (next !== sig) { sig = next; changedAt = Date.now(); }
+ });
+ const offPanel = layoutSvc.panelInfo.subscribe(() => {
+ const cur = layoutSvc.panelInfo.getSnapshot().activePanelId;
+ const was = prevPanel; prevPanel = cur;
+ if (was !== PANEL_ID || cur !== null) return;
+ setTimeout(() => { // let replaceMain release the previous main reference first
+ if (Date.now() - changedAt > 800) return; // left split view without opening a chat
+ const id = mainId();
+ if (!id || !store.has(id)) return;
+ openSplit();
+ store.glow(id);
+ }, 0);
+ });
+ ctx.effect(() => () => { offList(); offPanel(); }, 'dsh-chat-split: main selection');
}
- // ---- Session Menu Item: Include / Exclude from Split View ------------
- function SplitSessionMenuItem(props) {
- const { sessionId, useMenuOpenState } = props;
- const [, setMenuOpen] = useMenuOpenState ? useMenuOpenState() : [false, () => {}];
- const [isInSplit, setIsInSplit] = useState(() => store.getSessions().includes(sessionId));
-
- useEffect(() => {
- return store.subscribe(() => {
- setIsInSplit(store.getSessions().includes(sessionId));
- });
- }, [sessionId]);
-
- const handleToggle = (e) => {
- e?.stopPropagation?.();
- setMenuOpen(false);
- if (isInSplit) {
- store.removeSession(sessionId);
- } else {
- store.addSession(sessionId);
- // If split mode is active and we re-selected an open chat, trigger glow
- if (store.isSplitActive()) {
- store.triggerGlow(sessionId);
- }
- }
- };
-
- return h('button', {
- type: 'button',
- role: 'menuitem',
- className: 'dsh-split-menu-row',
- onClick: handleToggle,
- style: {
- display: 'flex',
- alignItems: 'center',
- gap: 8,
- width: '100%',
- padding: '6px 12px',
- border: 'none',
- background: 'transparent',
- color: 'var(--dsw-alias-label-primary, #e6edf3)',
- cursor: 'pointer',
- fontSize: 13,
- textAlign: 'left',
- borderRadius: 4
- }
- },
- h('span', { style: { color: isInSplit ? '#f85149' : '#58a6ff', fontSize: 14 } },
- isInSplit ? '⊟' : '⊞'
- ),
- h('span', null, isInSplit ? 'Exclude from split view' : 'Include in split view')
- );
- }
-
- // ---- Main Plugin Registration ----------------------------------------
+ // ------------------------------------------------------------------ plugin
return {
- inject: ['slots', 'sessions'],
+ inject: ['slots', 'sessions', 'layout'],
apply(ctx) {
- installStyles();
- const disposers = [];
+ sessionsSvc = ctx.sessions;
+ layoutSvc = ctx.layout;
+ ctx.effect(installCss, 'dsh-chat-split: css');
- // Observe main session retention changes to trigger blue glow when selecting an already-open chat in split mode
- if (ctx?.sessions?.list) {
- let lastMainId = null;
- const unsubscribeList = ctx.sessions.list.subscribe?.(() => {
- if (!store.isSplitActive()) return;
- const snapshot = ctx.sessions.list.getSnapshot();
- const byId = snapshot?.byId || {};
- const currentMainId = Object.keys(byId).find((id) => (byId[id]?.retainedBy?.mainView ?? 0) > 0);
- if (currentMainId && currentMainId !== lastMainId) {
- lastMainId = currentMainId;
- if (store.getSessions().includes(currentMainId)) {
- store.triggerGlow(currentMainId);
- }
- }
- });
- if (typeof unsubscribeList === 'function') disposers.push(unsubscribeList);
- }
+ ctx.effect(() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
+ name: 'sidebar.panellist', id: PANEL_ID, order: 45, label: 'Split view',
+ }, PanelIcon)), 'dsh-chat-split: panel list');
- // 1. Sidebar Panel entry
- disposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
- name: 'sidebar.panellist',
- id: PANEL_ID,
- order: 75,
- label: 'Split view',
- }, SplitPanelIcon)));
+ ctx.effect(() => ctx.slots.inject('main', () => ctx.slots.register({
+ name: 'main', key: PANEL_ID,
+ children: { [CHILD_SLOT]: { kind: 'single', scope: 'session' } },
+ }, SplitPanel)), 'dsh-chat-split: main panel');
- // 2. Child slot occupant for each split window
- disposers.push(ctx.slots.inject('dsh-chat-split.window', () => ctx.slots.register({
- name: 'dsh-chat-split.window',
- }, SplitWindowConversationPanel)));
+ ctx.effect(() => ctx.slots.inject(CHILD_SLOT, () => ctx.slots.register({ name: CHILD_SLOT }, WindowConversation)), 'dsh-chat-split: window conversation');
- // 3. Main keyed slot: Split View Grid
- disposers.push(ctx.slots.inject('main', () => ctx.slots.register({
- name: 'main',
- key: PANEL_ID,
- children: {
- 'dsh-chat-split.window': {
- kind: 'single',
- scope: 'session'
- }
- }
- }, (kitProps) => h(SplitPanel, { ...kitProps, ctx }))));
+ ctx.effect(() => ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
+ name: 'sidebar.workspaces.session.menu.item', id: 'dsh-chat-split', order: 250,
+ }, IncludeMenuItem)), 'dsh-chat-split: session menu');
- // 4. Session menu item: Include / Exclude from Split View
- disposers.push(ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
- name: 'sidebar.workspaces.session.menu.item',
- id: 'dsh-chat-split-toggle',
- order: 450,
- }, SplitSessionMenuItem)));
+ ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
+ name: 'settings.section', id: 'dsh-chat-split', order: 175, label: 'Chat split',
+ }, SettingsPanel)), 'dsh-chat-split: settings');
- // 5. Settings section
- disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
- name: 'settings.section',
- id: 'dsh-chat-split',
- order: 175,
- label: 'Chat split',
- }, SettingsPanel)));
-
- return () => {
- disposers.forEach((d) => {
- try { d(); } catch {}
- });
- };
- }
+ if (sessionsSvc?.list && layoutSvc?.panelInfo) watchMainSelection(ctx);
+ },
};
- }
+ },
});
diff --git a/package.json b/package.json
index 9f73769..182673b 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,16 @@
{
"name": "@local/dsh-chat-split",
- "version": "1.0.0",
- "description": "DSH plugin: Multi-chat split grid view with responsive wrapping, per-window sidebars, interactive preview, and include/exclude controls.",
+ "version": "1.1.0",
+ "description": "DSH plugin: several chats side by side in a split view that wraps into a grid, with per-window chat lists, top bars, blue focus glow, include/exclude menu items and a settings preview.",
"private": true,
"type": "module",
+ "license": "MIT",
"exports": {
".": "./index.js",
- "./client": "./client.js"
+ "./client": "./client.js",
+ "./package.json": "./package.json"
},
+ "scripts": { "test": "node --test test/*.test.mjs" },
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
@@ -19,8 +22,9 @@
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-sidebar",
"@deepseek-ai/dsh-client-ui-workspace",
- "@deepseek-ai/dsh-client-ui-session",
- "@deepseek-ai/dsh-client-ui-conversation"
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-client-ui-settings",
+ "@deepseek-ai/dsh-client-ui-primitives"
]
}
}
diff --git a/test/client.test.mjs b/test/client.test.mjs
new file mode 100644
index 0000000..832f920
--- /dev/null
+++ b/test/client.test.mjs
@@ -0,0 +1,162 @@
+// Headless checks for client.js: loads it through a fake __ModuleLoader__, captures the slot
+// registrations, renders the components with react-dom/server against fake DSH services, and
+// drives the "chat opened from the main sidebar while split view is showing" path.
+// Needs a React install: REACT_DIR= (skips otherwise).
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const candidates = [
+ process.env.REACT_DIR,
+ '/home/deepseek-harness/rizzy-wt/m3-history/node_modules/.pnpm/react-dom@19.3.0_react@19.3.0/node_modules',
+].filter(Boolean)
+const reactDir = candidates.find((d) => fs.existsSync(path.join(d, 'react')) && fs.existsSync(path.join(d, 'react-dom')))
+const skip = reactDir ? false : 'no React install found (set REACT_DIR)'
+
+function memStorage() {
+ const m = new Map()
+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }
+}
+
+function snapshotStore(initial) {
+ let v = initial; const subs = new Set()
+ return { getSnapshot: () => v, subscribe: (f) => { subs.add(f); return () => subs.delete(f) }, set: (n) => { v = n; subs.forEach((f) => f()) } }
+}
+
+/** Load client.js and apply it against fakes; returns everything the test needs. */
+function load() {
+ const req = createRequire(path.join(reactDir, 'react', 'package.json'))
+ const React = req('react')
+ const ReactDOMServer = req('react-dom/server')
+ globalThis.localStorage = memStorage()
+ globalThis.window = { innerWidth: 1600, innerHeight: 1000 }
+ globalThis.document = { head: { appendChild() {} }, createElement: () => ({ remove() {} }), querySelector: () => null }
+ globalThis.ResizeObserver = class { observe() {} disconnect() {} }
+ let mod
+ window.__ModuleLoader__ = { load: (m) => { mod = m } }
+ new Function(fs.readFileSync(path.join(here, '..', 'client.js'), 'utf8'))()
+ const plugin = mod.factory((name) => (name === 'react' ? React : {}))
+
+ const list = snapshotStore({
+ phase: 'ready',
+ ids: ['a', 'b', 'c', 'sub'],
+ byId: {
+ a: { id: 'a', displayTitle: 'Alpha chat', updatedAt: 3, retainedBy: {} },
+ b: { id: 'b', displayTitle: 'Beta chat', updatedAt: 2, retainedBy: {} },
+ c: { id: 'c', displayTitle: 'Gamma chat', updatedAt: 1, retainedBy: {} },
+ sub: { id: 'sub', displayTitle: 'child', updatedAt: 9, parentId: 'a', origin: 'subagent', retainedBy: {} },
+ },
+ })
+ const retained = []
+ const sessions = { list, retain: (id, opts) => { const r = { sessionId: id, source: opts.source, released: false, release() { this.released = true } }; retained.push(r); return r } }
+ const panel = snapshotStore({ activePanelId: null })
+ const layout = { panelInfo: panel, selectPanel: (id) => panel.set({ activePanelId: id }) }
+ const regs = {}
+ const effects = []
+ const ctx = {
+ sessions, layout,
+ effect: (fn) => { const d = fn(); effects.push(d) },
+ slots: {
+ inject: (name, make) => make(),
+ register: (opts, Comp) => { regs[opts.key ? `${opts.name}:${opts.key}` : opts.name] = { opts, Comp }; return () => {} },
+ },
+ }
+ assert.deepEqual(plugin.inject, ['slots', 'sessions', 'layout'])
+ plugin.apply(ctx)
+ return { React, ReactDOMServer, regs, list, panel, layout, retained }
+}
+
+test('registers panel, main entry with a session-scope child, menu item and settings', { skip }, () => {
+ const { regs } = load()
+ assert.equal(regs['sidebar.panellist'].opts.id, 'dsh-chat-split')
+ assert.equal(regs['sidebar.panellist'].opts.label, 'Split view')
+ const main = regs['main:dsh-chat-split']
+ assert.ok(main, 'main panel registered under key dsh-chat-split')
+ assert.deepEqual(main.opts.children, { 'dsh-chat-split.window': { kind: 'single', scope: 'session' } })
+ assert.ok(regs['dsh-chat-split.window'])
+ assert.ok(regs['sidebar.workspaces.session.menu.item'])
+ assert.equal(regs['settings.section'].opts.label, 'Chat split')
+})
+
+test('menu item includes/excludes, panel renders windows with top bars and own chat lists', { skip }, () => {
+ const { React, ReactDOMServer, regs } = load()
+ const h = React.createElement
+ const render = (el) => ReactDOMServer.renderToString(el)
+ const Menu = regs['sidebar.workspaces.session.menu.item'].Comp
+ const useMenuOpenState = () => [true, () => {}]
+ assert.match(render(h(Menu, { sessionId: 'a', useMenuOpenState })), /Include in split view/)
+ const Panel = regs['main:dsh-chat-split'].Comp
+ const empty = render(h(Panel, { renderSlot: () => null, SessionProvider: ({ children }) => children }))
+ assert.match(empty, /No chats in split view/)
+ assert.match(empty, /\+ Alpha chat/)
+ assert.doesNotMatch(empty, /child/, 'subagent children are not offered')
+})
+
+test('layout: side by side while each keeps the readability width, then wraps', { skip }, () => {
+ const { React, ReactDOMServer, regs } = load()
+ const h = React.createElement
+ const Settings = regs['settings.section'].Comp
+ const html = ReactDOMServer.renderToString(h(Settings, { close() {} }))
+ assert.match(html, /Minimum window height/)
+ assert.match(html, /396px/)
+ assert.match(html, /Readability width/)
+ assert.match(html, /Preview/)
+ // window.innerWidth 1600 → preview width 1300; 3 chats at 420+8 → 3 columns side by side.
+ assert.match(html, /3 columns × 1 row/)
+ assert.match(html, /all chats side by side/)
+ // Real chat titles appear in the preview windows.
+ assert.match(html, /Alpha chat/)
+})
+
+test('choosing an open chat in the main sidebar keeps split view and glows it', { skip }, async () => {
+ const { React, ReactDOMServer, regs, list, panel, layout } = load()
+ const h = React.createElement
+ const Menu = regs['sidebar.workspaces.session.menu.item'].Comp
+ // Capture the onSelect handler of the menu item by rendering its element tree shallowly.
+ let onSelect
+ const fakeMenuButton = Menu.length // keep lint quiet
+ void fakeMenuButton
+ // Use the plugin's own store through the panel: include 'b' via a synthetic menu render.
+ const MenuWithCapture = (props) => {
+ const el = Menu(props)
+ onSelect = el.props.onSelect || el.props.onClick
+ return el
+ }
+ ReactDOMServer.renderToString(h(MenuWithCapture, { sessionId: 'b', useMenuOpenState: () => [true, () => {}] }))
+ onSelect()
+ const Panel = regs['main:dsh-chat-split'].Comp
+ const html = ReactDOMServer.renderToString(h(Panel, { renderSlot: () => h('i', null, 'conv'), SessionProvider: ({ children }) => children }))
+ assert.match(html, /data-session-id="b"/)
+ assert.match(html, /Beta chat/)
+ assert.match(html, /Close window/)
+ assert.match(html, /Hide this window's chat list/)
+
+ // Split view showing → user clicks 'b' in the stock sidebar: mainView retain changes, then panel → null.
+ layout.selectPanel('dsh-chat-split')
+ const s = list.getSnapshot()
+ list.set({ ...s, byId: { ...s.byId, b: { ...s.byId.b, retainedBy: { mainView: 1 } } } })
+ layout.selectPanel(null)
+ await new Promise((r) => setTimeout(r, 10))
+ assert.equal(panel.getSnapshot().activePanelId, 'dsh-chat-split', 'returned to split view')
+ const glowing = ReactDOMServer.renderToString(h(Panel, { renderSlot: () => null, SessionProvider: ({ children }) => children }))
+ assert.match(glowing, /dcs-win glow/)
+
+ // Leaving split view without opening a chat (no mainView change) stays left.
+ await new Promise((r) => setTimeout(r, 900))
+ layout.selectPanel('dsh-chat-split')
+ layout.selectPanel(null)
+ await new Promise((r) => setTimeout(r, 10))
+ assert.equal(panel.getSnapshot().activePanelId, null)
+
+ // Opening a chat that is NOT in the split leaves split view normally.
+ layout.selectPanel('dsh-chat-split')
+ const s2 = list.getSnapshot()
+ list.set({ ...s2, byId: { ...s2.byId, b: { ...s2.byId.b, retainedBy: {} }, c: { ...s2.byId.c, retainedBy: { mainView: 1 } } } })
+ layout.selectPanel(null)
+ await new Promise((r) => setTimeout(r, 10))
+ assert.equal(panel.getSnapshot().activePanelId, null)
+})