fix(ui): preserve Hero tree when selecting a Workspace

This commit is contained in:
imccyu
2026-08-06 17:44:06 +08:00
committed by imccyu
parent e69eb9830e
commit f9f72e2f09
29 changed files with 395 additions and 223 deletions
@@ -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()