fix(ui): preserve Hero tree when selecting a Workspace
This commit is contained in:
@@ -21,7 +21,8 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
@@ -70,7 +71,7 @@ async function bench() {
|
||||
// The host face (store resolution) exists only inside the installed
|
||||
// renderer, so materialize it the way the shell does.
|
||||
runtime.renderRoot()
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
@@ -80,6 +81,13 @@ async function bench() {
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const conversationHeaderSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session.header')
|
||||
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const residentSurface = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
||||
@@ -111,7 +119,7 @@ async function bench() {
|
||||
}
|
||||
return {
|
||||
runtime, feature, slots: runtime.slots, entryOf,
|
||||
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, layoutFake,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
@@ -83,6 +84,16 @@ const LAYOUT_CHILDREN = {
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
} as const
|
||||
|
||||
/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
|
||||
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<button data-testid="workspace-probe" onClick={() => { setCount(value => value + 1) }}>
|
||||
{String(open)}:{count}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
@@ -188,6 +199,49 @@ describe('resident composer', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
|
||||
})
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
const root = view.container.querySelector('[data-phase="hero"]')!
|
||||
const scrollBody = view.container.querySelector('[data-conversation-scroll]')!
|
||||
const composerSeat = view.container.querySelector('[data-composer-seat]')!
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const workspaceChip = view.getByRole('button', { name: '选择工作区' })
|
||||
const workspaceProbe = view.getByTestId('workspace-probe')
|
||||
expect(textarea.disabled).toBe(true)
|
||||
|
||||
fireEvent.click(workspaceChip)
|
||||
fireEvent.click(workspaceProbe)
|
||||
expect(workspaceProbe.textContent).toBe('true:1')
|
||||
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
|
||||
snapshot: { blank: true, composerPhase: 'blank' },
|
||||
})
|
||||
|
||||
expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
|
||||
expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
|
||||
expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat)
|
||||
expect(view.container.querySelector('textarea')).toBe(textarea)
|
||||
expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip)
|
||||
expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe)
|
||||
expect(workspaceProbe.textContent).toBe('true:1')
|
||||
expect(textarea.disabled).toBe(false)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
|
||||
const runtime = await bench([], { blank: true })
|
||||
|
||||
@@ -45,7 +45,7 @@ async function bench() {
|
||||
}
|
||||
|
||||
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
||||
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
|
||||
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
|
||||
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('apply wiring', () => {
|
||||
const b = await bench()
|
||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
|
||||
const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header')
|
||||
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
||||
const details = renderEntryOf(b.slots, 'details')
|
||||
expect(conversation?.inject).toBeTypeOf('function')
|
||||
@@ -81,6 +82,7 @@ describe('apply wiring', () => {
|
||||
// The shared handle: one apply-built store value on ALL session entries
|
||||
// (the session-maybe 'conversation' shell carries no store by design).
|
||||
expect(conversationSession?.store).toBeDefined()
|
||||
expect(conversationHeader?.store).toBe(conversationSession?.store)
|
||||
expect(details?.store).toBe(conversationSession?.store)
|
||||
expect(chatView?.store).toBe(conversationSession?.store)
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
|
||||
@@ -15,16 +15,17 @@ type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both strict-session slot
|
||||
// registrations ('conversation.session'/'details'); the session-maybe
|
||||
// 'conversation' shell carries no store by design. The slots must first
|
||||
// exist in the ledger — the test root declares them (the AppFrame role).
|
||||
// The apply.ts shape: one shared handle across the strict Session header,
|
||||
// body, and details registrations; the session-maybe 'conversation' shell
|
||||
// carries no store by design. The slots must first exist in the ledger.
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'conversation.session.header': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
|
||||
runtime.slots.register({ name: 'conversation.session.header', store: chat }, () => null)
|
||||
runtime.slots.register({ name: 'details', store: chat }, () => null)
|
||||
runtime.renderRoot() // materializes the host face storeOf resolves through
|
||||
return { runtime, chat }
|
||||
|
||||
@@ -18,7 +18,7 @@ import { createChatStore } from '../src/client/stores.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
@@ -122,6 +122,33 @@ function mount(
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
slotCalls.push(key)
|
||||
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
||||
if (key === 'conversation.session.header') {
|
||||
return (
|
||||
<ConversationSessionHeader
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
views={{
|
||||
list: () => [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
],
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
open={open}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (key === 'conversation.session') {
|
||||
return (
|
||||
<ConversationSession
|
||||
@@ -145,9 +172,6 @@ function mount(
|
||||
version: () => 1,
|
||||
}}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
t={t}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -340,7 +364,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const before = b.view.getByRole('textbox')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank. Composer
|
||||
// already sat in the Session scrollport during hero, so the textarea
|
||||
// already sat in the resident scrollport during hero, so the textarea
|
||||
// node and InputHub draft both survive.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
|
||||
Reference in New Issue
Block a user