Merge branch 'master' into worktree/web-session-titles

This commit is contained in:
Tianyi Cui
2026-07-23 20:51:17 +08:00
30 changed files with 795 additions and 106 deletions
@@ -4,9 +4,11 @@
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
@@ -44,7 +46,7 @@ describe('MessageItem arms', () => {
describe('small branch tails', () => {
it('PendingCard approval reason renders when present', () => {
const view = render(
<PendingCard item={{ kind: 'approval', rpcId: 'r1' as RpcId, approvalId: 'a1', toolName: 'rm', reason: 'careful' }} />,
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
)
expect(view.getByText('careful')).toBeTruthy()
})
@@ -10,7 +10,8 @@ import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
@@ -342,7 +343,8 @@ describe('ChatView', () => {
it('pending interactions render placeholder cards', () => {
const h = makeHarness({
pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
pending: [new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()
@@ -8,7 +8,8 @@ import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -34,7 +35,7 @@ describe('tails', () => {
it('PendingCard renders the question arm with its count', () => {
const view = render(
<PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
)
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
})
@@ -21,6 +21,9 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Fallback-only chain stub (no takeover registered in these benches). */
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
(_key, _owner, opts) => opts?.fallback ?? null
function snapshotBase(): ConversationSnapshot {
return {
@@ -73,6 +76,7 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
@@ -131,6 +135,7 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
@@ -13,7 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
// Export discipline: packages/client/AGENTS.md.
@@ -36,11 +38,12 @@ interface FakeSnapshot {
running: boolean
removed: boolean
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
pending: readonly PendingInteraction[]
}
function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -99,8 +102,11 @@ describe('EmptyState', () => {
})
describe('ConversationRoot', () => {
function bench(tabs: ViewTab[], activeView?: string) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
function bench(
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
renderSlotChain?: ConversationRootProps['renderSlotChain'],
) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
@@ -124,6 +130,7 @@ describe('ConversationRoot', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
SessionProvider={SessionProviderStub}
views={{
list: () => tabs,
@@ -179,6 +186,30 @@ describe('ConversationRoot', () => {
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('hi', 'queue')
})
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
// A matching entry takes the composer over.
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
// The owner dispatches the raw pending list (chain currency); routing
// lives in entry selectors, not here.
expect(renderSlotChain).toHaveBeenCalledWith(
'conversation.composer',
expect.objectContaining({
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
}),
expect.objectContaining({ fallback: expect.anything() }),
)
cleanup()
// Zero registered entries (default all-decline stub): the fallback IS the
// default InputBar — behavior equals the pre-chain composer.
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
})
})
describe('DetailsPanel', () => {