1072 lines
39 KiB
JavaScript
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 {}
|
|
});
|
|
};
|
|
}
|
|
};
|
|
}
|
|
});
|