Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/src/web.ts # apps/web/tests/smoke-fixture.e2e.ts # docs/architecture.i18n.yaml # packages/client/connection/src/client/fixture.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/host/runtime/src/api-proxy.ts # packages/host/runtime/src/boot.ts # packages/host/webserver/tests/webserver.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -28,4 +28,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
|
||||
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-i18n",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -35,21 +37,25 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -69,7 +69,12 @@ export function apply(ctx: Context): void {
|
||||
// ConversationRoot is the only component authorized to render the ring.
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
// The composer chain rides the same declaration table: takeover plugins
|
||||
// register selector-routed replacements of the InputBar.
|
||||
children: {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
|
||||
// History pull is NOT triggered here: the runtime sessions service opens
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
@@ -46,7 +46,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MessageText key={i} text={block.text} />
|
||||
case 'text': return <MarkdownText key={i} text={block.text} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
case 'image': return <ImageGallery key={i} images={[block]} load={loadImage} align="start" />
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass.
|
||||
|
||||
@@ -256,7 +256,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
|
||||
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
|
||||
@@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
|
||||
<div className={css.card}>
|
||||
{item.kind === 'approval' ? (
|
||||
<>
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
|
||||
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
|
||||
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={css.title}>等待回答({item.questions.length} 题)</div>
|
||||
<JsonBlock label="问题内容" payload={item.questions} />
|
||||
<div className={css.title}>等待回答({item.payload.questions.length} 题)</div>
|
||||
<JsonBlock label="问题内容" payload={item.payload.questions} />
|
||||
</>
|
||||
)}
|
||||
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* here.
|
||||
*/
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
@@ -42,6 +42,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* `fallback` for unregistered tools.
|
||||
*/
|
||||
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
|
||||
/**
|
||||
* The composer takeover chain: entries are selector-routed replacements
|
||||
* of the default InputBar. Declared by this package's 'conversation'
|
||||
* entry; the owner dispatches the {@link ComposerChainProps} currency and
|
||||
* routing lives in entry selectors — new takeover kinds register with
|
||||
* zero owner changes.
|
||||
*/
|
||||
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +120,7 @@ export interface ConversationInjected {
|
||||
addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null
|
||||
/** Release one browser preview and remove its id through the declared store action. */
|
||||
removeImage(id: string): void
|
||||
/** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */
|
||||
/** Resolve ordered store ids to browser-owned draft attachments still available this runtime. */
|
||||
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
|
||||
/** Release historical image URLs when this rendered session scope unmounts. */
|
||||
releaseSessionImages(sessionId: SessionId): void
|
||||
@@ -124,9 +132,22 @@ export interface ConversationInjected {
|
||||
open(id: SessionId): void
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */
|
||||
/**
|
||||
* Composer chain currency: what ConversationRoot dispatches at its
|
||||
* renderSlotChain site. The owner declares the currency only — never a
|
||||
* per-entry contract; takeover packages narrow it in their own selectors
|
||||
* (`interactions.find(i => i.kind === ...)`), so new takeover kinds register
|
||||
* with zero owner changes.
|
||||
*/
|
||||
export interface ComposerChainProps {
|
||||
/** The session's live pending waits, in arrival order (snapshot reference). */
|
||||
interactions: readonly PendingInteraction[]
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore<ChatStore> & ConversationInjected
|
||||
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
|
||||
& PropsStore<ChatStore> & ConversationInjected
|
||||
|
||||
/**
|
||||
* Injected share of the chat view entry: the two callbacks whose targets live
|
||||
|
||||
@@ -18,9 +18,10 @@ export type {
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type {
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerAttachment, ConversationInjected, ConversationSlotProps,
|
||||
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerAttachment, ComposerChainProps,
|
||||
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps,
|
||||
DetailsInjected, DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps,
|
||||
ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
|
||||
@@ -15,13 +15,9 @@
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
// Value import MUST use the /client subpath: only that specifier is in the
|
||||
// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime
|
||||
// module at load time. A bare-specifier value import gets INLINED as a second
|
||||
// module instance whose private scope-tag Symbol never matches the one
|
||||
// SessionsService tags contexts with — scopeOf then always returns undefined
|
||||
// in the browser while unit tests (single-instance path resolution) stay green.
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only imports: a plugin-to-plugin value import is a bundle purity
|
||||
// error, so scope resolution goes through the sessions service (scopeOf
|
||||
// method) instead of the standalone helper.
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ComposerAttachment } from './contract/slots.ts'
|
||||
@@ -160,14 +156,17 @@ export class ConversationService extends Service {
|
||||
const cached = this.imageUrls.get(key)
|
||||
if (cached !== undefined) return cached.pending
|
||||
const generation = this.imageGenerations.get(sessionId) ?? 0
|
||||
const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId)
|
||||
const pending = this.requireSessions().manager.get(sessionId)
|
||||
.readAttachment(attachment.attachmentId)
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
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 }))
|
||||
const url = URL.createObjectURL(new Blob([bytes.buffer], {
|
||||
type: result.value.attachment.mediaType,
|
||||
}))
|
||||
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
|
||||
revokePreview(url)
|
||||
throw new Error('historical image scope was released before loading completed')
|
||||
@@ -242,11 +241,17 @@ export class ConversationService extends Service {
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = scopeOf(this.ctx)
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
|
||||
private scopeId(op: string): SessionId {
|
||||
const id = this.requireSessions().scopeOf(this.ctx)
|
||||
if (id === undefined) {
|
||||
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
|
||||
}
|
||||
return this.requireSessions().manager.get(id)
|
||||
return id
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// props: the framework standard kit (useSession/sessionId/useSessions), the
|
||||
// declared chat store's useStore/actions, the injected business face, and the
|
||||
// renderSlot share for the declared 'conversation.view' child slot (views are
|
||||
// slot entries; the active one renders via the list `only` filter).
|
||||
// slot entries; the active one renders via the list `only` filter) plus the
|
||||
// renderSlotChain share for the 'conversation.composer' takeover chain.
|
||||
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
|
||||
// view id lives in the chat store's `view` field (per-session by store scope).
|
||||
|
||||
@@ -36,7 +37,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions, renderSlot,
|
||||
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
|
||||
views, addImages, removeImage, draftImages, releaseSessionImages,
|
||||
send, stop, open,
|
||||
}: ConversationRootProps) {
|
||||
@@ -55,6 +56,7 @@ export function ConversationRoot({
|
||||
const removed = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
const pending = useSession(s => s.pending)
|
||||
|
||||
// Browser File/object-URL values are runtime-only. A reload may rehydrate
|
||||
// ids whose objects no longer exist; prune those ids after the first render.
|
||||
@@ -72,6 +74,24 @@ export function ConversationRoot({
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
|
||||
// The default composer doubles as the chain's all-decline fallback: a
|
||||
// pending wait with no registered takeover must still leave the input usable.
|
||||
const composerBar = (
|
||||
<InputBar
|
||||
draft={draft}
|
||||
attachments={attachments}
|
||||
running={running}
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
onDraftChange={actions.setDraft}
|
||||
onAddImages={files => addImages(files, attachments)}
|
||||
onRemoveAttachment={removeImage}
|
||||
onSend={(mode) => { send(draft, attachments, mode) }}
|
||||
onStop={stop}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<header className={css.header}>
|
||||
@@ -88,7 +108,7 @@ export function ConversationRoot({
|
||||
disabled={last}
|
||||
onClick={() => { open(s.id) }}
|
||||
>
|
||||
{s.title}
|
||||
{s.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
@@ -121,19 +141,7 @@ export function ConversationRoot({
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>
|
||||
|
||||
<InputBar
|
||||
draft={draft}
|
||||
attachments={attachments}
|
||||
running={running}
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
onDraftChange={actions.setDraft}
|
||||
onAddImages={files => addImages(files, attachments)}
|
||||
onRemoveAttachment={removeImage}
|
||||
onSend={(mode) => { send(draft, attachments, mode) }}
|
||||
onStop={stop}
|
||||
/>
|
||||
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ async function bench() {
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: ROOT,
|
||||
} as SessionListState)
|
||||
const sessionFake = {
|
||||
@@ -59,6 +59,7 @@ async function bench() {
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
|
||||
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
}
|
||||
@@ -76,6 +77,7 @@ async function bench() {
|
||||
manager: { get: () => sessionFake },
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
hostDescription: () => undefined,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
|
||||
@@ -26,8 +26,8 @@ async function bench() {
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 },
|
||||
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
|
||||
},
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
|
||||
// machinery specs since the tool ring dissolved into renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
@@ -44,7 +46,7 @@ describe('MessageItem arms', () => {
|
||||
describe('small branch tails', () => {
|
||||
it('PendingCard approval reason renders when present', () => {
|
||||
const view = render(
|
||||
<PendingCard item={{ kind: 'approval', rpcId: 'r1' as RpcId, approvalId: 'a1', toolName: 'rm', reason: 'careful' }} />,
|
||||
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
|
||||
)
|
||||
expect(view.getByText('careful')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -122,8 +122,8 @@ describe('bash sample row', () => {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', running: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', parentId: ROOT, running: false, updatedAt: 0 },
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
@@ -157,7 +157,7 @@ describe('bash sample row', () => {
|
||||
const orphan = 'late-child' as SessionId
|
||||
store.update((d) => {
|
||||
d.ids.push(orphan)
|
||||
d.byId[orphan] = { id: orphan, title: 'l', running: false, updatedAt: 0 }
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
|
||||
})
|
||||
const view = render(<BashRow {...rowProps(orphan, { store })} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
@@ -158,6 +159,44 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
|
||||
const markdown = '# Rendered\n\n- **one**\n- `two`'
|
||||
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(1)
|
||||
const literal = view.getByText((_content, element) => (
|
||||
element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
|
||||
))
|
||||
expect(literal.querySelector('h1')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({ 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({
|
||||
nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
|
||||
partial: null,
|
||||
})
|
||||
})
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [
|
||||
user(1, markdown),
|
||||
assistant(2, markdown),
|
||||
{ ...assistant(3, markdown), interrupted: true },
|
||||
],
|
||||
})
|
||||
})
|
||||
expect(view.getByText('已停止')).toBeTruthy()
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('streaming partial frames re-render only the tail (Profiler count)', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
|
||||
@@ -305,7 +344,8 @@ describe('ChatView', () => {
|
||||
|
||||
it('pending interactions render placeholder cards', () => {
|
||||
const h = makeHarness({
|
||||
pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
|
||||
pending: [new PendingWait('approval', RpcId('r1'), SID,
|
||||
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||
|
||||
@@ -8,7 +8,8 @@ import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
@@ -34,7 +35,7 @@ describe('tails', () => {
|
||||
|
||||
it('PendingCard renders the question arm with its count', () => {
|
||||
const view = render(
|
||||
<PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
|
||||
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
|
||||
)
|
||||
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -123,23 +123,25 @@ describe('selection survives on the store seat', () => {
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a title-upgrading list refresh keeps instance identity and the selection value', async () => {
|
||||
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
|
||||
const b = bench()
|
||||
// First-send shape: client-side create inserts the row without cwd (title = bare id).
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
|
||||
const id = await b.sessions.create({})
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
|
||||
const store = storeFor(b, 'conversation', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
// The late list refresh lands (host knows the cwd → formal title).
|
||||
// The late list refresh lands (host knows the cwd → better fallback label).
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
|
||||
const after = storeFor(b, 'conversation', id)
|
||||
expect(after).toBe(store)
|
||||
|
||||
@@ -78,6 +78,7 @@ async function bench(opts?: {
|
||||
create: createMock,
|
||||
open: openMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
scopeOf,
|
||||
hostDescription: () => opts?.description,
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
@@ -126,7 +127,8 @@ describe('send / cancel', () => {
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: () => Promise.resolve(Uint8Array.of(1).buffer),
|
||||
})
|
||||
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file])).rejects.toThrow(/不支持的图片格式/)
|
||||
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file]))
|
||||
.rejects.toThrow(/不支持的图片格式/)
|
||||
expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -177,7 +179,11 @@ describe('image admission and URL lifecycle', () => {
|
||||
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
|
||||
|
||||
const attachments = b.svc.createDraftImages([first])
|
||||
expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' })
|
||||
expect(attachments[0]).toMatchObject({
|
||||
kind: 'image',
|
||||
file: first,
|
||||
previewUrl: 'blob:draft',
|
||||
})
|
||||
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -195,12 +201,15 @@ describe('image admission and URL lifecycle', () => {
|
||||
},
|
||||
})
|
||||
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/)
|
||||
expect(() => textOnly.svc.createDraftImages([png], [], true))
|
||||
.toThrow(/当前模型不支持图片/)
|
||||
|
||||
const b = await bench({ description })
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
|
||||
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' })
|
||||
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
|
||||
const existing = b.svc.createDraftImages([png, png])
|
||||
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
|
||||
@@ -235,7 +244,9 @@ describe('image admission and URL lifecycle', () => {
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.svc.releaseSessionImages(sid('s1'))
|
||||
await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') })
|
||||
await vi.waitFor(() => {
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1')
|
||||
})
|
||||
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -21,6 +21,9 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
/** Fallback-only chain stub (no takeover registered in these benches). */
|
||||
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
|
||||
(_key, _owner, opts) => opts?.fallback ?? null
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
@@ -43,7 +46,7 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => r.id as SessionId),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: r.id as SessionId, title: r.title, running: false, updatedAt: 1,
|
||||
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
|
||||
}])),
|
||||
@@ -73,6 +76,7 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={stubRenderSlot}
|
||||
renderSlotChain={fallbackRenderSlotChain}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn(() => null)}
|
||||
@@ -135,6 +139,7 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={stubRenderSlot}
|
||||
renderSlotChain={fallbackRenderSlotChain}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn(() => null)}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
@@ -36,11 +38,12 @@ interface FakeSnapshot {
|
||||
running: boolean
|
||||
removed: boolean
|
||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||
pending: readonly PendingInteraction[]
|
||||
}
|
||||
|
||||
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
||||
const store = createSnapshotStore<FakeSnapshot>({
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
@@ -50,7 +53,7 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => sid(r.id)),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: sid(r.id), title: r.title, running: false, updatedAt: 1,
|
||||
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
|
||||
}])),
|
||||
@@ -154,8 +157,11 @@ describe('EmptyState', () => {
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
function bench(tabs: ViewTab[], activeView?: string) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
|
||||
function bench(
|
||||
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
|
||||
renderSlotChain?: ConversationRootProps['renderSlotChain'],
|
||||
) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'root', title: 'proj' },
|
||||
{ id: 's1', title: 'child', parentId: 'root' },
|
||||
@@ -179,6 +185,7 @@ describe('ConversationRoot', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
|
||||
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{
|
||||
list: () => tabs,
|
||||
@@ -238,6 +245,30 @@ describe('ConversationRoot', () => {
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('hi', [], 'queue')
|
||||
})
|
||||
|
||||
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
|
||||
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
|
||||
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
|
||||
// A matching entry takes the composer over.
|
||||
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
|
||||
expect(screen.getByText('question takeover')).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
|
||||
// The owner dispatches the raw pending list (chain currency); routing
|
||||
// lives in entry selectors, not here.
|
||||
expect(renderSlotChain).toHaveBeenCalledWith(
|
||||
'conversation.composer',
|
||||
expect.objectContaining({
|
||||
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
|
||||
}),
|
||||
expect.objectContaining({ fallback: expect.anything() }),
|
||||
)
|
||||
cleanup()
|
||||
// Zero registered entries (default all-decline stub): the fallback IS the
|
||||
// default InputBar — behavior equals the pre-chain composer.
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
|
||||
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel', () => {
|
||||
|
||||
Reference in New Issue
Block a user