Merge pull request #373 from Jstn-1g/fix/162-narrow-auto-activation
fix: avoid forcing mobile drawer on auto-activation
This commit is contained in:
+2
-2
@@ -306,8 +306,8 @@ describe('side card preferences', () => {
|
||||
}
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
// The narrow viewport keeps a fresh session collapsed for the FIRST
|
||||
// seeding only (a later user expansion persists).
|
||||
// The narrow viewport keeps a fresh session collapsed. Persisted
|
||||
// sessions follow the same load-time visibility rule in state.spec.ts.
|
||||
store.setSession('narrow-fresh')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
// The width seeding still follows the window (clamped to the floor).
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Real-Sidebar regressions for issue #162: background activity should activate
|
||||
* the Tasks page on every viewport, but must not force a narrow full-screen
|
||||
* drawer open over the chat.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, 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'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
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 type { Context, SidebarSessionList } from '../src/context-types.ts'
|
||||
|
||||
class FakeWebSocket {
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
close = (): void => {}
|
||||
constructor(_url: string) {}
|
||||
}
|
||||
|
||||
function makeSessionFeed(initial: SidebarSessionList) {
|
||||
let snapshot = initial
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
set(next: SidebarSessionList): void {
|
||||
snapshot = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SessionFeed = ReturnType<typeof makeSessionFeed>
|
||||
|
||||
interface MountedSidebar {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
feed: SessionFeed
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
let sessionSeq = 0
|
||||
const mounted: MountedSidebar[] = []
|
||||
|
||||
function setViewport(width: number): void {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
|
||||
}
|
||||
|
||||
function mountSidebar(width: number, panelOpen = false): MountedSidebar {
|
||||
setViewport(width)
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
const sessionId = `auto-activation-${++sessionSeq}`
|
||||
const initial: SidebarSessionList = {
|
||||
current: sessionId,
|
||||
byId: {
|
||||
[sessionId]: { id: sessionId, cwd: '/tmp', displayTitle: 'Root' },
|
||||
},
|
||||
jobsBySession: { [sessionId]: [] },
|
||||
}
|
||||
const feed = makeSessionFeed(initial)
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), autoOpenSubagent: true, autoOpenJobs: true })
|
||||
store.setSession(sessionId)
|
||||
store.reduce(state => ({ ...state, panelOpen }))
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'subagent', title: 'Subagent', component: () => null })
|
||||
const localeSnapshot = { active: 'en' }
|
||||
const ctx = {
|
||||
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
||||
sessions: { list: feed },
|
||||
betterSidebar: service,
|
||||
get: (name: string) => name === 'betterSidebar' ? service : undefined,
|
||||
} as unknown as Context
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(createElement(Sidebar, { ctx, store })) })
|
||||
const result = {
|
||||
store,
|
||||
service,
|
||||
feed,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
mounted.push(result)
|
||||
return result
|
||||
}
|
||||
|
||||
function publishSubagent(sidebar: MountedSidebar): void {
|
||||
const before = sidebar.feed.getSnapshot()
|
||||
const sessionId = before.current!
|
||||
act(() => {
|
||||
sidebar.feed.set({
|
||||
...before,
|
||||
byId: {
|
||||
...before.byId,
|
||||
child: {
|
||||
id: 'child',
|
||||
displayTitle: 'Worker',
|
||||
origin: 'subagent',
|
||||
parentId: sessionId,
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function flushSubagentDebounce(): void {
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
}
|
||||
|
||||
function publishJob(sidebar: MountedSidebar): void {
|
||||
const before = sidebar.feed.getSnapshot()
|
||||
const sessionId = before.current!
|
||||
act(() => {
|
||||
sidebar.feed.set({
|
||||
...before,
|
||||
jobsBySession: {
|
||||
...before.jobsBySession,
|
||||
[sessionId]: [{
|
||||
id: 'bash-1',
|
||||
kind: 'bash',
|
||||
label: 'sleep 30',
|
||||
status: 'running',
|
||||
startedAt: 1_000,
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function expectSubagentFocused(sidebar: MountedSidebar, panelOpen: boolean): void {
|
||||
const state = sidebar.store.getSnapshot().state!
|
||||
const right = firstLeaf(state.splits)
|
||||
expect(state.panelOpen).toBe(panelOpen)
|
||||
expect(state.activePane).toBe(right.id)
|
||||
expect(allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'subagent')).toHaveLength(1)
|
||||
expect(right.tabs.find(tab => tab.id === right.active)?.type).toBe('subagent')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()!.unmount()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
document.body.innerHTML = ''
|
||||
setViewport(1024)
|
||||
})
|
||||
|
||||
describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
it.each([
|
||||
{ source: 'subagent', width: 390, opens: false },
|
||||
{ source: 'job', width: 390, opens: false },
|
||||
{ source: 'subagent', width: 1024, opens: true },
|
||||
{ source: 'job', width: 1024, opens: true },
|
||||
] as const)('$source activation at $width px sets panelOpen=$opens', ({ source, width, opens }) => {
|
||||
const sidebar = mountSidebar(width)
|
||||
if (source === 'subagent') {
|
||||
publishSubagent(sidebar)
|
||||
flushSubagentDebounce()
|
||||
} else {
|
||||
publishJob(sidebar)
|
||||
}
|
||||
expectSubagentFocused(sidebar, opens)
|
||||
})
|
||||
|
||||
it.each(['subagent', 'job'] as const)('%s activation preserves an already-open narrow drawer', (source) => {
|
||||
const sidebar = mountSidebar(390, true)
|
||||
if (source === 'subagent') {
|
||||
publishSubagent(sidebar)
|
||||
flushSubagentDebounce()
|
||||
} else {
|
||||
publishJob(sidebar)
|
||||
}
|
||||
expectSubagentFocused(sidebar, true)
|
||||
})
|
||||
|
||||
it('uses the current viewport when the delayed subagent activation fires', () => {
|
||||
const sidebar = mountSidebar(1024)
|
||||
publishSubagent(sidebar)
|
||||
setViewport(390)
|
||||
flushSubagentDebounce()
|
||||
expectSubagentFocused(sidebar, false)
|
||||
})
|
||||
})
|
||||
@@ -1328,6 +1328,105 @@ describe('pinned terminals (v0.17.0)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('narrow persisted-layout visibility (issue #162)', () => {
|
||||
const persistedLayout = JSON.stringify({
|
||||
panelOpen: true,
|
||||
width: 420,
|
||||
nextTerminal: 2,
|
||||
nextBrowser: 2,
|
||||
activePane: 'pane:2',
|
||||
expanded: ['/workspace/src'],
|
||||
splits: {
|
||||
kind: 'split',
|
||||
id: 'split:1',
|
||||
dir: 'row',
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: 'editor:readme',
|
||||
tabs: [{ id: 'editor:readme', type: 'editor', title: 'README.md', path: '/workspace/README.md' }],
|
||||
},
|
||||
{
|
||||
kind: 'leaf',
|
||||
id: 'pane:2',
|
||||
active: 'terminal:1',
|
||||
tabs: [{ id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
bottomOpen: true,
|
||||
bottomHeight: 300,
|
||||
bottomOpenedOnce: true,
|
||||
bottomSplits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:3',
|
||||
active: 'browser:1',
|
||||
tabs: [{ id: 'browser:1', type: 'browser', title: 'Docs', path: 'https://example.com' }],
|
||||
},
|
||||
floats: [{
|
||||
id: 'float:4',
|
||||
tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } },
|
||||
x: 16,
|
||||
y: 24,
|
||||
w: 320,
|
||||
h: 240,
|
||||
}],
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
delete g.window
|
||||
delete g.localStorage
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ width: 390, expectedOpen: false },
|
||||
{ width: 768, expectedOpen: true },
|
||||
])('restores panelOpen=$expectedOpen at $width px without changing pane or free-window state', ({ width, expectedOpen }) => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.window = {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: () => 0,
|
||||
innerWidth: width,
|
||||
innerHeight: 800,
|
||||
location: { search: '' },
|
||||
}
|
||||
g.localStorage = {
|
||||
getItem: (key: string) => key === 'dsh-sidebar:v1:restored' ? persistedLayout : null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
|
||||
const store = createSidebarStore()
|
||||
store.setSession('restored')
|
||||
const state = store.getSnapshot().state!
|
||||
|
||||
expect(state.panelOpen).toBe(expectedOpen)
|
||||
expect(state.activePane).toBe('pane:2')
|
||||
expect(allLeaves(state.splits).map(leaf => ({
|
||||
id: leaf.id,
|
||||
active: leaf.active,
|
||||
tabs: leaf.tabs.map(tab => tab.id),
|
||||
}))).toEqual([
|
||||
{ id: 'pane:1', active: 'editor:readme', tabs: ['editor:readme'] },
|
||||
{ id: 'pane:2', active: 'terminal:1', tabs: ['terminal:1'] },
|
||||
])
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
expect(allLeaves(state.bottomSplits).map(leaf => ({ id: leaf.id, tabs: leaf.tabs.map(tab => tab.id) })))
|
||||
.toEqual([{ id: 'pane:3', tabs: ['browser:1'] }])
|
||||
expect(state.floats).toEqual([{
|
||||
id: 'float:4',
|
||||
tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } },
|
||||
x: 16,
|
||||
y: 24,
|
||||
w: 320,
|
||||
h: 240,
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL reset escape hatch (issue #369)', () => {
|
||||
// Same browser-global stubs as the v0.12.0 block above; loadState reads
|
||||
// window.location.search (reset param) and localStorage (persisted state).
|
||||
|
||||
Reference in New Issue
Block a user