mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(conversation): separate Conversation, Chat, and Trajectory owners
This commit is contained in:
@@ -1,161 +0,0 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
|
||||
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
|
||||
import type {
|
||||
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionRuntime } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
|
||||
|
||||
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
||||
return {
|
||||
kind,
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
|
||||
return {
|
||||
target,
|
||||
create: () => ({
|
||||
empty: null,
|
||||
replace: () => null,
|
||||
apply: () => null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function bootRegistries(): Promise<{
|
||||
ctx: Context
|
||||
events: ConversationEventRegistry
|
||||
views: ConversationViewRegistry
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
const events = ctx.get('conversationEvents') as ConversationEventRegistry
|
||||
const views = ctx.get('conversationViews') as ConversationViewRegistry
|
||||
return { ctx, events, views }
|
||||
}
|
||||
|
||||
describe('Conversation registries', () => {
|
||||
it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const definition = eventDefinition('message')
|
||||
const dispose = events.register(definition)
|
||||
|
||||
expect(events.entries()).toEqual([definition])
|
||||
expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const fallback = eventDefinition('unknown')
|
||||
const dispose = events.registerFallback(fallback)
|
||||
|
||||
expect(events.fallbackEntry()).toBe(fallback)
|
||||
expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects rendering Definitions that omit either target or builder', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const targetOnly: ConversationNodeDefinition<null> = {
|
||||
kind: 'target-only',
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
const builderOnly: ConversationNodeDefinition<null> = {
|
||||
kind: 'builder-only',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
|
||||
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
|
||||
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
|
||||
})
|
||||
|
||||
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const fallback: ConversationNodeDefinition<null> = {
|
||||
kind: 'state-only-fallback',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
|
||||
expect(() => events.registerFallback(fallback))
|
||||
.toThrow('conversation fallback Definition must declare a target')
|
||||
})
|
||||
|
||||
it('rejects duplicate view targets and disposes a view registration once', async () => {
|
||||
const { views } = await bootRegistries()
|
||||
const definition = viewDefinition('chat')
|
||||
const dispose = views.register(definition)
|
||||
|
||||
expect(views.entries()).toEqual([definition])
|
||||
expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes Event, fallback, and view contributions with their caller fiber', async () => {
|
||||
const { ctx, events, views } = await bootRegistries()
|
||||
const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => {
|
||||
featureCtx.conversationEvents.register(eventDefinition('message'))
|
||||
featureCtx.conversationEvents.registerFallback(eventDefinition('unknown'))
|
||||
featureCtx.conversationViews.register(viewDefinition('chat'))
|
||||
})
|
||||
await feature.await()
|
||||
|
||||
expect(events.entries()).toHaveLength(1)
|
||||
expect(events.fallbackEntry()).toBeDefined()
|
||||
expect(views.entries()).toHaveLength(1)
|
||||
|
||||
await feature.dispose()
|
||||
expect(events.entries()).toEqual([])
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('coalesces registry changes into one rebuild of every resident Session', async () => {
|
||||
const { ctx, events, views } = await bootRegistries()
|
||||
const api = new FakeApiClient()
|
||||
const sessionId = 'resident' as SessionId
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
|
||||
}) as never)
|
||||
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
|
||||
await sessions.refresh()
|
||||
await Promise.resolve()
|
||||
sessions.scope(sessionId)
|
||||
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
|
||||
|
||||
events.register(eventDefinition('message'))
|
||||
views.register(viewDefinition('chat'))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(rebuild).toHaveBeenCalledOnce()
|
||||
rebuild.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,9 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
"@deepseek-ai/dsh-client-ui-chat",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -50,8 +52,9 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
@@ -67,8 +70,9 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { ImageGallery } from '../MessageImage.tsx'
|
||||
import { messageImageLabels } from './labels.ts'
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Browser attachment plugin: fills conversation's composer and message-image slots. */
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { ComposerAttachments } from './ComposerAttachments.tsx'
|
||||
import { MessageImages } from './MessageImages.tsx'
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
|
||||
import type { MessageImageLabels } from '../src/MessageImage.tsx'
|
||||
import { MessageImages } from '../src/client/MessageImages.tsx'
|
||||
@@ -28,6 +29,23 @@ const attachment = {
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
type AttentionSnapshot = Parameters<Parameters<MessageImagesProps['useSessionPendingInteraction']>[0]>[0]
|
||||
type TrajectorySnapshot = Parameters<Parameters<MessageImagesProps['useTrajectory']>[0]>[0]
|
||||
|
||||
const noAttention: AttentionSnapshot = new Map()
|
||||
const emptyTrajectory: TrajectorySnapshot = {
|
||||
eventNodes: [],
|
||||
eventLocations: new Map(),
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
}
|
||||
const useSessionPendingInteraction: MessageImagesProps['useSessionPendingInteraction'] = selector => selector(noAttention)
|
||||
const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT)
|
||||
const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT)
|
||||
const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory)
|
||||
|
||||
describe('MessageImage', () => {
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
@@ -190,8 +208,12 @@ describe('ImageGallery', () => {
|
||||
sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
|
||||
useSession,
|
||||
useSessions,
|
||||
useSessionPendingInteraction,
|
||||
useWorkspaces,
|
||||
useProjection: () => undefined,
|
||||
useConversation,
|
||||
useChat,
|
||||
useTrajectory,
|
||||
useInput,
|
||||
inputActions: {
|
||||
setDraft: vi.fn(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { apply as applyHost } from '../src/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx'
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
"path": "../ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../ui-chat"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-chat",
|
||||
"description": "Chat Conversation target, node definitions, renderers, and details surface",
|
||||
"version": "0.1.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-chat"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"external": [
|
||||
"@deepseek-ai/dsh-api-workspace-controller/client",
|
||||
"@deepseek-ai/dsh-client-ui-conversation/client"
|
||||
],
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-session-controller",
|
||||
"@deepseek-ai/dsh-api-workspace-controller",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/** Register the Chat Conversation target, renderers, stats, and details surface. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { resolveWorkspacePath } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// Type-only service and declaration merges used by the apply world.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type {
|
||||
ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected,
|
||||
TurnTailOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
import type { ChatSnapshot } from './contract/snapshot.ts'
|
||||
import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts'
|
||||
import { ApprovalCommand } from './chat/ApprovalCommand.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { registerConversationNodes } from './conversation-nodes/register.ts'
|
||||
import { DetailsPanel } from './details/DetailsPanel.tsx'
|
||||
import { HistoricalImageCache } from './historical-images.ts'
|
||||
import { en, NS, zh } from './locale.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
|
||||
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
|
||||
hooks: {
|
||||
turnData: ({ useChat }, nodeKey) => function useTurnData(key) {
|
||||
return useChat((snapshot) => {
|
||||
const location = snapshot.nodes.get(nodeKey)?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.data.get(key)
|
||||
: undefined
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Services required by the Chat target and its presentation registrations. */
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale',
|
||||
]
|
||||
|
||||
/**
|
||||
* Mount all Chat-owned contributions.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const chatSources = new WeakMap<SessionBinding, ObservableSnapshot<ChatSnapshot>>()
|
||||
const chatSource = (binding: SessionBinding): ObservableSnapshot<ChatSnapshot> => {
|
||||
let source = chatSources.get(binding)
|
||||
if (source === undefined) {
|
||||
const target = ctx.uiConversation.binding(binding).target('chat')
|
||||
source = {
|
||||
getSnapshot: () => target.getSnapshot() ?? EMPTY_CHAT_SNAPSHOT,
|
||||
subscribe: listener => target.subscribe(listener),
|
||||
}
|
||||
chatSources.set(binding, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
registerConversationNodes(ctx)
|
||||
registerChatNodeRenderers(ctx)
|
||||
ctx.uiSession.provide({
|
||||
hooks: ['chat'],
|
||||
resolve: binding => ({ hooks: { chat: chatSource(binding) } }),
|
||||
})
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-chat: dictionaries')
|
||||
const t = ctx.locale.bind(NS)
|
||||
const chatStore = createChatStore()
|
||||
const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
|
||||
const images = new HistoricalImageCache(ctx)
|
||||
|
||||
ctx.slots.inject('conversation.view', () => {
|
||||
const disposeView = ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'chat',
|
||||
order: 0,
|
||||
label: () => t('view.chat'),
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
|
||||
'conversation.message.images': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
|
||||
return {
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
ctx.layout.openDetails()
|
||||
},
|
||||
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner),
|
||||
openFile: (path) => {
|
||||
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path))
|
||||
},
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
loadImage: attachment => images.resolve(sessionId, attachment),
|
||||
chatScroll: {
|
||||
save: (position) => {
|
||||
if (position === null) chatScrollPositions.delete(sessionId)
|
||||
else chatScrollPositions.set(sessionId, position)
|
||||
},
|
||||
read: () => chatScrollPositions.get(sessionId) ?? null,
|
||||
},
|
||||
forkAt: (seq) => {
|
||||
ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
|
||||
.then((childId) => { ctx.sessions.open(childId) })
|
||||
.catch(() => {
|
||||
// Fork or child-title failure leaves the source view unchanged.
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ChatView)
|
||||
return disposeView
|
||||
})
|
||||
|
||||
ctx.slots.inject('conversation.composer.dock', () =>
|
||||
ctx.slots.register({
|
||||
name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS,
|
||||
}, StatsLine))
|
||||
|
||||
ctx.slots.inject('conversation.approval.detail', () =>
|
||||
ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand))
|
||||
|
||||
ctx.slots.inject('details', () => ctx.slots.register({
|
||||
name: 'details',
|
||||
locale: NS,
|
||||
children: { 'conversation.details.tool': { kind: 'single', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (): DetailsInjected => ({ closeDetails: () => { ctx.layout.closeDetails() } }),
|
||||
}, DetailsPanel))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Chat-owned approval detail resolving a correlated Tool call's command. */
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-approval/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
|
||||
interface ApprovalToolCall {
|
||||
readonly callId: string
|
||||
readonly argsRaw: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a shell command from a correlated Tool call when its arguments carry one.
|
||||
* @param call - Tool call arguments, when a correlated call exists.
|
||||
* @returns command text, or undefined for absent, malformed, or unrelated arguments.
|
||||
*/
|
||||
export function commandOf(call: ApprovalToolCall | undefined): string | undefined {
|
||||
if (call === undefined) return undefined
|
||||
try {
|
||||
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
|
||||
return typeof args.command === 'string' ? args.command : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the command of the Chat Tool node correlated with an approval.
|
||||
* @param props - Approval identity and Session-standard Chat selector hook.
|
||||
* @returns command text when the correlated call carries one.
|
||||
*/
|
||||
export function ApprovalCommand({ callId, useChat }: PropsRuntime<'conversation.approval.detail'>) {
|
||||
const command = useChat((snapshot) => {
|
||||
for (const node of snapshot.nodes.values()) {
|
||||
const root = node.kind === 'tool-call' ? (node as ChatNode<'tool-call'>).data.root : undefined
|
||||
if (root !== undefined && root.callId === callId && !('kind' in root)) return commandOf(root)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
return command ?? null
|
||||
}
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
import { Fragment, memo, useMemo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { AssistantBlock } from '../contract/snapshot.ts'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import css from './ChatView.module.css'
|
||||
|
||||
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
|
||||
readonly nodeKey: string
|
||||
readonly useSession: ChatViewSlotProps['useSession']
|
||||
readonly useChat: ChatViewSlotProps['useChat']
|
||||
readonly renderSlot: ChatViewSlotProps['renderSlot']
|
||||
readonly t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -18,9 +18,9 @@ type RoutedChatNodeOwner = {
|
||||
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
|
||||
export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
|
||||
renderMessageImages, fileMentions, useSession, renderSlot, t,
|
||||
renderMessageImages, fileMentions, useChat, renderSlot, t,
|
||||
}: ChatNodeSeatProps) {
|
||||
const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey))
|
||||
const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
|
||||
const routedNode = node as ChatNode | undefined
|
||||
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
|
||||
? null
|
||||
+9
-6
@@ -2,7 +2,7 @@
|
||||
// otherwise this view owns it. Each row subscribes to one stable node key.
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts'
|
||||
import { PendingSteeringBubble } from './MessageItem.tsx'
|
||||
@@ -144,12 +144,12 @@ function TurnStatus({ startTime, t }: {
|
||||
* ordered business Node crosses the keyed renderer seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, inspectCall, chatScroll, forkAt,
|
||||
useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
|
||||
fileMentions, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useSession(s => s.chat.order)
|
||||
const nodeStore = useSession(s => s.chat.nodes)
|
||||
const timeline = useSession(s => s.chat.timeline)
|
||||
const order = useChat(s => s.order)
|
||||
const nodeStore = useChat(s => s.nodes)
|
||||
const timeline = useChat(s => s.timeline)
|
||||
const inbox = useSession(s => s.queue)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -159,6 +159,9 @@ export function ChatView({
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
const inspectCall = useCallback((callId: string) => {
|
||||
openView('trajectory', callId)
|
||||
}, [openView])
|
||||
const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
|
||||
const [fileOpenBusy, setFileOpenBusy] = useState(false)
|
||||
// Close/retry must ignore a settlement that started before the latest
|
||||
@@ -421,7 +424,7 @@ export function ChatView({
|
||||
<ChatNodeSeat
|
||||
key={nodeKey}
|
||||
nodeKey={nodeKey}
|
||||
useSession={useSession}
|
||||
useChat={useChat}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
openFile={requestOpenFile}
|
||||
+1
-1
@@ -2,7 +2,6 @@
|
||||
// expandable only when the current window includes its cited summary.
|
||||
|
||||
import { memo, useState } from 'react'
|
||||
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconApiOutline14,
|
||||
IconChevronDownOutline14,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { CompactionSummaryNode } from '../contract/snapshot.ts'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
interface CompactionItemProps {
|
||||
+2
-1
@@ -5,9 +5,10 @@
|
||||
// even when this UI version has never seen its producer.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ContextMessageNode } from '../contract/snapshot.ts'
|
||||
import type { KnownContextForm } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './ContextBody.module.css'
|
||||
|
||||
/** Model-facing text stays bounded at the disclosure, not at the producer. */
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
|
||||
import type { ContextMessageNode } from '../contract/snapshot.ts'
|
||||
import { contextBody } from './ContextBody.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ModelRetryNode, TurnErrorNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
|
||||
import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
+13
-39
@@ -3,17 +3,21 @@
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { contextOccupancy } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
|
||||
import type {} from '@deepseek-ai/dsh-session-stats/client'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import { formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { assistantStepReading } from './turn-metrics.ts'
|
||||
import { assistantStepReading } from '../contract/turn-metrics.ts'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
export { contextOccupancy }
|
||||
|
||||
interface WindowStats {
|
||||
turns: number
|
||||
steps: number
|
||||
@@ -43,7 +47,7 @@ interface WindowStats {
|
||||
* @param nodes - snapshot nodes.
|
||||
* @returns fallback counts and summed wall times.
|
||||
*/
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
|
||||
export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats {
|
||||
const turns = new Set<number>()
|
||||
let steps = 0
|
||||
let llmMs = 0
|
||||
@@ -170,46 +174,16 @@ export function billedInputTokens(usage: TokenUsageProjection): number {
|
||||
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
||||
}
|
||||
|
||||
interface ContextOccupancy {
|
||||
percent: number
|
||||
usedTokens: number
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate context occupancy, using the TUI's integer rounding and upper
|
||||
* clamp. The numerator is `projectedTokens` — the provider sample carried
|
||||
* forward over the surface's movement since — so compaction shows immediately
|
||||
* instead of waiting for the next request to report usage; it falls back to the
|
||||
* bare sample only for a log whose projection predates that field. Numerator
|
||||
* and capacity remain independent last-wins projection fields, so this is a
|
||||
* reference figure rather than an exact measurement of one request (see the
|
||||
* token-meter README).
|
||||
* @param pressure - the session's context-pressure projection value.
|
||||
* @returns occupancy with its numerator and denominator, or null until both values are known.
|
||||
*/
|
||||
export function contextOccupancy(
|
||||
pressure: ContextPressureProjection | undefined,
|
||||
): ContextOccupancy | null {
|
||||
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
|
||||
if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
|
||||
return {
|
||||
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
|
||||
usedTokens,
|
||||
contextWindow: pressure.contextWindow,
|
||||
}
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector plus the projection read seat. */
|
||||
export interface StatsLineProps {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
useChat: SnapshotSelectorHook<ChatSnapshot>
|
||||
useProjection: UseProjection
|
||||
/** The owning dock's locale seat. */
|
||||
t: ComposerBarProps['t']
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
|
||||
const settledNodes = useSession(s => s.chat.legacy.nodes)
|
||||
export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) {
|
||||
const settledNodes = useChat(s => s.legacy.nodes)
|
||||
const usage = useProjection('tokenUsage')
|
||||
// Every figure rides the durable sessionStats projection, so paging and
|
||||
// compaction cannot change any of them; an assembly without the unit falls
|
||||
+3
-3
@@ -10,11 +10,11 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
|
||||
|
||||
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
|
||||
export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession,
|
||||
node, openFile, forkAt, renderSlot, renderSlotChain, t, useChat,
|
||||
}: TurnTailNodeViewProps) {
|
||||
const data = node.data
|
||||
const hasLaterChatNode = useSession(snapshot =>
|
||||
snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key)
|
||||
const hasLaterChatNode = useChat(snapshot =>
|
||||
snapshot.locations.getTurn(data.turn).at(-1) !== node.key)
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { NS } from '../locales.ts'
|
||||
import { NS } from '../locale.ts'
|
||||
import { AssistantNodeView } from './AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
|
||||
import {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { AssistantBlock } from '../contract/snapshot.ts'
|
||||
|
||||
/**
|
||||
* Collect visible prose from one Assistant lifecycle.
|
||||
+14
-3
@@ -1,7 +1,18 @@
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode,
|
||||
CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ConversationLocation, ConversationViewNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, CommandNode, CompactionSummaryNode,
|
||||
ModelRetryNode, RunningToolCall, ToolCallBlock,
|
||||
} from './snapshot.ts'
|
||||
|
||||
/** Final Chat render unit produced by a Chat business Definition. */
|
||||
export interface ChatConversationViewNode extends ConversationViewNode {
|
||||
readonly target: 'chat'
|
||||
readonly anchorSeq: number
|
||||
readonly location: ConversationLocation
|
||||
readonly visibility: 'visible' | 'hidden'
|
||||
}
|
||||
|
||||
/** Merge-extensible payload registry keyed by final Chat renderer kind. */
|
||||
export interface ChatNodeDataMap {}
|
||||
@@ -0,0 +1,205 @@
|
||||
/** Chat-owned Slot declarations and composed component props. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {
|
||||
ConversationTurnDataMap, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
|
||||
SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget } from './store.ts'
|
||||
import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
|
||||
import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts'
|
||||
|
||||
/** Selector hook over the current Conversation binding's Chat target. */
|
||||
export type UseChat = SnapshotSelectorHook<ChatSnapshot>
|
||||
|
||||
/** Historical image group handed to the optional attachment presentation plugin. */
|
||||
export interface MessageImagesOwnerProps {
|
||||
images: readonly { readonly attachment: ImageAttachmentRef }[]
|
||||
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
|
||||
align: 'start' | 'end'
|
||||
}
|
||||
|
||||
/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */
|
||||
export type RenderMessageImages = (owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => ReactNode
|
||||
|
||||
/** Owner currency of the completed-Turn extension chain. */
|
||||
export interface TurnTailOwnerProps {
|
||||
turn: TurnLocation
|
||||
seq: number
|
||||
openFile: (path: string) => void
|
||||
}
|
||||
|
||||
/** Owner currency of finalized-assistant actions. */
|
||||
export interface AssistantActionOwnerProps {
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Optional prose file-mention provider consumed by Chat. */
|
||||
export interface ChatFileMentions {
|
||||
/**
|
||||
* Resolve prose links for one closing Turn.
|
||||
* @param owner - closing-Turn identity and file opener.
|
||||
* @returns link resolver when available.
|
||||
*/
|
||||
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Optional prose file-mention provider. */
|
||||
chatFileMentions: ChatFileMentions
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook constrained to business data published on the current Chat Node's Turn. */
|
||||
export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
) => Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||
|
||||
/** Slot-level Hook factory for keyed Chat renderers. */
|
||||
export interface ChatNodeTurnDataInjected {
|
||||
hooks: { turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> }
|
||||
}
|
||||
|
||||
/** Stable owner currency delivered to a keyed Chat renderer. */
|
||||
export interface ChatNodeOwnerProps {
|
||||
selectedCallId?: CallId | undefined
|
||||
cwd?: string | undefined
|
||||
openFile: (path: string) => void
|
||||
inspectCall: (callId: CallId) => void
|
||||
forkAt: (seq: number) => void
|
||||
renderMessageImages: RenderMessageImages
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
/** Full props of one keyed Chat renderer. */
|
||||
export type ChatNodeViewProps<Kind extends ChatNodeKind = ChatNodeKind> =
|
||||
PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'>
|
||||
|
||||
/** Tool block rendered in the details panel. */
|
||||
export interface DetailsToolOwnerProps {
|
||||
block: ToolCallBlock
|
||||
cwd?: string | undefined
|
||||
}
|
||||
|
||||
/** Command-row owner share. */
|
||||
export interface CommandRowOwnerProps {
|
||||
node: CommandNode
|
||||
compaction?: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Full props of a registered command row. */
|
||||
export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
|
||||
|
||||
/** Shared Chat store handle. */
|
||||
export type ChatStore = ReturnType<typeof createChatStore>
|
||||
|
||||
/** In-memory reader position resilient to transcript reflow. */
|
||||
export interface ChatScrollPosition {
|
||||
readonly anchorKey: string
|
||||
readonly anchorTop: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the Chat view. */
|
||||
export interface ChatViewInjected {
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
openFile: (path: string) => Promise<void>
|
||||
loadOlder: () => void
|
||||
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
|
||||
chatScroll: {
|
||||
save: (position: ChatScrollPosition | null) => void
|
||||
read: () => ChatScrollPosition | null
|
||||
}
|
||||
forkAt: (seq: number) => void
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
/** Full Chat view props. */
|
||||
export type ChatViewSlotProps =
|
||||
PropsRuntime<'conversation.view'>
|
||||
& PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'>
|
||||
& PropsStore<ChatStore>
|
||||
& InjectFace<ChatViewInjected>
|
||||
& PropsLocale<'chat'>
|
||||
|
||||
/** Full props of the durable-message image renderer. */
|
||||
export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'>
|
||||
|
||||
/** Details-panel callbacks. */
|
||||
export interface DetailsInjected {
|
||||
closeDetails: () => void
|
||||
}
|
||||
|
||||
/** Full details-panel props. */
|
||||
export type DetailsSlotProps =
|
||||
PropsRuntime<'details'>
|
||||
& PropsRenderSlots<'conversation.details.tool'>
|
||||
& PropsStore<ChatStore>
|
||||
& InjectFace<DetailsInjected>
|
||||
& PropsLocale<'chat'>
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SessionStandardProps {
|
||||
/** Selector hook over the current Conversation binding's Chat target. */
|
||||
useChat: UseChat
|
||||
}
|
||||
|
||||
interface LocaleNamespaceMap {
|
||||
/** Chat target, transcript node, statistics, and details copy. */
|
||||
chat: import('../locale.ts').ChatKey
|
||||
}
|
||||
|
||||
interface SlotMap {
|
||||
/**
|
||||
* Final Chat node renderer, keyed by `ChatNodeKind`. The component receives
|
||||
* the typed node, shared Chat actions, and Turn-data hook. Reusing a key
|
||||
* replaces that node renderer; a kind with no occupant renders no row.
|
||||
*/
|
||||
'conversation.chat.node': {
|
||||
kind: 'keyed'
|
||||
scope: 'session'
|
||||
owner: ChatNodeOwnerProps
|
||||
keyProps: { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }
|
||||
hookContext: string
|
||||
inject: ChatNodeTurnDataInjected
|
||||
}
|
||||
/**
|
||||
* Renderer for one consecutive group of durable message images. The owner
|
||||
* supplies image references, an authorized loader, and alignment. A
|
||||
* registration replaces the shipped gallery; without one, images are omitted.
|
||||
*/
|
||||
'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps }
|
||||
/**
|
||||
* Command row keyed by the command name. The component receives the folded
|
||||
* command lifecycle and linked compaction when present. Reusing a key
|
||||
* replaces that command renderer; an unoccupied key uses the generic card.
|
||||
*/
|
||||
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
|
||||
/**
|
||||
* Selector-routed extension before a completed Turn's action row. The
|
||||
* component receives the Turn, closing sequence, and file opener. The first
|
||||
* selector that accepts the owner renders; an all-declined chain is empty.
|
||||
*/
|
||||
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
|
||||
/**
|
||||
* Ordered actions for one finalized assistant message. Each entry receives
|
||||
* the durable message id; a fresh `id` adds an action and reusing one replaces
|
||||
* that entry. With no entries, the standard action row remains unchanged.
|
||||
*/
|
||||
'conversation.chat.assistant-actions': { kind: 'list'; scope: 'session'; owner: AssistantActionOwnerProps }
|
||||
/**
|
||||
* Whole details-panel body for the selected Tool call. The component receives
|
||||
* the running or settled block and optional workspace root. A registration
|
||||
* replaces the shipped Tool details renderer; absence uses the raw fallback.
|
||||
*/
|
||||
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
ConversationNode, ConversationTimelineSnapshot, PartialAssistant, RunningToolCall,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatConversationViewNode } from './chat-nodes.ts'
|
||||
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
|
||||
ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem,
|
||||
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
|
||||
UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
export {
|
||||
emptyAssistantBlock, toAssistantBlock, toAssistantBlocks,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/** Stable live per-key reader for Chat nodes. */
|
||||
export interface ChatNodeStore {
|
||||
/** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */
|
||||
get(key: string): ChatConversationViewNode | undefined
|
||||
/** @returns all currently materialized Nodes without imposing render order. */
|
||||
values(): readonly ChatConversationViewNode[]
|
||||
}
|
||||
|
||||
/** Stable live Location index for Chat nodes. */
|
||||
export interface ChatLocationNodeIndex {
|
||||
/** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */
|
||||
getTurn(turn: number): readonly string[]
|
||||
/** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */
|
||||
getStep(turn: number, step: number): readonly string[]
|
||||
}
|
||||
|
||||
/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */
|
||||
export interface LegacyConversationSlice {
|
||||
readonly nodes: readonly ConversationNode[]
|
||||
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
|
||||
readonly turnEnds: ReadonlyMap<number, number>
|
||||
readonly partial: PartialAssistant | null
|
||||
readonly runningCalls: readonly RunningToolCall[]
|
||||
}
|
||||
|
||||
/** Incremental Chat publication with immutable order and stable live keyed readers. */
|
||||
export interface ChatSnapshot {
|
||||
readonly order: readonly string[]
|
||||
readonly nodes: ChatNodeStore
|
||||
readonly locations: ChatLocationNodeIndex
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
readonly legacy: LegacyConversationSlice
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationViewSnapshotMap {
|
||||
chat: ChatSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_LIST: readonly never[] = []
|
||||
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
|
||||
|
||||
/** Empty Chat target used before a view builder is registered. */
|
||||
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
|
||||
order: EMPTY_LIST,
|
||||
nodes: {
|
||||
get: () => undefined,
|
||||
values: () => EMPTY_LIST,
|
||||
},
|
||||
locations: {
|
||||
getTurn: () => EMPTY_LIST,
|
||||
getStep: () => EMPTY_LIST,
|
||||
},
|
||||
timeline: EMPTY_TIMELINE,
|
||||
legacy: {
|
||||
nodes: EMPTY_LIST,
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
partial: null,
|
||||
runningCalls: EMPTY_LIST,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Chat-owned selection state shared by the transcript and details panel. */
|
||||
|
||||
/** Tool call identity as carried by Chat nodes. */
|
||||
export type CallId = string
|
||||
|
||||
/** Selection target for the Chat details linkage channel. */
|
||||
export interface SelectionTarget {
|
||||
turnSeq: number
|
||||
stepSeq?: number
|
||||
callId?: CallId
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
/** Per-Session state shared only by the Chat view and details surface. */
|
||||
export interface ChatStoreState {
|
||||
selection: SelectionTarget | null
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// Latency/throughput folds shared by the settled turn footer and StatsLine.
|
||||
|
||||
import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { AssistantMessageNode, ConversationNode } from './snapshot.ts'
|
||||
|
||||
/** Latency and decode-throughput readings for one turn's footer. */
|
||||
export interface TurnMetrics {
|
||||
+10
-9
@@ -1,23 +1,24 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
|
||||
ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { AssistantChatData } from '../contract/chat-nodes.ts'
|
||||
import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts'
|
||||
import { toAssistantBlock, toAssistantBlocks } from '../contract/snapshot.ts'
|
||||
import { emptyAssistantBlock } from '../contract/snapshot.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Streaming, settled, or interrupted Assistant step. */
|
||||
'assistant-step': AssistantChatData
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-runtime/client' {
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationStepDataMap {
|
||||
/** Streaming, settled, or interrupted Assistant material for this Step. */
|
||||
'assistant-step': AssistantChatData
|
||||
@@ -314,5 +315,5 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerAssistantConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(assistantDefinition)
|
||||
ctx.uiConversation.events.register(assistantDefinition)
|
||||
}
|
||||
+12
-9
@@ -1,13 +1,15 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
|
||||
ConversationLocation, ConversationNode, ConversationTimelineSnapshot,
|
||||
ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder,
|
||||
ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
|
||||
import { isRunningTool } from '../contract/chat-nodes.ts'
|
||||
import type {
|
||||
ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode,
|
||||
LegacyConversationSlice, PartialAssistant, RunningToolCall,
|
||||
} from '../contract/snapshot.ts'
|
||||
import { sessionRecallLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const EMPTY_KEYS: readonly string[] = []
|
||||
const EMPTY_TURNS: readonly number[] = []
|
||||
@@ -542,10 +544,11 @@ function locationIdentity(location: ConversationLocation): string {
|
||||
return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}`
|
||||
}
|
||||
|
||||
/** Chat target factory contributed to the Runtime view registry. */
|
||||
/** Chat target factory contributed to the Conversation view registry. */
|
||||
export const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
|
||||
target: 'chat',
|
||||
create: () => new ChatSnapshotBuilder(),
|
||||
isActive: snapshot => snapshot.order.some(key => snapshot.nodes.get(key)?.kind !== 'command'),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,5 +556,5 @@ export const chatViewDefinition: ConversationViewDefinition<ChatConversationView
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerChatConversationView(ctx: Context): void {
|
||||
ctx.conversationViews.register(chatViewDefinition)
|
||||
ctx.uiConversation.views.register(chatViewDefinition)
|
||||
}
|
||||
+6
-6
@@ -1,16 +1,16 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext,
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint'
|
||||
import type {} from '@deepseek-ai/dsh-compaction/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { ManualCompactionChatData } from '../contract/chat-nodes.ts'
|
||||
import type { CommandNode, CompactionSummaryNode } from '../contract/snapshot.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Ordinary slash-command lifecycle. */
|
||||
command: CommandNode
|
||||
@@ -222,7 +222,7 @@ export const commandDefinition: ConversationNodeDefinition<CommandState> = {
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerCommandConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(commandDefinition)
|
||||
ctx.uiConversation.events.register(commandDefinition)
|
||||
}
|
||||
|
||||
/** Shared structural checkpoint recognizer for automatic compaction. */
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
ConversationLocation, ConversationNodeContext,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatNode, ChatNodeDataMap, ChatNodeKind,
|
||||
} from '../contract/chat-nodes.ts'
|
||||
+5
-4
@@ -1,12 +1,13 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-compaction/types'
|
||||
import type { CompactionSummaryNode } from '../contract/snapshot.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
import { compactSource, compactSummary, updateCompactionState } from './command.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Automatic compaction checkpoint marker. */
|
||||
compaction: CompactionSummaryNode
|
||||
@@ -61,5 +62,5 @@ export const compactionDefinition: ConversationNodeDefinition<CompactionState> =
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerCompactionConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(compactionDefinition)
|
||||
ctx.uiConversation.events.register(compactionDefinition)
|
||||
}
|
||||
+5
-6
@@ -1,11 +1,10 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, UnknownSurfaceNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { UnknownSurfaceNode } from '../contract/snapshot.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Generic presentation of an unclaimed append-surface event. */
|
||||
unknown: UnknownSurfaceNode
|
||||
@@ -37,5 +36,5 @@ export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfac
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerUnknownConversationFallback(ctx: Context): void {
|
||||
ctx.conversationEvents.registerFallback(unknownFallbackDefinition)
|
||||
ctx.uiConversation.events.registerFallback(unknownFallbackDefinition)
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, ConversationPreviousContext,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
|
||||
|
||||
interface InboxIdentity {
|
||||
@@ -64,6 +64,6 @@ export const nextStepInboxDefinition = inboxDefinition('next-step')
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerInboxConversationNodes(ctx: Context): void {
|
||||
ctx.conversationEvents.register(nextTurnInboxDefinition)
|
||||
ctx.conversationEvents.register(nextStepInboxDefinition)
|
||||
ctx.uiConversation.events.register(nextTurnInboxDefinition)
|
||||
ctx.uiConversation.events.register(nextStepInboxDefinition)
|
||||
}
|
||||
+6
-8
@@ -1,10 +1,8 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts'
|
||||
import { contextForm, contextProvenance } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InboxState } from './inbox.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
@@ -20,7 +18,7 @@ interface ReferencedSteeringMessageNode extends SteeringMessageNode {
|
||||
|
||||
type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Ordinary turn-opening user message. */
|
||||
user: ReferencedUserMessageNode
|
||||
@@ -90,5 +88,5 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerMessageConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(messageDefinition)
|
||||
ctx.uiConversation.events.register(messageDefinition)
|
||||
}
|
||||
+2
-16
@@ -1,6 +1,6 @@
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
|
||||
import { toAssistantBlock } from './conversation.ts'
|
||||
import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts'
|
||||
import { emptyAssistantBlock, toAssistantBlock } from '../contract/snapshot.ts'
|
||||
|
||||
/**
|
||||
* Whether a stream chunk changes the partial assistant projection shown by the UI.
|
||||
@@ -97,17 +97,3 @@ export class PartialAccumulator {
|
||||
return this.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the empty client projection for one streamed Assistant block kind.
|
||||
* @param blockType - wire block kind.
|
||||
* @returns empty projected block ready to receive deltas.
|
||||
*/
|
||||
export function emptyAssistantBlock(blockType: string): AssistantBlock {
|
||||
switch (blockType) {
|
||||
case 'text': return { kind: 'text', text: '' }
|
||||
case 'reasoning': return { kind: 'reasoning', text: '' }
|
||||
case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
|
||||
default: return { kind: 'other', block: null }
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,12 +1,13 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ConversationLocation, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { RetryChatData } from '../contract/chat-nodes.ts'
|
||||
import type { ModelRetryNode } from '../contract/snapshot.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Producer-correlated model retry chain. */
|
||||
'model-retry': RetryChatData
|
||||
@@ -93,5 +94,5 @@ export const retryDefinition: ConversationNodeDefinition<RetryState> = {
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerRetryConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(retryDefinition)
|
||||
ctx.uiConversation.events.register(retryDefinition)
|
||||
}
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type { ToolChatData } from '../contract/chat-nodes.ts'
|
||||
import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Root Tool lifecycle with recursively nested subcalls. */
|
||||
'tool-call': ToolChatData
|
||||
@@ -273,5 +273,5 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerToolConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(toolDefinition)
|
||||
ctx.uiConversation.events.register(toolDefinition)
|
||||
}
|
||||
+6
-5
@@ -1,11 +1,12 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { TurnErrorNode } from '../contract/snapshot.ts'
|
||||
import { displayFailureMessage } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Terminal turn failure recorded on the turn's end reason. */
|
||||
'turn-error': TurnErrorNode
|
||||
@@ -92,5 +93,5 @@ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerTurnErrorConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(turnErrorDefinition)
|
||||
ctx.uiConversation.events.register(turnErrorDefinition)
|
||||
}
|
||||
+5
-4
@@ -1,10 +1,11 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { TurnMaxTokensNode } from '../contract/snapshot.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Turn ended by the per-request output-token cap. */
|
||||
'turn-max-tokens': TurnMaxTokensNode
|
||||
@@ -78,5 +79,5 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensSt
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerTurnMaxTokensConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(turnMaxTokensDefinition)
|
||||
ctx.uiConversation.events.register(turnMaxTokensDefinition)
|
||||
}
|
||||
+7
-6
@@ -1,23 +1,24 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
|
||||
} from '../contract/chat-nodes.ts'
|
||||
import { deriveTurnMetrics } from '../chat/turn-metrics.ts'
|
||||
import { toAssistantBlocks } from '../contract/snapshot.ts'
|
||||
import { deriveTurnMetrics } from '../contract/turn-metrics.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Completed-turn actions and extension tail. */
|
||||
'turn-tail': TurnTailChatData
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-runtime/client' {
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationTurnDataMap {
|
||||
/** Closing Assistant and footer facts derived for this completed Turn. */
|
||||
'turn-tail': TurnTailChatData
|
||||
@@ -192,5 +193,5 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerTurnTailConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(turnTailDefinition)
|
||||
ctx.uiConversation.events.register(turnTailDefinition)
|
||||
}
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
import { Fragment } from 'react'
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-store'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { findToolCall } from '../chat/tool-node-reader.ts'
|
||||
import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts'
|
||||
import { findToolCall } from './tool-node-reader.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
@@ -23,7 +23,7 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
function materialFor(s: ChatSnapshot, callId: string): CallMaterial | null {
|
||||
const found = findToolCall(s, callId)
|
||||
if (found === undefined) return null
|
||||
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
|
||||
@@ -45,7 +45,7 @@ function rawResultText(block: ToolCallBlock): string {
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
|
||||
export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
@@ -53,7 +53,7 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, ren
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
// stable members (result node reference rides the snapshot's structural sharing).
|
||||
const material = useSession(
|
||||
const material = useChat(
|
||||
s => (callId === undefined ? null : materialFor(s, callId)),
|
||||
(a, b) => shallowEqual(a, b))
|
||||
|
||||
+7
-9
@@ -1,10 +1,8 @@
|
||||
import type {
|
||||
ConversationSnapshot, ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts'
|
||||
|
||||
function toolNode(node: ReturnType<ConversationSnapshot['chat']['nodes']['get']>): ChatNode<'tool-call'> | undefined {
|
||||
function toolNode(node: ReturnType<ChatNodeStore['get']>): ChatNode<'tool-call'> | undefined {
|
||||
return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined
|
||||
}
|
||||
|
||||
@@ -15,10 +13,10 @@ function toolNode(node: ReturnType<ConversationSnapshot['chat']['nodes']['get']>
|
||||
* @returns root lifecycle when it is materialized in the current window.
|
||||
*/
|
||||
export function rootToolCall(
|
||||
snapshot: ConversationSnapshot,
|
||||
snapshot: ChatSnapshot,
|
||||
rootCallId: string,
|
||||
): ToolCallBlock | undefined {
|
||||
return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root
|
||||
return toolNode(snapshot.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +25,7 @@ export function rootToolCall(
|
||||
* @param callId - root or nested call identity.
|
||||
* @returns current Tool lifecycle when materialized in the loaded window.
|
||||
*/
|
||||
export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined {
|
||||
export function findToolCall(snapshot: ChatSnapshot, callId: string): ToolCallBlock | undefined {
|
||||
const visit = (block: ToolCallBlock): ToolCallBlock | undefined => {
|
||||
if (block.callId === callId) return block
|
||||
for (const child of block.subCalls) {
|
||||
@@ -36,7 +34,7 @@ export function findToolCall(snapshot: ConversationSnapshot, callId: string): To
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
for (const node of snapshot.chat.nodes.values()) {
|
||||
for (const node of snapshot.nodes.values()) {
|
||||
const root = toolNode(node)?.data.root
|
||||
if (root === undefined) continue
|
||||
const found = visit(root)
|
||||
@@ -0,0 +1,107 @@
|
||||
/** Session-scoped historical image URL cache owned by the Chat plugin. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { bytesToBase64 } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
interface ImageUrlEntry {
|
||||
readonly sessionId: SessionId
|
||||
readonly generation: number
|
||||
readonly pending: Promise<string>
|
||||
}
|
||||
|
||||
/** Resolve durable Chat images and release their browser URLs with Session scope. */
|
||||
export class HistoricalImageCache {
|
||||
private readonly sessions: ISessions
|
||||
private readonly entries = new Map<string, ImageUrlEntry>()
|
||||
private readonly generations = new Map<SessionId, number>()
|
||||
private readonly scopeDisposers = new Map<SessionId, () => void>()
|
||||
private readonly urls = new Set<string>()
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param ctx - Owning ui-chat fiber.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
this.sessions = ctx.sessions
|
||||
ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and cache one session-authorized image URL.
|
||||
* @param sessionId - Session authorization and lifetime scope.
|
||||
* @param attachment - Durable image reference.
|
||||
* @returns browser URL valid until the Session binding is released.
|
||||
*/
|
||||
resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
|
||||
if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed'))
|
||||
const key = `${sessionId}:${attachment.attachmentId}`
|
||||
const cached = this.entries.get(key)
|
||||
if (cached !== undefined) return cached.pending
|
||||
const binding = this.sessions.binding(sessionId)
|
||||
if (binding === undefined) {
|
||||
return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`))
|
||||
}
|
||||
this.bindScope(sessionId, binding.ctx)
|
||||
const generation = this.generations.get(sessionId) ?? 0
|
||||
const pending = binding.session.readAttachment(attachment.attachmentId)
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed')
|
||||
if ((this.generations.get(sessionId) ?? 0) !== generation) {
|
||||
throw new Error('ui-chat image scope was released before loading completed')
|
||||
}
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
|
||||
}
|
||||
const bytes = Uint8Array.from(result.value.data)
|
||||
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
|
||||
this.urls.add(url)
|
||||
return url
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.entries.get(key)?.generation === generation) this.entries.delete(key)
|
||||
throw error
|
||||
})
|
||||
this.entries.set(key, { sessionId, generation, pending })
|
||||
return pending
|
||||
}
|
||||
|
||||
private bindScope(sessionId: SessionId, scope: Context): void {
|
||||
if (this.scopeDisposers.has(sessionId)) return
|
||||
const dispose = scope.effect(() => () => {
|
||||
this.scopeDisposers.delete(sessionId)
|
||||
this.release(sessionId)
|
||||
}, 'ui-chat historical image scope')
|
||||
this.scopeDisposers.set(sessionId, () => { void dispose() })
|
||||
}
|
||||
|
||||
private release(sessionId: SessionId): void {
|
||||
this.generations.set(sessionId, (this.generations.get(sessionId) ?? 0) + 1)
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.sessionId !== sessionId) continue
|
||||
this.entries.delete(key)
|
||||
void entry.pending.then((url) => {
|
||||
if (!this.urls.delete(url)) return
|
||||
revokeUrl(url)
|
||||
}, () => {
|
||||
// Failed and invalidated loads create no browser URL.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
for (const dispose of [...this.scopeDisposers.values()]) dispose()
|
||||
this.scopeDisposers.clear()
|
||||
for (const url of this.urls) revokeUrl(url)
|
||||
this.urls.clear()
|
||||
this.entries.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function revokeUrl(url: string): void {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Browser Chat target plugin. */
|
||||
export { apply, inject } from './apply.ts'
|
||||
export type {} from './conversation-nodes/assistant.ts'
|
||||
export type {} from './conversation-nodes/command.ts'
|
||||
export type {} from './conversation-nodes/compaction.ts'
|
||||
export type {} from './conversation-nodes/fallback.ts'
|
||||
export type {} from './conversation-nodes/message.ts'
|
||||
export type {} from './conversation-nodes/retry.ts'
|
||||
export type {} from './conversation-nodes/tool.ts'
|
||||
export type {} from './conversation-nodes/turn-error.ts'
|
||||
export type {} from './conversation-nodes/turn-max-tokens.ts'
|
||||
export type {} from './conversation-nodes/turn-tail.ts'
|
||||
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, CommandNode,
|
||||
CompactionSummaryNode, ContextMessageNode, ConversationNode, LegacyConversationSlice,
|
||||
ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, ToolCallBlock,
|
||||
ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './contract/snapshot.ts'
|
||||
export type {
|
||||
AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind,
|
||||
FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData,
|
||||
TurnTailChatData,
|
||||
} from './contract/chat-nodes.ts'
|
||||
export type { CallId, ChatStoreState, SelectionTarget } from './contract/store.ts'
|
||||
export type {
|
||||
AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
|
||||
ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
|
||||
CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps,
|
||||
DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages,
|
||||
TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
|
||||
} from './contract/slots.ts'
|
||||
export type { ChatKey } from './locale.ts'
|
||||
export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts'
|
||||
export type {
|
||||
ContextProvenanceView, ContextRole, KnownContextForm,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts'
|
||||
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts'
|
||||
export {
|
||||
contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/** Public merge surface for Chat renderer payloads contributed by other plugins. */
|
||||
export interface ChatNodeDataMap {}
|
||||
|
||||
type PublicChatNodeDataMap = ChatNodeDataMap
|
||||
|
||||
declare module './contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap extends PublicChatNodeDataMap {}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/** Chat-owned locale namespace and dictionaries. */
|
||||
|
||||
/** Namespace for Chat target, node, statistics, and details copy. */
|
||||
export const NS = 'chat'
|
||||
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'view.chat': '对话',
|
||||
'stats.counts': '{turns} 轮 · {steps} 步',
|
||||
'stats.llm': 'LLM {duration}',
|
||||
'stats.toolCall': '工具调用 {duration}',
|
||||
'stats.ttftAverage': '首 token 平均 {duration}',
|
||||
'stats.tokensPerSecond': '{throughput} tok/s',
|
||||
'stats.cacheHit': '缓存命中 {percent}%',
|
||||
'stats.tokens': '输入 {input} tok · 输出 {output} tok',
|
||||
'details.title': '详情',
|
||||
'details.close': '关闭详情',
|
||||
'details.empty': '点击消息流中的工具行查看详情',
|
||||
'details.notInWindow': '该调用不在当前窗口内',
|
||||
'details.input': '输入',
|
||||
'details.output': '输出',
|
||||
'details.running': '运行中…',
|
||||
'chat.loadingHistory': '载入历史…',
|
||||
'chat.loadError': '历史加载失败:{message}({code})',
|
||||
'chat.loadOlder': '加载更早',
|
||||
'chat.toBottom': '回到底部',
|
||||
'fileOpen.title': '无法打开文件',
|
||||
'fileOpen.unknown': '无法打开此文件',
|
||||
'fileOpen.folderTitle': '无法打开文件夹',
|
||||
'fileOpen.folderUnknown': '无法打开此文件夹',
|
||||
'message.extraBlock': '附加内容块',
|
||||
'message.contextInjection': '上下文注入',
|
||||
'message.contextRecall': '跨会话召回',
|
||||
'message.referenceSummary': '引用会话 · {labels}',
|
||||
'message.referenceSeparator': '、',
|
||||
'message.context.instructions.loaded': '已载入',
|
||||
'message.context.instructions.added': '已新增',
|
||||
'message.context.instructions.updated': '已更新',
|
||||
'message.context.instructions.removed': '已移除',
|
||||
'message.context.catalog.replaced': '替换目录',
|
||||
'message.context.catalog.more': '…还有 {count} 条',
|
||||
'message.context.snapshot.supersedes': '取代先前的快照',
|
||||
'message.context.relay.from': '来自会话 {session}',
|
||||
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
|
||||
'message.context.recall.truncated': '已截断',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.running': '正在压缩…',
|
||||
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
'message.compaction.unavailable': '压缩摘要不可用',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
'message.unknownBlock': '未知内容块',
|
||||
'message.stopped': '已停止',
|
||||
'message.branch': '在新对话中分支',
|
||||
'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
|
||||
'message.retry.active': '正在重试模型请求',
|
||||
'message.retry.cancelled': '模型请求重试已取消',
|
||||
'message.retry.started': '已重试模型请求',
|
||||
'message.retry.scheduled': '等待重试模型请求',
|
||||
'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': '重试延迟:',
|
||||
'message.retry.failure': '失败原因:',
|
||||
'message.turnError': '本轮运行失败',
|
||||
'message.maxTokens': '已达到输出 token 上限',
|
||||
'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
|
||||
'message.ranFor': '用时 {duration}',
|
||||
'message.ttft': '首 token {seconds}秒',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'command.running': '执行中…',
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
'command.title': '命令',
|
||||
'row.running': '运行中',
|
||||
'row.failed': '失败',
|
||||
'json.truncated': '… 已截断,共 {total} 字符',
|
||||
'clock.md': '{m}月{d}日',
|
||||
'clock.ymd': '{y}年{m}月{d}日',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Chat dictionary key union. */
|
||||
export type ChatKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'view.chat': 'Chat',
|
||||
'stats.counts': '{turns} turns · {steps} steps',
|
||||
'stats.llm': 'LLM {duration}',
|
||||
'stats.toolCall': 'Tool call {duration}',
|
||||
'stats.ttftAverage': 'TTFT avg {duration}',
|
||||
'stats.tokensPerSecond': '{throughput} tok/s',
|
||||
'stats.cacheHit': 'Cache hit {percent}%',
|
||||
'stats.tokens': 'Input {input} tok · Output {output} tok',
|
||||
'details.title': 'Details',
|
||||
'details.close': 'Close details',
|
||||
'details.empty': 'Click a tool row in the message flow to view its details',
|
||||
'details.notInWindow': 'This call is outside the current window',
|
||||
'details.input': 'Input',
|
||||
'details.output': 'Output',
|
||||
'details.running': 'Running…',
|
||||
'chat.loadingHistory': 'Loading history…',
|
||||
'chat.loadError': 'Failed to load history: {message} ({code})',
|
||||
'chat.loadOlder': 'Load earlier',
|
||||
'chat.toBottom': 'Back to bottom',
|
||||
'fileOpen.title': 'Couldn’t open file',
|
||||
'fileOpen.unknown': 'Couldn’t open this file',
|
||||
'fileOpen.folderTitle': 'Couldn’t open folder',
|
||||
'fileOpen.folderUnknown': 'Couldn’t open this folder',
|
||||
'message.extraBlock': 'Extra content block',
|
||||
'message.contextInjection': 'Context injection',
|
||||
'message.contextRecall': 'Session recall',
|
||||
'message.referenceSummary': 'Referenced session · {labels}',
|
||||
'message.referenceSeparator': ', ',
|
||||
'message.context.instructions.loaded': 'loaded',
|
||||
'message.context.instructions.added': 'added',
|
||||
'message.context.instructions.updated': 'updated',
|
||||
'message.context.instructions.removed': 'removed',
|
||||
'message.context.catalog.replaced': 'Replacement catalog',
|
||||
'message.context.catalog.more': '… {count} more',
|
||||
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
|
||||
'message.context.relay.from': 'From session {session}',
|
||||
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
|
||||
'message.context.recall.truncated': 'truncated',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.running': 'Compacting context…',
|
||||
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
'message.unknownBlock': 'Unknown content block',
|
||||
'message.stopped': 'Stopped',
|
||||
'message.branch': 'Branch into a new conversation',
|
||||
'message.branchUnavailable': 'Available only on the last message of a completed turn',
|
||||
'message.retry.active': 'Retrying model request',
|
||||
'message.retry.cancelled': 'Model request retry cancelled',
|
||||
'message.retry.started': 'Retried model request',
|
||||
'message.retry.scheduled': 'Waiting to retry model request',
|
||||
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': 'Retry delay: ',
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'message.turnError': 'This turn failed',
|
||||
'message.maxTokens': 'Output token limit reached',
|
||||
'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
|
||||
'message.ranFor': 'Ran for {duration}',
|
||||
'message.ttft': 'TTFT {seconds}s',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'command.running': 'Running…',
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
'command.title': 'Command',
|
||||
'row.running': 'Running',
|
||||
'row.failed': 'Failed',
|
||||
'json.truncated': '… truncated, {total} characters total',
|
||||
'clock.md': '{m}/{d}',
|
||||
'clock.ymd': '{y}-{m}-{d}',
|
||||
} satisfies Record<ChatKey, string>
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { ConversationPromptSnapshot } from './request-inspection.ts'
|
||||
import type { ConversationNode } from '../contract/snapshot.ts'
|
||||
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/** Operation that started a new append-only model context. */
|
||||
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
|
||||
+1
-1
@@ -2,7 +2,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type {
|
||||
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from './conversation.ts'
|
||||
} from '../contract/snapshot.ts'
|
||||
|
||||
interface ProjectedBlock {
|
||||
source: ToolCallBlock
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Per-Session Chat selection store shared by the transcript and details panel. */
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/store.ts'
|
||||
|
||||
type ChatActions = {
|
||||
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Chat selection store handle.
|
||||
* @returns a handle instantiated once per rendered Session scope.
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
init: (): ChatStoreState => ({ selection: null }),
|
||||
actions: {
|
||||
select: (draft, target: SelectionTarget | null) => { draft.selection = target },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Host loader entry for the browser-only Chat UI target. */
|
||||
|
||||
/** Provides no Host-side behavior. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Package-owned invariant companion for the Chat UI target. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-chat'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-chat-invariant'
|
||||
/** Service required before the companion reserves package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: Conversation and Slot registration enforce Chat target consistency. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import {
|
||||
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
apply as applyConversation, inject as injectConversation,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import {
|
||||
apply as applyChat, inject as injectChat, type ChatViewInjected, type DetailsInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const ATTACHMENT = {
|
||||
attachmentId: AttachmentId('image-1'),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
} as const
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
type ChatActions = ChatInstance['actions']
|
||||
|
||||
function sessionFakeFor() {
|
||||
return {
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
|
||||
readAttachment: vi.fn<ISession['readAttachment']>(() => Promise.resolve({
|
||||
ok: true,
|
||||
value: { attachment: ATTACHMENT, data: Uint8Array.of(1) },
|
||||
})),
|
||||
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
} satisfies SessionBehaviorOverrides
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.ctx.provide('layout', layout as never)
|
||||
const openPath = vi.fn<(path: string) => Promise<void>>(async () => {})
|
||||
runtime.ctx.provide('uiWorkspace', {
|
||||
connectWorkspace: vi.fn(async () => ROOT),
|
||||
openPath,
|
||||
} as never)
|
||||
const session = sessionFakeFor()
|
||||
await runtime.sessions.add({
|
||||
id: ROOT,
|
||||
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
|
||||
session,
|
||||
}, { current: false })
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_props: { renderSlot?: unknown }) => null)
|
||||
await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
|
||||
await runtime.mount({ inject: [...injectChat], apply: applyChat })
|
||||
runtime.renderRoot()
|
||||
|
||||
const chatViewApi = (id: SessionId) => {
|
||||
const entry = runtime.slots.entries('conversation.view')[0]!
|
||||
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (
|
||||
sessionId: SessionId,
|
||||
actions: ChatActions,
|
||||
) => ChatViewInjected)(id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
return { runtime, layout, openPath, session, chatViewApi }
|
||||
}
|
||||
|
||||
describe('Chat inject API', () => {
|
||||
it('loads older history and forks through the Session Controller', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewApi(ROOT)
|
||||
injected.loadOlder()
|
||||
expect(b.session.loadOlder).toHaveBeenCalledOnce()
|
||||
|
||||
injected.forkAt(17)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
})
|
||||
expect(b.runtime.sessions.calls).toContainEqual({
|
||||
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
|
||||
})
|
||||
|
||||
const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed'))
|
||||
injected.forkAt(18)
|
||||
await vi.waitFor(() => {
|
||||
expect(fork).toHaveBeenCalledWith({ sessionId: ROOT, atSeq: 18, increaseTitle: true })
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('writes Chat selection before opening details', async () => {
|
||||
const b = await bench()
|
||||
const { instance, injected } = b.chatViewApi(ROOT)
|
||||
injected.openDetails({ turnSeq: 2, callId: 'c1' })
|
||||
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
|
||||
expect(b.layout.openDetails).toHaveBeenCalledOnce()
|
||||
expect(b.runtime.storeOf('details', ROOT)).toBe(instance)
|
||||
expect(b.runtime.storeOf('conversation.session', ROOT)).not.toBe(instance)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('resolves file paths against the Session cwd and preserves failures', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewApi(ROOT)
|
||||
await injected.openFile('src/a.ts')
|
||||
expect(b.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
|
||||
|
||||
b.openPath.mockRejectedValueOnce(new Error('xdg-open is not available'))
|
||||
await expect(injected.openFile('src/b.ts')).rejects.toThrow('xdg-open is not available')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loud when a Chat View inject resolves no Session', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.runtime.slots.entries('conversation.view')[0]!
|
||||
const injectView = entry.inject as unknown as (
|
||||
sessionId: SessionId,
|
||||
actions: ChatActions,
|
||||
) => ChatViewInjected
|
||||
expect(() => injectView('never-listed' as SessionId, {} as ChatActions))
|
||||
.toThrow(/unknown session/)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('closes details while sharing selection through the Chat store', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.runtime.slots.entries('details')[0]!
|
||||
const injected = (entry.inject as unknown as () => DetailsInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['closeDetails'])
|
||||
injected.closeDetails()
|
||||
expect(b.layout.closeDetails).toHaveBeenCalledOnce()
|
||||
expect(b.runtime.storeOf('details', ROOT)).toBe(b.runtime.storeOf('conversation.view', ROOT))
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('owns image loading, scroll memory, and optional closing-file mentions', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewApi(ROOT)
|
||||
const owner = {} as never
|
||||
|
||||
expect(injected.fileMentions(owner)).toBeUndefined()
|
||||
const mentions = { resolve: vi.fn() } as never
|
||||
const forClosing = vi.fn(() => mentions)
|
||||
b.runtime.ctx.provide('chatFileMentions', { forClosing } as never)
|
||||
expect(injected.fileMentions(owner)).toBe(mentions)
|
||||
expect(forClosing).toHaveBeenCalledWith(owner)
|
||||
|
||||
expect(injected.chatScroll.read()).toBeNull()
|
||||
const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 }
|
||||
injected.chatScroll.save(position)
|
||||
expect(injected.chatScroll.read()).toEqual(position)
|
||||
injected.chatScroll.save(null)
|
||||
expect(injected.chatScroll.read()).toBeNull()
|
||||
|
||||
await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String))
|
||||
expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import type { ChatSnapshot, UseChat } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApprovalCommand, commandOf } from '../src/client/chat/ApprovalCommand.tsx'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import * as ChatInvariant from '../src/invariant.ts'
|
||||
|
||||
function props(
|
||||
nodes: readonly unknown[],
|
||||
callId = 'call-1',
|
||||
): PropsRuntime<'conversation.approval.detail'> {
|
||||
const snapshot = {
|
||||
nodes: { values: () => nodes },
|
||||
} as unknown as ChatSnapshot
|
||||
const useChat = ((selector: (value: ChatSnapshot) => unknown) => selector(snapshot)) as UseChat
|
||||
return { callId, useChat } as PropsRuntime<'conversation.approval.detail'>
|
||||
}
|
||||
|
||||
describe('commandOf', () => {
|
||||
it('accepts only a string command from valid JSON arguments', () => {
|
||||
expect(commandOf(undefined)).toBeUndefined()
|
||||
expect(commandOf({ callId: 'c1', argsRaw: '{' })).toBeUndefined()
|
||||
expect(commandOf({ callId: 'c1', argsRaw: '{}' })).toBeUndefined()
|
||||
expect(commandOf({ callId: 'c1', argsRaw: '{"command":42}' })).toBeUndefined()
|
||||
expect(commandOf({ callId: 'c1', argsRaw: '{"command":"pnpm test"}' })).toBe('pnpm test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApprovalCommand', () => {
|
||||
it('renders the running correlated Tool command', () => {
|
||||
render(<ApprovalCommand {...props([
|
||||
{ kind: 'assistant-step', data: {} },
|
||||
{ kind: 'tool-call', data: { root: { callId: 'other', argsRaw: '{"command":"wrong"}' } } },
|
||||
{ kind: 'tool-call', data: { root: { callId: 'call-1', argsRaw: '{"command":"pnpm test"}' } } },
|
||||
] as never)} />)
|
||||
|
||||
expect(screen.getByText('pnpm test')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits absent, uncorrelated, and settled Tool calls', () => {
|
||||
const { container, rerender } = render(<ApprovalCommand {...props([
|
||||
{ kind: 'assistant-step', data: {} },
|
||||
{ kind: 'tool-call', data: { root: undefined } },
|
||||
{ kind: 'tool-call', data: { root: { callId: 'other', argsRaw: '{}' } } },
|
||||
{
|
||||
kind: 'tool-call',
|
||||
data: { root: { kind: 'tool-result', callId: 'call-1', argsRaw: '{"command":"ignored"}' } },
|
||||
},
|
||||
] as never)} />)
|
||||
expect(container.textContent).toBe('')
|
||||
|
||||
rerender(<ApprovalCommand {...props([
|
||||
{ kind: 'tool-call', data: { root: { callId: 'call-1', argsRaw: '{}' } } },
|
||||
] as never)} />)
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-chat package entries', () => {
|
||||
it('keeps the Host half inert and registers the invariant companion', async () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
chatSnapshot, SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
apply as applyConversation, inject as injectConversation,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import {
|
||||
apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {
|
||||
ChatNodeTurnDataInjected, ChatSnapshot, UseChat,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationTurnDataMap {
|
||||
metric: number
|
||||
}
|
||||
}
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const SID = 'session-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never)
|
||||
runtime.ctx.provide('uiWorkspace', {
|
||||
connectWorkspace: vi.fn(async () => SID),
|
||||
openPath: vi.fn(async () => {}),
|
||||
} as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.approval.detail': { kind: 'single', scope: 'session' },
|
||||
'settings.general.item': { kind: 'list', scope: 'root' },
|
||||
}, (_props: { renderSlot?: unknown }) => null)
|
||||
const conversation = await runtime.mount({
|
||||
inject: [...injectConversation],
|
||||
apply: applyConversation,
|
||||
})
|
||||
const provide = vi.spyOn(runtime.ctx.uiSession, 'provide')
|
||||
const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat })
|
||||
const sourceDescriptor = provide.mock.calls[0]?.[0]
|
||||
if (sourceDescriptor === undefined) throw new Error('ui-chat did not provide its standard source')
|
||||
return { runtime, conversation, chat, sourceDescriptor }
|
||||
}
|
||||
|
||||
function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
|
||||
return (runtime.slots.entries(key)[0] as { store?: unknown } | undefined)?.store
|
||||
}
|
||||
|
||||
describe('Chat apply wiring', () => {
|
||||
it('contributes Chat View, node renderers, stats, and details', async () => {
|
||||
const b = await bench()
|
||||
const views = b.runtime.slots.entries('conversation.view')
|
||||
expect(views.map(row => row.options.id)).toEqual(['chat'])
|
||||
expect(resolveSlotLabel(views[0]?.options.label)).toBe('对话')
|
||||
expect(b.runtime.slots.spec('conversation.chat.node'))
|
||||
.toMatchObject({ kind: 'keyed', scope: 'session' })
|
||||
expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id))
|
||||
.toEqual(['stats'])
|
||||
expect(b.runtime.slots.entries('details')).toHaveLength(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('shares one Chat store while keeping it distinct from Conversation state', async () => {
|
||||
const b = await bench()
|
||||
const conversationStore = storeOf(b.runtime, 'conversation.session')
|
||||
const chatStore = storeOf(b.runtime, 'conversation.view')
|
||||
expect(storeOf(b.runtime, 'conversation.session.header')).toBe(conversationStore)
|
||||
expect(storeOf(b.runtime, 'details')).toBe(chatStore)
|
||||
expect(chatStore).toBeDefined()
|
||||
expect(chatStore).not.toBe(conversationStore)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('removes only Chat contributions when Chat unloads', async () => {
|
||||
const b = await bench()
|
||||
await b.chat.dispose()
|
||||
expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0)
|
||||
expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined()
|
||||
expect(b.runtime.slots.entries('conversation')).toHaveLength(1)
|
||||
expect(b.runtime.ctx.get('uiConversation')).toBeDefined()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps the Chat standard source total while its target enters and leaves', async () => {
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.add({ id: SID }, { current: false })
|
||||
const binding = b.runtime.sessions.binding(SID)
|
||||
if (binding === undefined) throw new Error('Chat source test Session binding is unavailable')
|
||||
const resolveSource = (owner: SessionBinding): ObservableSnapshot<ChatSnapshot> => {
|
||||
const contribution = b.sourceDescriptor.resolve(owner) as {
|
||||
hooks: { chat: ObservableSnapshot<ChatSnapshot> }
|
||||
}
|
||||
return contribution.hooks.chat
|
||||
}
|
||||
const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.chat as
|
||||
ObservableSnapshot<ChatSnapshot>
|
||||
expect(resolveSource(binding)).toBe(source)
|
||||
expect(resolveSource(binding)).toBe(source)
|
||||
const listener = vi.fn()
|
||||
const off = source.subscribe(listener)
|
||||
|
||||
expect(source.getSnapshot()).toBeDefined()
|
||||
await b.chat.dispose()
|
||||
expect(source.getSnapshot()).toBe(EMPTY_CHAT_SNAPSHOT)
|
||||
|
||||
off()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('binds Turn data through the Chat selector hook for Turn and Step locations', async () => {
|
||||
const b = await bench()
|
||||
const spec = b.runtime.slots.spec('conversation.chat.node') as unknown as {
|
||||
inject: ChatNodeTurnDataInjected
|
||||
}
|
||||
let snapshot: ChatSnapshot = chatSnapshot()
|
||||
const useChat = ((selector: (value: ChatSnapshot) => unknown) => selector(snapshot)) as UseChat
|
||||
const useTurnData = spec.inject.hooks.turnData(
|
||||
{ useChat } as Parameters<typeof spec.inject.hooks.turnData>[0],
|
||||
'node-1',
|
||||
)
|
||||
const data = { get: (key: string) => key === 'metric' ? 42 : undefined }
|
||||
const turn = { data }
|
||||
|
||||
snapshot = chatSnapshot({
|
||||
nodes: { get: () => ({ location: { kind: 'turn', turn } }), values: () => [] } as never,
|
||||
})
|
||||
expect(useTurnData('metric')).toBe(42)
|
||||
snapshot = chatSnapshot({
|
||||
nodes: { get: () => ({ location: { kind: 'step', turn } }), values: () => [] } as never,
|
||||
})
|
||||
expect(useTurnData('metric')).toBe(42)
|
||||
snapshot = chatSnapshot({
|
||||
nodes: { get: () => ({ location: { kind: 'session' } }), values: () => [] } as never,
|
||||
})
|
||||
expect(useTurnData('metric')).toBeUndefined()
|
||||
snapshot = chatSnapshot({
|
||||
nodes: { get: () => undefined, values: () => [] },
|
||||
})
|
||||
expect(useTurnData('metric')).toBeUndefined()
|
||||
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
+5
-5
@@ -7,7 +7,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ChatNodeViewProps } from '../src/client/contract/slots.ts'
|
||||
import {
|
||||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
UserMessageNodeView,
|
||||
} from '../src/client/chat/MessageItem.tsx'
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
@@ -1022,12 +1022,12 @@ describe('small branch tails', () => {
|
||||
const nodes = [{
|
||||
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 },
|
||||
}] as const
|
||||
const snap = { chat: chatSnapshotFixture({ nodes }), nodes }
|
||||
const snap = chatSnapshotFixture({ nodes })
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
t={t}
|
||||
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
|
||||
useChat={bindSnapshotSelector(source)}
|
||||
useProjection={(key: string) => key === 'tokenUsage'
|
||||
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
: undefined}
|
||||
+7
-5
@@ -1,10 +1,12 @@
|
||||
import type {
|
||||
AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
|
||||
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, ConversationLocationDataStore,
|
||||
ConversationTurnDataMap, LegacyConversationSlice, PartialAssistant, RunningToolCall,
|
||||
ToolCallBlock, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
|
||||
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice,
|
||||
PartialAssistant, RunningToolCall, ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {
|
||||
ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
|
||||
|
||||
const EMPTY: readonly never[] = []
|
||||
|
||||
+10
-41
@@ -3,15 +3,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { EMPTY_CONVERSATION_VIEWS } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
AssistantMessageNode, ChatSnapshot, LegacyConversationSlice, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locale.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
|
||||
@@ -32,48 +31,19 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
})
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
type ChatUpdate = Partial<LegacyConversationSlice>
|
||||
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
const initial = { ...snapshotBase(), ...init }
|
||||
let snap: ConversationSnapshot = {
|
||||
...initial,
|
||||
chat: init?.chat ?? chatSnapshotFixture({
|
||||
nodes: initial.nodes,
|
||||
partial: initial.partial,
|
||||
runningCalls: initial.runningCalls,
|
||||
turnTimings: initial.turnTimings,
|
||||
turnEnds: initial.turnEnds,
|
||||
}),
|
||||
}
|
||||
function makeSource(init: ChatUpdate = {}) {
|
||||
let snap = chatSnapshotFixture(init)
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
const merged = { ...snap, ...next }
|
||||
snap = {
|
||||
...merged,
|
||||
chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({
|
||||
nodes: merged.nodes,
|
||||
partial: merged.partial,
|
||||
runningCalls: merged.runningCalls,
|
||||
turnTimings: merged.turnTimings,
|
||||
turnEnds: merged.turnEnds,
|
||||
})),
|
||||
}
|
||||
set: (next: ChatUpdate) => {
|
||||
snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap)
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
@@ -182,10 +152,10 @@ describe('StatsLine', () => {
|
||||
}
|
||||
|
||||
function props(
|
||||
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
|
||||
source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void },
|
||||
values: Record<string, unknown> = { tokenUsage: USAGE },
|
||||
): StatsLineProps {
|
||||
return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
|
||||
return { useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
|
||||
}
|
||||
|
||||
function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) {
|
||||
@@ -413,7 +383,6 @@ describe('StatsLine', () => {
|
||||
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) })
|
||||
act(() => { set({ running: true }) })
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
describe('createChatStore', () => {
|
||||
it('starts without a selected Chat target', () => {
|
||||
const store = createChatStore().create()
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null })
|
||||
})
|
||||
|
||||
it('selects and clears one Chat details target', () => {
|
||||
const store = createChatStore().create()
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
|
||||
expect(store.store.getSnapshot().selection)
|
||||
.toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
|
||||
store.actions.select(null)
|
||||
expect(store.store.getSnapshot().selection).toBeNull()
|
||||
})
|
||||
|
||||
it('creates independent instances', () => {
|
||||
const handle = createChatStore()
|
||||
const first = handle.create()
|
||||
const second = handle.create()
|
||||
first.actions.select({ turnSeq: 1 })
|
||||
expect(second.store.getSnapshot().selection).toBeNull()
|
||||
})
|
||||
})
|
||||
+154
-93
@@ -4,22 +4,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
|
||||
TurnMaxTokensNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
createSnapshotStore, EMPTY_CONVERSATION_VIEWS,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot,
|
||||
ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode,
|
||||
LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget,
|
||||
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
|
||||
UseChatNodeTurnData, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {
|
||||
ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
SessionListState, SessionSnapshot,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
|
||||
import {
|
||||
@@ -43,32 +47,54 @@ beforeEach(() => {
|
||||
const SID = 's1' as SessionId
|
||||
type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||||
return {
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [],
|
||||
turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
sessionId: SID,
|
||||
queue: [],
|
||||
running: false,
|
||||
removed: false,
|
||||
openState: 'open',
|
||||
openError: null,
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
promptError: null,
|
||||
blank: false,
|
||||
subagent: null,
|
||||
lastAgentError: null,
|
||||
promptAttempted: true,
|
||||
awaitingFirstTurn: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Scripted snapshot source: set() swaps the top-level object like the real Session. */
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
const initial = { ...snapshotBase(), ...init }
|
||||
let snap: ConversationSnapshot = {
|
||||
...initial,
|
||||
chat: init?.chat ?? chatSnapshotFixture(initial),
|
||||
}
|
||||
/** Scripted Session source: set() swaps the top-level object like the real Controller binding. */
|
||||
function makeSessionSource(init: Partial<SessionSnapshot> = {}) {
|
||||
let snap = sessionSnapshot(init)
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
const merged = { ...snap, ...next }
|
||||
snap = {
|
||||
...merged,
|
||||
chat: Object.hasOwn(next, 'chat') && next.chat !== undefined
|
||||
? next.chat
|
||||
: chatSnapshotFixture(merged, snap.chat),
|
||||
}
|
||||
set: (next: Partial<SessionSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: (fn: () => void) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ChatSlice = Partial<LegacyConversationSlice>
|
||||
|
||||
/** Scripted Chat target source, independent from Session lifecycle state. */
|
||||
function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
|
||||
let snap = snapshot ?? chatSnapshotFixture(init)
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set: (next: ChatSlice) => {
|
||||
snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap)
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
@@ -138,19 +164,23 @@ function emptySessions() {
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const { set, source } = makeSource(init)
|
||||
function makeHarness(
|
||||
chatSlice: ChatSlice = {},
|
||||
sessionInit: Partial<SessionSnapshot> = {},
|
||||
chatSnapshot?: ChatSnapshot,
|
||||
) {
|
||||
const session = makeSessionSource(sessionInit)
|
||||
const chatSource = makeChatSource(chatSlice, chatSnapshot)
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const loadOlder = vi.fn()
|
||||
const inspectCall = vi.fn<(callId: string) => void>()
|
||||
const openView = vi.fn<(view: string, focus: string) => void>()
|
||||
// In-memory scroll memory matching the apply.ts per-session map contract.
|
||||
let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
|
||||
const chatScroll: ChatViewSlotProps['chatScroll'] = {
|
||||
@@ -182,8 +212,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
if (key !== 'conversation.chat.node') return opts?.fallback ?? null
|
||||
const nodeOwner = owner as RoutedChatNodeOwner
|
||||
const nodeKey = opts?.hookContext as string | undefined
|
||||
const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => {
|
||||
const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location
|
||||
const useTurnData: UseChatNodeTurnData = dataKey => props.useChat((snapshot) => {
|
||||
const location = nodeKey === undefined ? undefined : snapshot.nodes.get(nodeKey)?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.data.get(dataKey)
|
||||
: undefined
|
||||
@@ -255,12 +285,18 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
}) as unknown as ChatViewSlotProps['renderSlot']
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
// ChatView never invokes it (render-prop pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
// ChatView never invokes it (pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
|
||||
const props: ChatViewSlotProps = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSession: bindSnapshotSelector(session.source),
|
||||
useChat: bindSnapshotSelector(chatSource.source),
|
||||
useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
|
||||
useTrajectory: (() => { throw new Error('unused') }),
|
||||
useSessions: emptySessions(),
|
||||
useSessionPendingInteraction: bindSnapshotSelector(
|
||||
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
|
||||
),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined),
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
@@ -275,11 +311,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
viewRequest: null,
|
||||
openView,
|
||||
completeViewRequest: () => {},
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
forkAt,
|
||||
// Absent-service default; mention tests override with a real resolver.
|
||||
@@ -288,7 +326,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return {
|
||||
set, ChatView, props, openDetails, openFile, loadOlder, inspectCall,
|
||||
setSession: session.set, setChat: chatSource.set, ChatView, props,
|
||||
openDetails, openFile, loadOlder, openView,
|
||||
chatScroll, forkAt, setSelection, toolOwners,
|
||||
}
|
||||
}
|
||||
@@ -380,7 +419,10 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
|
||||
const h = makeHarness(
|
||||
{ nodes: [user(9, 'first visible'), user(10, 'next visible')] },
|
||||
{ hasMore: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
|
||||
@@ -407,7 +449,9 @@ describe('ChatView', () => {
|
||||
readerScroll(scroller, 90)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
nextTop = 560
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
|
||||
act(() => {
|
||||
h.setChat({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] })
|
||||
})
|
||||
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
|
||||
})
|
||||
|
||||
@@ -460,7 +504,10 @@ describe('ChatView', () => {
|
||||
preview: 'later',
|
||||
text: 'later',
|
||||
}
|
||||
const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true })
|
||||
const h = makeHarness(
|
||||
{ nodes: [assistant(1, 'working')] },
|
||||
{ queue: [queued, pending], running: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull()
|
||||
@@ -474,8 +521,8 @@ describe('ChatView', () => {
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
queue: [queued],
|
||||
h.setSession({ queue: [queued] })
|
||||
h.setChat({
|
||||
nodes: [
|
||||
assistant(1, 'working'),
|
||||
{
|
||||
@@ -496,7 +543,8 @@ describe('ChatView', () => {
|
||||
expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
||||
h.setSession({ running: false })
|
||||
h.setChat({ turnEnds: new Map([[1, 3]]) })
|
||||
})
|
||||
// The Turn Tail belongs to the closed Turn, independently of a later
|
||||
// steering bubble's placement in the Chat list.
|
||||
@@ -517,13 +565,11 @@ describe('ChatView', () => {
|
||||
text: 'same steering',
|
||||
}
|
||||
const h = makeHarness({
|
||||
queue: [pending],
|
||||
nodes: [{
|
||||
kind: 'user', seq: 2, time: 2_000,
|
||||
content: pending.content, source: null,
|
||||
}],
|
||||
running: true,
|
||||
})
|
||||
}, { queue: [pending], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
expect(view.getAllByText('same steering')).toHaveLength(2)
|
||||
@@ -538,35 +584,36 @@ describe('ChatView', () => {
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as const satisfies ConversationNode
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode] }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const disclosure = view.container.querySelector('details') as HTMLDetailsElement
|
||||
expect(disclosure.dataset.active).toBe('true')
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), nextRetry] })
|
||||
h.setChat({ nodes: [user(1, 'try'), nextRetry] })
|
||||
})
|
||||
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
|
||||
expect(view.container.querySelector('details')).toBe(disclosure)
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
h.setChat({
|
||||
nodes: [
|
||||
user(1, 'try'),
|
||||
{ ...nextRetry, retryState: 'started' },
|
||||
context,
|
||||
assistant(5, 'done'),
|
||||
],
|
||||
running: false,
|
||||
})
|
||||
h.setSession({ running: false })
|
||||
})
|
||||
expect(disclosure.dataset.active).toBeUndefined()
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
|
||||
h.setChat({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }] })
|
||||
h.setSession({ running: true })
|
||||
})
|
||||
const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement
|
||||
expect(cancelledDisclosure.dataset.active).toBeUndefined()
|
||||
@@ -598,7 +645,8 @@ describe('ChatView', () => {
|
||||
nodes: [toolResult(3, 'a')],
|
||||
})
|
||||
render(<h.ChatView {...h.props} />)
|
||||
expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall)
|
||||
h.toolOwners[0]?.inspectCall('a')
|
||||
expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
|
||||
})
|
||||
|
||||
it('shows assistant IconActions only on the last content message of each turn', () => {
|
||||
@@ -623,7 +671,6 @@ describe('ChatView', () => {
|
||||
|
||||
it('withholds assistant IconActions while the turn is still running', () => {
|
||||
const h = makeHarness({
|
||||
running: true,
|
||||
runningCalls: [runningCall('a')],
|
||||
nodes: [
|
||||
user(1, 'first'),
|
||||
@@ -633,7 +680,7 @@ describe('ChatView', () => {
|
||||
],
|
||||
// Boundary seqs follow the log: a turn/end is strictly after its own nodes.
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
}, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// 2 user + the settled turn-1 tail, which keeps its seat while a later
|
||||
// turn runs; turn 2's narration stays chrome-free while its tool runs, so
|
||||
@@ -641,7 +688,10 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
|
||||
expect(view.getByText('mid-turn text')).toBeTruthy()
|
||||
// turn/end lands: the same node becomes the settled answer and takes the seat.
|
||||
act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) })
|
||||
act(() => {
|
||||
h.setSession({ running: false })
|
||||
h.setChat({ runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) })
|
||||
})
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
})
|
||||
|
||||
@@ -694,8 +744,7 @@ describe('ChatView', () => {
|
||||
nodes: [user(1, 'hi'), settled],
|
||||
turnTimings: new Map([[1, { startTime: 1_000 }]]),
|
||||
turnEnds: new Map(),
|
||||
running: true,
|
||||
})
|
||||
}, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
|
||||
})
|
||||
@@ -748,7 +797,7 @@ describe('ChatView', () => {
|
||||
getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
|
||||
},
|
||||
}
|
||||
const h = makeHarness({ chat })
|
||||
const h = makeHarness({}, {}, chat)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const branch = view.getByRole('button', { name: '在新对话中分支' })
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
@@ -785,13 +834,13 @@ describe('ChatView', () => {
|
||||
expect(literal.querySelector('h1')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
|
||||
h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
|
||||
})
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
h.setChat({
|
||||
nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
|
||||
partial: null,
|
||||
})
|
||||
@@ -800,7 +849,7 @@ describe('ChatView', () => {
|
||||
expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
h.setChat({
|
||||
nodes: [
|
||||
user(1, markdown),
|
||||
assistant(2, markdown),
|
||||
@@ -820,10 +869,10 @@ describe('ChatView', () => {
|
||||
const tool = view.getByTestId('tool-seat-a')
|
||||
const beforeHtml = tool.innerHTML
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
|
||||
h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
|
||||
})
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
|
||||
h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
|
||||
})
|
||||
expect(view.getByText('streaming… more')).toBeTruthy()
|
||||
expect(view.getByTestId('tool-seat-a')).toBe(tool)
|
||||
@@ -847,10 +896,10 @@ describe('ChatView', () => {
|
||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||
const afterMount = rowRenders
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
|
||||
h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
|
||||
})
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
|
||||
h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
|
||||
})
|
||||
expect(rowRenders).toBe(afterMount)
|
||||
})
|
||||
@@ -864,7 +913,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it('hands running calls to a live Tool group', () => {
|
||||
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
|
||||
const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
|
||||
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
|
||||
@@ -890,8 +939,7 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(4, 'later')],
|
||||
runningCalls: [runningCall('r1')],
|
||||
running: true,
|
||||
})
|
||||
}, { running: true })
|
||||
h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
|
||||
const routed = owner as RoutedChatNodeOwner
|
||||
return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
|
||||
@@ -905,11 +953,11 @@ describe('ChatView', () => {
|
||||
expect(mounted).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
h.setChat({
|
||||
nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')],
|
||||
runningCalls: [],
|
||||
running: false,
|
||||
})
|
||||
h.setSession({ running: false })
|
||||
})
|
||||
|
||||
expect(view.getByTestId('stateful-tool')).toBe(tool)
|
||||
@@ -922,16 +970,17 @@ describe('ChatView', () => {
|
||||
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
|
||||
const startTime = Date.now() - 125_000
|
||||
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
|
||||
const h = makeHarness({
|
||||
nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true,
|
||||
})
|
||||
const h = makeHarness(
|
||||
{ nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
|
||||
{ running: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Freshly mounted (as after a reload) yet already past the 15s gate.
|
||||
const status = view.getByRole('status')
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
|
||||
act(() => {
|
||||
h.set({ queue: [{
|
||||
h.setSession({ queue: [{
|
||||
id: 'steering-occurrence' as never,
|
||||
messageId: 'steering-message' as never,
|
||||
placement: 'steering',
|
||||
@@ -963,7 +1012,8 @@ describe('ChatView', () => {
|
||||
expect(owner.openFile).not.toBe(h.openFile)
|
||||
owner.openFile('src/a.ts')
|
||||
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
|
||||
expect(owner.inspectCall).toBe(h.inspectCall)
|
||||
owner.inspectCall('a')
|
||||
expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
|
||||
})
|
||||
|
||||
it('shows a Host open refusal with the reason and retries the same path', async () => {
|
||||
@@ -1068,7 +1118,10 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
|
||||
const h = makeHarness(
|
||||
{ nodes: [user(5, 'later'), assistant(6, 'a')] },
|
||||
{ hasMore: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
// jsdom has no layout: fake the metrics the anchor math reads.
|
||||
@@ -1084,15 +1137,21 @@ describe('ChatView', () => {
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
anchoredTop = 700
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
|
||||
act(() => {
|
||||
h.setChat({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] })
|
||||
})
|
||||
expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
|
||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
|
||||
act(() => {
|
||||
h.setChat({
|
||||
nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')],
|
||||
})
|
||||
})
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
it('back-to-bottom cancels an in-flight paging anchor', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
|
||||
const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
@@ -1101,7 +1160,7 @@ describe('ChatView', () => {
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
act(() => { h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(1_300)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
})
|
||||
@@ -1116,7 +1175,9 @@ describe('ChatView', () => {
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||
act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
|
||||
act(() => {
|
||||
h.setChat({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } })
|
||||
})
|
||||
expect(scroller.scrollTop).toBe(100)
|
||||
fireEvent.click(backButton)
|
||||
expect(scroller.scrollTop).toBe(1000)
|
||||
@@ -1142,7 +1203,7 @@ describe('ChatView', () => {
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
metrics.setHeight(1_200)
|
||||
act(() => { h.set({ running: true }) })
|
||||
act(() => { h.setSession({ running: true }) })
|
||||
expect(scroller.scrollTop).toBe(900)
|
||||
})
|
||||
|
||||
@@ -1318,22 +1379,22 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
||||
const h = makeHarness({ nodes: [user(5, 'later')] }, { hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||
act(() => { h.set({ loadingOlder: true }) })
|
||||
act(() => { h.setSession({ loadingOlder: true }) })
|
||||
expect(view.getByText('加载中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows open error and loading states', () => {
|
||||
const h = makeHarness({
|
||||
const h = makeHarness({}, {
|
||||
openState: 'error',
|
||||
openError: { code: 'internal', message: 'boom' } as never,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
|
||||
const loading = makeHarness({ openState: 'loading' })
|
||||
const loading = makeHarness({}, { openState: 'loading' })
|
||||
const lv = render(<loading.ChatView {...loading.props} />)
|
||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||
})
|
||||
@@ -1388,7 +1449,7 @@ describe('ChatView', () => {
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
h.setChat({
|
||||
nodes: [{
|
||||
...running,
|
||||
outcome: {
|
||||
+27
-5
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
|
||||
ConversationNodeDefinition, ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
ChatConversationViewNode, ChatSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import {
|
||||
ConversationNodeAssembler,
|
||||
type ConversationEventInput,
|
||||
type ConversationNodeDefinition,
|
||||
type ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
|
||||
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
|
||||
@@ -64,7 +68,6 @@ function at(
|
||||
data,
|
||||
...extra,
|
||||
} as unknown as ConversationEventInput['event'],
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +121,25 @@ function toolResult(callId: string, text: string) {
|
||||
}
|
||||
|
||||
describe('built-in conversation node Definitions', () => {
|
||||
it('keeps ordinary command-only history inactive for the Conversation shell', () => {
|
||||
const value = assembler([
|
||||
at(1, 'command/run', {
|
||||
commandId: 'command-1',
|
||||
name: 'help',
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
at(2, 'command/done', {
|
||||
commandId: 'command-1',
|
||||
kind: 'success',
|
||||
}),
|
||||
])
|
||||
const current = snapshot(value)
|
||||
|
||||
expect(current.order).toHaveLength(1)
|
||||
expect(current.nodes.get(current.order[0] ?? '')?.kind).toBe('command')
|
||||
expect(chatViewDefinition.isActive?.(current)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
|
||||
import { toAssistantBlock, toAssistantBlocks } from '../src/client/contract/snapshot.ts'
|
||||
|
||||
describe('toAssistantBlock', () => {
|
||||
it('classifies the four block shapes', () => {
|
||||
+1
-7
@@ -1,13 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
|
||||
@@ -15,10 +13,6 @@ const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () =>
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply tolerates a Host without settings', () => {
|
||||
expect(() => { nodeApply(new Context()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
+61
-39
@@ -3,20 +3,25 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type {
|
||||
SessionListState, SessionSnapshot,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { DetailsSlotProps, DetailsToolOwnerProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
DetailsSlotProps, DetailsToolOwnerProps, RunningToolCall, SelectionTarget,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { DetailsPanel } from '../src/client/details/DetailsPanel.tsx'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
@@ -38,7 +43,7 @@ afterEach(() => {
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Minimal framework seat for direct DetailsPanel host tests. */
|
||||
const SessionProviderStub: SessionProviderComponent = ({ children }) => children(SID)
|
||||
const SessionProviderStub: SessionProviderComponent = ({ children }) => children
|
||||
|
||||
/** Observe the owner currency without importing the Tool details renderer. */
|
||||
function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] {
|
||||
@@ -48,15 +53,31 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
function sessionSnapshot(): SessionSnapshot {
|
||||
return {
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
sessionId: SID,
|
||||
queue: [],
|
||||
running: false,
|
||||
removed: false,
|
||||
openState: 'open',
|
||||
openError: null,
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
promptError: null,
|
||||
blank: false,
|
||||
subagent: null,
|
||||
lastAgentError: null,
|
||||
promptAttempted: true,
|
||||
awaitingFirstTurn: false,
|
||||
}
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
}
|
||||
|
||||
describe('render branch tails', () => {
|
||||
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
|
||||
const view = render(
|
||||
@@ -81,18 +102,12 @@ describe('render branch tails', () => {
|
||||
{ kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
{ kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
] as const
|
||||
const snap = {
|
||||
...snapshotBase(),
|
||||
chat: chatSnapshotFixture({ nodes }),
|
||||
nodes: [
|
||||
...nodes,
|
||||
],
|
||||
}
|
||||
const snap = chatSnapshotFixture({ nodes })
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
t={t}
|
||||
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useChat={bindSnapshotSelector(source)}
|
||||
useProjection={() => undefined}
|
||||
/>,
|
||||
)
|
||||
@@ -113,23 +128,27 @@ describe('render branch tails', () => {
|
||||
|
||||
it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
|
||||
localStorage.clear()
|
||||
const snap = snapshotBase()
|
||||
const session = sessionSnapshot()
|
||||
const chatSnapshot = chatSnapshotFixture()
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const workspaces = emptyWorkspaces()
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
SessionProvider={SessionProviderStub}
|
||||
renderSlot={renderToolDetailsProbe()}
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSession={bindSnapshotSelector(createSnapshotStore(session))}
|
||||
useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))}
|
||||
useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))}
|
||||
useTrajectory={(() => { throw new Error('unused') })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useSessionPendingInteraction={bindSnapshotSelector(
|
||||
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
|
||||
)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{
|
||||
@@ -151,9 +170,9 @@ describe('render branch tails', () => {
|
||||
|
||||
it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
|
||||
localStorage.clear()
|
||||
const snap = snapshotBase()
|
||||
const session = sessionSnapshot()
|
||||
const longText = 'x'.repeat(1_000)
|
||||
snap.runningCalls = [{
|
||||
const runningCalls: readonly RunningToolCall[] = [{
|
||||
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
|
||||
time: 7_000, callView: null, subCalls: [{
|
||||
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
|
||||
@@ -169,24 +188,27 @@ describe('render branch tails', () => {
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls })
|
||||
const chatSnapshot = chatSnapshotFixture({ runningCalls })
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const workspaces = emptyWorkspaces()
|
||||
const owners: DetailsToolOwnerProps[] = []
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
SessionProvider={SessionProviderStub}
|
||||
renderSlot={renderToolDetailsProbe(owners)}
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSession={bindSnapshotSelector(createSnapshotStore(session))}
|
||||
useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))}
|
||||
useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))}
|
||||
useTrajectory={(() => { throw new Error('unused') })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useSessionPendingInteraction={bindSnapshotSelector(
|
||||
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
|
||||
)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{
|
||||
@@ -202,7 +224,7 @@ describe('render branch tails', () => {
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
// Conversation resolves the selected sub-call and hands its complete
|
||||
// Chat resolves the selected sub-call and hands its complete
|
||||
// frozen block to the Tool-owned details seat.
|
||||
expect(view.getByText('read')).toBeTruthy()
|
||||
expect(view.getByTestId('tool-details-seat')).toBeTruthy()
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { HistoricalImageCache } from '../src/client/historical-images.ts'
|
||||
|
||||
describe('HistoricalImageCache', () => {
|
||||
it('invalidates a pending image load when its Session binding is released', async () => {
|
||||
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const sessionId = await runtime.sessions.add({
|
||||
id: 's1',
|
||||
session: { readAttachment: () => read.promise },
|
||||
})
|
||||
const cache = new HistoricalImageCache(runtime.ctx)
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
|
||||
} as const
|
||||
|
||||
const pending = cache.resolve(sessionId, attachment)
|
||||
await runtime.sessions.remove(sessionId)
|
||||
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
|
||||
|
||||
await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed')
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
+1
-40
@@ -7,13 +7,11 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import type { RenderMessageImages } from '../src/client/contract/slots.ts'
|
||||
import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const enT = makeTranslate(en, commonZh)
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
@@ -39,43 +37,6 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages {
|
||||
}
|
||||
}
|
||||
|
||||
describe('attachment rejection copy', () => {
|
||||
const limits = {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 20,
|
||||
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
maxImageDimension: 2000,
|
||||
mediaTypes: ['image/png'] as const,
|
||||
}
|
||||
|
||||
it('renders megabytes without a trailing fraction unless one exists', () => {
|
||||
expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB')
|
||||
expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB')
|
||||
})
|
||||
|
||||
it('maps user-solvable reasons to limit-naming copy', () => {
|
||||
expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型')
|
||||
expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试')
|
||||
expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB')
|
||||
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片')
|
||||
expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试')
|
||||
expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images')
|
||||
})
|
||||
|
||||
it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => {
|
||||
expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试')
|
||||
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试')
|
||||
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试')
|
||||
expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant image slot handoff', () => {
|
||||
it('passes one image group and its message alignment to the renderer', () => {
|
||||
const calls: MessageImagesRenderOwner[] = []
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { PartialAccumulator } from '../src/client/sessions/partial.ts'
|
||||
import { PartialAccumulator } from '../src/client/conversation-nodes/partial.ts'
|
||||
|
||||
const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
let nextAnimationFrameId = 1
|
||||
let animationFrames = new Map<number, FrameRequestCallback>()
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Exercises Chat selection through the real SlotRegistry store axis. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
|
||||
async function createBench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const chat = createChatStore()
|
||||
await runtime.root.declare({
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_props: PropsRenderSlots<'conversation.view' | 'details'>) => null)
|
||||
runtime.slots.register({ name: 'conversation.view', id: 'chat', store: chat }, () => null)
|
||||
runtime.slots.register({ name: 'details', store: chat }, () => null)
|
||||
runtime.renderRoot()
|
||||
return { runtime }
|
||||
}
|
||||
|
||||
function storeFor(
|
||||
current: Awaited<ReturnType<typeof createBench>>,
|
||||
slot: 'conversation.view' | 'details',
|
||||
sessionId: SessionId,
|
||||
): ChatInstance {
|
||||
return current.runtime.storeOf(slot, sessionId) as ChatInstance
|
||||
}
|
||||
|
||||
describe('Chat selection survives on its store seat', () => {
|
||||
it('shares one instance between the Chat View and details panel', async () => {
|
||||
const b = await createBench()
|
||||
await b.runtime.sessions.add({ id: 's1' })
|
||||
const chat = storeFor(b, 'conversation.view', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
chat.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
|
||||
expect(details).toBe(chat)
|
||||
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('isolates Session instances and preserves identity across list projection updates', async () => {
|
||||
const b = await createBench()
|
||||
const oneId = sid('s1')
|
||||
await b.runtime.sessions.add({ id: 's1' })
|
||||
await b.runtime.sessions.add({ id: 's2' })
|
||||
const one = storeFor(b, 'conversation.view', oneId)
|
||||
const two = storeFor(b, 'conversation.view', sid('s2'))
|
||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||
|
||||
await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' })
|
||||
|
||||
expect(storeFor(b, 'conversation.view', oneId)).toBe(one)
|
||||
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('buries selection with the Session scope', async () => {
|
||||
const b = await createBench()
|
||||
await b.runtime.sessions.add({ id: 's1' })
|
||||
const doomed = storeFor(b, 'conversation.view', sid('s1'))
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
|
||||
await b.runtime.sessions.remove('s1')
|
||||
|
||||
await b.runtime.sessions.add({ id: 's1' })
|
||||
const reborn = storeFor(b, 'conversation.view', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
|
||||
import type { RunningToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts'
|
||||
import {
|
||||
MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
|
||||
} from '../src/client/sessions/tool-call-tree.ts'
|
||||
} from '../src/client/model/tool-call-tree.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
// Per-turn latency/throughput fold and the footer figure formatters.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AssistantMessageNode, ConversationNode, UserMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { assistantStepReading, deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { assistantStepReading, deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
|
||||
import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
interface StepSpec {
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatViewSlotProps } from '../src/client/contract/slots.ts'
|
||||
|
||||
describe('Chat View type chain', () => {
|
||||
it('keeps Chat injection and store props out of the target-neutral base', () => {
|
||||
const negatives = (
|
||||
base: ConvViewProps,
|
||||
chat: ChatViewSlotProps,
|
||||
): ReactNode => {
|
||||
// @ts-expect-error openDetails belongs to the Chat inject face.
|
||||
void base.openDetails
|
||||
// @ts-expect-error openDetails accepts a SelectionTarget.
|
||||
chat.openDetails('nope')
|
||||
// @ts-expect-error openFile accepts a path.
|
||||
void chat.openFile({ turnSeq: 1, callId: 'c' })
|
||||
return null
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/session-controller/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/workspace-controller/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../compaction/compaction"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-stats"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../ui-approval"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../ui-session"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-chat', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Encode bytes as canonical browser base64 without overflowing argument limits.
|
||||
* @param data - bytes to encode.
|
||||
* @returns base64 text.
|
||||
*/
|
||||
export function bytesToBase64(data: Uint8Array): string {
|
||||
let binary = ''
|
||||
const chunk = 0x8000
|
||||
for (let offset = 0; offset < data.length; offset += chunk) {
|
||||
binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
|
||||
/** Context usage rendered by conversation and Chat status surfaces. */
|
||||
export interface ContextOccupancy {
|
||||
percent: number
|
||||
usedTokens: number
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bounded display occupancy from independently updated pressure fields.
|
||||
* @param pressure - latest token-meter projection.
|
||||
* @returns occupancy, or null until numerator and capacity are known.
|
||||
*/
|
||||
export function contextOccupancy(
|
||||
pressure: ContextPressureProjection | undefined,
|
||||
): ContextOccupancy | null {
|
||||
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
|
||||
if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
|
||||
return {
|
||||
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
|
||||
usedTokens,
|
||||
contextWindow: pressure.contextWindow,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Why one session's composer is inert. */
|
||||
export interface ComposerBlock {
|
||||
/** Localized placeholder owned by the plugin that raised the block. */
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** The registry face other plugins reach through `ctx.conversation.blocks`. */
|
||||
export interface ComposerBlocks {
|
||||
/**
|
||||
* Raise or clear this session's block.
|
||||
* @param sessionId - Session whose composer is affected.
|
||||
* @param block - Block to raise, or undefined to clear it.
|
||||
*/
|
||||
set(sessionId: SessionId, block: ComposerBlock | undefined): void
|
||||
/**
|
||||
* Resolve the observable block state for one Session.
|
||||
* @param sessionId - Session to observe.
|
||||
* @returns Identity-stable block store.
|
||||
*/
|
||||
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined>
|
||||
/**
|
||||
* Drop one Session's store.
|
||||
* @param sessionId - Session being released.
|
||||
*/
|
||||
forget(sessionId: SessionId): void
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Context source projection: the role and the human-facing producer name
|
||||
// Conversation context source projection: the role and the human-facing producer name
|
||||
// of one logged non-user `user/message`, read from its durable `source` alone.
|
||||
// The client keeps no table of known plugin ids — a renamed or newly mounted
|
||||
// producer must never need a client release to stay identifiable, and a resumed
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user