refactor(conversation): separate Conversation, Chat, and Trajectory owners

This commit is contained in:
imccyu
2026-08-23 16:28:17 +08:00
parent d231c8777a
commit c7d8e32aec
182 changed files with 4157 additions and 2903 deletions
@@ -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()
})
})
+7 -3
View File
@@ -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'
+4 -1
View File
@@ -15,7 +15,10 @@
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../runtime"
"path": "../ui-renderer"
},
{
"path": "../ui-chat"
},
{
"path": "../ui-conversation"
+115
View File
@@ -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"
]
}
+141
View File
@@ -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,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'
@@ -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
@@ -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}
@@ -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 {
@@ -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. */
@@ -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'
@@ -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'
@@ -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
@@ -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,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,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.
@@ -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,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 {
@@ -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)
}
@@ -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)
}
@@ -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,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'
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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 }
}
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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))
@@ -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': 'Couldnt open file',
'fileOpen.unknown': 'Couldnt open this file',
'fileOpen.folderTitle': 'Couldnt open folder',
'fileOpen.folderUnknown': 'Couldnt 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>
@@ -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'
@@ -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 },
},
})
}
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
+4
View File
@@ -0,0 +1,4 @@
/** Host loader entry for the browser-only Chat UI target. */
/** Provides no Host-side behavior. */
export function apply(): void {}
+21
View File
@@ -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()
})
})
@@ -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}
@@ -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[] = []
@@ -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()
})
})
@@ -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: {
@@ -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 }),
@@ -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,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
@@ -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()
})
})
@@ -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[] = []
@@ -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
@@ -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()
})
})
@@ -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
@@ -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')
})
})
+87
View File
@@ -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"
}
]
}
+3
View File
@@ -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,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