Seamless split: no separate view; 'Include in split view' opens the chat beside the one being read, sidebar picks open in the last used window or glow an open one, closing to one chat returns to the normal view; opaque popovers; tests that actually find React
This commit is contained in:
1 parent
3445b2b972
commit
ff3e09ac97
4 files changed
+320
-242
No files matched your search
@@ -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=<dir with react/ and react-dom/>`) 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=<dir with react/ and react-dom/>`. It skips when none is found.
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+129
-104
@@ -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=<dir containing react/ and react-dom/> (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=<dir containing react/ and react-dom/>, 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/)
|
||||
})
|
||||
Reference in new issue
Block a user