feat(web): show command inputs in the human transcript

This commit is contained in:
NI0317
2026-08-10 17:13:01 +08:00
parent 1d12ae62e7
commit d06a7e07e5
31 changed files with 582 additions and 17 deletions

View File

@@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts'
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -52,6 +53,7 @@ async function bench(options: {
} = {}) {
const ctx = new Context()
const calls: { method: string; args: unknown[] }[] = []
const conversationEvents = new ConversationEventRegistry(ctx)
function answer<T>(method: string, value: T) {
return (...args: unknown[]) => {
calls.push({ method, args })
@@ -85,7 +87,10 @@ async function bench(options: {
})
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
name: 'root', children: {
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.chat.node': { kind: 'keyed', scope: 'session' },
},
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
ctx.provide('sessions', {
@@ -103,6 +108,7 @@ async function bench(options: {
ctx,
fiber,
calls,
definitions: () => conversationEvents.entries(),
remountGoals: () => { activeGoals = goals('remounted-goals') },
unmountGoals: () => { activeGoals = undefined },
entry: () => {
@@ -114,15 +120,19 @@ async function bench(options: {
inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
}
},
chatEntry: () => ctx.slots.entries('conversation.chat.node')[0],
}
}
describe('ui-goal browser plugin', () => {
it('registers the GoalBar dock entry with the documented id and order', async () => {
it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
const b = await bench()
await b.fiber.await()
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
expect(b.entry()?.inject).toBeTypeOf('function')
expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input'])
expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' })
expect(b.chatEntry()?.locale).toBe('goal')
})
it('verbs read the CAS ref from the current projected value at call time', async () => {
@@ -199,8 +209,12 @@ describe('ui-goal browser plugin', () => {
const b = await bench()
await b.fiber.await()
expect(b.entry()).toBeDefined()
expect(b.chatEntry()).toBeDefined()
expect(b.definitions()).toHaveLength(1)
await b.fiber.dispose()
expect(b.entry()).toBeUndefined()
expect(b.chatEntry()).toBeUndefined()
expect(b.definitions()).toHaveLength(0)
})
})

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { cleanup, render, within } from '@testing-library/react'
import { afterEach, 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'
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 { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts'
import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts'
import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx'
import {
goalCommandInputDefinition, goalCommandText,
} from '../src/client/goal-command-input.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
class TestEventDefinitions {
entries(): readonly ConversationNodeDefinition[] {
return [commandDefinition, goalCommandInputDefinition]
}
fallbackEntry(): undefined {
return undefined
}
}
class TestViewDefinitions {
entries(): readonly ConversationViewDefinition[] {
return [chatViewDefinition]
}
}
function entry(seq: number, type: string, data: unknown): ConversationEventInput {
return {
event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'],
view: undefined,
}
}
function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot {
const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
assembler.replaceWindow(entries, hasMore)
assembler.flush()
const value = assembler.snapshot('chat') as ChatSnapshot | undefined
if (value === undefined) throw new Error('chat view was not registered')
return value
}
function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
return value.nodes.values().find(candidate => candidate.kind === kind)
}
describe('goal command input projection', () => {
it('builds a separate input Node before the generic command result and restores it on replay', () => {
const run = entry(1, 'command/run', {
commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' },
})
const done = entry(2, 'command/done', {
commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.',
})
const value = snapshot([run, done])
expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command'])
expect(node(value, 'command-input')).toMatchObject({
anchorSeq: 0.9,
data: { commandId: 'command-goal', text: '/goal' },
})
expect(node(value, 'command')?.data).toMatchObject({
name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' },
})
const doneOnly = snapshot([done], true)
expect(node(doneOnly, 'command-input')).toBeUndefined()
expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null })
})
it('ignores other commands and preserves internal multiline arguments', () => {
const plan = entry(1, 'command/run', {
commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' },
})
const goal = entry(2, 'command/run', {
commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' },
})
expect(goalCommandInputDefinition.match(plan.event)).toBeNull()
expect(goalCommandText(goal.event as SessionEvent<'command/run'>))
.toBe('/goal\nfirst line\nsecond line')
})
it('keeps the Definition total across required interface and window fallback paths', () => {
const run = entry(3, 'command/run', {
commandId: 'command-goal', name: 'goal', source: { kind: 'user' },
})
const match = {
...run,
role: 'start' as const,
location: { kind: 'session' as const },
}
const state = goalCommandInputDefinition.start({} as never, match, {} as never)
expect(state.text).toBe('/goal')
expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state)
expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull()
expect(goalCommandInputDefinition.buildViewNode!({
key: 'goal-command-input', id: 'command-goal', state, start: undefined,
} as never)).toMatchObject({ location: { kind: 'unresolved' } })
const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' })
expect(() => goalCommandInputDefinition.start({} as never, {
...done, role: 'start', location: { kind: 'session' },
} as never, {} as never)).toThrow('goal-command-input start requires command/run')
})
it('renders the user-style command bubble without ordinary message actions', () => {
const t = makeTranslate(zh, commonZh)
const props = {
node: {
key: 'goal-command-input:one',
data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 },
},
t,
} as unknown as Parameters<typeof GoalCommandInputView>[0]
const view = render(<GoalCommandInputView {...props} />)
const bubble = view.getByRole('group', { name: '命令输入' })
expect(bubble.textContent).toBe('/goal ship it')
expect(within(bubble).queryByRole('button')).toBeNull()
})
})