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/)
})