Seamless split: no separate view; 'Include in split view' opens the chat beside the one being read, sidebar picks open in the last used window or glow an open one, closing to one chat returns to the normal view; opaque popovers; tests that actually find React

This commit is contained in:
Agent committed 2026-10-21 19:58:43 +00:00
1 parent 3445b2b972
commit ff3e09ac97
4 files changed
+320 -242

No files matched your search

+172 -118
View File
@@ -1,14 +1,16 @@
/**
* @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
* 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 (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.
* - 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({
@@ -23,6 +25,7 @@ window.__ModuleLoader__.load({
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;
@@ -35,6 +38,7 @@ window.__ModuleLoader__.load({
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();
@@ -45,14 +49,13 @@ window.__ModuleLoader__.load({
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))); },
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 } });
@@ -64,6 +67,7 @@ window.__ModuleLoader__.load({
/** Services captured in apply(). */
let sessionsSvc = null;
let layoutSvc = null;
let workspaceSvc = null;
const EMPTY_LIST = { ids: [], byId: {}, phase: 'pending' };
const noopSub = () => () => {};
function useSessionList() {
@@ -78,27 +82,118 @@ window.__ModuleLoader__.load({
.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 openSplit = () => { try { layoutSvc?.selectPanel(PANEL_ID); } catch { /* not registered yet */ } };
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-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.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-l2);color:var(--dsw-alias-label-primary)}
.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}
@@ -118,10 +213,9 @@ window.__ModuleLoader__.load({
.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)}
/* 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)}
@@ -200,7 +294,7 @@ window.__ModuleLoader__.load({
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),
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' : ''}` }),
@@ -208,9 +302,21 @@ window.__ModuleLoader__.load({
})));
}
// ------------------------------------------------------------------ one split window
function SplitWindow({ id, index, total, cols, list, settings, glowKey, renderSlot, SessionProvider }) {
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
@@ -221,7 +327,8 @@ window.__ModuleLoader__.load({
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,
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) }, '☰'),
@@ -231,7 +338,10 @@ window.__ModuleLoader__.load({
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('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'),
@@ -242,7 +352,7 @@ window.__ModuleLoader__.load({
: h('div', { className: 'dcs-msg' }, 'Opening…'))));
}
// ------------------------------------------------------------------ main panel
// ------------------------------------------------------------------ the split (main area)
function useWidth() {
const [w, setW] = useState(0);
const ro = useRef(null);
@@ -256,77 +366,55 @@ window.__ModuleLoader__.load({
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 focused = useStore((s) => s.focused);
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.
// Drop chats that no longer exist (deleted/archived); fewer than two → the normal view.
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]));
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);
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,
}))));
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.includes(sessionId));
const included = useStore((s) => s.ids.length >= 2 && s.ids.includes(sessionId));
const onSelect = () => {
setMenuOpen(false);
if (included) { store.remove(sessionId); return; }
store.add(sessionId);
if (isSplitShowing()) store.glow(sessionId);
if (included) closeWindow(sessionId); else include(sessionId);
};
const label = included ? 'Exclude from split view' : 'Include in split view';
const icon = h(PanelIcon, { size: 16 });
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 PanelIcon({ size = 16 }) {
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 2v12M1.5 8h6.5' }));
h('path', { d: 'M8 2v12' }));
}
// ------------------------------------------------------------------ settings + live preview
@@ -337,7 +425,7 @@ window.__ModuleLoader__.load({
hint && h('small', null, hint));
}
/** Scaled replica of the split panel at a chosen screen size, using your real chat titles. */
/** 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);
@@ -347,7 +435,7 @@ window.__ModuleLoader__.load({
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 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);
@@ -358,9 +446,9 @@ window.__ModuleLoader__.load({
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 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,
@@ -369,22 +457,21 @@ window.__ModuleLoader__.load({
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('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 grid scrolls` : ''),
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', { 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('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'),
@@ -403,9 +490,9 @@ window.__ModuleLoader__.load({
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('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 grid scrolls instead.', onChange: (v) => store.setSettings({ minHeight: v }) }),
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 }) }),
@@ -417,54 +504,21 @@ window.__ModuleLoader__.load({
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'],
inject: ['slots', 'sessions', 'layout', 'uiWorkspace'],
apply(ctx) {
sessionsSvc = ctx.sessions;
layoutSvc = ctx.layout;
workspaceSvc = ctx.uiWorkspace;
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');
// 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 panel');
}, 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');
@@ -476,7 +530,7 @@ window.__ModuleLoader__.load({
name: 'settings.section', id: 'dsh-chat-split', order: 175, label: 'Chat split',
}, SettingsPanel)), 'dsh-chat-split: settings');
if (sessionsSvc?.list && layoutSvc?.panelInfo) watchMainSelection(ctx);
if (sessionsSvc?.list && layoutSvc?.panelInfo) watchConversationView(ctx);
},
};
},