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:
Agent committed 2026-10-21 19:10:03 +00:00
1 parent 1e908ed64e
commit f931abd470
4 files changed
+608 -1023

No files matched your search

+25 -13
View File
@@ -1,20 +1,32 @@
# @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
- **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.
- **Configurable Minimum Height**: Windows enforce a minimum height (default `396px`, configurable in Settings) to guarantee conversation readability.
- **Settings with Interactive Preview**: Dedicated Settings section with an interactive resizable preview box and chat-count selector showing real-time reflow behavior.
- **Per-Chat Sidebar**: Every split window features its own toggleable mini-sidebar listing sessions, letting you switch individual windows or jump to conversations.
- **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.
- **Include / Exclude Controls**: Add or remove any chat directly from the session menu (`...`) in the workspace sidebar.
- **Window Top Bar**: Every window includes controls to move position in the grid (`◀` and `▶`) or close (`✕`) the window.
- **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.
## Usage
## Use
1. Open **Split view** from the sidebar panellist icon (`⊞`).
2. Use "+ Add open chats" or click the `...` menu on any session in the workspace sidebar and choose **Include in split view**.
3. In any split window, use `◀ Sidebar` to view available sessions and switch chats.
4. Customize minimum height and readability width threshold in **Settings → Chat split**.
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 `✕`.
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.
+412 -1005
View File
File diff suppressed because it is too large. Load diff
+9 -5
View File
@@ -1,13 +1,16 @@
{
"name": "@local/dsh-chat-split",
"version": "1.0.0",
"description": "DSH plugin: Multi-chat split grid view with responsive wrapping, per-window sidebars, interactive preview, and include/exclude controls.",
"version": "1.1.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",
"license": "MIT",
"exports": {
".": "./index.js",
"./client": "./client.js"
"./client": "./client.js",
"./package.json": "./package.json"
},
"scripts": { "test": "node --test test/*.test.mjs" },
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
@@ -19,8 +22,9 @@
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-sidebar",
"@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"
]
}
}
+162
View File
@@ -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&#x27;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)
})