Merge remote-tracking branch 'origin/master' into worktree/web-ask-user-question

This commit is contained in:
imccyu
2026-07-23 14:05:08 +08:00
190 changed files with 6061 additions and 4213 deletions

View File

@@ -35,7 +35,6 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"react": "^18.2.0"
},
"peerDependencies": {

View File

@@ -6,7 +6,7 @@
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans, deriveSpanStats } from './spans.ts'
import css from './TrajectoryStatsHeader.module.css'

View File

@@ -4,7 +4,7 @@
import { useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import css from './views.module.css'

View File

@@ -4,7 +4,7 @@
import { useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import css from './views.module.css'

View File

@@ -9,10 +9,8 @@ import { TrajectoryStatsHeader, type TrajectoryChromeProps } from './TrajectoryS
import { TrajectoryView } from './TrajectoryView.tsx'
import { WaterfallView, type WaterfallExtraProps } from './WaterfallView.tsx'
export { deriveSpans, deriveSpanStats, type SpanStats, type TurnSpan } from './spans.ts'
export { TrajectoryStatsHeader, type TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
export { TrajectoryView } from './TrajectoryView.tsx'
export { WaterfallView, type WaterfallExtraProps } from './WaterfallView.tsx'
export type { TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
export type { WaterfallExtraProps } from './WaterfallView.tsx'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationViewMap {

View File

@@ -2,30 +2,44 @@
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real ConversationService, tabs switch
* inside ConversationRoot without collapsing chat, chrome.header renders the
* span stats bar, and fiber disposal removes both tabs. Span derivation edge
* cases ride along.
* inside ConversationRoot (four-share props form; view rendering is
* in-component now) without collapsing chat, chrome.header renders the span
* stats bar, and fiber disposal removes both tabs. Span derivation edge cases
* ride along.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, Fragment, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationRoot, ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationInjected, ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
apply, deriveSpans, deriveSpanStats, inject, TrajectoryStatsHeader, TrajectoryView, WaterfallView,
} from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { createElement, type FC } from 'react'
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationRoot } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import type { ConversationSlotProps, ConvViewProps, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
const SID = 's1' as SessionId
const fallbackSlots: ConversationInjected['slots'] = {
renderSlot: (_key, _props, opts) => opts?.fallback ?? null,
}
/** Fallback-only renderSlot stub (no composer takeover in these benches). */
const fallbackRenderSlot: ConversationSlotProps['renderSlot'] =
(_key, _owner, opts) => opts?.fallback ?? null
/** Standard-seat stub: ConversationRoot never renders it, delivery is mandatory in the props type. */
const StubSessionProvider: ConversationSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
const NODES = [
@@ -37,7 +51,25 @@ const NODES = [
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return bindSnapshotSelector(store)
}
/** Chat-view stand-in props for standalone view mounts. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
const chat = createChatStore().create()
return {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useStore: bindSnapshotSelector(chat),
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
} as unknown as ConvViewProps
}
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
@@ -51,28 +83,8 @@ async function bench() {
return { ctx, svc, fiber }
}
/** Mount ConversationRoot over the service's registry face, rendering chrome like the conversation apply does. */
/** Mount ConversationRoot over the service's registry face (four-share form: chrome/view rendering is in-component). */
function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) {
const { useSession } = fakeSession(nodes)
const activeStore = createSnapshotStore<string | undefined>(undefined)
const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }]
const viewProps = {
sessionId: SID, useSession,
useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
slots: undefined,
} as unknown as ConvViewProps
const renderView = (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'h', sessionId: SID, useSession }))
}
children.push(createElement(entry.component, { key: 'b', ...viewProps }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'f', sessionId: SID, useSession }))
}
return createElement(Fragment, null, children)
}
const sessionSnapshot = createSnapshotStore<{
running: boolean
removed: boolean
@@ -82,21 +94,26 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
}>({
running: false, removed: false, promptError: null, nodes, pending: [],
})
const chat = createChatStore().create()
return render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession}
useAncestry={() => ancestry}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
useSessions={emptySessions()}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
views={{
list: () => svc.views(),
subscribe: (fn) => svc.subscribeViews(fn),
version: () => svc.viewsVersion(),
}}
useActiveView={() => activeStore.useSelector((s) => s) as ViewId | undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
renderView={renderView}
slots={fallbackSlots}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
renderSlot={fallbackRenderSlot}
SessionProvider={StubSessionProvider}
/>,
)
}
@@ -173,28 +190,22 @@ describe('span derivation', () => {
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>, {
sessionId: SID, useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps))
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
describe('WaterfallView standalone branches', () => {
const props = (nodes: ConversationSnapshot['nodes']) => ({
sessionId: SID, useSession: fakeSession(nodes).useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps)
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>, props([] as unknown as ConversationSnapshot['nodes'])))
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
})
it('a turn without tool calls renders the node bar only', () => {
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
render(createElement(WaterfallView as FC<ConvViewProps>, props(nodes)))
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
expect(screen.getByTitle('1 nodes')).toBeTruthy()
expect(screen.queryByTitle(/tool calls/)).toBeNull()
})

View File

@@ -1,15 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"