Files
dsh-chat-split/client.js
T

1072 lines
39 KiB
JavaScript

/**
* @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 {}
});
};
}
};
}
});