Merge origin/main into feat/wait-banner: resolve use-host-feeds import overlap
Conflict: src/client/sidebar/use-host-feeds.ts
- ours: import { mirrorAgentWaits, ... } from '../state.ts'
- theirs: same line + new import { isNarrowWidth } from '../breakpoints.ts'
Resolution keeps both imports; the two sides' semantic changes are in
disjoint regions (ours: tab-disabled wait mirroring; theirs:
activateTasksPage native-landing) and both survive intact.
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* Real-Sidebar regressions for issue #162: background activity (a new
|
||||
* subagent, a new background job) activates the Tasks page in the bottom
|
||||
* workbench. The right column belongs to DSH's native Sidebar, so there is no
|
||||
* full-screen drawer to guard against any more — the activation lands in the
|
||||
* docked workbench on every viewport width.
|
||||
* Auto-activation regressions for issue #162: background activity (a new
|
||||
* subagent, a new background job) activates the Tasks page in DSH's NATIVE
|
||||
* right Sidebar — the column the two auto-open switches and the README promise
|
||||
* ("wide viewports also expand the sidebar, while narrow full-screen drawers
|
||||
* are not forced open"). The plugin's own bottom workbench is NOT that
|
||||
* surface: it serves only its own flows (its + menu, the first-expansion
|
||||
* auto-terminal), so a background activation must leave it exactly as it was.
|
||||
*
|
||||
* The narrow half of that promise is a PARK: the host draws the native column
|
||||
* fullscreen below 768px and expands it on every open, so the activation places
|
||||
* the tab and puts the column back to collapsed (src/client/sidebar/
|
||||
* use-host-feeds.ts `activateTasksPage`). The topology jump-back is an explicit
|
||||
* user gesture and always takes the host's expansion.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createElement, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
@@ -15,8 +23,13 @@ import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
|
||||
import { Sidebar } from '../src/client/Sidebar.tsx'
|
||||
import { allLeaves, createSidebarStore, firstLeaf, type SidebarStore } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { allLeaves, createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||||
import {
|
||||
createBetterSidebarService,
|
||||
type BetterSidebarService,
|
||||
type SidebarSurface,
|
||||
type TabComponentProps,
|
||||
} from '../src/client/service.ts'
|
||||
import type { Context, SidebarSessionList } from '../src/context-types.ts'
|
||||
|
||||
class FakeWebSocket {
|
||||
@@ -45,10 +58,79 @@ function makeSessionFeed(initial: SidebarSessionList) {
|
||||
|
||||
type SessionFeed = ReturnType<typeof makeSessionFeed>
|
||||
|
||||
/** One recorded native-surface open. */
|
||||
interface NativeOpen {
|
||||
sessionId: string
|
||||
kind: string
|
||||
params: unknown
|
||||
revealIfOpened: boolean
|
||||
}
|
||||
|
||||
/** The native right Sidebar, replaced by a recording stand-in: the shell's
|
||||
* opens must reach THIS surface, not the plugin's own layout. */
|
||||
interface NativeSurfaceSpy {
|
||||
surface: SidebarSurface
|
||||
opens: NativeOpen[]
|
||||
}
|
||||
|
||||
function makeNativeSurfaceSpy(): NativeSurfaceSpy {
|
||||
const opens: NativeOpen[] = []
|
||||
return {
|
||||
opens,
|
||||
surface: {
|
||||
openTab: input => { opens.push({ ...input }) },
|
||||
openResource: () => {},
|
||||
fileAddress: (sessionId, cwd, path) => `addr://${sessionId}${cwd ?? ''}${path}`,
|
||||
close: () => undefined,
|
||||
update: () => false,
|
||||
activate: () => false,
|
||||
has: () => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** `ctx.sidebarRight`, replaced by a stand-in column: `isExpanded` reports the
|
||||
* current state, `toggleExpanded` flips it and counts the calls. */
|
||||
interface NativeColumnSpy {
|
||||
face: { isExpanded: () => boolean; toggleExpanded: () => void }
|
||||
toggles: number
|
||||
}
|
||||
|
||||
function makeNativeColumnSpy(expanded: boolean): NativeColumnSpy {
|
||||
const spy = {
|
||||
toggles: 0,
|
||||
expanded,
|
||||
face: {} as { isExpanded: () => boolean; toggleExpanded: () => void },
|
||||
}
|
||||
spy.face = {
|
||||
isExpanded: () => spy.expanded,
|
||||
toggleExpanded: () => {
|
||||
spy.toggles += 1
|
||||
spy.expanded = !spy.expanded
|
||||
},
|
||||
}
|
||||
return spy
|
||||
}
|
||||
|
||||
/** Stands in for the Tasks page: its node click is the jump gesture that arms
|
||||
* the shell's jump-back (fired once, like a real click). */
|
||||
function JumpHarness({ onSubagentJump }: TabComponentProps): ReactNode {
|
||||
const fired = useRef(false)
|
||||
useEffect(() => {
|
||||
if (fired.current) return
|
||||
fired.current = true
|
||||
onSubagentJump?.('child')
|
||||
}, [onSubagentJump])
|
||||
return null
|
||||
}
|
||||
|
||||
interface MountedSidebar {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
feed: SessionFeed
|
||||
surface: NativeSurfaceSpy
|
||||
column: NativeColumnSpy
|
||||
sessionId: string
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
@@ -59,7 +141,11 @@ function setViewport(width: number): void {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
|
||||
}
|
||||
|
||||
function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
||||
function mountSidebar(
|
||||
width: number,
|
||||
bottomOpen = false,
|
||||
options: { columnExpanded?: boolean } = {},
|
||||
): MountedSidebar {
|
||||
setViewport(width)
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
const sessionId = `auto-activation-${++sessionSeq}`
|
||||
@@ -76,13 +162,18 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
||||
store.setSession(sessionId)
|
||||
store.reduce(state => ({ ...state, bottomOpen }))
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'subagent', title: 'Subagent', component: () => null })
|
||||
const surface = makeNativeSurfaceSpy()
|
||||
service.setSurface(surface.surface)
|
||||
service.registerTab({ id: 'subagent', title: 'Subagent', component: JumpHarness })
|
||||
const column = makeNativeColumnSpy(options.columnExpanded ?? false)
|
||||
const localeSnapshot = { active: 'en' }
|
||||
const ctx = {
|
||||
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
||||
sessions: { list: feed },
|
||||
betterSidebar: service,
|
||||
get: (name: string) => name === 'betterSidebar' ? service : undefined,
|
||||
get: (name: string) => name === 'betterSidebar'
|
||||
? service
|
||||
: name === 'sidebarRight' ? column.face : undefined,
|
||||
} as unknown as Context
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
@@ -92,6 +183,9 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
||||
store,
|
||||
service,
|
||||
feed,
|
||||
surface,
|
||||
column,
|
||||
sessionId,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
@@ -101,6 +195,18 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
||||
return result
|
||||
}
|
||||
|
||||
type ActivitySource = 'subagent' | 'job'
|
||||
|
||||
/** Deliver one piece of background activity through the host's own feeds. */
|
||||
function publishActivity(sidebar: MountedSidebar, source: ActivitySource): void {
|
||||
if (source === 'job') {
|
||||
publishJob(sidebar)
|
||||
return
|
||||
}
|
||||
publishSubagent(sidebar)
|
||||
flushSubagentDebounce()
|
||||
}
|
||||
|
||||
function publishSubagent(sidebar: MountedSidebar): void {
|
||||
const before = sidebar.feed.getSnapshot()
|
||||
const sessionId = before.current!
|
||||
@@ -145,14 +251,44 @@ function publishJob(sidebar: MountedSidebar): void {
|
||||
})
|
||||
}
|
||||
|
||||
function expectSubagentFocused(sidebar: MountedSidebar): void {
|
||||
/** Switch the conversation to the child session the Tasks page jumped to. */
|
||||
function switchToChild(sidebar: MountedSidebar): void {
|
||||
const before = sidebar.feed.getSnapshot()
|
||||
const parent = before.current!
|
||||
act(() => {
|
||||
sidebar.feed.set({
|
||||
...before,
|
||||
current: 'child',
|
||||
byId: {
|
||||
...before.byId,
|
||||
child: {
|
||||
id: 'child',
|
||||
displayTitle: 'Worker',
|
||||
origin: 'subagent',
|
||||
parentId: parent,
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** The Tasks page landed in the native right Sidebar (the default open). */
|
||||
function expectNativeTasksOpen(sidebar: MountedSidebar, sessionId: string): void {
|
||||
expect(sidebar.surface.opens).toEqual([{
|
||||
sessionId,
|
||||
kind: 'subagent',
|
||||
params: expect.objectContaining({ title: expect.any(String) }),
|
||||
revealIfOpened: true,
|
||||
}])
|
||||
}
|
||||
|
||||
/** The plugin's own bottom workbench kept its own state and gained no tab. */
|
||||
function expectWorkbenchUntouched(sidebar: MountedSidebar, open = false): void {
|
||||
const state = sidebar.store.getSnapshot().state!
|
||||
const pane = firstLeaf(state.bottomSplits)
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
expect(state.activePane).toBe(pane.id)
|
||||
expect(state.bottomOpen).toBe(open)
|
||||
expect(allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'subagent')).toHaveLength(1)
|
||||
expect(pane.tabs.find(tab => tab.id === pane.active)?.type).toBe('subagent')
|
||||
.filter(tab => tab.type === 'subagent')).toHaveLength(0)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -176,33 +312,57 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
{ source: 'job', width: 390 },
|
||||
{ source: 'subagent', width: 1024 },
|
||||
{ source: 'job', width: 1024 },
|
||||
] as const)('$source activation at $width px focuses the Tasks page in the workbench', ({ source, width }) => {
|
||||
] as const)('$source activation at $width px activates the Tasks page in the native Sidebar', ({ source, width }) => {
|
||||
const sidebar = mountSidebar(width)
|
||||
if (source === 'subagent') {
|
||||
publishSubagent(sidebar)
|
||||
flushSubagentDebounce()
|
||||
} else {
|
||||
publishJob(sidebar)
|
||||
}
|
||||
expectSubagentFocused(sidebar)
|
||||
publishActivity(sidebar, source)
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
expectWorkbenchUntouched(sidebar)
|
||||
// Parking is the narrow-viewport half of the same promise.
|
||||
expect(sidebar.column.toggles).toBe(width < 768 ? 1 : 0)
|
||||
})
|
||||
|
||||
it.each(['subagent', 'job'] as const)('%s activation keeps an already-open workbench open', (source) => {
|
||||
const sidebar = mountSidebar(390, true)
|
||||
if (source === 'subagent') {
|
||||
publishSubagent(sidebar)
|
||||
flushSubagentDebounce()
|
||||
} else {
|
||||
publishJob(sidebar)
|
||||
}
|
||||
expectSubagentFocused(sidebar)
|
||||
it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', (source) => {
|
||||
const sidebar = mountSidebar(390)
|
||||
publishActivity(sidebar, source)
|
||||
expect(sidebar.column.toggles).toBe(1)
|
||||
})
|
||||
|
||||
it('a resize between arming and firing the debounce does not change the landing', () => {
|
||||
it('a narrow column the user already expanded is not closed under them', () => {
|
||||
const sidebar = mountSidebar(390, false, { columnExpanded: true })
|
||||
publishActivity(sidebar, 'subagent')
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
expect(sidebar.column.toggles).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the viewport when the debounced activation fires, not when it arms', () => {
|
||||
const sidebar = mountSidebar(1024)
|
||||
publishSubagent(sidebar)
|
||||
setViewport(390)
|
||||
flushSubagentDebounce()
|
||||
expectSubagentFocused(sidebar)
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
expect(sidebar.column.toggles).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves an already-open bottom workbench open and untouched', () => {
|
||||
const sidebar = mountSidebar(1024, true)
|
||||
publishActivity(sidebar, 'subagent')
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
expectWorkbenchUntouched(sidebar, true)
|
||||
})
|
||||
|
||||
it('the topology jump-back opens the Tasks page for the child session and never parks it', () => {
|
||||
const sidebar = mountSidebar(390)
|
||||
// The Tasks page the user opened in the workbench (its own + menu) is what
|
||||
// arms the jump: its node click records the child session.
|
||||
act(() => {
|
||||
sidebar.service.openTab(
|
||||
{ type: 'subagent', title: 'Tasks', target: 'bottom' },
|
||||
{ sessionId: sidebar.sessionId },
|
||||
)
|
||||
})
|
||||
expect(sidebar.store.getSnapshot().state!.bottomOpen).toBe(true)
|
||||
switchToChild(sidebar)
|
||||
expectNativeTasksOpen(sidebar, 'child')
|
||||
expect(sidebar.column.toggles).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user