feat(client): localize composer hints and rework input command interaction
Unify the /plan claimed hint with the plan placeholder through a locale namespace, localize slash menu group titles, replace the PermissionSelect native select with the Menu primitive, add a goal pause verb chain, clamp anchored popups to the viewport with scroll-into-view and outside-dismiss, and fix onPasteUpgrade insertedRange to account for the chip trailing gap.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -31,12 +32,25 @@ async function bench() {
|
||||
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
return { ctx, slots }
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots, locale }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the sessions dependency (controller resolution reads the scope tree)', () => {
|
||||
expect(inject).toEqual(['sessions'])
|
||||
it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
|
||||
expect(inject).toEqual(['sessions', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
|
||||
const { ctx, locale } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const t = locale.bind('slash.menu')
|
||||
expect(t('command')).toBe('命令')
|
||||
locale.setLocale('en')
|
||||
expect(t('skill')).toBe('Skills')
|
||||
expect(t('subagent')).toBe('Subagents')
|
||||
expect(t('loading')).toBe('Loading…')
|
||||
})
|
||||
|
||||
it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => {
|
||||
@@ -65,6 +79,8 @@ describe('apply', () => {
|
||||
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
|
||||
)
|
||||
expect(injected.menu).toBe(controller.menu)
|
||||
// The injected translator is the menu-namespace binding.
|
||||
expect(injected.t('command')).toBe('命令')
|
||||
// The pick face routes into the controller pipeline (closed menu → no-op).
|
||||
injected.onPick('command', 0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MenuView rendering spec, props-direct (slot-parity doctrine): closed store
|
||||
* renders null, groups render in roster order with pending rows as loading,
|
||||
* pointer picks route (source, index) back without stealing focus, and the
|
||||
* highlight is exposed through aria-activedescendant + aria-selected.
|
||||
* renders null, groups render in roster order under localized title rows
|
||||
* (unknown sources fall back to the raw name) with pending rows as loading,
|
||||
* pointer picks route (source, index) back without stealing focus, the
|
||||
* highlight is exposed through aria-activedescendant + aria-selected, and
|
||||
* the list height clamps to the space above the composer.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -34,13 +36,35 @@ function openState(partial?: Partial<MenuState>): MenuState {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
// jsdom has no scrollIntoView; the view calls it on the highlighted option.
|
||||
const scrollIntoView = vi.fn()
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Dictionary-backed fake mirroring the LocaleService key fallback (an
|
||||
// unknown key comes back verbatim, so unknown sources show their raw name).
|
||||
const DICT: Record<string, string> = { command: 'Commands', skill: 'Skills', loading: 'Loading…' }
|
||||
const t = (key: string) => DICT[key] ?? key
|
||||
|
||||
function mount(state: MenuState) {
|
||||
const menu = createSnapshotStore<MenuState>(state)
|
||||
const onPick = vi.fn()
|
||||
const view = render(<MenuView menu={menu} onPick={onPick} />)
|
||||
return { menu, onPick, view }
|
||||
const onDismiss = vi.fn()
|
||||
const view = render(<MenuView menu={menu} onPick={onPick} onDismiss={onDismiss} t={t} />)
|
||||
return { menu, onPick, onDismiss, view }
|
||||
}
|
||||
|
||||
/** The non-interactive group title rows (role=presentation), in document order. */
|
||||
function titles(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('div[role="presentation"][data-source]')]
|
||||
.map(el => el.textContent ?? '')
|
||||
}
|
||||
|
||||
describe('MenuView', () => {
|
||||
@@ -57,7 +81,19 @@ describe('MenuView', () => {
|
||||
mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
|
||||
expect(screen.queryByText('Loading skill…')).not.toBeNull()
|
||||
expect(screen.queryByText('Loading…')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
|
||||
const { view } = mount(openState({
|
||||
groups: [
|
||||
{ source: 'command', status: 'ready', items: [{ name: 'goal' }] },
|
||||
{ source: 'hollow', status: 'ready', items: [] },
|
||||
{ source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
],
|
||||
}))
|
||||
expect(titles(view.container)).toEqual(['Commands', 'mystery', 'Skills'])
|
||||
})
|
||||
|
||||
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
|
||||
@@ -75,6 +111,37 @@ describe('MenuView', () => {
|
||||
expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
|
||||
})
|
||||
|
||||
it('scrolls the highlighted option into view when the highlight moves', () => {
|
||||
const { menu } = mount(openState())
|
||||
scrollIntoView.mockClear()
|
||||
act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
|
||||
})
|
||||
|
||||
it('caps the list height at the design maximum when the composer sits low enough', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
})
|
||||
|
||||
it('clamps the list height to the space above the composer minus the safe margin', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('re-fits the height when the window resizes', () => {
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
rect.mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
rect.mockReturnValue({ bottom: 100 } as DOMRect)
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
|
||||
})
|
||||
|
||||
it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
|
||||
const { onPick } = mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
|
||||
Reference in New Issue
Block a user