From f931abd470a73859659168abe6ca1c659cb3a88c Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 21 Oct 2026 19:10:03 +0000 Subject: [PATCH] Rewrite against the real slot/session APIs: grid reflow fix, window top bars, per-window chat lists, glow on main-sidebar selection, MenuItemButton include/exclude, live preview from real chats; headless tests --- README.md | 38 +- client.js | 1429 ++++++++++++------------------------------ package.json | 14 +- test/client.test.mjs | 162 +++++ 4 files changed, 614 insertions(+), 1029 deletions(-) create mode 100644 test/client.test.mjs 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) +})