/** * @local/dsh-chat-split — browser side. * * Chats side by side, directly in the conversation area — there is no separate view to open. * - "Include in split view" in a chat's "…" menu puts that chat next to the one you are viewing. * From then on the conversation area shows all included chats; with one left it is the normal * single-chat view again. * - Windows sit in one row while each conversation still gets the readability width; otherwise * they wrap into a grid. Each window is at least `minHeight` px tall (default 396). * - Every window has its own top bar (add a chat beside, move, keep only this one, close) and * its own chat list to switch what that window shows. * - Picking a chat in the main sidebar while split: it opens in the window you last used, or — * if it is already open — that window flashes with a short blue glow. * - 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 FOCUS_KEY = 'dsh.chat-split.focused.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'), focused: read(FOCUS_KEY, null), 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), replace(oldId, newId) { store.setIds(state.ids.map((x) => (x === oldId ? newId : x))); if (state.focused === oldId) store.focus(newId); }, 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); }, focus(id) { if (state.focused !== id) { write(FOCUS_KEY, id); set({ focused: id }); } }, 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; let workspaceSvc = 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'; // ------------------------------------------------------------------ split controller /** Split is on while two or more chats are open side by side. */ const isSplit = () => state.ids.length >= 2; const isSplitShowing = () => layoutSvc?.panelInfo?.getSnapshot().activePanelId === PANEL_ID; const showSplit = () => { try { layoutSvc?.selectPanel(PANEL_ID); return true; } catch { return false; } }; /** The chat of the normal conversation view (retained by the workspace as `mainView`). */ const mainSessionId = () => { const byId = sessionsSvc?.list?.getSnapshot().byId || {}; return Object.keys(byId).find((id) => byId[id].retainedBy?.mainView); }; const glowSoon = (id) => setTimeout(() => store.glow(id), 120); // after the window mounts /** Back to the normal single-chat view, showing `id`. */ function unsplit(id) { store.setIds([]); if (id && workspaceSvc?.openSession) workspaceSvc.openSession(id); else if (isSplitShowing()) layoutSvc.selectPanel(null); } /** "Include in split view": put `id` beside the chat being viewed (or beside the split). */ function include(id) { if (store.has(id)) { if (isSplit()) { showSplit(); glowSoon(id); } return; } const ids = [...state.ids]; if (!isSplit()) { ids.length = 0; const current = mainSessionId(); const blank = sessionsSvc?.list?.getSnapshot().byId[current]?.blank; if (current && current !== id && !blank) ids.push(current); // an empty new chat does not take a window } ids.push(id); if (ids.length < 2) { unsplit(id); return; } // nothing to put it beside: just open it store.setIds(ids); store.focus(id); showSplit(); glowSoon(id); } /** Close one window; with one chat left, that chat goes back to the normal view. */ function closeWindow(id) { const rest = state.ids.filter((x) => x !== id); if (rest.length >= 2) { store.setIds(rest); if (state.focused === id) store.focus(rest[0]); return; } unsplit(rest[0]); } /** Show `id` in the window last used (or glow it if it is already open). */ function openInSplit(id) { if (store.has(id)) { store.focus(id); store.glow(id); return; } const target = state.ids.includes(state.focused) ? state.focused : state.ids[state.ids.length - 1]; store.replace(target, id); store.focus(id); glowSoon(id); } /** * The stock sidebar opens a chat by retaining it as `mainView` and then returning to the * conversation view (selectPanel(null)); the retention is published synchronously before * that. While split is on, the conversation view *is* the split: switch straight back (in a * microtask, before the browser paints) and place the picked chat in it. */ function watchConversationView(ctx) { // Every pick in the sidebar retains the chat as `mainView` again (even the current one), // so a change in that retention marks "a chat was just picked". const signature = () => { const byId = sessionsSvc.list.getSnapshot().byId; return Object.keys(byId).filter((id) => byId[id].retainedBy?.mainView).map((id) => `${id}:${byId[id].retainedBy.mainView}`).join(','); }; let sig = signature(); let pickedAt = 0; const offList = sessionsSvc.list.subscribe(() => { const next = signature(); if (next !== sig) { sig = next; pickedAt = Date.now(); } }); const offPanel = layoutSvc.panelInfo.subscribe(() => { if (layoutSvc.panelInfo.getSnapshot().activePanelId !== null || !isSplit()) return; queueMicrotask(() => { if (layoutSvc.panelInfo.getSnapshot().activePanelId !== null || !isSplit()) return; const main = mainSessionId(); if (main && Date.now() - pickedAt < 800) openInSplit(main); showSplit(); }); }); ctx.effect(() => () => { offPanel(); offList(); }, 'dsh-chat-split: conversation view'); // A split that was open before a reload comes back right away. let tries = 0; const restore = () => { if (!isSplit() || layoutSvc.panelInfo.getSnapshot().activePanelId !== null) return; if (!showSplit() && ++tries < 40) setTimeout(restore, 100); }; setTimeout(restore, 0); } // ------------------------------------------------------------------ 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-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.focused{border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))} .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-win.focused .dcs-bar{background:var(--dsw-alias-bg-l2,var(--dsw-alias-bg-l1))} .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-l3,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-msg{padding:16px;color:var(--dsw-alias-label-tertiary)} /* Popovers are opaque: the menu colour painted over the page background. */ .dcs-pop{position:absolute;z-index:20;top:34px;right:6px;width:300px;max-width:calc(100% - 12px);max-height:360px;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background-color:var(--dsw-alias-bg-base,#1c1c1c);background-image:linear-gradient(var(--dsw-specific-menu,transparent),var(--dsw-specific-menu,transparent));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 beside` : 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))); }))); } function AddBeside({ list, onClose }) { useEffect(() => { const off = (e) => { if (!e.target.closest?.('.dcs-pop, .dcs-add')) onClose(); }; document.addEventListener('pointerdown', off, true); return () => document.removeEventListener('pointerdown', off, true); }, []); return h('div', { className: 'dcs-pop', onKeyDown: (e) => { if (e.key === 'Escape') onClose(); } }, h('div', { className: 'dcs-side-head' }, 'Open a chat beside'), h(ChatList, { list, currentId: null, autoFocus: true, onPick: (id) => { onClose(); include(id); } })); } // ------------------------------------------------------------------ one window function SplitWindow({ id, index, total, cols, list, settings, focused, glowKey, renderSlot, SessionProvider }) { const [sideOpen, setSideOpen] = useState(settings.sidebarOpen); const [adding, setAdding] = useState(false); 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${focused ? ' focused' : ''}${glowKey ? ' glow' : ''}`, 'data-session-id': id, onPointerDownCapture: () => store.focus(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 dcs-add${adding ? ' on' : ''}`, title: 'Open another chat beside', onClick: () => setAdding(!adding) }, '+'), h('button', { className: 'dcs-ib', title: 'Show only this chat (leave split)', onClick: () => unsplit(id) }, '⤢'), h('button', { className: 'dcs-ib', title: 'Close this window', onClick: () => closeWindow(id) }, '✕')), adding && h(AddBeside, { list, onClose: () => setAdding(false) }), 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…')))); } // ------------------------------------------------------------------ the split (main area) 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 SplitPanel({ renderSlot, SessionProvider }) { const ids = useStore((s) => s.ids); const focused = useStore((s) => s.focused); const settings = useStore((s) => s.settings); const glow = useStore((s) => s.glow); const list = useSessionList(); const [ref, width] = useWidth(); // Drop chats that no longer exist (deleted/archived); fewer than two → the normal view. useEffect(() => { if (list.phase !== 'ready') return; const alive = ids.filter((id) => list.byId[id]); if (alive.length < 2) unsplit(alive[0] || mainSessionId()); else if (alive.length !== ids.length) store.setIds(alive); }, [list, ids]); const cols = columnsFor(width - 16, ids.length, settings); return h('div', { className: 'dcs-root', 'data-dsh-chat-split': '' }, 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, focused: id === focused, 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.length >= 2 && s.ids.includes(sessionId)); const onSelect = () => { setMenuOpen(false); if (included) closeWindow(sessionId); else include(sessionId); }; const label = included ? 'Exclude from split view' : 'Include in split view'; const icon = h(SplitIcon, { size: 16 }); if (P.MenuItemButton) return h(P.MenuItemButton, { icon, onSelect }, label); return h('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label); } function SplitIcon({ 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 2v12' })); } // ------------------------------------------------------------------ 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 conversation area 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 - 20) }); 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 pad = 16; const rowH = Math.max(settings.minHeight, (vh - pad - (rows - 1) * settings.gap) / rows); const fullH = 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)) }, [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 area 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', { 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('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, 'Use "Include in split view" in a chat\'s "…" menu to open it beside the one you are viewing. Chats sit side by side while each keeps the readability width; otherwise they wrap into a grid. Close windows down to one to get the normal view back.'), 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 area 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 })); } // ------------------------------------------------------------------ plugin return { inject: ['slots', 'sessions', 'layout', 'uiWorkspace'], apply(ctx) { sessionsSvc = ctx.sessions; layoutSvc = ctx.layout; workspaceSvc = ctx.uiWorkspace; ctx.effect(installCss, 'dsh-chat-split: css'); // The split lives in the main area as a panel without a panel-list button: it is shown // automatically instead of the single conversation whenever two or more chats are open. 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 area'); 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) watchConversationView(ctx); }, }; }, });