diff --git a/README.md b/README.md index 34ca46b..f83e475 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,31 @@ # @local/dsh-chat-split -Split view for DeepSeek Harness: several chats open at once in the main area. - -## Features - -- **Side by side, then a grid.** Chats sit in one row while each still gets the *readability width* (default 420 px). When the window is too narrow for that, they wrap into a grid of rows and columns. -- **Minimum height.** Every window is at least **396 px** tall (configurable). If the rows don't fit, the grid scrolls instead of squashing the chats. -- **A full chat in every window.** Each window renders the real conversation (messages, composer, tools), the same embedded view DSH uses for sub-agent chats. -- **Own chat list per window.** The `☰` button opens that window's own sidebar: filter and switch which chat the window shows. A blue dot marks chats already open in another window, and a green dot marks running ones. -- **Top bar per window.** Title, grid position (row · column), move up / left / right / down, and close. -- **Blue glow on re-selection.** Selecting a chat that is already open (from a window's list, *+ Add chat*, the "…" menu, or the main sidebar) while split view is showing flashes that window with a short blue glow. Choosing it in the main sidebar keeps you in split view. -- **Include / exclude.** Every chat's "…" menu in the sidebar has *Include in split view* / *Exclude from split view*. -- **Settings with a live preview.** *Settings → Chat split* has sliders for minimum height, readability width, gap and window chat-list width, plus a scaled preview of the split view built from your real chats. Drag the preview's width/height or change the number of chats to see where it wraps. +Chats side by side for DeepSeek Harness, right in the normal conversation area. There's no separate view or button to open first. ## Use -1. Click **Split view** in the sidebar's panel list (the two-pane icon). -2. Add chats with **+ Add chat**, the suggestions on the empty page, or *Include in split view* in a chat's "…" menu. -3. Rearrange with the arrows in each window's top bar; close with `✕`. +- **Put a chat beside the one you're reading:** in the sidebar, open that chat's "…" menu and choose **Include in split view**. The conversation area turns into a split with both chats. +- **Add more:** *Include in split view* on more chats, or the `+` in any window's top bar. +- **Pick chats in the main sidebar as usual:** a chat that's already open flashes its window with a short blue glow. Any other chat opens in the window you used last. +- **Back to one chat:** close windows with `✕` (or *Exclude from split view*) until one is left, or press `⤢` (*show only this chat*) on a window. The normal single-chat view comes back with that chat. -The list of open chats and the settings are stored in the browser (`localStorage`). +The split survives reloads; the set of open chats and the settings live in the browser (`localStorage`). + +## Features + +- **Side by side, then a grid.** Windows sit in one row while each conversation still gets the *readability width* (default 420 px, plus the window's chat list when that is shown). When the area is too narrow for that, they wrap into a grid of rows and columns. +- **Minimum height.** Every window is at least **396 px** tall (configurable). If the rows don't fit, the area scrolls instead of squashing the chats. +- **A full chat in every window.** Each window renders the real conversation (messages, composer, tools), the same embedded view DSH uses for sub-agent chats. +- **Own chat list per window.** `☰` shows that window's own chat list: filter it and switch what the window shows. A blue dot marks chats already open in another window, a green dot running ones. +- **Top bar per window.** Title, grid position (row · column), move up / left / right / down, `+` open a chat beside, `⤢` show only this chat, `✕` close. +- **Settings with a live preview.** *Settings → Chat split*: minimum height, readability width, gap, window chat-list width, and whether chat lists start open. A scaled preview built from your real chats shows where the windows wrap; drag its width/height or change the number of chats. ## Development -`client.js` is the whole plugin (`index.js` is an empty host half). +`client.js` is the whole plugin (`index.js` is an empty host half). The split is a `main` area entry without a panel-list button: it is selected automatically while two or more chats are open, and when the stock sidebar returns to the conversation view after a pick, it switches back before the browser paints. ```bash -node --test test/*.test.mjs # headless: registrations, rendering, layout maths, main-sidebar glow +node --test test/*.test.mjs # headless: registrations, include / pick / close flows, layout maths ``` -The test loads `client.js` with real React (`REACT_DIR=`) against fake DSH services, and skips when React isn't found. +The test loads `client.js` with real React against fake DSH services (sessions, layout, workspace). It looks for a React install itself, or set `REACT_DIR=`. It skips when none is found. diff --git a/client.js b/client.js index de9de7f..1b4f52c 100644 --- a/client.js +++ b/client.js @@ -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); }, }; }, diff --git a/package.json b/package.json index 182673b..5e4a573 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local/dsh-chat-split", - "version": "1.1.0", + "version": "1.2.0", "description": "DSH plugin: several chats side by side in a split view that wraps into a grid, with per-window chat lists, top bars, blue focus glow, include/exclude menu items and a settings preview.", "private": true, "type": "module", diff --git a/test/client.test.mjs b/test/client.test.mjs index 6f6abf0..aa5d9b1 100644 --- a/test/client.test.mjs +++ b/test/client.test.mjs @@ -1,20 +1,46 @@ // Headless checks for client.js: loads it through a fake __ModuleLoader__, captures the slot -// registrations, renders the components with react-dom/server against fake DSH services, and -// drives the "chat opened from the main sidebar while split view is showing" path. -// Needs a React install: REACT_DIR= (skips otherwise). +// registrations, renders the components with react-dom/server against fake DSH services +// (sessions list, layout panels, workspace navigation) and drives the user flows: +// include a chat beside the current one, pick chats in the main sidebar, close back to one. +// Needs a React install: REACT_DIR=, otherwise one is +// searched for under the home directory; the tests skip when none is found. import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) -const candidates = [ - process.env.REACT_DIR, - '/home/deepseek-harness/rizzy-wt/m3-history/node_modules/.pnpm/react-dom@19.3.0_react@19.3.0/node_modules', -].filter(Boolean) -const reactDir = candidates.find((d) => fs.existsSync(path.join(d, 'react')) && fs.existsSync(path.join(d, 'react-dom'))) + +function findReact() { + const ok = (d) => d && fs.existsSync(path.join(d, 'react', 'package.json')) && fs.existsSync(path.join(d, 'react-dom', 'server.js')) + if (ok(process.env.REACT_DIR)) return process.env.REACT_DIR + const roots = [path.join(here, '..', 'node_modules'), path.join(os.homedir(), 'rizzy-wt'), path.join(os.homedir(), 'Python')] + const seen = new Set() + const walk = (dir, depth) => { + if (depth < 0 || seen.has(dir)) return null + seen.add(dir) + if (ok(dir)) return dir + let ents = [] + try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return null } + for (const e of ents) { + if (!e.isDirectory() || e.name === '.git') continue + const pnpm = e.name === '.pnpm' + if (pnpm) { + for (const p of fs.readdirSync(path.join(dir, e.name))) if (p.startsWith('react-dom@')) { const d = path.join(dir, e.name, p, 'node_modules'); if (ok(d)) return d } + continue + } + const r = walk(path.join(dir, e.name), depth - 1) + if (r) return r + } + return null + } + for (const r of roots) { const d = walk(r, 4); if (d) return d } + return null +} +const reactDir = findReact() const skip = reactDir ? false : 'no React install found (set REACT_DIR)' function memStorage() { @@ -24,17 +50,19 @@ function memStorage() { function snapshotStore(initial) { let v = initial; const subs = new Set() - return { getSnapshot: () => v, subscribe: (f) => { subs.add(f); return () => subs.delete(f) }, set: (n) => { v = n; subs.forEach((f) => f()) } } + return { getSnapshot: () => v, subscribe: (f) => { subs.add(f); return () => subs.delete(f) }, set: (n) => { v = n; [...subs].forEach((f) => f()) } } } -/** Load client.js and apply it against fakes; returns everything the test needs. */ +const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms)) + +/** Load client.js and apply it against fakes that behave like DSH's services. */ function load() { const req = createRequire(path.join(reactDir, 'react', 'package.json')) const React = req('react') const ReactDOMServer = req('react-dom/server') globalThis.localStorage = memStorage() globalThis.window = { innerWidth: 1600, innerHeight: 1000 } - globalThis.document = { head: { appendChild() {} }, createElement: () => ({ remove() {} }), querySelector: () => null } + globalThis.document = { head: { appendChild() {} }, createElement: () => ({ remove() {} }), querySelector: () => null, addEventListener() {}, removeEventListener() {} } globalThis.ResizeObserver = class { observe() {} disconnect() {} } let mod window.__ModuleLoader__ = { load: (m) => { mod = m } } @@ -43,129 +71,126 @@ function load() { const list = snapshotStore({ phase: 'ready', - ids: ['a', 'b', 'c', 'sub'], + ids: ['a', 'b', 'c', 'd', 'sub'], byId: { - a: { id: 'a', displayTitle: 'Alpha chat', updatedAt: 3, retainedBy: {} }, - b: { id: 'b', displayTitle: 'Beta chat', updatedAt: 2, retainedBy: {} }, - c: { id: 'c', displayTitle: 'Gamma chat', updatedAt: 1, retainedBy: {} }, + a: { id: 'a', displayTitle: 'Alpha chat', updatedAt: 4, retainedBy: {} }, + b: { id: 'b', displayTitle: 'Beta chat', updatedAt: 3, retainedBy: {} }, + c: { id: 'c', displayTitle: 'Gamma chat', updatedAt: 2, retainedBy: {} }, + d: { id: 'd', displayTitle: 'Delta chat', updatedAt: 1, retainedBy: {} }, sub: { id: 'sub', displayTitle: 'child', updatedAt: 9, parentId: 'a', origin: 'subagent', retainedBy: {} }, }, }) - const retained = [] - const sessions = { list, retain: (id, opts) => { const r = { sessionId: id, source: opts.source, released: false, release() { this.released = true } }; retained.push(r); return r } } + const setMainView = (id, count = 1) => { + const s = list.getSnapshot() + const byId = Object.fromEntries(Object.entries(s.byId).map(([k, v]) => [k, { ...v, retainedBy: k === id ? { mainView: count } : {} }])) + list.set({ ...s, byId }) + } + const sessions = { list, retain: (id, opts) => ({ sessionId: id, source: opts.source, release() {} }) } const panel = snapshotStore({ activePanelId: null }) const layout = { panelInfo: panel, selectPanel: (id) => panel.set({ activePanelId: id }) } + /** Like the stock workspace: retain as mainView (sync), release the old one, back to the conversation view. */ + const uiWorkspace = { + opened: [], + openSession(id) { this.opened.push(id); setMainView(id, 2); setMainView(id, 1); layout.selectPanel(null) }, + } const regs = {} - const effects = [] const ctx = { - sessions, layout, - effect: (fn) => { const d = fn(); effects.push(d) }, + sessions, layout, uiWorkspace, + effect: (fn) => { fn() }, slots: { inject: (name, make) => make(), register: (opts, Comp) => { regs[opts.key ? `${opts.name}:${opts.key}` : opts.name] = { opts, Comp }; return () => {} }, }, } - assert.deepEqual(plugin.inject, ['slots', 'sessions', 'layout']) + assert.deepEqual(plugin.inject, ['slots', 'sessions', 'layout', 'uiWorkspace']) plugin.apply(ctx) - return { React, ReactDOMServer, regs, list, panel, layout, retained } + const h = React.createElement + const renderPanel = () => ReactDOMServer.renderToString(h(regs['main:dsh-chat-split'].Comp, { renderSlot: () => null, SessionProvider: ({ children }) => children })) + /** Click "Include in / Exclude from split view" in a chat's "…" menu. */ + const menu = (sessionId) => { + let onSelect + let label + const Menu = regs['sidebar.workspaces.session.menu.item'].Comp + const Capture = (props) => { const el = Menu(props); onSelect = el.props.onSelect || el.props.onClick; label = String(el.props.children); return el } + ReactDOMServer.renderToString(h(Capture, { sessionId, useMenuOpenState: () => [true, () => {}] })) + return { label, click: () => onSelect() } + } + const windows = (html) => [...html.matchAll(/data-session-id="([^"]+)"/g)].map((m) => m[1]) + return { React, ReactDOMServer, regs, list, panel, layout, uiWorkspace, setMainView, renderPanel, menu, windows } } -test('registers panel, main entry with a session-scope child, menu item and settings', { skip }, () => { +test('no separate split button: only the main area, the window slot, the "…" menu item and settings', { skip }, () => { const { regs } = load() - assert.equal(regs['sidebar.panellist'].opts.id, 'dsh-chat-split') - assert.equal(regs['sidebar.panellist'].opts.label, 'Split view') + assert.equal(regs['sidebar.panellist'], undefined, 'no panel-list entry') const main = regs['main:dsh-chat-split'] - assert.ok(main, 'main panel registered under key dsh-chat-split') + assert.ok(main) assert.deepEqual(main.opts.children, { 'dsh-chat-split.window': { kind: 'single', scope: 'session' } }) assert.ok(regs['dsh-chat-split.window']) assert.ok(regs['sidebar.workspaces.session.menu.item']) assert.equal(regs['settings.section'].opts.label, 'Chat split') }) -test('menu item includes/excludes, panel renders windows with top bars and own chat lists', { skip }, () => { - const { React, ReactDOMServer, regs } = load() - const h = React.createElement - const render = (el) => ReactDOMServer.renderToString(el) - const Menu = regs['sidebar.workspaces.session.menu.item'].Comp - const useMenuOpenState = () => [true, () => {}] - assert.match(render(h(Menu, { sessionId: 'a', useMenuOpenState })), /Include in split view/) - const Panel = regs['main:dsh-chat-split'].Comp - const empty = render(h(Panel, { renderSlot: () => null, SessionProvider: ({ children }) => children })) - assert.match(empty, /No chats in split view/) - assert.match(empty, /\+ Alpha chat/) - assert.doesNotMatch(empty, /child/, 'subagent children are not offered') +test('"Include in split view" puts the chat beside the one being viewed, in place', { skip }, async () => { + const t = load() + t.setMainView('a') // viewing Alpha normally + const m = t.menu('c') + assert.equal(m.label, 'Include in split view') + m.click() + assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split', 'the conversation area turned into the split') + await tick(200) + const html = t.renderPanel() + assert.deepEqual(t.windows(html), ['a', 'c'], 'viewed chat first, included chat beside it') + assert.match(html, /dcs-win focused glow" data-session-id="c"/, 'the new window is focused and glows') + assert.match(html, /Show only this chat/) + assert.match(html, /Open another chat beside/) + assert.equal(t.menu('c').label, 'Exclude from split view') }) -test('layout: side by side while each keeps the readability width, then wraps', { skip }, () => { +test('picking chats in the main sidebar while split: already open → glow, otherwise opens in the last used window', { skip }, async () => { + const t = load() + t.setMainView('a'); t.menu('b').click(); await tick(150) // split: a | b (b focused) + // Pick Alpha (already open) in the sidebar → stays split, Alpha glows. + t.uiWorkspace.openSession('a'); await tick(10) + assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split') + let html = t.renderPanel() + assert.deepEqual(t.windows(html), ['a', 'b']) + assert.match(html, /glow" data-session-id="a"/) + // Pick Delta (not open) → it replaces the last used window (Alpha, just focused by the pick). + t.uiWorkspace.openSession('d'); await tick(200) + assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split') + html = t.renderPanel() + assert.deepEqual(t.windows(html), ['d', 'b']) + assert.match(html, /glow" data-session-id="d"/) + // Leaving to another panel and coming back without picking a chat changes nothing. + t.layout.selectPanel('plugins'); await tick(900); t.layout.selectPanel(null); await tick(10) + assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split') + assert.deepEqual(t.windows(t.renderPanel()), ['d', 'b']) +}) + +test('closing windows down to one returns to the normal single-chat view with that chat', { skip }, async () => { + const t = load() + t.setMainView('a'); t.menu('b').click(); await tick(150) + t.menu('c').click(); await tick(150) + assert.deepEqual(t.windows(t.renderPanel()), ['a', 'b', 'c']) + t.menu('a').click() // "Exclude from split view" + assert.deepEqual(t.windows(t.renderPanel()), ['b', 'c']) + t.menu('c').click() + await tick(10) + assert.deepEqual(t.uiWorkspace.opened.at(-1), 'b', 'the remaining chat opens normally') + assert.equal(t.panel.getSnapshot().activePanelId, null, 'normal conversation view') + assert.equal(t.menu('b').label, 'Include in split view') +}) + +test('layout: windows wrap into a grid when the conversation would get narrower than the readability width', { skip }, () => { const { React, ReactDOMServer, regs } = load() const h = React.createElement - const Settings = regs['settings.section'].Comp - const html = ReactDOMServer.renderToString(h(Settings, { close() {} })) + const html = ReactDOMServer.renderToString(h(regs['settings.section'].Comp, { close() {} })) assert.match(html, /Minimum window height/) assert.match(html, /396px/) - assert.match(html, /Readability width/) - assert.match(html, /Preview/) - // window.innerWidth 1600 → preview width 1300. A window needs 420 (conversation) + 180 (its - // chat list) = 600 px, so 3 chats do not fit side by side: 2 columns, wrapping to a 2nd row. + // Preview 1300 px wide: a window needs 420 (conversation) + 180 (its chat list) → 2 columns × 2 rows for 3 chats. assert.match(html, /2 columns × 2 rows/) assert.match(html, /too narrow for all chats side by side/) - // Real chat titles appear in the preview windows. - assert.match(html, /Alpha chat/) -}) - -test('layout: a wide screen keeps all chats in one row', { skip }, () => { - const { React, ReactDOMServer, regs } = load() - window.innerWidth = 2400 // preview 2100 px → 3 × 600 fits - const html = ReactDOMServer.renderToString(React.createElement(regs['settings.section'].Comp, { close() {} })) - assert.match(html, /3 columns × 1 row/) - assert.match(html, /all chats side by side/) -}) - -test('choosing an open chat in the main sidebar keeps split view and glows it', { skip }, async () => { - const { React, ReactDOMServer, regs, list, panel, layout } = load() - const h = React.createElement - const Menu = regs['sidebar.workspaces.session.menu.item'].Comp - // Capture the onSelect handler of the menu item by rendering its element tree shallowly. - let onSelect - const fakeMenuButton = Menu.length // keep lint quiet - void fakeMenuButton - // Use the plugin's own store through the panel: include 'b' via a synthetic menu render. - const MenuWithCapture = (props) => { - const el = Menu(props) - onSelect = el.props.onSelect || el.props.onClick - return el - } - ReactDOMServer.renderToString(h(MenuWithCapture, { sessionId: 'b', useMenuOpenState: () => [true, () => {}] })) - onSelect() - const Panel = regs['main:dsh-chat-split'].Comp - const html = ReactDOMServer.renderToString(h(Panel, { renderSlot: () => h('i', null, 'conv'), SessionProvider: ({ children }) => children })) - assert.match(html, /data-session-id="b"/) - assert.match(html, /Beta chat/) - assert.match(html, /Close window/) - assert.match(html, /Hide this window's chat list/) - - // Split view showing → user clicks 'b' in the stock sidebar: mainView retain changes, then panel → null. - layout.selectPanel('dsh-chat-split') - const s = list.getSnapshot() - list.set({ ...s, byId: { ...s.byId, b: { ...s.byId.b, retainedBy: { mainView: 1 } } } }) - layout.selectPanel(null) - await new Promise((r) => setTimeout(r, 10)) - assert.equal(panel.getSnapshot().activePanelId, 'dsh-chat-split', 'returned to split view') - const glowing = ReactDOMServer.renderToString(h(Panel, { renderSlot: () => null, SessionProvider: ({ children }) => children })) - assert.match(glowing, /dcs-win glow/) - - // Leaving split view without opening a chat (no mainView change) stays left. - await new Promise((r) => setTimeout(r, 900)) - layout.selectPanel('dsh-chat-split') - layout.selectPanel(null) - await new Promise((r) => setTimeout(r, 10)) - assert.equal(panel.getSnapshot().activePanelId, null) - - // Opening a chat that is NOT in the split leaves split view normally. - layout.selectPanel('dsh-chat-split') - const s2 = list.getSnapshot() - list.set({ ...s2, byId: { ...s2.byId, b: { ...s2.byId.b, retainedBy: {} }, c: { ...s2.byId.c, retainedBy: { mainView: 1 } } } }) - layout.selectPanel(null) - await new Promise((r) => setTimeout(r, 10)) - assert.equal(panel.getSnapshot().activePanelId, null) + assert.match(html, /Alpha chat/, 'preview uses real chat titles') + window.innerWidth = 2400 // 2100 px → 3 × 600 fits in one row + assert.match(ReactDOMServer.renderToString(h(regs['settings.section'].Comp, { close() {} })), /3 columns × 1 row/) })