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:
Atlas Zeng
2026-09-02 23:44:06 +08:00
committed by GitHub
31 changed files with 443 additions and 118 deletions
+2 -2
View File
@@ -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).
+205
View File
@@ -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)
})
})
+99
View File
@@ -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).