Add the browser Settings surface as slot-composed plugins over new preference services: - Rename dsh-client-i18n to dsh-client-locale (locale is the domain name); LocaleService adds getLocale()/setLocale(id), immutable snapshots, a locale/change event, and dsh.locale persistence. - ThemeService owns the light/dark/system preference (default system), resolves system via prefers-color-scheme, publishes theme/change snapshots, persists dsh.theme, and no longer touches the DOM; ui-layout's ThemePresenter applies resolved snapshots (body[data-ds-dark-theme] + alias tokens) and cleans up on dispose. - ui-sidebar drops the phase-1 settings dropdown/modal; the foot renders the new sidebar.settings slot with the column state. - New ui-settings shell occupies sidebar.settings: foot trigger row and the centered 1080x700 panel (figma 501:29947) with 24% mask, close button / mask click / Escape all closing, and a 188px nav projected from the settings.section list slot it declares. Nav labels are registrant-localized; sections re-register on locale change, so the ledger version is the shell's only subscription. - ui-settings-general registers the General section: Permission and Tool Call skeletons, live Language (locale menu) and Appearance (Light/Dark/System cubes following the persisted preference); its slot store mirrors both service snapshots via apply-side listeners. - ui-settings-models registers the Models nav entry with an empty content column. - Portaled menus pin z-index above modal overlays (a menu anchored inside the settings dialog rendered underneath it and was unclickable). - theme/data/list-pen icons in ui-primitives; settings copy ships as zh/en dictionaries; fixture manifests gain the settings rows.
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import type {
|
|
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
|
|
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.useRealTimers()
|
|
})
|
|
const sid = (id: string) => id as SessionId
|
|
const wid = (id: string) => id as WorkspaceId
|
|
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
|
const workspace: WorkspaceView = {
|
|
workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
|
|
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
|
}
|
|
const sessions: SessionListState = {
|
|
ids: [sid('s1')],
|
|
byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
|
|
current: undefined, phase: 'ready',
|
|
intent: undefined,
|
|
}
|
|
const workspaces: WorkspaceListState = {
|
|
items: [workspace], state: 'idle', phase: 'ready', error: null,
|
|
intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
|
|
}
|
|
|
|
function mount(sessionState: SessionListState = sessions) {
|
|
const startSession = vi.fn()
|
|
const open = vi.fn()
|
|
const owners: Record<string, unknown> = {}
|
|
const view = render(
|
|
<SidebarRoot
|
|
collapsed={false} width={300}
|
|
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
|
|
startSession={startSession} open={open} toggleSidebar={vi.fn()}
|
|
renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
|
/>,
|
|
)
|
|
return { view, startSession, open, pickerOwner: () => owners['sidebar.workspace'], settingsOwner: () => owners['sidebar.settings'] }
|
|
}
|
|
|
|
function mountSidebar({
|
|
sessionState = sessions,
|
|
workspaceState = workspaces,
|
|
collapsed = false,
|
|
width = 300,
|
|
}: {
|
|
sessionState?: SessionListState
|
|
workspaceState?: WorkspaceListState
|
|
collapsed?: boolean
|
|
width?: number
|
|
} = {}) {
|
|
const startSession = vi.fn()
|
|
const open = vi.fn()
|
|
const toggleSidebar = vi.fn()
|
|
const owners: Record<string, unknown> = {}
|
|
let current = { sessionState, workspaceState, collapsed, width }
|
|
const root = () => (
|
|
<SidebarRoot
|
|
collapsed={current.collapsed} width={current.width}
|
|
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
|
|
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
|
|
renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
|
/>
|
|
)
|
|
const view = render(root())
|
|
return {
|
|
startSession,
|
|
open,
|
|
toggleSidebar,
|
|
pickerOwner: () => owners['sidebar.workspace'],
|
|
settingsOwner: () => owners['sidebar.settings'],
|
|
rerender(next: Partial<typeof current>) {
|
|
current = { ...current, ...next }
|
|
view.rerender(root())
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('SidebarRoot', () => {
|
|
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
|
|
const b = mount()
|
|
expect(screen.getByText('Project')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
|
expect(b.startSession).toHaveBeenCalledWith()
|
|
})
|
|
|
|
it('shows a frontend Session under its real Workspace and routes its row plus', () => {
|
|
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
|
|
const b = mount({
|
|
...sessions,
|
|
current: intent.sessionId,
|
|
intent,
|
|
})
|
|
expect(screen.getByText('New session')).toBeTruthy()
|
|
expect(screen.getByText('2 sessions')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
|
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
|
})
|
|
|
|
it('forwards Workspace picker selection and closes the picker', () => {
|
|
const b = mount()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
|
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
|
expect(owner.open).toBe(true)
|
|
owner.onPick(workspace.workspaceId)
|
|
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
|
})
|
|
|
|
it('opens a real Session through the owner action', () => {
|
|
const b = mount({ ...sessions, current: sid('intent'), intent: {
|
|
sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
|
|
} })
|
|
fireEvent.click(screen.getByText('Project'))
|
|
fireEvent.click(screen.getByText('First session'))
|
|
expect(b.open).toHaveBeenCalledWith(sid('s1'))
|
|
})
|
|
|
|
it('opens, selects, dismisses, and toggles the group-by menu', () => {
|
|
mount()
|
|
const button = screen.getByRole('button', { name: 'Group by' })
|
|
|
|
fireEvent.click(button)
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
|
|
expect(screen.queryByRole('menu')).toBeNull()
|
|
|
|
fireEvent.click(button)
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
expect(screen.queryByRole('menu')).toBeNull()
|
|
|
|
fireEvent.click(button)
|
|
fireEvent.click(button)
|
|
expect(screen.queryByRole('menu')).toBeNull()
|
|
})
|
|
|
|
it('routes every Workspace picker close path', () => {
|
|
const b = mount()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
|
const owner = b.pickerOwner() as { open: boolean; onClose(): void }
|
|
expect(owner.open).toBe(true)
|
|
act(() => { owner.onClose() })
|
|
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
|
|
})
|
|
|
|
it('focuses, filters, and clears search while distinguishing both empty states', () => {
|
|
mount()
|
|
const input = screen.getByPlaceholderText('Search name, keywords...')
|
|
fireEvent.click(input.parentElement!)
|
|
expect(document.activeElement).toBe(input)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
|
|
|
fireEvent.change(input, { target: { value: 'missing' } })
|
|
expect(screen.getByText('No matches')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
expect(screen.queryByText('No matches')).toBeNull()
|
|
|
|
cleanup()
|
|
const emptySessions = listState()
|
|
const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
|
|
mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
|
|
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
|
})
|
|
|
|
it('toggles Workspace and nested Session expansion in both directions', () => {
|
|
const parent = sid('parent')
|
|
const child = sid('child')
|
|
const nestedSessions: SessionListState = {
|
|
...sessions,
|
|
ids: [parent, child],
|
|
byId: {
|
|
[parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
|
|
[child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
|
|
},
|
|
}
|
|
const nestedWorkspace: WorkspaceListState = {
|
|
...workspaces,
|
|
items: [{ ...workspace, sessionIds: [parent, child] }],
|
|
}
|
|
mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
|
|
|
|
fireEvent.click(screen.getByText('Project'))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
|
|
expect(screen.getByText('Child')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
|
expect(screen.queryByText('Child')).toBeNull()
|
|
fireEvent.click(screen.getByText('Project'))
|
|
expect(screen.queryByText('Parent')).toBeNull()
|
|
})
|
|
|
|
it('does not start a Session from an Ungrouped row create action', () => {
|
|
const loose = sid('loose')
|
|
const looseSessions: SessionListState = {
|
|
...listState(),
|
|
ids: [loose],
|
|
byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
|
|
current: loose,
|
|
}
|
|
const b = mountSidebar({
|
|
sessionState: looseSessions,
|
|
workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
|
|
})
|
|
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
|
expect(b.startSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
|
|
const b = mountSidebar()
|
|
fireEvent.click(screen.getByText('Project'))
|
|
const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
|
|
b.rerender({
|
|
sessionState: { ...sessions, current: sid('s1') },
|
|
workspaceState: { ...workspaces, items: [other, workspace] },
|
|
})
|
|
expect(screen.getByText('First session')).toBeTruthy()
|
|
|
|
b.rerender({
|
|
sessionState: {
|
|
...sessions,
|
|
current: sid('draft'),
|
|
intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
|
|
},
|
|
})
|
|
expect(screen.getByText('Project')).toBeTruthy()
|
|
})
|
|
|
|
it('renders the static collapsed rail and expands rail search into focused input', () => {
|
|
vi.useFakeTimers()
|
|
const b = mountSidebar({ collapsed: true })
|
|
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
|
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
|
|
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
|
expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
|
|
b.rerender({ collapsed: false })
|
|
const input = screen.getByPlaceholderText('Search name, keywords...')
|
|
act(() => { vi.advanceTimersByTime(300) })
|
|
expect(document.activeElement).toBe(input)
|
|
})
|
|
|
|
it('keeps wide content during live collapse, then settles to the rail', () => {
|
|
vi.useFakeTimers()
|
|
const b = mountSidebar({ width: 320 })
|
|
expect((b.settingsOwner() as { wide: boolean }).wide).toBe(true)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
|
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
|
b.rerender({ collapsed: true, width: 56 })
|
|
expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
|
|
act(() => { vi.advanceTimersByTime(150) })
|
|
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
|
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
|
// The settings seat share tracks the settled column state.
|
|
expect((b.settingsOwner() as { wide: boolean }).wide).toBe(false)
|
|
})
|
|
})
|
|
|
|
function listState(): SessionListState {
|
|
return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
|
|
}
|