Merge remote-tracking branch 'origin/main' into feat/changes-md-reading

# Conflicts:
#	src/client/MarkdownHtml.tsx
This commit is contained in:
lhh010
2026-09-06 22:59:56 +08:00
52 changed files with 1647 additions and 51 deletions
+7
View File
@@ -281,6 +281,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
if (title === BUILTIN_TABS[0]) {
// Layout-level evidence for the compact density: rows render ~26px
// tall (the default density would be 40px). Guard loose at ≤30px so
// font/zoom differences across runners cannot flake it.
const rowHeight = await item.evaluate(el => el.getBoundingClientRect().height)
expect(rowHeight, '+ menu rows must render at the compact density (≤30px)').toBeLessThanOrEqual(30)
}
await item.click()
// Let the activation commit (including any lazy-chunk fetch) before the
// crash assertions run.
+42
View File
@@ -204,3 +204,45 @@ describe('FileTree open-with menu', () => {
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
})
})
describe('FileTree open-with menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
document.body.removeAttribute(ATTR)
})
it('publishes "down" for a top-of-tree opening and clears on selection', async () => {
harness = await mountTree()
// openMenu() right-clicks at (20, 30) — the upper half, plenty of right
// room in jsdom's default viewport.
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Zed')
act(() => { zedRow!.click() })
expect(document.querySelector('[role="menuitem"]')).toBeNull()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
it('adds "left" when the cursor sits within the right-hand submenu room', async () => {
harness = await mountTree()
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: window.innerWidth - 10, clientY: 30 })
act(() => { fileRow(harness.container).dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
})
it('clears the attribute on unmount with the menu still open', async () => {
harness = await mountTree()
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
harness.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
+230
View File
@@ -0,0 +1,230 @@
/**
* FileTree explorer mutations: the context menu's rename (inline row editor)
* and delete (confirmation modal) — menu-item visibility (never on the
* workspace root row), the commit/cancel flows, the API calls with the row
* path, the tab-reconciliation callbacks, and the failure strip.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
// vi.mock factories are hoisted above every declaration, so the shared
// mocks ride vi.hoisted (mockClear in afterEach keeps the implementations).
const { fsRename, fsRemove } = vi.hoisted(() => ({
fsRename: vi.fn(async () => ({ path: '/tmp/renamed.ts' })),
fsRemove: vi.fn(async () => ({ path: '/tmp/a.ts' })),
}))
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
entries: [
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
{ name: 'sub', path: '/tmp/sub', isDir: true },
],
}),
fsRename,
fsRemove,
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
interface Harness {
container: HTMLDivElement
onPathRenamed: ReturnType<typeof vi.fn>
onPathDeleted: ReturnType<typeof vi.fn>
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const onPathRenamed = vi.fn()
const onPathDeleted = vi.fn()
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onOpenFileNewTab: () => {},
onOpenFileSide: () => {},
onReferenceFile: () => {},
onPathRenamed,
onPathDeleted,
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
})
return {
container,
onPathRenamed,
onPathDeleted,
unmount: () => { act(() => { root.unmount() }) },
}
}
/** One tree row by its displayed name (the root row included; it carries no
* role="button", so the query rides the row class). */
function rowByName(container: HTMLDivElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row "${name}" not found`)
return row
}
function openMenu(container: HTMLDivElement, name: string): void {
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
act(() => { rowByName(container, name).dispatchEvent(event) })
}
function menuLabels(): string[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(item => item.textContent ?? '')
}
function clickMenuitem(label: string): void {
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(el => el.textContent === label)
if (item === undefined) throw new Error(`menuitem "${label}" not found`)
act(() => { item.click() })
}
/** Set a controlled input's value the React way (native setter + input). */
function setNativeValue(el: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
if (setter === undefined) throw new Error('no native value setter')
setter.call(el, value)
el.dispatchEvent(new Event('input', { bubbles: true }))
}
function pressKey(el: HTMLElement, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsRename.mockClear()
fsRemove.mockClear()
})
describe('FileTree rename/delete menu items', () => {
it('offers rename and delete on file and directory rows, never on the root', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
expect(menuLabels()).toContain('Rename')
expect(menuLabels()).toContain('Delete')
openMenu(harness.container, 'sub')
expect(menuLabels()).toContain('Rename')
// The workspace root row (cwd itself) is the session — no mutations.
openMenu(harness.container, 'tmp')
expect(menuLabels()).not.toContain('Rename')
expect(menuLabels()).not.toContain('Delete')
})
})
describe('FileTree inline rename', () => {
it('replaces the row with a prefilled editor; Enter commits via fsRename + onPathRenamed', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Rename')
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')
expect(input).not.toBeNull()
expect(input!.value).toBe('a.ts')
setNativeValue(input!, 'b.ts')
await act(async () => { pressKey(input!, 'Enter') })
expect(fsRename).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts', 'b.ts')
expect(harness.onPathRenamed).toHaveBeenCalledWith('/tmp/a.ts', '/tmp/renamed.ts')
// The editor row is gone (the commit closed it).
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
})
it('Escape cancels without touching the API', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Rename')
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')!
setNativeValue(input, 'b.ts')
await act(async () => { pressKey(input, 'Escape') })
expect(fsRename).not.toHaveBeenCalled()
expect(harness.onPathRenamed).not.toHaveBeenCalled()
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
})
it('rejects separator names client-side with the error strip, no API call', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Rename')
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')!
setNativeValue(input, 'a/b.ts')
await act(async () => { pressKey(input, 'Enter') })
expect(fsRename).not.toHaveBeenCalled()
const alert = harness.container.querySelector('[role="alert"]')
expect(alert?.textContent).toContain('path separators')
})
})
describe('FileTree delete confirmation', () => {
it('confirms through the modal, then calls fsRemove + onPathDeleted', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Delete')
const dialog = document.querySelector('[role="dialog"]')
expect(dialog?.textContent).toContain('Delete "a.ts"?')
expect(dialog?.textContent).toContain('permanently deletes the file')
const confirm = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
expect(fsRemove).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts')
expect(harness.onPathDeleted).toHaveBeenCalledWith('/tmp/a.ts', false)
expect(document.querySelector('[role="dialog"]')).toBeNull()
})
it('the directory copy names the recursive wipe', async () => {
harness = await mountTree()
openMenu(harness.container, 'sub')
clickMenuitem('Delete')
const dialog = document.querySelector('[role="dialog"]')
expect(dialog?.textContent).toContain('everything inside it')
})
it('cancel closes the modal without calling anything', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Delete')
const cancel = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Cancel')!
act(() => { cancel.click() })
expect(fsRemove).not.toHaveBeenCalled()
expect(document.querySelector('[role="dialog"]')).toBeNull()
})
it('a server failure lands in the dismissable strip', async () => {
fsRemove.mockRejectedValueOnce(new Error('boom: refused'))
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Delete')
const confirm = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
const alert = harness.container.querySelector('[role="alert"]')
expect(alert?.textContent).toContain('boom: refused')
expect(harness.onPathDeleted).not.toHaveBeenCalled()
})
})
+91 -2
View File
@@ -1,8 +1,8 @@
import { afterAll, describe, expect, it } from 'vitest'
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { writeWorkspaceUpload } from '../src/fs-operations.ts'
import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from '../src/fs-operations.ts'
/** The test workspace root (each suite gets its own temp tree). */
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-'))
@@ -150,3 +150,92 @@ describe('writeWorkspaceUpload', () => {
expect(readFileSync(target, 'utf8')).toBe('original')
})
})
describe('renameWorkspaceEntry', () => {
it('renames a file within its directory', async () => {
writeFileSync(join(root, 'old-name.txt'), 'x')
const { path } = await renameWorkspaceEntry({ cwd: root, path: join(root, 'old-name.txt'), name: 'new-name.txt' })
// The returned path is CANONICAL (realpath-resolved ancestors — on macOS
// the tmpdir's /var becomes /private/var), so assert the shape, not the
// lexical spelling.
expect(path.endsWith('new-name.txt')).toBe(true)
expect(existsSync(join(root, 'old-name.txt'))).toBe(false)
expect(existsSync(join(root, 'new-name.txt'))).toBe(true)
})
it('renames a directory (recursive content moves with it)', async () => {
mkdirSync(join(root, 'olddir/nested'), { recursive: true })
writeFileSync(join(root, 'olddir/nested/deep.txt'), 'x')
await renameWorkspaceEntry({ cwd: root, path: join(root, 'olddir'), name: 'newdir' })
expect(readFileSync(join(root, 'newdir/nested/deep.txt'), 'utf8')).toBe('x')
})
it('is a no-op for the same name (destination-exists refusal must not bite)', async () => {
writeFileSync(join(root, 'same.txt'), 'x')
await renameWorkspaceEntry({ cwd: root, path: join(root, 'same.txt'), name: 'same.txt' })
expect(existsSync(join(root, 'same.txt'))).toBe(true)
})
it('refuses single-segment violations (empty, dot, traversal, separators)', async () => {
writeFileSync(join(root, 'r.txt'), 'x')
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'r.txt'), name }))
.rejects.toMatchObject({ code: 'bad-request' })
}
})
it('refuses an existing destination instead of clobbering it', async () => {
writeFileSync(join(root, 'src.txt'), 'src')
writeFileSync(join(root, 'dst.txt'), 'dst')
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'src.txt'), name: 'dst.txt' }))
.rejects.toMatchObject({ code: 'fs-error', status: 409 })
expect(readFileSync(join(root, 'dst.txt'), 'utf8')).toBe('dst')
})
it('refuses the workspace root and missing sources', async () => {
await expect(renameWorkspaceEntry({ cwd: root, path: root, name: 'nope' }))
.rejects.toMatchObject({ code: 'fs-error' })
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'missing.txt'), name: 'x' }))
.rejects.toMatchObject({ code: 'fs-error' })
})
it('renames a symlink ROW, not its target', async () => {
writeFileSync(join(root, 'target.txt'), 't')
symlinkSync(join(root, 'target.txt'), join(root, 'alias.txt'))
await renameWorkspaceEntry({ cwd: root, path: join(root, 'alias.txt'), name: 'alias2.txt' })
expect(existsSync(join(root, 'alias.txt'))).toBe(false)
expect(existsSync(join(root, 'alias2.txt'))).toBe(true)
expect(readFileSync(join(root, 'target.txt'), 'utf8')).toBe('t')
})
})
describe('removeWorkspaceEntry', () => {
it('unlinks a file', async () => {
writeFileSync(join(root, 'gone.txt'), 'x')
await removeWorkspaceEntry({ cwd: root, path: join(root, 'gone.txt') })
expect(existsSync(join(root, 'gone.txt'))).toBe(false)
})
it('removes a directory recursively', async () => {
mkdirSync(join(root, 'tree/sub'), { recursive: true })
writeFileSync(join(root, 'tree/sub/leaf.txt'), 'x')
await removeWorkspaceEntry({ cwd: root, path: join(root, 'tree') })
expect(existsSync(join(root, 'tree'))).toBe(false)
})
it('unlinks a symlink row without touching (or recursing into) its target', async () => {
mkdirSync(join(root, 'realdir'), { recursive: true })
writeFileSync(join(root, 'realdir/keep.txt'), 'x')
symlinkSync(join(root, 'realdir'), join(root, 'linkdir'))
await removeWorkspaceEntry({ cwd: root, path: join(root, 'linkdir') })
expect(existsSync(join(root, 'linkdir'))).toBe(false)
expect(readFileSync(join(root, 'realdir/keep.txt'), 'utf8')).toBe('x')
})
it('refuses the workspace root and missing paths', async () => {
await expect(removeWorkspaceEntry({ cwd: root, path: root }))
.rejects.toMatchObject({ code: 'fs-error' })
await expect(removeWorkspaceEntry({ cwd: root, path: join(root, 'no-such.txt') }))
.rejects.toMatchObject({ code: 'fs-error' })
})
})
+48
View File
@@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => {
await unmount(root)
})
})
describe('MarkdownDocument (local markdown images)', () => {
it('renders markdown-syntax local images through the /sidebar/file route', async () => {
const { container, root } = await renderDocument([
'# Title',
'',
'![icon](./assets/icon.svg)',
'',
'![logo][logo]',
'',
'[logo]: ./logo.png',
].join('\n'))
const imgs = [...container.querySelectorAll('img')]
expect(imgs.length, 'both local images must render as <img>, not alt fallback').toBe(2)
expect(imgs[0]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fassets%2Ficon.svg&cwd=%2Fws')
expect(imgs[0]?.getAttribute('alt')).toBe('icon')
expect(imgs[1]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Flogo.png&cwd=%2Fws')
expect(imgs[1]?.getAttribute('alt')).toBe('logo')
await unmount(root)
})
it('renders local markdown images in HTML-mixed documents (the production path)', async () => {
const { container, root } = await renderDocument([
'<div align="center">badges</div>',
'',
'![shot](./img.png)',
].join('\n'))
const img = container.querySelector('img')
expect(img, 'the markdown-syntax image after an HTML run must render').not.toBeNull()
expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws')
await unmount(root)
})
it('keeps remote image destinations untouched', async () => {
const { container, root } = await renderDocument('![r](https://example.com/x.png)')
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe('https://example.com/x.png')
await unmount(root)
})
it('is idempotent for already-rewritten media URLs', async () => {
const url = 'http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws'
const { container, root } = await renderDocument(`![a](${url})`)
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe(url)
await unmount(root)
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* The context-menu submenu flip geometry: the pure token computation behind
* the body attribute layout.css consumes (see src/client/menu-flip.ts for
* the contract) plus the attribute lifecycle of the hook itself.
*/
// @vitest-environment jsdom
import { createElement, useState } from 'react'
import { readFileSync } from 'node:fs'
import { act } from 'react-dom/test-utils'
import { describe, expect, it } from 'vitest'
import { renderRoot, setupReactAct } from './test-utils.ts'
import { SUBMENU_FLIP_ATTR, submenuFlipTokens, useSubmenuFlip } from '../src/client/menu-flip.ts'
setupReactAct()
describe('layout.css contract', () => {
it('consumes the exact attribute name menu-flip.ts publishes', () => {
// The two halves of the flip mechanism can drift silently: renaming the
// attribute here would leave layout.css matching nothing (submenus revert
// to overflowing) with every test still green. Tie them together. Specs
// run from the repo root (chunk-artifact.spec.ts reads lib/ the same way).
const css = readFileSync('src/client/layout.css', 'utf8')
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="down"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]::before`)
})
it('keeps the submenu visual tether rules (parent highlight + tight gap)', () => {
// Part 1 keeps the parent row lit while its submenu is open (the Menu
// marks it aria-expanded); part 2 tightens the card gap to 4px with the
// hover corridor narrowed to match. Losing either sends the submenu back
// to reading as an unrelated floating card.
const css = readFileSync('src/client/layout.css', 'utf8')
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}] div[role="menu"] button[aria-expanded="true"]`)
expect(css).toContain('left: calc(100% + 4px)')
expect(css).toContain('right: calc(100% + 4px)')
expect(css).toContain('width: 4px')
})
})
describe('submenuFlipTokens', () => {
// Default geometry (1024x768): the panel is docked right, so the
// interesting split is the right-hand 400px strip and the vertical half.
it('flips down for a cursor in the upper half, keeps primitive defaults below', () => {
expect(submenuFlipTokens(500, 20, 1024, 768)).toBe('down')
expect(submenuFlipTokens(500, 700, 1024, 768)).toBe('')
})
it('flips left when the right-hand room cannot fit card + submenu', () => {
// 700 + 400 > 1024 → left; the cursor is low, so no down token.
expect(submenuFlipTokens(700, 700, 1024, 768)).toBe('left')
// Both tokens compose for a top-right opening.
expect(submenuFlipTokens(700, 20, 1024, 768)).toBe('down left')
})
it('uses the exact boundary inclusively (midpoint and width-minus-room still flip)', () => {
expect(submenuFlipTokens(500, 384, 1024, 768)).toBe('')
expect(submenuFlipTokens(500, 383, 1024, 768)).toBe('down')
expect(submenuFlipTokens(624, 700, 1024, 768)).toBe('')
expect(submenuFlipTokens(625, 700, 1024, 768)).toBe('left')
})
it('flips left throughout a narrow panel viewport (PANEL_MIN 280)', () => {
// A 600px window: any cursor past x=200 flips; the left strip cannot
// fit a right-growing submenu beside the 218px card either way.
expect(submenuFlipTokens(300, 100, 600, 800)).toBe('down left')
expect(submenuFlipTokens(500, 700, 600, 800)).toBe('left')
})
})
describe('useSubmenuFlip', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** Mount a probe whose menu state the spec drives via the returned setter. */
function mountFlipProbe(initial: { x: number; y: number } | null): { setMenu: (next: { x: number; y: number } | null) => void, unmount: () => void } {
let applyMenu: (next: { x: number; y: number } | null) => void = () => {}
function Probe(): React.ReactElement {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(initial)
applyMenu = setMenu
useSubmenuFlip(menu)
return <div />
}
const rendered = renderRoot(createElement(Probe))
return {
setMenu: (next) => { act(() => { applyMenu(next) }) },
unmount: rendered.unmount,
}
}
it('publishes tokens while the menu is open and clears on close', () => {
const probe = mountFlipProbe({ x: 700, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down left')
probe.setMenu(null)
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
probe.unmount()
}
})
it('tracks the position across consecutive openings', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down')
// Still open, now lower-left: the attribute stays (menu lifetime) but
// carries no token, so the primitive's own geometry applies.
probe.setMenu({ x: 100, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('')
probe.setMenu({ x: 700, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('left')
} finally {
probe.unmount()
}
})
it('clears the attribute when the owner unmounts with the menu still open', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
expect(document.body.getAttribute(ATTR)).toBe('down')
probe.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
+16
View File
@@ -113,6 +113,7 @@ describe('sidechat.start', () => {
sessionId: string
meta: Record<string, unknown>
seed: readonly { type: string; data: Record<string, unknown> }[]
inheritedEventCount: number
agentOptions: { provider: string; model: string }
setup: unknown
}
@@ -120,10 +121,15 @@ describe('sidechat.start', () => {
expect(options.meta).toMatchObject({
parentSession: 'parent',
origin: 'subagent',
isSeeded: true,
delegationDepth: 1,
agentPreset: 'preset-a',
cwd: '/p',
})
// The fork-marker pair is load-bearing: without it the child's Inbox
// replays the parent's seed inbox splices and inherits whatever input sat
// unclaimed in the parent (see sidechat-seed-validation.spec.ts).
expect(options.inheritedEventCount).toBe(options.seed.length)
expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' })
// The child carries the parent's completed turns as a verbatim seed,
// closed by the durable subagent descriptor (honest catalog citizenship:
@@ -221,6 +227,16 @@ describe('sidechat.start', () => {
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' })
// The empty thread carries the same fork-marker pair (isSeeded +
// inheritedEventCount = seed length) as the prompted create.
const options = services.create.mock.calls[0]![0] as {
meta: Record<string, unknown>
seed: readonly { type: string }[]
inheritedEventCount: number
}
expect(options.meta.isSeeded).toBe(true)
expect(options.inheritedEventCount).toBe(options.seed.length)
// No prompt yet: the composer owns the first message; the placeholder
// label is pinned and the in-progress snapshot is parked for it.
expect(child.inject).not.toHaveBeenCalled()
+114 -1
View File
@@ -10,8 +10,9 @@
* not a mock, so the class of bug cannot silently return.
*/
import { describe, expect, it } from 'vitest'
import { Session } from '@deepseek-ai/dsh-session'
import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { Inbox } from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { SidebarSessionEvent } from '../src/context-types.ts'
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
@@ -111,3 +112,115 @@ describe('sidechat seed against the real dsh-session validator', () => {
expect(types.at(-1)).toBe('session/end-seed')
})
})
describe('sidechat seed fork markers vs the real dsh-agent Inbox replay', () => {
/**
* Regression: the thread-create call originally passed `seed` WITHOUT the
* fork-marker pair (`meta.isSeeded: true` + `inheritedEventCount`). A seed
* without them is REPLAY history, not an inherited prefix, so the child's
* ownEvents() includes the whole seed and its Inbox constructor replays the
* parent's `agent/inbox/spliced` events — inheriting whatever input sat
* UNCLAIMED in the parent at the click moment (a queued follow-up, or a
* tool-result context spliced into next-step between step boundaries of a
* long-running turn). The first side prompt then claimed and SENT that stale
* message before the boundary + question. These tests run the REAL
* dsh-session Session and dsh-agent Inbox.
*/
const pendingMessage = (id: string, kind: string, text: string) => ({
id,
role: 'user',
content: [{ type: 'text', text }],
source: { kind },
})
const inboxOver = (session: Session): { nextTurn: number; nextStep: number } => {
const inbox = new Inbox(session, { inserted() {}, discarded() {}, claimed() {} })
return { nextTurn: inbox.nextTurn.length, nextStep: inbox.nextStep.length }
}
/** The shape the routes pass since the fix: isSeeded + inheritedEventCount. */
const forkMarkedSession = (id: string, seed: readonly SidebarSessionEvent[]): Session =>
Session.create(
id as SessionId,
seed as never,
{ version: 0, id: id as SessionId, createdAt: Date.now(), isSeeded: true },
SessionLogOffset(seed.length),
)
/** Real loop order: inbox insert → turn/start → claim deletion → step/start
* → user/message. One completed, fully claimed turn (seq 0-7). */
const completedTurnLog = (): SidebarSessionEvent[] => [
ev('agent/inbox/spliced', 0, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q', 'user', 'q')] }),
ev('turn/start', 1, { turn: 1 }),
ev('agent/inbox/spliced', 2, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
ev('step/start', 3, { turn: 1, step: 1 }),
ev('user/message', 4, pendingMessage('m-q', 'user', 'q')),
ev('assistant/message', 5, { turn: 1, step: 1, message: assistantMessage('a') }),
ev('step/end', 6, { turn: 1, step: 1 }),
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
]
/** Mid-turn with an UNCLAIMED tool-result context in next-step (seq 15). */
const midTurnLog = (): SidebarSessionEvent[] => [
...completedTurnLog(),
ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q2', 'user', 'q2')] }),
ev('turn/start', 9, { turn: 2 }),
ev('agent/inbox/spliced', 10, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
ev('step/start', 11, { turn: 2, step: 1 }),
ev('user/message', 12, pendingMessage('m-q2', 'user', 'q2')),
ev('tool/call', 13, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }),
ev('tool/result', 14, {
turn: 2, step: 1,
message: {
id: 'm-r',
role: 'user',
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
source: { kind: 'tool', callId: 'c1' },
},
}),
ev('agent/inbox/spliced', 15, {
target: 'next-step',
start: 0,
inserted: [pendingMessage('m-ctx', 'tool', 'tool bash finished: exit 0 ... now continue the plan')],
}),
]
it('inherits the parent\'s UNCLAIMED next-step tool context without the fork markers (root cause)', () => {
// Parent mid-turn (user queue empty): the bash tool returned and spliced
// its follow-up context into next-step; the next step boundary has not
// claimed it yet. The reported long-context scenario.
const { seed } = buildSidechatInheritance(midTurnLog())
// OLD call shape (seed only): the whole seed counts as own events and the
// phantom context is inherited as pending input.
const unmarked = Session.create('session-inbox-unmarked' as SessionId, seed as never)
expect(unmarked.ownEvents().length).toBe(seed.length + 1)
expect(inboxOver(unmarked)).toEqual({ nextTurn: 0, nextStep: 1 })
})
it('inherits the parent\'s QUEUED follow-up (next-turn) without the fork markers', () => {
const queued = pendingMessage('m-queued', 'user', 'queued second message')
const log: SidebarSessionEvent[] = [
...completedTurnLog(),
ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [queued] }),
]
const { seed } = buildSidechatInheritance(log)
const unmarked = Session.create('session-inbox-unmarked-turn' as SessionId, seed as never)
expect(inboxOver(unmarked)).toEqual({ nextTurn: 1, nextStep: 0 })
})
it('the fork-marker pair (isSeeded + inheritedEventCount) leaves the inherited inbox empty', () => {
// Same mid-turn log as the root-cause case, plus the descriptor the
// routes append — exactly what sidechat.start passes to agents.create.
const { seed } = buildSidechatInheritance(midTurnLog())
const withDescriptor = [
...seed,
{
type: 'subagent/descriptor',
seq: seed.length,
time: Date.now(),
data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'sidechat', label: 'Side: test' }),
} as unknown as SidebarSessionEvent,
]
const marked = forkMarkedSession('session-inbox-marked', withDescriptor)
// Only the end-seed marker is own; the transcript cut is unchanged.
expect(marked.ownEvents().map(event => event.type)).toEqual(['session/end-seed'])
expect(marked.snapshotEvents().map(event => event.type).at(-1)).toBe('session/end-seed')
expect(inboxOver(marked)).toEqual({ nextTurn: 0, nextStep: 0 })
})
})
+58
View File
@@ -309,3 +309,61 @@ describe('TabBar pin submenu (v0.17.0)', () => {
}
})
})
describe('TabBar context menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** jsdom's default viewport (what the hook reads via window.innerWidth/Height). */
const VW = window.innerWidth
const VH = window.innerHeight
it('publishes "down" while the menu is open and clears it on selection', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
// rightClick() dispatches at clientY 40 — the top strip, upper half.
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
act(() => { menuItems()[1]!.click() })
expect(onClose).toHaveBeenCalledWith('t2')
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
unmount()
}
})
it('flips left when the cursor sits within the right-hand submenu room', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: VW - 10, clientY: 40 })
act(() => { tabEls[1]!.dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
} finally {
unmount()
}
})
it('keeps the primitive defaults (empty tokens) for a lower-half opening', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const lowLeft = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: VH - 40 })
act(() => { tabEls[1]!.dispatchEvent(lowLeft) })
// Attribute present but empty: layout.css matches no token, so the
// host primitive's own up/right geometry applies.
expect(document.body.getAttribute(ATTR)).toBe('')
} finally {
unmount()
}
})
it('clears the attribute on unmount with the menu still open', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})