fix(client): finish the preset intro inside one shared reveal window

A fixed 60ms per-character tick made a Latin preset name run three
times longer than its CJK counterpart. The stagger is now capped by a
200ms shared window (min(40, 200/(n-1))), the icon lands in 150ms with
the characters starting the moment it does, and the whole timeline is
pinned by component tests alongside the store acknowledgement and the
empty custom group.
This commit is contained in:
Yif
2026-08-10 23:59:53 +08:00
parent a8fae974c2
commit cac8e1c53d
8 changed files with 213 additions and 12 deletions

View File

@@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => {
expect(section.startCreatorDraft).toBeDefined()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(workspaces.starts).toHaveLength(1)
// A cross-screen stage carries the introduce cue; the chip acknowledges
// it once, and a repeat acknowledgement leaves the snapshot untouched.
expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true)
seat.introduced()
const acknowledged = seat.hooks.agentPresetSeat.getSnapshot()
expect(acknowledged.introduce).toBe(false)
seat.introduced()
expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged)
conversation()
})

View File

@@ -277,6 +277,94 @@ describe('the new-session chip', () => {
})
})
describe('the chip introduce cue', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
/** Character spans carry inline animation delays; nothing else does. */
function delayedChars(): HTMLElement[] {
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
}
it('reveals a long Latin name inside the shared window, then acknowledges', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }],
introduce: true,
})
// Eleven characters split the 200ms window into 20ms steps, where the
// fixed 40ms tick would have doubled the run for a Latin name.
const chars = delayedChars()
expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode')
expect(chars[0]!.style.animationDelay).toBe('150ms')
expect(chars[1]!.style.animationDelay).toBe('170ms')
expect(chars[10]!.style.animationDelay).toBe('350ms')
// 150 delay + 200 window + 400 fade: acknowledged only once the last
// character has settled, and the label is plain text again after.
act(() => { vi.advanceTimersByTime(749) })
expect(actions.introduced).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(1) })
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
it('keeps the per-tick cap for a short CJK name', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: '创造模式' }],
introduce: true,
})
// Four characters fit under the window, so the 40ms tick applies as-is.
const chars = delayedChars()
expect(chars).toHaveLength(4)
expect(chars[1]!.style.animationDelay).toBe('190ms')
expect(chars[3]!.style.animationDelay).toBe('270ms')
})
it('starts a one-character name with no stagger at all', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: 'C' }],
introduce: true,
})
expect(delayedChars()[0]!.style.animationDelay).toBe('150ms')
act(() => { vi.advanceTimersByTime(550) })
expect(actions.introduced).toHaveBeenCalledTimes(1)
})
it('skips the run under reduced motion and acknowledges at once', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
const actions = renderSeat({ introduce: true })
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
it('acknowledges an empty staged name without arming a run', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: '' }],
introduce: true,
})
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
})
describe('the session-header label', () => {
it('names the preset the session runs, and never offers a switch', async () => {
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })

View File

@@ -253,6 +253,20 @@ describe('the preset list', () => {
expect(actions.close).toHaveBeenCalledTimes(1)
})
it('keeps the empty custom group on screen: heading plus the creator entry', () => {
renderSection({
rows: [
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式' },
{ id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' },
],
})
// No member yet, but the place where one's own preset will appear stays.
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy()
expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull()
})
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
renderSection()
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()