Files
dsh-chat-split/client.js
T

484 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @local/dsh-chat-split — browser side.
*
* Split view: several conversations side by side in one main panel.
* - Windows sit in one row while each still gets `minWidth` px (readability); otherwise
* they wrap into a grid. Each window is at least `minHeight` px tall (default 396).
* - Every window has its own top bar (move up/left/right/down, sidebar toggle, close) and
* its own chat sidebar to switch what that window shows.
* - Selecting a chat that is already open (from a window sidebar or the main sidebar)
* while split view is showing flashes that window with a short blue glow.
* - "Include in / Exclude from split view" in every session's "..." menu.
* - Settings → Chat split: sizes plus an interactive preview built from your real chats.
*/
window.__ModuleLoader__.load({
id: '@local/dsh-chat-split',
factory(require) {
const React = require('react');
const P = require('@deepseek-ai/dsh-client-ui-primitives') || {};
const h = React.createElement;
const { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } = React;
const PANEL_ID = 'dsh-chat-split';
const CHILD_SLOT = 'dsh-chat-split.window';
const SETTINGS_KEY = 'dsh.chat-split.settings.v1';
const SESSIONS_KEY = 'dsh.chat-split.sessions.v1';
const DEFAULTS = { minHeight: 396, minWidth: 420, gap: 8, sidebarWidth: 180, sidebarOpen: true };
const GLOW_MS = 1400;
// ------------------------------------------------------------------ store
const read = (key, fallback) => {
try { const v = JSON.parse(localStorage.getItem(key)); return v ?? fallback; } catch { return fallback; }
};
const write = (key, v) => { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* quota */ } };
let state = {
settings: { ...DEFAULTS, ...read(SETTINGS_KEY, {}) },
ids: (read(SESSIONS_KEY, []) || []).filter((x) => typeof x === 'string'),
glow: null, // { id, n }
};
const subs = new Set();
const set = (patch) => { state = { ...state, ...patch }; subs.forEach((f) => { try { f(); } catch { /* ignore */ } }); };
const store = {
get: () => state,
subscribe: (f) => { subs.add(f); return () => subs.delete(f); },
setSettings(p) { const s = { ...state.settings, ...p }; write(SETTINGS_KEY, s); set({ settings: s }); },
setIds(ids) { const u = [...new Set(ids)]; write(SESSIONS_KEY, u); set({ ids: u }); },
has: (id) => state.ids.includes(id),
add(id) { if (!store.has(id)) store.setIds([...state.ids, id]); },
remove(id) { store.setIds(state.ids.filter((x) => x !== id)); },
replace(oldId, newId) { store.setIds(state.ids.map((x) => (x === oldId ? newId : x))); },
move(id, dir) {
const ids = [...state.ids]; const i = ids.indexOf(id); const j = i + dir;
if (i < 0 || j < 0 || j >= ids.length) return;
ids.splice(i, 1); ids.splice(j, 0, id); store.setIds(ids);
},
glow(id) {
const n = (state.glow?.n || 0) + 1;
set({ glow: { id, n } });
setTimeout(() => { if (state.glow?.n === n) set({ glow: null }); }, GLOW_MS);
},
};
const useStore = (sel) => { const get = () => sel(store.get()); return useSyncExternalStore(store.subscribe, get, get); };
/** Services captured in apply(). */
let sessionsSvc = null;
let layoutSvc = null;
const EMPTY_LIST = { ids: [], byId: {}, phase: 'pending' };
const noopSub = () => () => {};
function useSessionList() {
const src = sessionsSvc?.list;
const get = () => (src ? src.getSnapshot() : EMPTY_LIST);
return useSyncExternalStore(src ? src.subscribe.bind(src) : noopSub, get, get);
}
/** Top-level, non-blank chats, most recent first. */
function chatIds(list) {
return list.ids
.filter((id) => { const s = list.byId[id]; return s && !s.blank && !s.parentId && s.origin !== 'subagent'; })
.sort((a, b) => (list.byId[b].updatedAt || 0) - (list.byId[a].updatedAt || 0));
}
const titleOf = (list, id) => list.byId[id]?.displayTitle || list.byId[id]?.title || 'Untitled chat';
const isSplitShowing = () => layoutSvc?.panelInfo?.getSnapshot().activePanelId === PANEL_ID;
const openSplit = () => { try { layoutSvc?.selectPanel(PANEL_ID); } catch { /* not registered yet */ } };
// ------------------------------------------------------------------ css
const CSS = `
@keyframes dcs-glow{0%{box-shadow:0 0 0 2px #3b82f6,0 0 22px 6px rgba(59,130,246,.65)}70%{box-shadow:0 0 0 2px #3b82f6,0 0 14px 3px rgba(59,130,246,.35)}100%{box-shadow:0 0 0 0 rgba(59,130,246,0)}}
.dcs-root{display:flex;flex-direction:column;height:100%;width:100%;min-width:0;min-height:0;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);font-size:13px}
.dcs-head{position:relative;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none}
.dcs-head-title{font-weight:600;font-size:14px}
.dcs-head-meta{color:var(--dsw-alias-label-tertiary);font-size:12px}
.dcs-sp{flex:1}
.dcs-btn{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-l1);color:inherit;border-radius:7px;padding:4px 10px;font:inherit;font-size:12px;cursor:pointer}
.dcs-btn:hover{background:var(--dsw-alias-bg-l2)}
.dcs-grid{flex:1;min-height:0;overflow:auto;display:grid;padding:8px;box-sizing:border-box}
.dcs-win{position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-base)}
.dcs-win.glow{animation:dcs-glow ${GLOW_MS}ms ease-out;border-color:#3b82f6}
.dcs-bar{display:flex;align-items:center;gap:2px;height:32px;padding:0 6px;background:var(--dsw-alias-bg-l1);border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;user-select:none}
.dcs-bar-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:12px;margin-left:4px}
.dcs-bar-pos{font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0 4px}
.dcs-ib{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:0;border-radius:6px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:13px;line-height:1;padding:0}
.dcs-ib:hover:not(:disabled){background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary)}
.dcs-ib:disabled{opacity:.3;cursor:default}
.dcs-ib.on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-l2)}
.dcs-body{flex:1;display:flex;min-height:0}
.dcs-side{flex:none;display:flex;flex-direction:column;border-right:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-l1);min-height:0}
.dcs-side-head{padding:6px 8px 4px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase;letter-spacing:.04em}
.dcs-side-filter{margin:0 6px 4px;padding:4px 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-alias-bg-base);color:inherit;font:inherit;font-size:12px}
.dcs-side-list{flex:1;overflow:auto;padding:0 4px 6px}
.dcs-item{display:flex;align-items:center;gap:6px;width:100%;border:0;background:none;color:inherit;text-align:left;font:inherit;font-size:12px;padding:5px 6px;border-radius:6px;cursor:pointer}
.dcs-item:hover{background:var(--dsw-alias-bg-l2)}
.dcs-item.cur{background:var(--dsw-alias-bg-l2);font-weight:600}
.dcs-item-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dcs-dot{width:6px;height:6px;border-radius:50%;flex:none;background:transparent}
.dcs-dot.open{background:#3b82f6}
.dcs-dot.run{background:#22c55e}
.dcs-conv{flex:1;min-width:0;min-height:0;height:100%;display:flex;position:relative;overflow:hidden}
/* Composer status strips that wrap into one item per line in a narrow window keep to one scrollable line here. */
.dcs-win [data-omniroute-quotas]{flex-wrap:nowrap!important;justify-content:flex-start!important;overflow-x:auto;scrollbar-width:none;min-width:0}
.dcs-win [data-omniroute-quotas]::-webkit-scrollbar{display:none}
.dcs-win [data-omniroute-quotas] > *{flex:none!important}
.dcs-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;text-align:center;color:var(--dsw-alias-label-secondary)}
.dcs-chips{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;max-width:640px}
.dcs-msg{padding:16px;color:var(--dsw-alias-label-tertiary)}
.dcs-pop{position:absolute;z-index:20;top:40px;right:12px;width:300px;max-height:360px;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-l1));box-shadow:0 8px 28px rgba(0,0,0,.35)}
.dcs-set{max-width:860px}
.dcs-set h3{margin:0 0 4px;font-size:16px}
.dcs-set p{margin:0 0 14px;color:var(--dsw-alias-label-secondary)}
.dcs-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px 18px;margin-bottom:18px}
.dcs-f label{display:flex;justify-content:space-between;font-weight:600;font-size:12px;margin-bottom:4px}
.dcs-f input[type=range]{width:100%}
.dcs-f small{display:block;color:var(--dsw-alias-label-tertiary);font-size:11px;margin-top:2px}
.dcs-prev-ctl{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-bottom:8px;font-size:12px}
.dcs-prev-ctl input[type=range]{flex:1;min-width:140px}
.dcs-prev{border:1px dashed var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-base)}
.dcs-prev-scale{transform-origin:0 0;pointer-events:none}
.dcs-mock{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-base)}
.dcs-mline{height:10px;border-radius:5px;background:var(--dsw-alias-border-l1,rgba(127,127,127,.3));margin:8px 12px}
.dcs-mline.me{margin-left:40%;background:rgba(59,130,246,.28)}
.dcs-minput{margin:auto 12px 12px;height:34px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px}
`;
function installCss() {
const el = document.createElement('style'); el.id = 'dcs-css'; el.textContent = CSS;
document.head.appendChild(el);
return () => el.remove();
}
/** Narrowest a window may get: the readable conversation width plus its chat list when that is open by default. */
const windowMinWidth = (s) => s.minWidth + (s.sidebarOpen ? s.sidebarWidth : 0);
/** Columns that fit `width` while every window keeps windowMinWidth (never more than n). */
const columnsFor = (width, n, s) => Math.max(1, Math.min(n || 1, Math.floor((width + s.gap) / (windowMinWidth(s) + s.gap))));
// ------------------------------------------------------------------ embedded conversation
function FixedChatView(props) { return h(React.Fragment, null, props.renderSlot('conversation.session', { view: 'chat' })); }
/** Occupant of the per-window session-scope slot (same shape as ui-subagent's embedded chat). */
function WindowConversation({ sessionId, useSession, useConversation, useSessions, renderFactorySlot }) {
const session = useSession((v) => v);
const active = useConversation((v) => v).activeTargets.size > 0;
const summaryBlank = useSessions((s) => s.byId[sessionId]?.blank);
const shell = active || (!session.blank && !session.awaitingFirstTurn) || session.running ? 'active' : session.promptAttempted ? 'engaging' : 'blank';
const settling = shell === 'blank' && session.openState === 'loading' && summaryBlank !== true;
const hero = shell === 'blank' && (session.openState === 'open' || summaryBlank === true);
return renderFactorySlot('conversation.content', {
variant: 'embedded', phase: settling ? 'settling' : hero ? 'hero' : 'active', hero,
}, { slots: { views: FixedChatView } });
}
/** Retains the window's session for as long as the window shows it. */
function useRetained(sessionId) {
const [ref, setRef] = useState({ id: null, reference: null, error: null });
useEffect(() => {
if (!sessionsSvc || !sessionId) return undefined;
let reference = null;
try {
reference = sessionsSvc.retain(sessionId, { source: 'chatSplit' });
setRef({ id: sessionId, reference, error: null });
} catch (e) {
setRef({ id: sessionId, reference: null, error: String(e?.message || e) });
}
return () => { try { reference?.release(); } catch { /* ignore */ } };
}, [sessionId]);
return ref.id === sessionId ? ref : { reference: null, error: null };
}
// ------------------------------------------------------------------ chat list (window sidebar + add popover)
function ChatList({ list, currentId, onPick, autoFocus }) {
const [q, setQ] = useState('');
const openIds = useStore((s) => s.ids);
const ids = useMemo(() => {
const all = chatIds(list);
const needle = q.trim().toLowerCase();
return needle ? all.filter((id) => titleOf(list, id).toLowerCase().includes(needle)) : all;
}, [list, q]);
return h(React.Fragment, null,
h('input', { className: 'dcs-side-filter', placeholder: 'Filter chats…', value: q, autoFocus, onChange: (e) => setQ(e.target.value) }),
h('div', { className: 'dcs-side-list', role: 'listbox' },
ids.length === 0 && h('div', { className: 'dcs-msg' }, list.phase === 'ready' ? 'No chats' : 'Loading…'),
ids.map((id) => {
const open = openIds.includes(id);
return h('button', {
key: id, type: 'button', role: 'option', 'aria-selected': id === currentId,
className: `dcs-item${id === currentId ? ' cur' : ''}`,
title: open && id !== currentId ? `${titleOf(list, id)} — already open in split view` : titleOf(list, id),
onClick: () => onPick(id),
},
h('span', { className: `dcs-dot${list.byId[id]?.running ? ' run' : open ? ' open' : ''}` }),
h('span', { className: 'dcs-item-t' }, titleOf(list, id)));
})));
}
// ------------------------------------------------------------------ one split window
function SplitWindow({ id, index, total, cols, list, settings, glowKey, renderSlot, SessionProvider }) {
const [sideOpen, setSideOpen] = useState(settings.sidebarOpen);
const { reference, error } = useRetained(id);
const pick = (next) => {
if (store.has(next)) { store.glow(next); return; } // already open (here or elsewhere) → highlight it
store.replace(id, next);
};
const move = (steps) => { for (let i = 0; i < Math.abs(steps); i++) store.move(id, Math.sign(steps)); };
const row = Math.floor(index / cols) + 1;
const col = (index % cols) + 1;
return h('section', {
key: glowKey || 0, // restart the CSS animation on every glow
className: `dcs-win${glowKey ? ' glow' : ''}`, 'data-session-id': id,
},
h('header', { className: 'dcs-bar' },
h('button', { className: `dcs-ib${sideOpen ? ' on' : ''}`, title: sideOpen ? 'Hide this window\'s chat list' : 'Show this window\'s chat list', onClick: () => setSideOpen(!sideOpen) }, '☰'),
h('span', { className: 'dcs-bar-title', title: titleOf(list, id) }, titleOf(list, id)),
h('span', { className: 'dcs-bar-pos', title: 'Position in the grid (row · column)' }, `${row}·${col}`),
cols < total && h('button', { className: 'dcs-ib', title: 'Move up a row', disabled: index - cols < 0, onClick: () => move(-cols) }, '↑'),
h('button', { className: 'dcs-ib', title: 'Move left', disabled: index === 0, onClick: () => move(-1) }, '←'),
h('button', { className: 'dcs-ib', title: 'Move right', disabled: index === total - 1, onClick: () => move(1) }, '→'),
cols < total && h('button', { className: 'dcs-ib', title: 'Move down a row', disabled: index + cols > total - 1, onClick: () => move(cols) }, '↓'),
h('button', { className: 'dcs-ib', title: 'Close window', onClick: () => store.remove(id) }, '✕')),
h('div', { className: 'dcs-body' },
sideOpen && h('aside', { className: 'dcs-side', style: { width: settings.sidebarWidth } },
h('div', { className: 'dcs-side-head' }, 'Chats'),
h(ChatList, { list, currentId: id, onPick: pick })),
h('div', { className: 'dcs-conv' },
error ? h('div', { className: 'dcs-msg' }, `Cannot open this chat: ${error}`)
: reference ? h(SessionProvider, { session: reference }, renderSlot(CHILD_SLOT, {}))
: h('div', { className: 'dcs-msg' }, 'Opening…'))));
}
// ------------------------------------------------------------------ main panel
function useWidth() {
const [w, setW] = useState(0);
const ro = useRef(null);
const ref = useCallback((el) => {
ro.current?.disconnect(); ro.current = null;
if (!el) return;
setW(el.clientWidth);
ro.current = new ResizeObserver(([e]) => setW(e.contentRect.width));
ro.current.observe(el);
}, []);
return [ref, w];
}
function AddChat({ list, onClose }) {
return h('div', { className: 'dcs-pop', onKeyDown: (e) => { if (e.key === 'Escape') onClose(); } },
h('div', { className: 'dcs-side-head' }, 'Add chat to split view'),
h(ChatList, { list, currentId: null, autoFocus: true, onPick: (id) => { if (store.has(id)) store.glow(id); else store.add(id); onClose(); } }));
}
function SplitPanel({ renderSlot, SessionProvider }) {
const ids = useStore((s) => s.ids);
const settings = useStore((s) => s.settings);
const glow = useStore((s) => s.glow);
const list = useSessionList();
const [ref, width] = useWidth();
const [adding, setAdding] = useState(false);
// Drop chats that no longer exist (deleted/archived) once the list is loaded.
useEffect(() => {
if (list.phase !== 'ready') return;
const gone = ids.filter((id) => !list.byId[id]);
if (gone.length) store.setIds(ids.filter((id) => list.byId[id]));
}, [list, ids]);
const cols = columnsFor(width - 16, ids.length, settings);
const rows = Math.max(1, Math.ceil(ids.length / cols));
const recent = chatIds(list).filter((id) => !ids.includes(id)).slice(0, 8);
return h('div', { className: 'dcs-root', 'data-dsh-chat-split': '' },
h('div', { className: 'dcs-head' },
h('span', { className: 'dcs-head-title' }, 'Split view'),
h('span', { className: 'dcs-head-meta' }, ids.length ? `${ids.length} chat${ids.length > 1 ? 's' : ''} · ${cols} × ${rows}` : ''),
h('span', { className: 'dcs-sp' }),
h('button', { className: 'dcs-btn', onClick: () => setAdding(!adding) }, '+ Add chat'),
ids.length > 0 && h('button', { className: 'dcs-btn', onClick: () => store.setIds([]) }, 'Close all'),
adding && h(AddChat, { list, onClose: () => setAdding(false) })),
ids.length === 0
? h('div', { className: 'dcs-empty' },
h('strong', null, 'No chats in split view'),
h('span', null, 'Use "+ Add chat", or "Include in split view" in a chat\'s "…" menu in the sidebar.'),
h('div', { className: 'dcs-chips' }, recent.map((id) => h('button', { key: id, className: 'dcs-btn', onClick: () => store.add(id) }, `+ ${titleOf(list, id)}`))))
: h('div', {
ref, className: 'dcs-grid',
style: {
gap: settings.gap,
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gridAutoRows: `minmax(${settings.minHeight}px, 1fr)`,
},
}, ids.map((id, i) => h(SplitWindow, {
key: id, id, index: i, total: ids.length, cols, list, settings,
glowKey: glow?.id === id ? glow.n : 0, renderSlot, SessionProvider,
}))));
}
// ------------------------------------------------------------------ sidebar "…" menu item
function IncludeMenuItem({ sessionId, useMenuOpenState }) {
const [, setMenuOpen] = useMenuOpenState();
const included = useStore((s) => s.ids.includes(sessionId));
const onSelect = () => {
setMenuOpen(false);
if (included) { store.remove(sessionId); return; }
store.add(sessionId);
if (isSplitShowing()) store.glow(sessionId);
};
const label = included ? 'Exclude from split view' : 'Include in split view';
const icon = h(PanelIcon, { size: 16 });
if (P.MenuItemButton) return h(P.MenuItemButton, { icon, onSelect }, label);
return h('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label);
}
function PanelIcon({ size = 16 }) {
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round', 'aria-hidden': true },
h('rect', { x: 1.5, y: 2, width: 13, height: 12, rx: 1.6 }),
h('path', { d: 'M8 2v12M1.5 8h6.5' }));
}
// ------------------------------------------------------------------ settings + live preview
function Slider({ label, value, min, max, step = 1, unit = 'px', hint, onChange }) {
return h('div', { className: 'dcs-f' },
h('label', null, h('span', null, label), h('span', null, `${value}${unit}`)),
h('input', { type: 'range', min, max, step, value, onChange: (e) => onChange(Number(e.target.value)) }),
hint && h('small', null, hint));
}
/** Scaled replica of the split panel at a chosen screen size, using your real chat titles. */
function Preview({ settings, list }) {
const box = useRef(null);
const [boxW, setBoxW] = useState(760);
useEffect(() => {
const el = box.current; if (!el) return undefined;
setBoxW(el.clientWidth);
const ro = new ResizeObserver(([e]) => setBoxW(e.contentRect.width)); ro.observe(el);
return () => ro.disconnect();
}, []);
const screen = () => ({ w: Math.max(480, window.innerWidth - 300), h: Math.max(420, window.innerHeight - 60) });
const [vw, setVw] = useState(() => screen().w);
const [vh, setVh] = useState(() => screen().h);
const openIds = useStore((s) => s.ids);
const defaultN = Math.min(9, Math.max(2, openIds.length || 3));
const [n, setN] = useState(defaultN);
const pool = [...openIds, ...chatIds(list).filter((id) => !openIds.includes(id))];
const titles = pool.slice(0, n).map((id) => titleOf(list, id));
while (titles.length < n) titles.push(`Chat ${titles.length + 1}`);
const cols = columnsFor(vw - 16, n, settings);
const rows = Math.ceil(n / cols);
const headH = 41; const pad = 16;
const rowH = Math.max(settings.minHeight, (vh - headH - pad - (rows - 1) * settings.gap) / rows);
const fullH = headH + pad + rows * rowH + (rows - 1) * settings.gap;
const shownH = Math.min(fullH, vh);
const scale = Math.min(1, boxW / vw);
return h('div', null,
h('div', { className: 'dcs-prev-ctl' },
h('span', null, `Width ${vw}px`),
h('input', { type: 'range', min: 480, max: 3840, step: 10, value: vw, onChange: (e) => setVw(Number(e.target.value)) }),
h('span', null, `Height ${vh}px`),
h('input', { type: 'range', min: 400, max: 2160, step: 10, value: vh, onChange: (e) => setVh(Number(e.target.value)) }),
h('label', null, 'Chats ', h('select', { value: n, onChange: (e) => setN(Number(e.target.value)) }, [1, 2, 3, 4, 5, 6, 7, 8, 9].map((k) => h('option', { key: k, value: k }, k)))),
h('button', { className: 'dcs-btn', onClick: () => { const s = screen(); setVw(s.w); setVh(s.h); setN(defaultN); } }, 'Fit my screen')),
h('div', { style: { fontSize: 12, marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } },
`${cols} column${cols > 1 ? 's' : ''} × ${rows} row${rows > 1 ? 's' : ''}`,
cols < n ? ' — too narrow for all chats side by side, so they wrap into a grid' : ' — all chats side by side',
fullH > vh + 1 ? ` · rows keep their ${settings.minHeight}px minimum, so the grid scrolls` : ''),
h('div', { ref: box, className: 'dcs-prev', style: { height: shownH * scale } },
h('div', { className: 'dcs-prev-scale', style: { width: vw, height: shownH, transform: `scale(${scale})`, overflow: 'hidden' } },
h('div', { className: 'dcs-head' }, h('span', { className: 'dcs-head-title' }, 'Split view'), h('span', { className: 'dcs-head-meta' }, `${n} chats · ${cols} × ${rows}`)),
h('div', { style: { display: 'grid', padding: 8, gap: settings.gap, gridTemplateColumns: `repeat(${cols}, minmax(0,1fr))`, gridAutoRows: `${rowH}px` } },
titles.map((t, i) => h('div', { key: i, className: 'dcs-mock' },
h('div', { className: 'dcs-bar' },
h('span', { className: 'dcs-ib on' }, '☰'),
h('span', { className: 'dcs-bar-title' }, t),
h('span', { className: 'dcs-bar-pos' }, `${Math.floor(i / cols) + 1}·${(i % cols) + 1}`),
h('span', { className: 'dcs-ib' }, '←'), h('span', { className: 'dcs-ib' }, '→'), h('span', { className: 'dcs-ib' }, '✕')),
h('div', { className: 'dcs-body' },
settings.sidebarOpen && h('div', { className: 'dcs-side', style: { width: settings.sidebarWidth } },
h('div', { className: 'dcs-side-head' }, 'Chats'),
pool.slice(0, 6).map((id, k) => h('div', { key: k, className: `dcs-item${k === i ? ' cur' : ''}` }, h('span', { className: 'dcs-item-t' }, titleOf(list, id))))),
h('div', { className: 'dcs-conv' },
h('div', { style: { display: 'flex', flexDirection: 'column', flex: 1 } },
h('div', { className: 'dcs-mline', style: { width: '55%' } }),
h('div', { className: 'dcs-mline me' }),
h('div', { className: 'dcs-mline', style: { width: '75%' } }),
h('div', { className: 'dcs-mline', style: { width: '62%' } }),
h('div', { className: 'dcs-minput' }))))))))));
}
function SettingsPanel() {
const s = useStore((st) => st.settings);
const list = useSessionList();
return h('div', { className: 'dcs-set' },
h('h3', null, 'Chat split'),
h('p', null, 'Open several chats at once from "Split view" in the sidebar. Chats sit side by side while each keeps the readability width; otherwise they wrap into a grid.'),
h('div', { className: 'dcs-fields' },
h(Slider, { label: 'Minimum window height', value: s.minHeight, min: 240, max: 900, step: 4, hint: 'Default 396px. Rows never get shorter; the grid scrolls instead.', onChange: (v) => store.setSettings({ minHeight: v }) }),
h(Slider, { label: 'Readability width', value: s.minWidth, min: 300, max: 900, step: 10, hint: 'Narrowest the conversation itself may get (the window\'s chat list comes on top of it) before windows wrap to the next row.', onChange: (v) => store.setSettings({ minWidth: v }) }),
h(Slider, { label: 'Gap between windows', value: s.gap, min: 0, max: 24, onChange: (v) => store.setSettings({ gap: v }) }),
h(Slider, { label: 'Window chat-list width', value: s.sidebarWidth, min: 120, max: 320, step: 4, onChange: (v) => store.setSettings({ sidebarWidth: v }) }),
h('div', { className: 'dcs-f' },
h('label', null, h('span', null, 'Window chat list open by default'),
h('input', { type: 'checkbox', checked: !!s.sidebarOpen, onChange: (e) => store.setSettings({ sidebarOpen: e.target.checked }) }))),
h('div', { className: 'dcs-f' }, h('button', { className: 'dcs-btn', onClick: () => store.setSettings({ ...DEFAULTS }) }, 'Reset to defaults'))),
h('h3', { style: { fontSize: 14, margin: '6px 0 8px' } }, 'Preview'),
h(Preview, { settings: s, list }));
}
// ------------------------------------------------------------------ main-sidebar selection → glow
/**
* The stock sidebar opens a chat by retaining it as `mainView` and then leaving any panel
* (selectPanel(null)). If that happens while split view is showing and the chat is already
* in the split, go straight back to split view and glow its window.
*/
function watchMainSelection(ctx) {
const byId = () => sessionsSvc.list.getSnapshot().byId;
const signature = () => { const b = byId(); return Object.keys(b).filter((id) => b[id].retainedBy?.mainView).map((id) => `${id}:${b[id].retainedBy.mainView}`).join(','); };
const mainId = () => { const b = byId(); return Object.keys(b).find((id) => b[id].retainedBy?.mainView); };
let sig = signature();
let changedAt = 0;
let prevPanel = layoutSvc.panelInfo.getSnapshot().activePanelId;
const offList = sessionsSvc.list.subscribe(() => {
const next = signature();
if (next !== sig) { sig = next; changedAt = Date.now(); }
});
const offPanel = layoutSvc.panelInfo.subscribe(() => {
const cur = layoutSvc.panelInfo.getSnapshot().activePanelId;
const was = prevPanel; prevPanel = cur;
if (was !== PANEL_ID || cur !== null) return;
setTimeout(() => { // let replaceMain release the previous main reference first
if (Date.now() - changedAt > 800) return; // left split view without opening a chat
const id = mainId();
if (!id || !store.has(id)) return;
openSplit();
store.glow(id);
}, 0);
});
ctx.effect(() => () => { offList(); offPanel(); }, 'dsh-chat-split: main selection');
}
// ------------------------------------------------------------------ plugin
return {
inject: ['slots', 'sessions', 'layout'],
apply(ctx) {
sessionsSvc = ctx.sessions;
layoutSvc = ctx.layout;
ctx.effect(installCss, 'dsh-chat-split: css');
ctx.effect(() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
name: 'sidebar.panellist', id: PANEL_ID, order: 45, label: 'Split view',
}, PanelIcon)), 'dsh-chat-split: panel list');
ctx.effect(() => ctx.slots.inject('main', () => ctx.slots.register({
name: 'main', key: PANEL_ID,
children: { [CHILD_SLOT]: { kind: 'single', scope: 'session' } },
}, SplitPanel)), 'dsh-chat-split: main panel');
ctx.effect(() => ctx.slots.inject(CHILD_SLOT, () => ctx.slots.register({ name: CHILD_SLOT }, WindowConversation)), 'dsh-chat-split: window conversation');
ctx.effect(() => ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
name: 'sidebar.workspaces.session.menu.item', id: 'dsh-chat-split', order: 250,
}, IncludeMenuItem)), 'dsh-chat-split: session menu');
ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section', id: 'dsh-chat-split', order: 175, label: 'Chat split',
}, SettingsPanel)), 'dsh-chat-split: settings');
if (sessionsSvc?.list && layoutSvc?.panelInfo) watchMainSelection(ctx);
},
};
},
});