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
+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