Files

538 lines
33 KiB
JavaScript
Raw Permalink 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.
*
* 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);
},
};
},
});