From 1e908ed64ec43b008ee4d0550a8947b342222aa0 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 21 Oct 2026 18:31:31 +0000 Subject: [PATCH] Initial release of @local/dsh-chat-split v1.0.0 --- README.md | 20 + client.js | 1071 ++++++++++++++++++++++++++++++++++++++++++++++ cordis.patch.yml | 3 + index.js | 2 + package.json | 27 ++ 5 files changed, 1123 insertions(+) create mode 100644 README.md create mode 100644 client.js create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 package.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..beb2c00 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# @local/dsh-chat-split + +DSH plugin for multi-chat split screen with responsive grid wrapping, per-chat sidebars, and customizable layout rules. + +## 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. + +## Usage + +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**. diff --git a/client.js b/client.js new file mode 100644 index 0000000..747cf2b --- /dev/null +++ b/client.js @@ -0,0 +1,1071 @@ +/** + * @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. + */ +window.__ModuleLoader__.load({ + id: '@local/dsh-chat-split', + factory(require) { + const React = require('react'); + const h = React.createElement; + const { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } = React; + + const PANEL_ID = 'dsh-chat-split'; + const SETTINGS_KEY = 'dsh.chat-split.settings.v1'; + const SESSIONS_KEY = 'dsh.chat-split.sessions.v1'; + const ACTIVE_KEY = 'dsh.chat-split.active.v1'; + + // ---- Settings & State Store ------------------------------------------ + const defaultSettings = { + minHeight: 396, + minWidth: 420, + gap: 10, + }; + + 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; + + const store = { + getSettings() { + return currentSettings; + }, + updateSettings(patch) { + currentSettings = { ...currentSettings, ...patch }; + saveJson(SETTINGS_KEY, currentSettings); + notify(); + }, + 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); + } + }; + + // ---- 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); + } + `; + document.head.appendChild(el); + } + + // ---- Child Slot Occupant: Split Window Content ----------------------- + function FixedChatConversationView(props) { + return h(React.Fragment, null, props.renderSlot('conversation.session', { view: 'chat' })); + } + + 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}`); + } + + return renderFactorySlot('conversation.content', { + variant: 'embedded', + phase: settling ? 'settling' : hero ? 'hero' : 'active', + hero: !!hero, + }, { + slots: { views: FixedChatConversationView } + }); + } + + // ---- 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'; + + useEffect(() => { + if (!ctx?.sessions || !sessionId) return; + const ctrl = new AbortController(); + let ref = 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); + } + + 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...' + ) + ) + ) + ); + } + + // ---- 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); + + useEffect(() => { + installStyles(); + store.setSplitActive(true); + return () => { + store.setSplitActive(false); + }; + }, []); + + useEffect(() => { + return store.subscribe(() => { + setSettings(store.getSettings()); + setSessions(store.getSessions()); + setGlowingSessionId(store.getGlowingSessionId()); + }); + }, []); + + // 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]); + + // Measure container width for responsive column calculation + 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); + 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, + }) + ) + ) + ); + } + + // ---- 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)' } }) + ) + ) + ) + ) + ) + ) + ) + ); + } + + // ---- 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 }) + ); + } + + // ---- 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 ---------------------------------------- + return { + inject: ['slots', 'sessions'], + apply(ctx) { + installStyles(); + const disposers = []; + + // 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); + } + + // 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))); + + // 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))); + + // 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 })))); + + // 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))); + + // 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 {} + }); + }; + } + }; + } +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..4b5a377 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: dsh-chat-split + name: '@local/dsh-chat-split' diff --git a/index.js b/index.js new file mode 100644 index 0000000..3e48dc6 --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +/** Host half of @local/dsh-chat-split; the Client module owns all behavior. */ +export function apply() {} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f73769 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "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.", + "private": true, + "type": "module", + "exports": { + ".": "./index.js", + "./client": "./client.js" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@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" + ] + } + } +}