538 lines
33 KiB
JavaScript
538 lines
33 KiB
JavaScript
/**
|
||
* @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);
|
||
},
|
||
};
|
||
},
|
||
});
|