Rewrite against the real slot/session APIs: grid reflow fix, window top bars, per-window chat lists, glow on main-sidebar selection, MenuItemButton include/exclude, live preview from real chats; headless tests
This commit is contained in:
1 parent
1e908ed64e
commit
f931abd470
4 files changed
+614
-1029
No files matched your search
@@ -1,20 +1,32 @@
|
|||||||
# @local/dsh-chat-split
|
# @local/dsh-chat-split
|
||||||
|
|
||||||
DSH plugin for multi-chat split screen with responsive grid wrapping, per-chat sidebars, and customizable layout rules.
|
Split view for DeepSeek Harness: several chats open at once in the main area.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Responsive Grid Layout**: Places chats side-by-side; when the container width falls below the readability threshold, chats automatically wrap vertically into a grid of rows.
|
- **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.
|
||||||
- **Configurable Minimum Height**: Windows enforce a minimum height (default `396px`, configurable in Settings) to guarantee conversation readability.
|
- **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.
|
||||||
- **Settings with Interactive Preview**: Dedicated Settings section with an interactive resizable preview box and chat-count selector showing real-time reflow behavior.
|
- **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.
|
||||||
- **Per-Chat Sidebar**: Every split window features its own toggleable mini-sidebar listing sessions, letting you switch individual windows or jump to conversations.
|
- **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.
|
||||||
- **Active Split Blue Glow Highlight**: When selecting a chat that is already open in another split window while split view is active, that window pulses with a brief blue glow highlight.
|
- **Top bar per window.** Title, grid position (row · column), move up / left / right / down, and close.
|
||||||
- **Include / Exclude Controls**: Add or remove any chat directly from the session menu (`...`) in the workspace sidebar.
|
- **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.
|
||||||
- **Window Top Bar**: Every window includes controls to move position in the grid (`◀` and `▶`) or close (`✕`) the window.
|
- **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.
|
||||||
|
|
||||||
## Usage
|
## Use
|
||||||
|
|
||||||
1. Open **Split view** from the sidebar panellist icon (`⊞`).
|
1. Click **Split view** in the sidebar's panel list (the two-pane icon).
|
||||||
2. Use "+ Add open chats" or click the `...` menu on any session in the workspace sidebar and choose **Include in split view**.
|
2. Add chats with **+ Add chat**, the suggestions on the empty page, or *Include in split view* in a chat's "…" menu.
|
||||||
3. In any split window, use `◀ Sidebar` to view available sessions and switch chats.
|
3. Rearrange with the arrows in each window's top bar; close with `✕`.
|
||||||
4. Customize minimum height and readability width threshold in **Settings → Chat split**.
|
|
||||||
|
The list of open chats and the settings are stored in the browser (`localStorage`).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
`client.js` is the whole plugin (`index.js` is an empty host half).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test test/*.test.mjs # headless: registrations, rendering, layout maths, main-sidebar glow
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
+9
-5
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@local/dsh-chat-split",
|
"name": "@local/dsh-chat-split",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "DSH plugin: Multi-chat split grid view with responsive wrapping, per-window sidebars, interactive preview, and include/exclude controls.",
|
"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,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.js",
|
".": "./index.js",
|
||||||
"./client": "./client.js"
|
"./client": "./client.js",
|
||||||
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
|
"scripts": { "test": "node --test test/*.test.mjs" },
|
||||||
"dsh": {
|
"dsh": {
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"patch": "./cordis.patch.yml"
|
"patch": "./cordis.patch.yml"
|
||||||
@@ -19,8 +22,9 @@
|
|||||||
"@deepseek-ai/dsh-client-ui-layout",
|
"@deepseek-ai/dsh-client-ui-layout",
|
||||||
"@deepseek-ai/dsh-client-ui-sidebar",
|
"@deepseek-ai/dsh-client-ui-sidebar",
|
||||||
"@deepseek-ai/dsh-client-ui-workspace",
|
"@deepseek-ai/dsh-client-ui-workspace",
|
||||||
"@deepseek-ai/dsh-client-ui-session",
|
"@deepseek-ai/dsh-client-ui-conversation",
|
||||||
"@deepseek-ai/dsh-client-ui-conversation"
|
"@deepseek-ai/dsh-client-ui-settings",
|
||||||
|
"@deepseek-ai/dsh-client-ui-primitives"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// 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).
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
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')))
|
||||||
|
const skip = reactDir ? false : 'no React install found (set REACT_DIR)'
|
||||||
|
|
||||||
|
function memStorage() {
|
||||||
|
const m = new Map()
|
||||||
|
return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load client.js and apply it against fakes; returns everything the test needs. */
|
||||||
|
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.ResizeObserver = class { observe() {} disconnect() {} }
|
||||||
|
let mod
|
||||||
|
window.__ModuleLoader__ = { load: (m) => { mod = m } }
|
||||||
|
new Function(fs.readFileSync(path.join(here, '..', 'client.js'), 'utf8'))()
|
||||||
|
const plugin = mod.factory((name) => (name === 'react' ? React : {}))
|
||||||
|
|
||||||
|
const list = snapshotStore({
|
||||||
|
phase: 'ready',
|
||||||
|
ids: ['a', 'b', 'c', '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: {} },
|
||||||
|
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 panel = snapshotStore({ activePanelId: null })
|
||||||
|
const layout = { panelInfo: panel, selectPanel: (id) => panel.set({ activePanelId: id }) }
|
||||||
|
const regs = {}
|
||||||
|
const effects = []
|
||||||
|
const ctx = {
|
||||||
|
sessions, layout,
|
||||||
|
effect: (fn) => { const d = fn(); effects.push(d) },
|
||||||
|
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'])
|
||||||
|
plugin.apply(ctx)
|
||||||
|
return { React, ReactDOMServer, regs, list, panel, layout, retained }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('registers panel, main entry with a session-scope child, 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')
|
||||||
|
const main = regs['main:dsh-chat-split']
|
||||||
|
assert.ok(main, 'main panel registered under key dsh-chat-split')
|
||||||
|
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('layout: side by side while each keeps the readability width, then wraps', { skip }, () => {
|
||||||
|
const { React, ReactDOMServer, regs } = load()
|
||||||
|
const h = React.createElement
|
||||||
|
const Settings = regs['settings.section'].Comp
|
||||||
|
const html = ReactDOMServer.renderToString(h(Settings, { 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; 3 chats at 420+8 → 3 columns side by side.
|
||||||
|
assert.match(html, /3 columns × 1 row/)
|
||||||
|
assert.match(html, /all chats side by side/)
|
||||||
|
// Real chat titles appear in the preview windows.
|
||||||
|
assert.match(html, /Alpha chat/)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user