Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -21,9 +21,9 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
@@ -71,7 +71,7 @@ async function bench() {
|
||||
// The host face (store resolution) exists only inside the installed
|
||||
// renderer, so materialize it the way the shell does.
|
||||
runtime.renderRoot()
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
@@ -81,6 +81,13 @@ async function bench() {
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const conversationHeaderSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session.header')
|
||||
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const residentSurface = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
||||
@@ -101,20 +108,19 @@ async function bench() {
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const info = runtime.sessions.provideInfo(id)!
|
||||
const state = info.hooks['input'] as {
|
||||
getSnapshot: () => { draft: string; imageIds: readonly DraftAttachmentId[] }
|
||||
getSnapshot: () => { draft: string }
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
const actions = info.props['inputActions'] as {
|
||||
setDraft: (text: string) => void
|
||||
addImages: (ids: readonly DraftAttachmentId[]) => boolean
|
||||
submit: () => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
return {
|
||||
runtime, feature, slots: runtime.slots, entryOf,
|
||||
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, layoutFake, locale,
|
||||
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, layoutFake,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,12 +236,9 @@ describe('conversation slot inject surface', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
|
||||
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const resident = b.residentSurface(ROOT)
|
||||
injected.open(ROOT)
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
// Same-session connect (the picked workspace resolves to this session):
|
||||
// no draft movement, plain re-open.
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
@@ -243,7 +246,7 @@ describe('conversation slot inject surface', () => {
|
||||
actions.setDraft('carry me')
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
|
||||
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1)
|
||||
})
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
@@ -294,41 +297,6 @@ describe('conversation slot inject surface', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps a mixed draft together when the destination refuses its images', async () => {
|
||||
const b = await bench()
|
||||
const OTHER = 'mixed-target' as SessionId
|
||||
await b.runtime.sessions.add({ id: OTHER }, { current: false })
|
||||
const source = b.inputSurface(ROOT)
|
||||
const destination = b.inputSurface(OTHER)
|
||||
const imageId = 'draft-mixed' as DraftAttachmentId
|
||||
source.actions.setDraft('carry together')
|
||||
source.actions.addImages([imageId])
|
||||
const destinationShell = b.composerSurface(OTHER).keyboard as unknown as {
|
||||
addImages: (ids: readonly DraftAttachmentId[]) => boolean
|
||||
}
|
||||
vi.spyOn(destinationShell, 'addImages').mockReturnValue(false)
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
|
||||
|
||||
await b.residentSurface(ROOT).selectWorkspace('workspace-mixed' as never)
|
||||
|
||||
expect(source.state.getSnapshot()).toMatchObject({
|
||||
draft: 'carry together',
|
||||
imageIds: [imageId],
|
||||
})
|
||||
expect(destination.state.getSnapshot()).toMatchObject({ draft: '', imageIds: [] })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('localizes browser image-type rejection through the active conversation locale', async () => {
|
||||
const b = await bench()
|
||||
b.locale.setLocale('en')
|
||||
const error = b.composerSurface(ROOT).addImages?.([
|
||||
new File([Uint8Array.of(1)], 'vector.svg', { type: 'image/svg+xml' }),
|
||||
])
|
||||
expect(error).toBe('Unsupported image format: image/svg+xml')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('scopedConversation fails loud when the session resolves no scope', async () => {
|
||||
const b = await bench()
|
||||
// The chat-view inject resolves the scoped conversation service at inject
|
||||
|
||||
@@ -124,11 +124,13 @@ describe('AskQuestionRow', () => {
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
it('askQuestionToolview injects the toolview declaration directly', () => {
|
||||
expect(askQuestionToolview.name).toBe('ask-question-toolview')
|
||||
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
askQuestionToolview.apply({ slots: { register } } as never)
|
||||
expect(askQuestionToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
askQuestionToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
|
||||
AskQuestionRow,
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
@@ -83,6 +84,16 @@ const LAYOUT_CHILDREN = {
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
} as const
|
||||
|
||||
/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
|
||||
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<button data-testid="workspace-probe" onClick={() => { setCount(value => value + 1) }}>
|
||||
{String(open)}:{count}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
@@ -122,7 +133,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中')
|
||||
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
@@ -149,7 +160,7 @@ describe('terminal card assembly', () => {
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
|
||||
const keyedRow = view.container.querySelector('[data-sample="bash-global"]')
|
||||
const keyedRow = view.container.querySelector('[data-sample="bash"]')
|
||||
const keyed = keyedRow?.parentElement
|
||||
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(keyedRow!)
|
||||
@@ -188,6 +199,49 @@ describe('resident composer', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
|
||||
})
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
const root = view.container.querySelector('[data-phase="hero"]')!
|
||||
const scrollBody = view.container.querySelector('[data-conversation-scroll]')!
|
||||
const composerSeat = view.container.querySelector('[data-composer-seat]')!
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const workspaceChip = view.getByRole('button', { name: '选择工作区' })
|
||||
const workspaceProbe = view.getByTestId('workspace-probe')
|
||||
expect(textarea.disabled).toBe(true)
|
||||
|
||||
fireEvent.click(workspaceChip)
|
||||
fireEvent.click(workspaceProbe)
|
||||
expect(workspaceProbe.textContent).toBe('true:1')
|
||||
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
|
||||
snapshot: { blank: true, composerPhase: 'blank' },
|
||||
})
|
||||
|
||||
expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
|
||||
expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
|
||||
expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat)
|
||||
expect(view.container.querySelector('textarea')).toBe(textarea)
|
||||
expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip)
|
||||
expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe)
|
||||
expect(workspaceProbe.textContent).toBe('true:1')
|
||||
expect(textarea.disabled).toBe(false)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
|
||||
const runtime = await bench([], { blank: true })
|
||||
@@ -256,16 +310,17 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
})
|
||||
|
||||
describe('title projection across assembled surfaces', () => {
|
||||
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
|
||||
it('one summary update re-labels the current-session crumb', async () => {
|
||||
const runtime = await bench([])
|
||||
const view = runtime.renderRoot()
|
||||
// The strict session header breadcrumb reads useSessions ancestry.
|
||||
const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement)
|
||||
expect(crumb.getByText('S')).toBeTruthy()
|
||||
const hierarchy = view.getByRole('navigation', { name: '会话层级' })
|
||||
expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true)
|
||||
|
||||
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
|
||||
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
|
||||
expect(crumb.queryByText('S')).toBeNull()
|
||||
await waitFor(() => {
|
||||
expect(within(hierarchy).getByRole('button', { name: '修订标题' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
expect(within(hierarchy).queryByRole('button', { name: 'S' })).toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
||||
// hole, the slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all strict
|
||||
// session entries, and the bash sample + todo row mount through the
|
||||
// load-order seam as keyed entries. Full-chain rendering belongs to the
|
||||
// session entries, and the bash sample + todo row mount through declaration
|
||||
// injection as keyed entries. Full-chain rendering belongs to the
|
||||
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
|
||||
// stops at the assembly surface.
|
||||
|
||||
@@ -37,6 +37,7 @@ async function bench() {
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'settings.general.item': { kind: 'list', scope: 'root' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
@@ -44,7 +45,7 @@ async function bench() {
|
||||
}
|
||||
|
||||
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
||||
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
|
||||
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
|
||||
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ describe('apply wiring', () => {
|
||||
const b = await bench()
|
||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
|
||||
const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header')
|
||||
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
||||
const details = renderEntryOf(b.slots, 'details')
|
||||
expect(conversation?.inject).toBeTypeOf('function')
|
||||
@@ -80,18 +82,19 @@ describe('apply wiring', () => {
|
||||
// The shared handle: one apply-built store value on ALL session entries
|
||||
// (the session-maybe 'conversation' shell carries no store by design).
|
||||
expect(conversationSession?.store).toBeDefined()
|
||||
expect(conversationHeader?.store).toBe(conversationSession?.store)
|
||||
expect(details?.store).toBe(conversationSession?.store)
|
||||
expect(chatView?.store).toBe(conversationSession?.store)
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
// declaration (the empty-state occupant is gone).
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the tool rows as keyed entries through declaration injection', async () => {
|
||||
const b = await bench()
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first. The
|
||||
// The actual toolview declaration activates every registrant. The
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// one search row registers under both grep and glob; the web rows register
|
||||
// one component under both web tool names.
|
||||
@@ -112,6 +115,7 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
|
||||
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// Remaining chat branch tails: MessageItem context/unknown arms,
|
||||
// user IconActions, StatsLine no-cache join,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
@@ -18,16 +18,25 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
|
||||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -46,7 +55,7 @@ describe('MessageItem arms', () => {
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
@@ -74,16 +83,10 @@ describe('MessageItem arms', () => {
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
it('user copy never claims success when the host rejects the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
@@ -94,69 +97,588 @@ describe('MessageItem arms', () => {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => {
|
||||
it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const copy = screen.getByRole('button', { name: '复制' })
|
||||
fireEvent.click(copy)
|
||||
fireEvent.click(copy)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
// Two microtask ticks: writeClipboard's own await, then the .then that
|
||||
// lands the success chrome.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
const done = screen.getByRole('button', { name: '复制成功' })
|
||||
fireEvent.click(done)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears copy feedback work when the message unmounts', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finishWrite!: () => void
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve }))
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
finishWrite()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const mounted = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 2, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
mounted.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
|
||||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.queryByText('插话')).toBeNull()
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('steer!')
|
||||
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
|
||||
content: [{ type: 'text', text: 'line one\n\nline two' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
|
||||
provenance: { role: 'inject', label: 'fixture' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
|
||||
const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
expect(ctxView.container.querySelector('svg')).not.toBeNull()
|
||||
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
|
||||
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
|
||||
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
|
||||
)
|
||||
// An unknown form renders the opaque body: the model-facing text keeps its
|
||||
// real line breaks instead of being escaped into one JSON line, and the
|
||||
// remaining provenance follows it as fields.
|
||||
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
|
||||
.toBe('line one\n\nline two')
|
||||
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
|
||||
expect(fields).toEqual(['plugin', 'empty', 'list'])
|
||||
|
||||
fireEvent.keyDown(disclosure, { key: ' ' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('context preserves the bounded JSON truncation contract', () => {
|
||||
it('the instructions form names the files it reconciled above their text', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
|
||||
content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
baseline: true,
|
||||
changes: [
|
||||
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
|
||||
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
|
||||
{ action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ }))
|
||||
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
|
||||
expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除'])
|
||||
// The `<system-reminder>` framing is part of what the model read, so the
|
||||
// body keeps it verbatim rather than presenting a cleaned-up excerpt.
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent)
|
||||
.toContain('<system-reminder>')
|
||||
})
|
||||
|
||||
it('a delta distinguishes a newly reconciled file from a rewritten one', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'delta' }],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
changes: [
|
||||
{ action: 'set', scope: 'a', path: 'new/AGENTS.md' },
|
||||
{ action: 'replace', scope: 'b', path: 'old/AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ }))
|
||||
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
|
||||
expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新'])
|
||||
})
|
||||
|
||||
it('keeps an interleaved unknown block in the order the model received it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'future-block', payload: 1 },
|
||||
{ type: 'text', text: 'after' },
|
||||
],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
|
||||
const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent)
|
||||
expect(texts).toEqual(['before', 'after'])
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
|
||||
expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
|
||||
expect(view.container.querySelector('[data-context-text]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a replacement catalog says so above its entries', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
|
||||
})
|
||||
|
||||
it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
|
||||
// All-or-nothing: a body that replaces the model-facing text must not show
|
||||
// a confident, incomplete account of what the model read.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
|
||||
// The marker reports what rendered, not what was declared.
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('an unreadable instruction list falls back to the opaque body with its fields', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
|
||||
expect(view.container.querySelector('[data-context-files]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('joins adjacent text blocks the way a provider adapter flattens them', () => {
|
||||
// No invented separator: showing a line break the model never saw would
|
||||
// misreport the request.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
|
||||
})
|
||||
|
||||
it('bounds an oversized provenance field, not only the model-facing text', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'short' }],
|
||||
source: { kind: 'plugin', note: 'y'.repeat(21_000) },
|
||||
provenance: { role: 'inject', label: 'plugin' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('an empty replacement catalog stays a catalog: it retires every earlier name', () => {
|
||||
// `renderCatalogUpdate` legitimately publishes zero entries when the last
|
||||
// skill disappears; falling back would hide that the catalog was cleared.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0)
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBe('catalog')
|
||||
})
|
||||
|
||||
it('a catalog whose entries are unreadable falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
|
||||
})
|
||||
|
||||
it('bounds a large catalog and says how many rows it withheld', () => {
|
||||
const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' }))
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200)
|
||||
expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条')
|
||||
})
|
||||
|
||||
it('a catalog keeps a content block this version does not know', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an instruction change with an unrecognized action falls back whole', () => {
|
||||
// The action decides the word the row shows, so an unknown one cannot be
|
||||
// presented as loaded or updated.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
|
||||
expect(view.container.querySelector('[data-context-files]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
|
||||
})
|
||||
|
||||
it('the opaque fallback keeps a form declaration this version cannot present', () => {
|
||||
// Otherwise a newer or foreign log's declared shape vanishes from the UI.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }],
|
||||
source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' },
|
||||
provenance: { role: 'inject', label: 'later' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
|
||||
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
|
||||
expect(fields).toEqual(['plugin', 'form'])
|
||||
})
|
||||
|
||||
it('the snapshot form attributes each part to the subsystem that produced it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: '@deepseek-ai/dsh-system-prompt',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
|
||||
form: 'snapshot',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
|
||||
})
|
||||
|
||||
it('a notice puts its account on the collapsed row', () => {
|
||||
// The whole point of the form: readable without expanding.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'background task bash-1 finished.' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')?.textContent)
|
||||
.toBe('bash pnpm test [status: completed]')
|
||||
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a notice without its account falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('each form falls back to the opaque body when its required facts are unreadable', () => {
|
||||
// The fallback chain is the load-bearing wall: every dedicated form must
|
||||
// reach it, and the row marker must not claim a form that did not render.
|
||||
const cases = [
|
||||
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
|
||||
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
|
||||
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
|
||||
] as const
|
||||
for (const { form, source, label } of cases) {
|
||||
cleanup()
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
|
||||
source, provenance: { role: 'inject', label }, form,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('a snapshot states the supersession its framing line carries', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
|
||||
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
|
||||
provenance: { role: 'inject', label: 'plugin' },
|
||||
form: 'snapshot',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
|
||||
.toBe('取代先前的快照')
|
||||
})
|
||||
|
||||
it('a relay names the agent that sent it above what it said', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'child report body' }],
|
||||
source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' },
|
||||
provenance: { role: 'inject', label: 'subagent-report' },
|
||||
form: 'relay',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ }))
|
||||
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
|
||||
})
|
||||
|
||||
it('a recall reports how much of each source session survived the read', () => {
|
||||
// Recalled context is bounded on the way in, so hiding the omitted count
|
||||
// would overstate what the model received.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'recalled material' }],
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [
|
||||
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
|
||||
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'recall', label: '重构 loader, 修 CI' },
|
||||
form: 'recall',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
@@ -410,12 +932,15 @@ describe('small branch tails', () => {
|
||||
})
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const onFork = vi.fn()
|
||||
const settled = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
seq={3}
|
||||
onFork={onFork}
|
||||
/>,
|
||||
)
|
||||
expect(settled.getByText('14:24')).toBeTruthy()
|
||||
@@ -423,6 +948,8 @@ describe('small branch tails', () => {
|
||||
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
fireEvent.click(settled.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(onFork).toHaveBeenCalledWith(3)
|
||||
settled.unmount()
|
||||
|
||||
const thinkOnly = render(
|
||||
@@ -444,6 +971,31 @@ describe('small branch tails', () => {
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
|
||||
streaming={false}
|
||||
time={1_000}
|
||||
seq={1}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// Cache hit is null only when all three prompt buckets are zero (pure
|
||||
// output accounting) — any billed input makes it a real 0%.
|
||||
@@ -453,12 +1005,13 @@ describe('small branch tails', () => {
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
t={t}
|
||||
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
|
||||
useProjection={(key: string) => key === 'tokenUsage'
|
||||
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
: undefined}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
|
||||
expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,10 +67,10 @@ function snapshotWith(
|
||||
runningCalls: RunningToolCall[] = [],
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
|
||||
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +90,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
@@ -181,7 +181,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
// sub-tool fell back to GenericToolCard at the same render site.
|
||||
const nest = view.container.querySelector('[data-subcalls]')
|
||||
expect(nest).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('List notes')).toBeTruthy()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
@@ -264,7 +264,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(running).not.toBeNull()
|
||||
const nest = view.container.querySelector('[data-subcalls]')
|
||||
expect(nest).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (composer.dock entry): totals derivation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: the
|
||||
// canonical sub-agent differential decided INSIDE the component off the
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
|
||||
// chrome (Bash · description) without a row click target.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
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, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
|
||||
const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
@@ -33,9 +47,9 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +81,11 @@ describe('deriveStats', () => {
|
||||
expect(stats.turns).toBe(2)
|
||||
expect(stats.steps).toBe(3)
|
||||
// Window-scoped by design: the paged window is not an accounting source, so
|
||||
// the fold exposes no token fields at all (billing rides the projection).
|
||||
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
|
||||
// the fold exposes no billing fields (billing rides the projection);
|
||||
// decodeTokens is a throughput input, not a billed total.
|
||||
expect(Object.keys(stats).sort()).toEqual(
|
||||
['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'],
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores tool results with no call time', () => {
|
||||
@@ -98,6 +115,23 @@ describe('deriveStats', () => {
|
||||
expect(stats.llmMs).toBe(2_500)
|
||||
expect(stats.toolMs).toBe(3_000)
|
||||
})
|
||||
|
||||
it('sums ttft per recorded step and decode throughput inputs per usage-carrying step', () => {
|
||||
const sampled: AssistantMessageNode = {
|
||||
...assistant(1, 1, { outputTokens: 40 }),
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
|
||||
}
|
||||
const ttftOnly: AssistantMessageNode = {
|
||||
...assistant(2, 1),
|
||||
timing: { stepStartTime: 5_000, firstTokenTime: 5_400, completedTime: 7_400 },
|
||||
}
|
||||
const stats = deriveStats([sampled, ttftOnly, assistant(3, 2)])
|
||||
expect(stats.ttftMs).toBe(1_200)
|
||||
expect(stats.ttftSteps).toBe(2)
|
||||
// The usage-less step contributes no decode share, keeping the ratio honest.
|
||||
expect(stats.decodeMs).toBe(3_000)
|
||||
expect(stats.decodeTokens).toBe(40)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatters', () => {
|
||||
@@ -126,7 +160,7 @@ describe('StatsLine', () => {
|
||||
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
|
||||
values: Record<string, unknown> = { tokenUsage: USAGE },
|
||||
): StatsLineProps {
|
||||
return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
|
||||
return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
|
||||
}
|
||||
|
||||
it('renders the grouped stats row and hides a brand-new empty session', () => {
|
||||
@@ -134,7 +168,7 @@ describe('StatsLine', () => {
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
// No timing on the fixture: the duration group drops out whole. Tokens come
|
||||
// from the projection, so paging the window cannot change them.
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
const empty = makeSource()
|
||||
const emptyView = render(<StatsLine {...props(empty.source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
@@ -143,47 +177,84 @@ describe('StatsLine', () => {
|
||||
expect(emptyView.container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('keeps durable token and context groups after the visible step window is empty', () => {
|
||||
it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
|
||||
vi.useFakeTimers()
|
||||
// jsdom lays nothing out; fake a row narrower than its content.
|
||||
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
|
||||
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
fireEvent.mouseEnter(view.container.firstElementChild!)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(view.container.querySelector('[role="tooltip"]')?.textContent)
|
||||
.toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok')
|
||||
})
|
||||
|
||||
it('suppresses the tooltip while the row fits without truncation', () => {
|
||||
vi.useFakeTimers()
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
fireEvent.mouseEnter(view.container.firstElementChild!)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders window latency and throughput beside the wall-time group', () => {
|
||||
const timed: AssistantMessageNode = {
|
||||
...assistant(1, 1, { outputTokens: 60 }),
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
|
||||
}
|
||||
const { source } = makeSource({ nodes: [timed] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
expect(view.container.textContent).toContain('LLM 3.8s| TTFT avg 0.8s · 20 tok/s')
|
||||
})
|
||||
|
||||
it('takes every stats label from the active locale', () => {
|
||||
const timed: AssistantMessageNode = {
|
||||
...assistant(1, 1, { outputTokens: 60 }),
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
|
||||
}
|
||||
const { source } = makeSource({ nodes: [timed] })
|
||||
const view = render(<StatsLine {...props(source)} t={t} />)
|
||||
expect(view.container.textContent)
|
||||
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok')
|
||||
})
|
||||
|
||||
it('renders without ResizeObserver support', () => {
|
||||
vi.unstubAllGlobals()
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
expect(() => render(<StatsLine {...props(source)} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps durable token groups after the visible step window is empty', () => {
|
||||
const { source } = makeSource()
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
// Context occupancy lives on the composer's ContextMeter ring, not here.
|
||||
expect(view.container.textContent)
|
||||
.toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok')
|
||||
.toBe('Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
})
|
||||
|
||||
it('renders context occupancy only when the projection knows a capacity', () => {
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const withCapacity = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
|
||||
// Pressure without capacity has no denominator: the group drops out.
|
||||
const noCapacity = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000 },
|
||||
})} />)
|
||||
expect(noCapacity.container.textContent).not.toContain('Context')
|
||||
// Capacity arrives before usage in the log; no provider sample means there
|
||||
// is no numerator yet, rather than a synthetic 0%.
|
||||
const noPressure = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(noPressure.container.textContent).not.toContain('Context')
|
||||
})
|
||||
|
||||
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
|
||||
// Capacity and pressure are independent last-wins fields, so a model switch
|
||||
// can pair a smaller new window with the previous route's larger prompt.
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(view.container.textContent).toContain('Context 100% of 128K')
|
||||
it('computes context occupancy only when both a numerator and capacity are known', () => {
|
||||
// The projected figure wins: it is the provider sample carried forward over
|
||||
// the surface's movement, so a compaction shows without waiting a request.
|
||||
expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
|
||||
.toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
|
||||
// A log whose projection predates the field still reads its bare sample.
|
||||
expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
|
||||
.toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
|
||||
// A numerator without capacity has no denominator; capacity without a
|
||||
// provider sample has no numerator yet, rather than a synthetic 0%.
|
||||
expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
|
||||
expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
|
||||
expect(contextOccupancy(undefined)).toBeNull()
|
||||
// Capacity and the sample are independent last-wins fields, so a model
|
||||
// switch can pair a smaller new window with the previous route's prompt.
|
||||
expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
|
||||
})
|
||||
|
||||
it('drops every token group when no projection is composed', () => {
|
||||
@@ -197,7 +268,7 @@ describe('StatsLine', () => {
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
})} />)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 7 tok')
|
||||
})
|
||||
|
||||
it('includes cache writes in billed input and the cache-hit denominator', () => {
|
||||
@@ -211,7 +282,7 @@ describe('StatsLine', () => {
|
||||
},
|
||||
})} />)
|
||||
expect(view.container.textContent)
|
||||
.toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok')
|
||||
.toBe('1 turns · 1 steps| Cache hit 45%| Input 200 tok · Output 7 tok')
|
||||
})
|
||||
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
@@ -232,8 +303,7 @@ describe('StatsLine', () => {
|
||||
})
|
||||
|
||||
describe('bash sample row', () => {
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
const SID = 'root-1' as SessionId
|
||||
|
||||
const result = (callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId,
|
||||
@@ -242,68 +312,32 @@ describe('bash sample row', () => {
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
|
||||
function listStore() {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
ids: [SID],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
}): BashRowProps => ({
|
||||
const rowProps = (): BashRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openFile: vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
sessionId: SID,
|
||||
useSessions: bindSnapshotSelector(listStore()),
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
expect(scoped.getByText('scoped')).toBeTruthy()
|
||||
const plain = render(<BashRow {...rowProps(ROOT)} />)
|
||||
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a session outside the list renders the global arm (no parent known)', () => {
|
||||
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a live parentId write flips the row to the scoped variant (store subscription)', () => {
|
||||
const store = listStore()
|
||||
const orphan = 'late-child' as SessionId
|
||||
store.update((d) => {
|
||||
d.ids.push(orphan)
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 }
|
||||
})
|
||||
const view = render(<BashRow {...rowProps(orphan, { store })} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
act(() => {
|
||||
store.update((d) => { d.byId[orphan]!.parentId = ROOT })
|
||||
})
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes as Bash · description on both arms without row click targets', () => {
|
||||
const global = render(<BashRow {...rowProps(ROOT)} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Bash')
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
expect(globalRow.getAttribute('data-clickable')).toBeNull()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Bash')
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
|
||||
it('summarizes as Bash · description without a row click target', () => {
|
||||
const view = render(<BashRow {...rowProps()} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
expect(row.textContent).toContain('Bash')
|
||||
expect(row.textContent).toContain('Build')
|
||||
expect(row.getAttribute('data-clickable')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } 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'
|
||||
@@ -12,6 +11,36 @@ import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
let nextAnimationFrameId = 1
|
||||
let animationFrames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
function flushAnimationFrames(count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const callbacks = [...animationFrames.values()]
|
||||
animationFrames.clear()
|
||||
for (const callback of callbacks) callback(index)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nextAnimationFrameId = 1
|
||||
animationFrames = new Map()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId
|
||||
nextAnimationFrameId += 1
|
||||
animationFrames.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
animationFrames.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
@@ -30,6 +59,7 @@ const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
describe('tool-call-model', () => {
|
||||
it('classifies known tools and falls back to others', () => {
|
||||
expect(classifyTool('bash')).toBe('bash')
|
||||
expect(classifyTool('pwsh')).toBe('bash')
|
||||
expect(classifyTool('read')).toBe('read')
|
||||
expect(classifyTool('web_fetch')).toBe('read')
|
||||
expect(classifyTool('web_search')).toBe('search')
|
||||
@@ -42,6 +72,12 @@ describe('tool-call-model', () => {
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
it('gives the pwsh shell row the bash family treatment with its own title', () => {
|
||||
const m = toolRowModel('pwsh', running())
|
||||
expect(m.variant).toBe('bash')
|
||||
expect(m.title).toBe('Pwsh')
|
||||
})
|
||||
|
||||
it('derives state across running/ok/error/interrupted', () => {
|
||||
expect(toolRowModel('bash', running()).state).toBe('running')
|
||||
expect(toolRowModel('bash', result()).state).toBe('ok')
|
||||
@@ -265,6 +301,20 @@ describe('ToolRow', () => {
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
|
||||
const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
|
||||
const summary = view.getByText('List files')
|
||||
const suffix = view.getByText('+2')
|
||||
// Separate spans: .summary truncates, the suffix must not travel inside it.
|
||||
expect(summary.contains(suffix)).toBe(false)
|
||||
view.unmount()
|
||||
// The failure line replaces the summary wholesale, so the suffix goes with it.
|
||||
const failed = render(
|
||||
<ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />,
|
||||
)
|
||||
expect(failed.queryByText('+2')).toBeNull()
|
||||
})
|
||||
|
||||
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
@@ -320,6 +370,47 @@ describe('ToolRow', () => {
|
||||
})
|
||||
|
||||
describe('ThinkRow', () => {
|
||||
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
const summary = view.getByText('Newest reasoning tokens')
|
||||
Object.defineProperties(summary, {
|
||||
scrollWidth: { configurable: true, value: 300 },
|
||||
clientWidth: { configurable: true, value: 100 },
|
||||
})
|
||||
|
||||
view.rerender(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
flushAnimationFrames(2)
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
flushAnimationFrames(1)
|
||||
expect(summary.scrollLeft).toBe(200)
|
||||
expect(summary.getAttribute('data-follow-end')).toBe('true')
|
||||
|
||||
view.rerender(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
flushAnimationFrames(3)
|
||||
expect(view.getByText('Inspect the session')).toBeTruthy()
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
expect(summary.hasAttribute('data-follow-end')).toBe(false)
|
||||
})
|
||||
|
||||
it('expands from either Think or the reasoning summary', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
// entryKey (the bash sample lands through its plugin), unregistered tools
|
||||
// fall back to GenericToolCard at the render site, live registration/unload
|
||||
// flips rows in place, duplicate keys fail loud, the inject channel feeds
|
||||
// (sessionId) => I into row components, and a registrant's
|
||||
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
||||
// semantics until the service (and with it the hole declaration) is present.
|
||||
// (sessionId) => I into row components, and a registrant can activate before
|
||||
// the declaration then land through slots.inject when the chat entry appears.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
@@ -91,7 +90,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
const view = b.runtime.renderRoot()
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
@@ -191,8 +190,8 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
describe('registrant declaration injection', () => {
|
||||
it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
@@ -200,31 +199,26 @@ describe('registrant load-order seam', () => {
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
// semantics hold it — apply must not run while 'conversation' is absent.
|
||||
// Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
|
||||
// would fail loud on the missing service). (Plain arrow, not vi.fn: mock
|
||||
// functions carry a prototype and trip the fiber's isConstructor branch.)
|
||||
// Third-party posture, mounted BEFORE ui-conversation. Plugin apply runs,
|
||||
// while slots.inject waits for the declaration itself.
|
||||
let applyRuns = 0
|
||||
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
|
||||
applyRuns += 1
|
||||
registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
||||
registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null))
|
||||
}
|
||||
const late = runtime.ctx.plugin({
|
||||
name: 'late-registrant',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
apply: registrantApply,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(applyRuns).toBe(0)
|
||||
|
||||
// Mounting the package resolves the seam: service present ⟹ the chat
|
||||
// entry (and its hole declaration) is already on the ledger, so the
|
||||
// suspended registrant lands without an undeclared-slot throw.
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
await late.await()
|
||||
expect(applyRuns).toBe(1)
|
||||
expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
|
||||
// Mounting the package declares the slot and activates the waiting entry.
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
.toEqual(expect.arrayContaining(['bash', 'late']))
|
||||
await runtime.dispose()
|
||||
|
||||
@@ -20,9 +20,13 @@ 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 { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
// Keyless create() persists under the bare declared key; clear between cases
|
||||
// so one harness's selection cannot rehydrate into the next.
|
||||
beforeEach(() => {
|
||||
@@ -33,9 +37,9 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +97,7 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -112,10 +116,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const loadOlder = vi.fn()
|
||||
const inspectCall = vi.fn<(callId: string) => void>()
|
||||
// In-memory scroll memory matching the apply.ts per-session map contract.
|
||||
let savedScrollTop: number | null = null
|
||||
const chatScroll = {
|
||||
save: (top: number | null) => { savedScrollTop = top },
|
||||
read: () => savedScrollTop,
|
||||
let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
|
||||
const chatScroll: ChatViewSlotProps['chatScroll'] = {
|
||||
save: (position) => { savedScroll = position },
|
||||
read: () => savedScroll,
|
||||
}
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
@@ -126,6 +130,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const chat = createChatStore().create()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
|
||||
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
|
||||
// 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)}</>
|
||||
@@ -146,6 +152,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
@@ -161,6 +168,32 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
}
|
||||
|
||||
/** Simulate reader input (any device): a delivered position that deviates
|
||||
* from the observed-top ledger of programmatic writes. */
|
||||
function readerScroll(element: HTMLElement, top: number): void {
|
||||
element.scrollTop = top
|
||||
fireEvent.scroll(element)
|
||||
}
|
||||
|
||||
function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
|
||||
let scrollHeight = initialHeight
|
||||
let scrollTop = 0
|
||||
Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
|
||||
Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
|
||||
Object.defineProperty(element, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
|
||||
})
|
||||
return {
|
||||
setHeight: (value: number) => { scrollHeight = value },
|
||||
setLayout: (height: number, top: number) => {
|
||||
scrollHeight = height
|
||||
scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
it('groups consecutive tool results and keeps stable keys', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
@@ -202,12 +235,12 @@ describe('chat-flow derivation', () => {
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
})
|
||||
|
||||
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
|
||||
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
|
||||
const thinkOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'planning' }],
|
||||
}
|
||||
const seqs = assistantActionsSeqs([
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'looking', 1),
|
||||
thinkOnly,
|
||||
@@ -215,8 +248,49 @@ describe('chat-flow derivation', () => {
|
||||
assistant(5, 'done', 1),
|
||||
user(6, 'again'),
|
||||
assistant(7, 'second turn', 2),
|
||||
])
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
]
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
// Turn 2 is still producing steps: its latest narration owns nothing, and
|
||||
// the settled turn 1 keeps its seat.
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000 }],
|
||||
]))).toBe(6_000)
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000, endTime: 9_000 }],
|
||||
]))).toBeNull()
|
||||
})
|
||||
|
||||
it('formatRunDuration localizes units and floors partial seconds', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
expect(formatRunDuration(0, t)).toBe('0秒')
|
||||
expect(formatRunDuration(-500, t)).toBe('0秒')
|
||||
expect(formatRunDuration(15_999, t)).toBe('15秒')
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
}
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'first'),
|
||||
assistant(2, 'answer before tools'),
|
||||
toolResult(3, 'a'),
|
||||
interruptedThink,
|
||||
user(6, 'second'),
|
||||
assistant(7, 'clean tail', 2),
|
||||
user(10, 'user-only tail'),
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -231,20 +305,36 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('w1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => {
|
||||
// Covers the prepend early-return arm where lastItem exists but the key
|
||||
// path is not taken (anchor branch wins before the appended-user check).
|
||||
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
|
||||
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 view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
|
||||
const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
|
||||
let firstTop = 100
|
||||
let nextTop = 300
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: 0, bottom: 200 } as DOMRect),
|
||||
)
|
||||
vi.spyOn(first, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect),
|
||||
)
|
||||
vi.spyOn(next, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect),
|
||||
)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
scroller.scrollTop = 50
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// The reader moves after the request starts; this, not the click-time
|
||||
// row, is the intent the arriving page must preserve.
|
||||
firstTop = -200
|
||||
nextTop = 60
|
||||
readerScroll(scroller, 90)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
|
||||
nextTop = 560
|
||||
act(() => { h.set({ 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
|
||||
})
|
||||
|
||||
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
|
||||
@@ -256,6 +346,115 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('running tools')).toBeTruthy()
|
||||
expect(view.getAllByText('Bash')).toHaveLength(2)
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
|
||||
key: row.getAttribute('data-chat-flow-key'),
|
||||
kind: row.getAttribute('data-chat-flow-kind'),
|
||||
}))).toEqual([
|
||||
{ key: 'n1', kind: 'user' },
|
||||
{ key: 'n2', kind: 'assistant' },
|
||||
{ key: 'g3', kind: 'tool-group' },
|
||||
])
|
||||
expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
|
||||
.toEqual(['a', 'b'])
|
||||
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
|
||||
.toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
|
||||
})
|
||||
|
||||
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const pending = {
|
||||
id: 'steer-occurrence' as never,
|
||||
messageId: 'steer-message' as never,
|
||||
placement: 'steering' as const,
|
||||
content: [{ type: 'text' as const, text: 'interrupt now' }],
|
||||
preview: 'interrupt now',
|
||||
text: 'interrupt now',
|
||||
}
|
||||
const queued = {
|
||||
id: 'queued-occurrence' as never,
|
||||
messageId: 'queued-message' as never,
|
||||
placement: 'queued' as const,
|
||||
content: [{ type: 'text' as const, text: 'later' }],
|
||||
preview: 'later',
|
||||
text: 'later',
|
||||
}
|
||||
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()
|
||||
expect(view.queryByText('later')).toBeNull()
|
||||
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
|
||||
expect(pendingBubble).not.toBeNull()
|
||||
// Pending and durable steering carry the same interjection caption, so the
|
||||
// hand-off does not change what the row says it is.
|
||||
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
|
||||
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('interrupt now')
|
||||
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
queue: [queued],
|
||||
nodes: [
|
||||
assistant(1, 'working'),
|
||||
{
|
||||
kind: 'steering', messageId: pending.messageId,
|
||||
seq: 2, time: 2_000,
|
||||
content: [{ type: 'text', text: 'interrupt now' }], source: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByText('插话')).toHaveLength(1)
|
||||
// Only the durable steering bubble: the turn is still running, so its
|
||||
// assistant narration owns no footer yet, and a steering bubble never
|
||||
// carries a branch action.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1)
|
||||
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
|
||||
expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
||||
})
|
||||
// The completed turn's transcript tail is the steering bubble, not the
|
||||
// narration, so the assistant's branch action stays unavailable and the
|
||||
// steering bubble still offers none.
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(1)
|
||||
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(branchButtons[0]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
|
||||
const pending = {
|
||||
id: 'steer-occurrence-later' as never,
|
||||
messageId: 'shared-steer-message' as never,
|
||||
placement: 'steering' as const,
|
||||
content: [{ type: 'text' as const, text: 'same steering' }],
|
||||
preview: 'same steering',
|
||||
text: 'same steering',
|
||||
}
|
||||
const h = makeHarness({
|
||||
queue: [pending],
|
||||
nodes: [{
|
||||
kind: 'user', seq: 2, time: 2_000,
|
||||
content: pending.content, source: null,
|
||||
}],
|
||||
running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
expect(view.getAllByText('same steering')).toHaveLength(2)
|
||||
expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('animates only the latest unresolved model retry', () => {
|
||||
@@ -263,6 +462,8 @@ describe('ChatView', () => {
|
||||
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
|
||||
const context = {
|
||||
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as const satisfies ConversationNode
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -330,21 +531,145 @@ describe('ChatView', () => {
|
||||
user(5, 'next'),
|
||||
assistant(6, 'second turn', 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 4], [2, 6]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
|
||||
// Branch renders only under assistant answers; user bubbles keep copy alone.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(2)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('forks from both user and finalized assistant message actions at their event seq', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
|
||||
it('withholds assistant IconActions while the turn is still running', () => {
|
||||
const h = makeHarness({
|
||||
running: true,
|
||||
runningCalls: [runningCall('a')],
|
||||
nodes: [
|
||||
user(1, 'first'),
|
||||
assistant(2, 'previous answer', 1),
|
||||
user(4, 'second'),
|
||||
assistant(5, 'mid-turn text', 2),
|
||||
],
|
||||
// Boundary seqs follow the log: a turn/end is strictly after its own nodes.
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
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
|
||||
// the footer never appears and then moves.
|
||||
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]]) }) })
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('the actions-owning assistant footer shows the turn run time', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'), // time 1_000
|
||||
assistant(2, 'mid-turn text'),
|
||||
assistant(16, 'final answer'),
|
||||
toolResult(18, 'trailing'),
|
||||
],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
||||
turnEnds: new Map([[1, 20]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The exact turn/end includes trailing tool activity after the final text.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the settled footer appends first-step ttft and turn decode throughput', () => {
|
||||
const first: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
|
||||
usage: { outputTokens: 40 },
|
||||
}
|
||||
const second: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
|
||||
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
|
||||
usage: { outputTokens: 60 },
|
||||
}
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), first, second],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
||||
turnEnds: new Map([[1, 20]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// First-step ttft (1.2s) plus 100 tokens over 5s of decode.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1)
|
||||
expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('withholds ttft and throughput while the turn is still running', () => {
|
||||
const settled: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 },
|
||||
usage: { outputTokens: 10 },
|
||||
}
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), settled],
|
||||
turnTimings: new Map([[1, { startTime: 1_000 }]]),
|
||||
turnEnds: new Map(),
|
||||
running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
|
||||
})
|
||||
|
||||
it('user and assistant message containers scope the hover-revealed time chrome', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), assistant(2, 'answer')],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
|
||||
turnEnds: new Map([[1, 2]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// One scope per message row; the CSS reveal keys off this attribute.
|
||||
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('the run-time label is withheld when the turn start is outside the window', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [assistant(16, 'tail without trigger')],
|
||||
turnEnds: new Map([[1, 16]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/用时/)).toBeNull()
|
||||
})
|
||||
|
||||
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer')],
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The user bubble offers no branch; the settled answer's is live.
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
|
||||
expect(h.forkAt.mock.calls).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
}
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(buttons[0]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
|
||||
@@ -417,7 +742,8 @@ describe('ChatView', () => {
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = ((_key: string, _owner: object) => {
|
||||
h.props.renderSlot = ((key: string, _owner: object) => {
|
||||
if (key !== 'conversation.chat.toolview') return null
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
@@ -474,6 +800,30 @@ describe('ChatView', () => {
|
||||
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||
})
|
||||
|
||||
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 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: [{
|
||||
id: 'steering-occurrence' as never,
|
||||
messageId: 'steering-message' as never,
|
||||
placement: 'steering',
|
||||
content: [{ type: 'text', text: 'also' }],
|
||||
preview: 'also',
|
||||
text: 'also',
|
||||
}] })
|
||||
})
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
})
|
||||
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
@@ -489,31 +839,106 @@ describe('ChatView', () => {
|
||||
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
|
||||
})
|
||||
|
||||
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
|
||||
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 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.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
|
||||
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
|
||||
let anchoredTop = 100
|
||||
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
|
||||
)
|
||||
readerScroll(scroller, 80)
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
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')] }) })
|
||||
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
|
||||
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')] }) })
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'call:late') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// Total height grows by 500, but only 300 belongs before the call row.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
|
||||
const h = makeHarness({ nodes: [retry(5)], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:5') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('back-to-bottom cancels an in-flight paging anchor', () => {
|
||||
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 })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 50)
|
||||
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')] }) })
|
||||
expect(scroller.scrollTop).toBe(1_300)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
})
|
||||
|
||||
it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
scroller.scrollTop = 100 // far from bottom
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 100) // far from bottom
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||
@@ -525,6 +950,73 @@ describe('ChatView', () => {
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
// Stream finalization shrinks the column: the browser clamps the pinned
|
||||
// position onto the new floor and delivers a scroll event. The clamp
|
||||
// lands exactly on the ledger's floor min, so it is not reader input.
|
||||
metrics.setLayout(800, 700)
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(500)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
metrics.setHeight(1_200)
|
||||
act(() => { h.set({ running: true }) })
|
||||
expect(scroller.scrollTop).toBe(900)
|
||||
})
|
||||
|
||||
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
// Chromium advances compositor geometry before delivering the event:
|
||||
// attribution must compare against the observed-top ledger, never a
|
||||
// baseline sampled from already-moved raw geometry.
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
|
||||
let notify: (() => void) | undefined
|
||||
const observe = vi.fn()
|
||||
class ResizeObserverStub {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
notify = () => { callback([], this as unknown as ResizeObserver) }
|
||||
}
|
||||
|
||||
observe = observe
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(1_200)
|
||||
readerScroll(scroller, 200)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(200)
|
||||
expect(observe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -533,8 +1025,7 @@ describe('ChatView', () => {
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
|
||||
// re-render from setAtBottom must not force scrollTop to scrollHeight.
|
||||
scroller.scrollTop = 690 // distance-to-bottom = 10
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 690) // distance-to-bottom = 10
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
})
|
||||
@@ -551,8 +1042,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// Open jump uses the host, not the local .scroll node.
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
readerScroll(host, 100)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
@@ -561,29 +1051,72 @@ describe('ChatView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
|
||||
it('a remount restores the saved semantic row after width reflow', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
let anchorTop = 80
|
||||
vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: 0, bottom: 500 } as DOMRect),
|
||||
)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') {
|
||||
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 40 } as DOMRect
|
||||
})
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
// Fresh open (nothing saved): the bottom jump stands.
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
// Reader scrolls up; the position is recorded continuously.
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
readerScroll(host, 100)
|
||||
// View-tab switch away and back: the view unmounts, then remounts.
|
||||
view.rerender(<div />)
|
||||
anchorTop = 560
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(100)
|
||||
expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
|
||||
// The restored position is above the floor: follow stays disarmed.
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
let scrollTop = 0
|
||||
Object.defineProperty(host, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = Math.min(value, 1_500) },
|
||||
})
|
||||
document.body.appendChild(host)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
|
||||
return { top: 0, bottom: 500 } as DOMRect
|
||||
})
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(1_500)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(1_500)
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
158
packages/client/ui-conversation/tests/context-meter.spec.tsx
Normal file
158
packages/client/ui-conversation/tests/context-meter.spec.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment jsdom
|
||||
// ContextMeter (composer trailing control): occupancy ring gating, the
|
||||
// click-open breakdown panel, and its close gestures.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
|
||||
import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
|
||||
import css from '../src/client/skeleton/ContextMeter.module.css'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
|
||||
const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t']
|
||||
|
||||
const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 }
|
||||
|
||||
const segmentClass = css.segment
|
||||
if (segmentClass === undefined) throw new Error('segment class missing from ContextMeter.module.css')
|
||||
|
||||
/** Stub the projection seat: a key-addressed table of whole values. */
|
||||
function projections(values: Record<string, unknown>): ContextMeterProps['useProjection'] {
|
||||
return (key: string) => values[key]
|
||||
}
|
||||
|
||||
function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'] = t) {
|
||||
return render(<ContextMeter useProjection={projections(values)} t={translate} />)
|
||||
}
|
||||
|
||||
describe('ContextMeter', () => {
|
||||
it('renders nothing until both pressure and capacity are known', () => {
|
||||
expect(meter({}).container.textContent).toBe('')
|
||||
expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('')
|
||||
expect(meter({ contextPressure: { contextWindow: 128_000 } }).container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('shows the occupancy ring and opens the breakdown panel on click', () => {
|
||||
const view = meter({
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
})
|
||||
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
fireEvent.click(trigger)
|
||||
const panel = view.container.querySelector('[role="dialog"]')!
|
||||
expect(panel.textContent).toContain('~32K / 128K')
|
||||
expect(panel.textContent).toContain('25%')
|
||||
expect(panel.textContent).toContain('上下文已用')
|
||||
expect(panel.textContent).toContain('系统提示词~120')
|
||||
expect(panel.textContent).toContain('工具~21.5K')
|
||||
expect(panel.textContent).toContain('对话消息~477K')
|
||||
// The occupancy bar splits into one colored segment per composition row.
|
||||
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
|
||||
// Clicking the trigger again toggles the panel shut.
|
||||
fireEvent.click(trigger)
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets each locale own the headline word order around the reading', () => {
|
||||
const values = {
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
}
|
||||
const zhView = meter(values)
|
||||
fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
|
||||
// The reading follows the label in Chinese and leads it in English; both
|
||||
// headers read as one sentence rather than a concatenated fragment.
|
||||
expect(zhView.container.querySelector('[role="dialog"]')!.textContent)
|
||||
.toMatch(/^上下文已用25%/)
|
||||
const enView = meter(values, tEn)
|
||||
fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
|
||||
expect(enView.container.querySelector('[role="dialog"]')!.textContent)
|
||||
.toMatch(/^25%of context used/)
|
||||
})
|
||||
|
||||
it('draws no bar segment at zero occupancy', () => {
|
||||
const view = meter({
|
||||
contextPressure: { pressureTokens: 0, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
})
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
|
||||
const panel = view.container.querySelector('[role="dialog"]')!
|
||||
// `.segment` carries a min-width, so a zero-width part would still paint a
|
||||
// filled sliver over an empty context.
|
||||
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
|
||||
expect(panel.textContent).toContain('~0 / 128K')
|
||||
})
|
||||
|
||||
it('reads the ring from the projected figure so a compaction shows at once', () => {
|
||||
// Same provider sample, a surface a compaction just shrank: the ring must
|
||||
// follow the projection rather than the sample it is anchored to.
|
||||
const view = meter({
|
||||
contextPressure: { pressureTokens: 32_000, projectedTokens: 3_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
})
|
||||
const trigger = view.getByRole('button', { name: '上下文已用 2%' })
|
||||
fireEvent.click(trigger)
|
||||
expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K')
|
||||
})
|
||||
|
||||
it('omits the composition rows while the contextBreakdown projection is absent', () => {
|
||||
const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
|
||||
const panel = view.container.querySelector('[role="dialog"]')!
|
||||
expect(panel.textContent).toContain('~32K / 128K')
|
||||
expect(panel.textContent).not.toContain('系统提示词')
|
||||
expect(panel.textContent).not.toContain('对话消息')
|
||||
// Without composition shares, the bar falls back to one plain segment.
|
||||
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes when capacity disappears and stays closed when it returns', () => {
|
||||
let values: Record<string, unknown> = {
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
}
|
||||
const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
|
||||
expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
|
||||
values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN }
|
||||
view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
|
||||
expect(view.container.textContent).toBe('')
|
||||
|
||||
values = {
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
}
|
||||
view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
|
||||
expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on outside pointerdown and Escape — but not inside clicks', () => {
|
||||
const view = meter({
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
})
|
||||
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
|
||||
const openPanel = () => {
|
||||
fireEvent.click(trigger)
|
||||
return view.container.querySelector('[role="dialog"]')!
|
||||
}
|
||||
// A pointerdown inside the panel keeps it open; outside closes it.
|
||||
const again = openPanel()
|
||||
fireEvent.pointerDown(again)
|
||||
expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
// Escape.
|
||||
openPanel()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -89,13 +89,15 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
|
||||
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
@@ -125,7 +127,7 @@ describe('tails', () => {
|
||||
runningView.unmount()
|
||||
|
||||
const errorView = render(<BashRow {...props(errorResult)} />)
|
||||
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(errorView.getByText('失败')).toBeTruthy()
|
||||
errorView.unmount()
|
||||
|
||||
@@ -153,9 +153,11 @@ describe('chat row diff body', () => {
|
||||
describe('FileMutationRow diff card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
|
||||
@@ -272,8 +274,14 @@ describe('fileMutationToolview registration', () => {
|
||||
it('registers one component under both edit and write, and each disposes', () => {
|
||||
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
|
||||
const disposers: (() => void)[] = []
|
||||
let disposeInjection = (): void => {}
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
const active = [...callback()]
|
||||
disposeInjection = () => { for (const dispose of active.reverse()) dispose() }
|
||||
return disposeInjection
|
||||
},
|
||||
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
|
||||
const entry = { key, locale, disposed: false }
|
||||
registered.push(entry)
|
||||
@@ -287,10 +295,9 @@ describe('fileMutationToolview registration', () => {
|
||||
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
|
||||
// Both keys claim the conversation locale seat ToolRow's body copy needs.
|
||||
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
|
||||
// The registrant's inject seam is the load-order contract the row relies on.
|
||||
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(fileMutationToolview.inject).toEqual(['slots'])
|
||||
// Disposal removes each contribution (packages/AGENTS.md registry contract).
|
||||
for (const dispose of disposers) dispose()
|
||||
disposeInjection()
|
||||
expect(registered.every(r => r.disposed)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -301,12 +308,14 @@ describe('DetailsPanel diff Output section', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
@@ -337,10 +346,10 @@ describe('DetailsPanel diff Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx'
|
||||
import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx'
|
||||
import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
function emptySessions() {
|
||||
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) })
|
||||
const props: EnterBehaviorRowProps = {
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
|
||||
setBusyEnter,
|
||||
t: makeTranslate(en),
|
||||
}
|
||||
render(<EnterBehaviorRow {...props} />)
|
||||
return { policy, setBusyEnter }
|
||||
}
|
||||
|
||||
describe('EnterBehaviorRow', () => {
|
||||
it('explains the busy-only scope and shows Queue by default', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Enter behavior while busy')).toBeDefined()
|
||||
expect(screen.getByText('Busy only; Cmd/Ctrl+Enter uses the other behavior')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /Queue/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('selects Steer, follows later preference changes, and closes outside', () => {
|
||||
const b = mount()
|
||||
const trigger = screen.getByRole('button', { name: /Queue/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Steer' }))
|
||||
expect(b.setBusyEnter).toHaveBeenCalledWith('steer')
|
||||
expect(screen.getByRole('button', { name: /Steer/ })).toBeDefined()
|
||||
|
||||
act(() => { b.policy.setBusyEnter('queue') })
|
||||
const queueTrigger = screen.getByRole('button', { name: /Queue/ })
|
||||
fireEvent.click(queueTrigger)
|
||||
expect(screen.getByRole('menuitem', { name: 'Steer' })).toBeDefined()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Steer' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -18,15 +18,26 @@ import { zh } from '../src/client/locales.ts'
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +67,12 @@ describe('render branch tails', () => {
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
t={t}
|
||||
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useProjection={() => undefined}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('2 turns · 3 steps')
|
||||
expect(view.container.textContent).toBe('2 轮 · 3 步')
|
||||
})
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
@@ -76,7 +88,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
@@ -119,7 +131,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
|
||||
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
|
||||
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
|
||||
// semantics (input stays free; primary turns stop), the machine pending lock,
|
||||
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -12,23 +12,35 @@ 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 { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { ComposerAttachment } from '../src/client/contract/slots.ts'
|
||||
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
|
||||
// is absent — and the composer measures the caret with one when it restores the
|
||||
// selection after an edit it performed itself. Every case here runs against a
|
||||
// zero rect; the reveal case below substitutes its own and restores this one.
|
||||
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
|
||||
Range.prototype.getBoundingClientRect = ZERO_RECT
|
||||
|
||||
// Read through the descriptor so the native method is never referenced unbound;
|
||||
// the reveal case below wraps it to record what it was asked to measure.
|
||||
const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
|
||||
.value as (this: Range, node: Node, offset: number) => void
|
||||
|
||||
const SCTX = {} as ClientContext
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +55,7 @@ interface BenchOptions {
|
||||
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
||||
draft?: string
|
||||
running?: boolean
|
||||
subagent?: Exclude<ConversationSnapshot['subagent'], null>
|
||||
disabled?: boolean
|
||||
promptError?: ConversationSnapshot['promptError']
|
||||
variant?: 'hero' | 'composer'
|
||||
@@ -56,6 +69,7 @@ interface BenchOptions {
|
||||
attachments?: readonly ComposerAttachment[]
|
||||
addImages?: (files: readonly File[]) => string | null
|
||||
commandMenuOpen?: boolean
|
||||
busyEnter?: 'queue' | 'steer'
|
||||
toggleCommandMenu?: (selection: { start: number; end: number }) => void
|
||||
}
|
||||
|
||||
@@ -81,6 +95,7 @@ function bench(over?: BenchOptions) {
|
||||
if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
|
||||
running: over?.running ?? false,
|
||||
subagent: over?.subagent ?? null,
|
||||
removed: over?.disabled ?? false,
|
||||
promptError: over?.promptError ?? null,
|
||||
}))
|
||||
@@ -100,6 +115,7 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
@@ -116,6 +132,11 @@ function bench(over?: BenchOptions) {
|
||||
const attachment = over?.attachments?.find(candidate => candidate.id === id)
|
||||
return attachment === undefined ? [] : [attachment]
|
||||
}),
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) => {
|
||||
if (!running || !steeringAvailable) return 'queue'
|
||||
const preferred = over?.busyEnter ?? 'queue'
|
||||
return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue'
|
||||
},
|
||||
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
@@ -134,20 +155,72 @@ function bench(over?: BenchOptions) {
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
// aria-label (not role name): title carries the same label and would double-match.
|
||||
const stopping = over?.running === true && over.subagent === undefined
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
|
||||
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
|
||||
)!
|
||||
return {
|
||||
view, textarea, button, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls, menuLauncher,
|
||||
}
|
||||
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls, menuLauncher }
|
||||
}
|
||||
|
||||
describe('image draft rail', () => {
|
||||
it('collects clipboard files while preserving text from a mixed paste', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { textarea, shell } = bench({ addImages })
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
|
||||
{ kind: 'file', type: 'image/png', getAsFile: () => image },
|
||||
],
|
||||
getData: () => '同时粘贴的文字',
|
||||
},
|
||||
})
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||
})
|
||||
|
||||
it('accepts file drops and prevents browser navigation', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
||||
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
|
||||
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
|
||||
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
})
|
||||
|
||||
it('sends an image-only draft and removes its thumbnail', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
|
||||
expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue')
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
||||
})
|
||||
|
||||
it('opens the original image on double-click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
|
||||
const { textarea, sink } = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('hello', [])
|
||||
expect(sink).toHaveBeenCalledWith('hello', [], 'queue')
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
|
||||
expect(sink).toHaveBeenCalledTimes(1)
|
||||
const empty = bench({ draft: ' ' })
|
||||
@@ -169,12 +242,18 @@ describe('Enter semantics', () => {
|
||||
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
|
||||
})
|
||||
|
||||
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
|
||||
const { textarea, shell, sink } = bench({ draft: 'hello' })
|
||||
textarea.setSelectionRange(5, 5)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(shell.snapshot.draft).toBe('hello\n')
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
|
||||
const idle = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue')
|
||||
|
||||
const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
|
||||
fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer')
|
||||
|
||||
const busyMeta = bench({ running: true, draft: 'steer with cmd' })
|
||||
fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer')
|
||||
})
|
||||
|
||||
it('platform undo/redo chords route to the machine, never the browser stack', () => {
|
||||
@@ -215,12 +294,78 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect(textarea.disabled).toBe(false) // running no longer locks
|
||||
fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', [])
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue')
|
||||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||||
fireEvent.click(button)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('running plain Enter follows the busy-state Steer preference', () => {
|
||||
const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer')
|
||||
})
|
||||
|
||||
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
|
||||
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
|
||||
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue')
|
||||
|
||||
const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
|
||||
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue')
|
||||
})
|
||||
|
||||
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
|
||||
const { button, sink, stop } = bench({
|
||||
running: true,
|
||||
draft: '后续消息',
|
||||
subagent: {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
},
|
||||
parentAvailable: true,
|
||||
},
|
||||
})
|
||||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue')
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
|
||||
const empty = bench({
|
||||
running: true,
|
||||
subagent: {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
},
|
||||
parentAvailable: true,
|
||||
},
|
||||
})
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps both running subagent Enter gestures on Queue transport', () => {
|
||||
const subagent = {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable' as const,
|
||||
},
|
||||
parentAvailable: true,
|
||||
}
|
||||
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
|
||||
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
|
||||
expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue')
|
||||
|
||||
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
|
||||
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue')
|
||||
})
|
||||
|
||||
it('disabled (session removed) locks the textarea and chrome', () => {
|
||||
const { textarea, view } = bench({ disabled: true })
|
||||
expect(textarea.disabled).toBe(true)
|
||||
@@ -231,7 +376,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
const { button, sink } = bench({ draft: 'go' })
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('go', [])
|
||||
expect(sink).toHaveBeenCalledWith('go', [], 'queue')
|
||||
const empty = bench()
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
})
|
||||
@@ -253,7 +398,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
|
||||
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
@@ -269,17 +414,18 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
|
||||
it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
|
||||
const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
|
||||
let scrollTop = 150
|
||||
Object.defineProperty(textarea, 'scrollTop', {
|
||||
Object.defineProperty(scrollport, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
@@ -303,35 +449,151 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
|
||||
it('the caret layer and the glyph layer ride one scrollport', () => {
|
||||
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
|
||||
textarea.scrollTop = 120
|
||||
fireEvent.scroll(textarea)
|
||||
expect(backdrop.scrollTop).toBe(120)
|
||||
// Every later move tracks too, including back to the top — a one-shot
|
||||
// mirror would leave the glyphs parked at the first offset it saw.
|
||||
textarea.scrollTop = 0
|
||||
fireEvent.scroll(textarea)
|
||||
expect(backdrop.scrollTop).toBe(0)
|
||||
// The caret is the textarea's and every visible glyph is the backdrop's, so
|
||||
// one box has to carry both or an offset can exist in one and not the other.
|
||||
// jsdom has no layout and loads no stylesheet — which box scrolls is the
|
||||
// browser scenario's to assert; what is checkable here is that the
|
||||
// scrollport element holds both layers.
|
||||
expect(scroll.contains(textarea)).toBe(true)
|
||||
expect(scroll.contains(backdrop)).toBe(true)
|
||||
// The glyph layer carries the draft and nothing else: with one scrollport
|
||||
// it no longer pads its own height to match a second box's scroll extent.
|
||||
expect(backdrop.textContent).toBe('line\n'.repeat(40))
|
||||
})
|
||||
|
||||
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
|
||||
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
|
||||
// (the browser scenario owns that); what is checkable is that the backdrop's
|
||||
// text is the draft plus exactly one newline. A textarea reserves a line box
|
||||
// after a final newline and `pre-wrap` collapses one, so without the
|
||||
// sentinel a draft ending in a newline leaves the backdrop a line short and
|
||||
// the mirrored offset clamps.
|
||||
const withNewline = bench({ draft: 'alpha\nbeta\n' })
|
||||
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
|
||||
const withoutNewline = bench({ draft: 'alpha\nbeta' })
|
||||
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
expect(plain.textContent).toBe('alpha\nbeta\n')
|
||||
it('an edit the composer performs itself scrolls the caret back into view', async () => {
|
||||
// Paste and cut suppress the native edit, so no engine reveals the caret
|
||||
// for them. jsdom has no layout: the rects are stubbed,
|
||||
// and what is asserted is the arithmetic — minimal scroll, in both
|
||||
// directions, and nothing at all for a caret already inside the box.
|
||||
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
expect(mirror.firstChild).toBeInstanceOf(Text)
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
// jsdom reports scrollHeight === clientHeight for every element, which is
|
||||
// the composer's own "nothing to reveal" case; a scrollable box is what
|
||||
// puts the reveal on the table at all.
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
onTestFinished(() => {
|
||||
Range.prototype.getBoundingClientRect = ZERO_RECT
|
||||
Range.prototype.setStart = NATIVE_SET_START
|
||||
})
|
||||
// Which layer the caret is measured against, and at which index: the stub
|
||||
// records `setStart` so a helper that measured the backdrop instead, or
|
||||
// always collapsed at 0, fails here rather than only in the browser lane.
|
||||
let measured: { node: Node; offset: number } | null = null
|
||||
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
||||
measured = { node, offset }
|
||||
NATIVE_SET_START.call(this, node, offset)
|
||||
}
|
||||
const caretAt = (top: number): void => {
|
||||
Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
|
||||
}
|
||||
const settle = async (): Promise<void> => {
|
||||
await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
|
||||
}
|
||||
// Pasted text lands below the fold: scroll down by exactly the overshoot.
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88) // 524 - 436
|
||||
// Measured on the mirror's own text, at the index the paste left the caret
|
||||
// (an empty draft's selection start, 0, plus the pasted length).
|
||||
expect(measured!.node).toBe(mirror.firstChild)
|
||||
expect(measured!.offset).toBe('pasted'.length)
|
||||
// A caret already inside the box does not move it.
|
||||
caretAt(200)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88)
|
||||
// Above the fold (a cut can leave it there): scroll back up.
|
||||
caretAt(60)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
|
||||
// A caret straight after a newline has nothing on its line to measure, so
|
||||
// the newline it just left is measured instead and one line is added.
|
||||
// chromium reports no client rects at all for the collapsed position.
|
||||
mirror.style.lineHeight = '24px'
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
|
||||
await settle()
|
||||
// The four pastes accumulate at the draft's head, so the caret is at the
|
||||
// end of what they inserted — and the measured index is the newline before it.
|
||||
expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
|
||||
expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
|
||||
})
|
||||
|
||||
it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
|
||||
// The composer DOM is reused across sessions, so the previous session's
|
||||
// offset survives while the value swap puts the caret at the new draft's
|
||||
// end. `preventScroll` keeps the browser from revealing it through the
|
||||
// conversation scrollport, which leaves the reveal to the effect itself.
|
||||
const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
||||
// The draft ends in a newline, so the reveal takes the after-newline path
|
||||
// and needs a resolvable line-height (jsdom computes `normal`).
|
||||
mirror.style.lineHeight = '24px'
|
||||
// Which index the effect reveals at, not merely that it scrolled: a
|
||||
// revealCaret(0) would land the same offset without this.
|
||||
onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
|
||||
let measured: { node: Node; offset: number } | null = null
|
||||
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
||||
measured = { node, offset }
|
||||
NATIVE_SET_START.call(this, node, offset)
|
||||
}
|
||||
const focused: (boolean | undefined)[] = []
|
||||
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
||||
expect(focused).toEqual([true])
|
||||
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
||||
// The draft ends in a newline, so the rule measures that newline: the
|
||||
// caret's own index is the mirror text's length minus its sentinel.
|
||||
expect(measured!.node).toBe(mirror.firstChild)
|
||||
expect(measured!.offset).toBe(textarea.value.length - 1)
|
||||
})
|
||||
|
||||
it('a persisted draft adopted after mount gets its caret revealed too', () => {
|
||||
// ConversationSession seeds the stored draft in its own mount effect, which
|
||||
// runs after this component's: the first reveal measures an empty mirror,
|
||||
// so the draft's arrival has to run it again without reclaiming focus.
|
||||
const { view, textarea, shell } = bench()
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
// The restored draft ends in a newline, so the reveal takes the
|
||||
// after-newline path and needs a resolvable line-height (jsdom says `normal`).
|
||||
mirror.style.lineHeight = '24px'
|
||||
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
||||
const other = document.createElement('input')
|
||||
document.body.appendChild(other)
|
||||
onTestFinished(() => { other.remove() })
|
||||
other.focus()
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
act(() => { shell.setDraft('restored\n'.repeat(40)) })
|
||||
expect(document.activeElement).toBe(other)
|
||||
// The caret the machine left at the draft's end, revealed once the draft exists.
|
||||
expect(textarea.selectionStart).toBe(textarea.value.length)
|
||||
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
@@ -378,26 +640,6 @@ describe('machine pending lock', () => {
|
||||
expect(textarea.readOnly).toBe(true)
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('addImages reports refusal in busy phases so callers keep draft ownership', () => {
|
||||
const { shell } = bench()
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
{
|
||||
token: '/goal ',
|
||||
submit: () => new Promise<never>(() => {}), // never settles: stays submitting
|
||||
},
|
||||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
shell.submit()
|
||||
})
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
// A refused batch must be observable (the workspace-switch transfer keeps
|
||||
// the source shell's drafts alive instead of leaking their object URLs).
|
||||
expect(shell.addImages(['busy-1' as never])).toBe(false)
|
||||
expect(shell.snapshot.imageIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('decorations', () => {
|
||||
@@ -509,103 +751,6 @@ describe('strips and variants', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('image draft rail', () => {
|
||||
it('collects clipboard files while preserving text from a mixed paste', () => {
|
||||
const addImages = vi.fn((files: readonly File[]) =>
|
||||
files.some(file => file.type === 'video/mp4') ? '不支持的图片格式:video/mp4' : null)
|
||||
const { view, textarea, shell } = bench({ addImages })
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
|
||||
{ kind: 'file', type: 'image/png', getAsFile: () => image },
|
||||
],
|
||||
getData: () => '同时粘贴的文字',
|
||||
},
|
||||
})
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => video }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
expect(addImages).toHaveBeenCalledTimes(2)
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('accepts supported image drops, highlights the target, and prevents browser navigation', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' })
|
||||
const dataTransfer = {
|
||||
types: ['Files'],
|
||||
files: [image],
|
||||
dropEffect: 'none',
|
||||
}
|
||||
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
|
||||
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
|
||||
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
})
|
||||
|
||||
it('ignores unsupported dropped files and refuses drops while locked', () => {
|
||||
const addImages = vi.fn((files: readonly File[]) =>
|
||||
files.some(file => file.type === 'text/plain') ? '不支持的图片格式:text/plain' : null)
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' })
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
|
||||
})
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
expect(addImages).toHaveBeenCalledWith([documentFile])
|
||||
|
||||
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
|
||||
const locked = bench({ disabled: true, addImages })
|
||||
const lockedCard = locked.view.container.querySelector('[class*="card"]')!
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
|
||||
fireEvent.dragEnter(lockedCard, { dataTransfer })
|
||||
expect(locked.view.queryByRole('status')).toBeNull()
|
||||
fireEvent.dragOver(lockedCard, { dataTransfer })
|
||||
expect(dataTransfer.dropEffect).toBe('none')
|
||||
fireEvent.drop(lockedCard, { dataTransfer })
|
||||
expect(addImages).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
|
||||
const send = view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement
|
||||
expect(send.disabled).toBe(false)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('', ['draft-1'])
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
||||
})
|
||||
|
||||
it('opens the original preview on double-click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
expect(view.getAllByAltText('pixel.png').every(node => (node as HTMLImageElement).src.includes('blob:draft-1'))).toBe(true)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('command launcher chrome and control seats', () => {
|
||||
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
@@ -644,6 +789,8 @@ describe('command launcher chrome and control seats', () => {
|
||||
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Read Only')
|
||||
expect([...trigger.querySelectorAll('svg')]
|
||||
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
|
||||
@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
|
||||
}
|
||||
|
||||
/** Drive plain → adjudicating and hand back the minted attempt. */
|
||||
function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt {
|
||||
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
|
||||
m.dispatch({ type: 'draft-changed', draft })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
const fx = m.dispatch({ type: 'enter', mode })
|
||||
return effectAt(fx, 0, 'adjudicate').attempt
|
||||
}
|
||||
|
||||
@@ -52,35 +52,42 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
|
||||
m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
|
||||
m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
|
||||
m.dispatch({ type: 'draft-changed', draft: claim.token + args })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
|
||||
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
|
||||
}
|
||||
|
||||
function staleAttempt(): SubmitAttempt {
|
||||
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' }
|
||||
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
|
||||
}
|
||||
|
||||
describe('input-machine: plain × enter', () => {
|
||||
it('empty and whitespace-only drafts produce nothing', () => {
|
||||
const m = new InputMachine()
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('non-command text falls to the default sink', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
|
||||
expect(m.dispatch({ type: 'enter' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'hello world' }])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'queue' }])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('retains an explicit steer mode on the default sink effect', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: 'steer now' })
|
||||
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'steer now', mode: 'steer' }])
|
||||
})
|
||||
|
||||
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
|
||||
const eff = effectAt(fx, 0, 'adjudicate')
|
||||
expect(eff.draft).toBe('/goal x')
|
||||
expect(eff.attempt.draftSnapshot).toBe('/goal x')
|
||||
@@ -91,14 +98,14 @@ describe('input-machine: plain × enter', () => {
|
||||
it('leading is judged after trim including newlines', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
|
||||
expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate')
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
|
||||
})
|
||||
|
||||
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
|
||||
expect(m.dispatch({ type: 'enter' }))
|
||||
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
|
||||
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,9 +133,9 @@ describe('input-machine: adjudication outcomes', () => {
|
||||
|
||||
it('undefined outcome falls back to the default sink', () => {
|
||||
const m = new InputMachine()
|
||||
const attempt = enterAdjudicating(m, '/unknown thing')
|
||||
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
|
||||
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
|
||||
.toEqual([{ type: 'default-sink', draft: '/unknown thing' }])
|
||||
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
@@ -152,7 +159,7 @@ describe('input-machine: adjudication outcomes', () => {
|
||||
it('enter is a no-op while adjudicating (pending lock)', () => {
|
||||
const m = new InputMachine()
|
||||
enterAdjudicating(m, '/goal x')
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.state.phase).toBe('adjudicating')
|
||||
})
|
||||
|
||||
@@ -344,31 +351,6 @@ describe('input-machine: occurrence reconciliation on draft edits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('input-machine: newline transaction (F1)', () => {
|
||||
it('inserts \\n at the caret and shifts trailing occurrences', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
|
||||
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
|
||||
expect(m.state.draft).toBe(`ab\n ${P} `)
|
||||
expect(m.state.occurrences[0]?.offset).toBe(4)
|
||||
m.dispatch({ type: 'undo' })
|
||||
expect(m.state.draft).toBe(`ab ${P} `)
|
||||
})
|
||||
|
||||
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '/go' })
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([])
|
||||
expect(m.state.phase).toBe('claimed')
|
||||
m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } })
|
||||
expect(m.state.draft).toBe('\n/goal ')
|
||||
expect(m.state.phase).toBe('plain')
|
||||
expect(m.state.claim).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('input-machine: consume-token guards', () => {
|
||||
it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
|
||||
const m = new InputMachine()
|
||||
@@ -587,7 +569,7 @@ describe('input-machine: paste plane', () => {
|
||||
|
||||
const b = new InputMachine()
|
||||
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
|
||||
b.dispatch({ type: 'enter' })
|
||||
b.dispatch({ type: 'enter', mode: 'queue' })
|
||||
expect(b.state.paste).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -755,7 +737,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
|
||||
const m = new InputMachine()
|
||||
enterSubmitting(m, 'goal', 'x')
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
|
||||
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
|
||||
})
|
||||
@@ -768,7 +750,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
|
||||
const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
|
||||
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
|
||||
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
|
||||
expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
|
||||
expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
|
||||
@@ -809,7 +791,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
const m = new InputMachine()
|
||||
const { attempt: first } = enterSubmitting(m, 'goal', 'x')
|
||||
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
|
||||
const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
|
||||
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
|
||||
expect(second.seq).not.toBe(first.seq)
|
||||
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
|
||||
expect(m.state.phase).toBe('submitting')
|
||||
|
||||
@@ -26,10 +26,10 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
})
|
||||
const props: InputBarProps = {
|
||||
sessionId: SID,
|
||||
@@ -37,6 +37,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
@@ -49,6 +50,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
addImages: () => null,
|
||||
removeImage: () => {},
|
||||
draftImages: () => [],
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: vi.fn(),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
@@ -90,7 +92,7 @@ describe('matrix row: plain', () => {
|
||||
fireEvent.change(textarea, { target: { value: '普通消息' } })
|
||||
expect(shell.snapshot.claim).toBeUndefined()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('普通消息', [])
|
||||
expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue')
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
})
|
||||
@@ -189,7 +191,7 @@ describe('matrix row: locked (session disabled)', () => {
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队', [])
|
||||
expect(sink).toHaveBeenCalledWith('排队', [], 'queue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -112,10 +112,10 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
})
|
||||
const barProps: InputBarProps = {
|
||||
sessionId,
|
||||
@@ -123,6 +123,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
@@ -135,6 +136,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
addImages: () => null,
|
||||
removeImage: () => {},
|
||||
draftImages: () => [],
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: (selection) => {
|
||||
const snapshot = shell.snapshot
|
||||
controller.toggleSource('command', {
|
||||
@@ -237,7 +239,7 @@ describe('scenario D: execute-kind /compact', () => {
|
||||
act(() => { b2.shell.setDraft('/compact 现在') })
|
||||
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
|
||||
// execute with trailing → matchEnter answers undefined → default sink.
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', []) })
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') })
|
||||
expect(b2.executed).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -291,7 +293,7 @@ describe('scenario I: unknown /xyz + enter', () => {
|
||||
const b = await bench()
|
||||
act(() => { b.shell.setDraft('/xyz 干点啥') })
|
||||
fireEvent.keyDown(b.textarea, { key: 'Enter' })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', []) })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') })
|
||||
expect(b.shell.snapshot.phase).toBe('plain')
|
||||
expect(b.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* QueueDock rendering and operations: authoritative rows, inline editing,
|
||||
* collapse state, removal, failure notices, and live retirement.
|
||||
* collapse state, removal, strict steering, failure notices, and live retirement.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
@@ -23,14 +23,18 @@ const SID = 's1' as SessionId
|
||||
const iid = (id: string): QueueItemId => id as QueueItemId
|
||||
|
||||
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
|
||||
return { id: iid(id), preview, text }
|
||||
return {
|
||||
id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
|
||||
content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
|
||||
preview, text,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +90,14 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('leaves pending steering to the conversation flow', () => {
|
||||
const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
|
||||
const snap = snapshotWith([steering])
|
||||
const source = liveSession(snap)
|
||||
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
|
||||
const single = snapshotWith([row('i-1', 'one')])
|
||||
const source = liveSession(single)
|
||||
@@ -188,10 +200,10 @@ describe('QueueDock', () => {
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
expect([...container.querySelectorAll('li')].map(item => item.textContent))
|
||||
.toEqual(['第一条排队消息', 'image [image]'])
|
||||
expect(container.querySelectorAll('button')).toHaveLength(5)
|
||||
expect(container.querySelectorAll('button')).toHaveLength(7)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
|
||||
expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
|
||||
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
|
||||
@@ -274,6 +286,68 @@ describe('QueueDock', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('strictly steers complete row content only while the agent is running', async () => {
|
||||
const running = snapshotWith([row('i-steer', null, 'image [image]')])
|
||||
const source = liveSession(running)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const rendered = render(
|
||||
<QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
const button = rendered.getByLabelText('插话发送')
|
||||
expect(button).toHaveProperty('disabled', false)
|
||||
fireEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
|
||||
})
|
||||
|
||||
act(() => { source.push({ ...running, running: false }) })
|
||||
expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
|
||||
expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
|
||||
})
|
||||
|
||||
it('renders a session-backed subagent Queue without unsupported actions', () => {
|
||||
const snap = {
|
||||
...snapshotWith([row('i-subagent', 'pending child follow-up')]),
|
||||
subagent: {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable' as const,
|
||||
},
|
||||
parentAvailable: true,
|
||||
},
|
||||
}
|
||||
const source = liveSession(snap)
|
||||
const view = render(
|
||||
<QueueDock {...kitFor(snap)} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
expect(view.getByText('pending child follow-up')).toBeTruthy()
|
||||
expect(view.queryByLabelText('编辑排队消息')).toBeNull()
|
||||
expect(view.queryByLabelText('删除排队消息')).toBeNull()
|
||||
expect(view.queryByLabelText('插话发送')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the row and reports a genuine steer failure', async () => {
|
||||
const snap = snapshotWith([row('i-steer-race', 'pending steer')])
|
||||
const source = liveSession(snap)
|
||||
const notify = vi.fn()
|
||||
const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
|
||||
const { getByLabelText, getByText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('插话发送'))
|
||||
await waitFor(() => {
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'插话发送失败,请重试。',
|
||||
)
|
||||
})
|
||||
expect(getByText('pending steer')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
|
||||
const snap = snapshotWith([row('i-race', 'pending')])
|
||||
const source = liveSession(snap)
|
||||
@@ -302,8 +376,10 @@ describe('QueueDock', () => {
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
queueDockEntry.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
|
||||
@@ -167,9 +167,11 @@ describe('GenericToolCard read body', () => {
|
||||
describe('ReadRow keyed toolview', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
|
||||
@@ -235,11 +237,14 @@ describe('ReadRow keyed toolview', () => {
|
||||
|
||||
it('registers under the read key of the keyed toolview slot', () => {
|
||||
const registered: { name: unknown; key?: unknown }[] = []
|
||||
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
|
||||
const ctx = { slots: {
|
||||
inject: (_name: string, callback: () => () => void) => callback(),
|
||||
register: (options: { name: unknown; key?: unknown }) => { registered.push(options); return () => undefined },
|
||||
} } as unknown as Context
|
||||
readToolview.apply(ctx)
|
||||
// The row composes ToolRow, so it declares its locale namespace at the seat.
|
||||
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
|
||||
expect(readToolview.inject).toContain('conversation')
|
||||
expect(readToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,12 +254,14 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
@@ -285,10 +292,10 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -349,8 +349,13 @@ describe('SearchRow keyed card', () => {
|
||||
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
for (const _dispose of callback()) { /* exhaust transactional setup */ }
|
||||
return () => undefined
|
||||
},
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
return () => undefined
|
||||
},
|
||||
},
|
||||
} as never
|
||||
@@ -361,7 +366,7 @@ describe('SearchRow keyed card', () => {
|
||||
// One component, two keys.
|
||||
expect(registered[0]!.component).toBe(SearchRow)
|
||||
expect(registered[1]!.component).toBe(SearchRow)
|
||||
expect(searchToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(searchToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -370,7 +375,10 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
@@ -400,10 +408,10 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,17 @@ type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both strict-session slot
|
||||
// registrations ('conversation.session'/'details'); the session-maybe
|
||||
// 'conversation' shell carries no store by design. The slots must first
|
||||
// exist in the ledger — the test root declares them (the AppFrame role).
|
||||
// The apply.ts shape: one shared handle across the strict Session header,
|
||||
// body, and details registrations; the session-maybe 'conversation' shell
|
||||
// carries no store by design. The slots must first exist in the ledger.
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'conversation.session.header': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
|
||||
runtime.slots.register({ name: 'conversation.session.header', store: chat }, () => null)
|
||||
runtime.slots.register({ name: 'details', store: chat }, () => null)
|
||||
runtime.renderRoot() // materializes the host face storeOf resolves through
|
||||
return { runtime, chat }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
|
||||
|
||||
@@ -23,12 +24,14 @@ async function bench(readAttachment?: SessionFace['readAttachment']) {
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
const hub = new InputHub(runtime.ctx)
|
||||
const fiber = runtime.ctx.plugin(ConversationService, { input: hub })
|
||||
const fiber = runtime.ctx.plugin(ConversationService, {
|
||||
input: new InputHub(runtime.ctx),
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
})
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
|
||||
return { runtime, fiber, hub, root, scoped, prompt, updateQueue, cancel, loadOlder }
|
||||
return { runtime, fiber, root, scoped, prompt, updateQueue, cancel, loadOlder }
|
||||
}
|
||||
|
||||
describe('ConversationService', () => {
|
||||
@@ -51,20 +54,43 @@ describe('ConversationService', () => {
|
||||
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
||||
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
|
||||
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'internal', message: 'broken', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' }))
|
||||
.rejects.toThrow('conversation.updateQueue failed: internal: broken')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases draft images when the session scope is disposed', async () => {
|
||||
it('treats strict-steer races as converged Queue delivery', async () => {
|
||||
const b = await bench()
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined()
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined()
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' }))
|
||||
.rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases draft previews when their session scope is disposed', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
|
||||
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
||||
try {
|
||||
const [attachment] = b.root.createDraftImages([new File([new Uint8Array(4)], 'a.png', { type: 'image/png' })])
|
||||
const [attachment] = b.root.createDraftImages([
|
||||
new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
|
||||
])
|
||||
if (attachment === undefined) throw new Error('draft attachment missing')
|
||||
// Land the id in the session shell exactly as the composer does.
|
||||
b.hub.shell(b.runtime.sessions.behavior('s1').sessionId).addImages([attachment.id])
|
||||
b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id])
|
||||
await b.runtime.sessions.remove('s1')
|
||||
// Scope teardown released the service-held File and its object URL.
|
||||
expect(b.root.draftImages([attachment.id])).toEqual([])
|
||||
expect(revoked).toHaveBeenCalledWith('blob:draft-1')
|
||||
} finally {
|
||||
@@ -74,116 +100,32 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('checks media type before preview allocation and leaves deployment limits to the host', async () => {
|
||||
it('validates every MIME type before allocating previews', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockImplementation(file => `blob:${(file as File).name}`)
|
||||
try {
|
||||
const files = Array.from(
|
||||
{ length: 11 },
|
||||
(_, index) => new File([Uint8Array.of(index)], `${index}.png`, { type: 'image/png' }),
|
||||
)
|
||||
expect(b.root.createDraftImages(files)).toHaveLength(11)
|
||||
expect(created).toHaveBeenCalledTimes(11)
|
||||
|
||||
const beforeRejectedBatch = created.mock.calls.length
|
||||
expect(() => b.root.createDraftImages([
|
||||
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
|
||||
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
|
||||
]))
|
||||
.toThrow(UnsupportedImageMediaTypeError)
|
||||
expect(created).toHaveBeenCalledTimes(beforeRejectedBatch)
|
||||
} finally {
|
||||
created.mockRestore()
|
||||
}
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases in-flight send images when the scope dies before the failure lands', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1')
|
||||
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
||||
try {
|
||||
const [attachment] = b.root.createDraftImages([new File([new Uint8Array(4)], 'b.png', { type: 'image/png' })])
|
||||
if (attachment === undefined) throw new Error('draft attachment missing')
|
||||
const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId)
|
||||
shell.addImages([attachment.id])
|
||||
let reject!: (error: Error) => void
|
||||
b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never)
|
||||
shell.setDraft('x')
|
||||
shell.submit()
|
||||
// commitSend already removed the ids from the shell; kill the scope
|
||||
// while the RPC is still pending, then land the failure.
|
||||
await b.runtime.sessions.remove('s1')
|
||||
reject(new Error('transport died'))
|
||||
await vi.waitFor(() => {
|
||||
expect(revoked).toHaveBeenCalledWith('blob:inflight-1')
|
||||
})
|
||||
expect(b.root.draftImages([attachment.id])).toEqual([])
|
||||
} finally {
|
||||
created.mockRestore()
|
||||
revoked.mockRestore()
|
||||
}
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('restores failed-send images before images added while the request was in flight', async () => {
|
||||
const b = await bench()
|
||||
const first = b.root.createDraftImages([
|
||||
new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
|
||||
])[0]
|
||||
const second = b.root.createDraftImages([
|
||||
new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }),
|
||||
])[0]
|
||||
if (first === undefined || second === undefined) throw new Error('draft attachment missing')
|
||||
const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId)
|
||||
const request = Promise.withResolvers<{ ok: true; value: { accepted: true } }>()
|
||||
b.prompt.mockReturnValueOnce(request.promise)
|
||||
|
||||
shell.addImages([first.id])
|
||||
shell.setDraft('describe')
|
||||
shell.submit()
|
||||
expect(shell.addImages([second.id])).toBe(true)
|
||||
expect(shell.snapshot.imageIds).toEqual([second.id])
|
||||
|
||||
request.reject(new Error('transport died'))
|
||||
await vi.waitFor(() => {
|
||||
expect(shell.snapshot.imageIds).toEqual([first.id, second.id])
|
||||
})
|
||||
expect(b.root.draftImages(shell.snapshot.imageIds)).toEqual([first, second])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('does not publish a historical image URL after disposal', async () => {
|
||||
let resolveRead!: (result: Awaited<ReturnType<SessionFace['readAttachment']>>) => void
|
||||
const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise<Awaited<ReturnType<SessionFace['readAttachment']>>>(
|
||||
(resolve) => { resolveRead = resolve },
|
||||
))
|
||||
const b = await bench(readAttachment)
|
||||
const created = vi.spyOn(URL, 'createObjectURL')
|
||||
const sessionId = b.runtime.sessions.behavior('s1').sessionId
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId('image-1'),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
} as const
|
||||
const pending = b.root.resolveImage(sessionId, attachment)
|
||||
await b.fiber.dispose()
|
||||
await expect(b.root.resolveImage(sessionId, attachment)).rejects.toThrow('service is disposed')
|
||||
resolveRead({
|
||||
ok: true,
|
||||
value: {
|
||||
attachment,
|
||||
data: Uint8Array.of(1),
|
||||
},
|
||||
})
|
||||
await expect(pending).rejects.toThrow('service was disposed before loading completed')
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
|
||||
expect(() => b.root.createDraftImages([
|
||||
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
|
||||
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
|
||||
])).toThrow(UnsupportedImageMediaTypeError)
|
||||
expect(created).not.toHaveBeenCalled()
|
||||
created.mockRestore()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('invalidates pending historical image loads when the rendered session is released', async () => {
|
||||
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
|
||||
const b = await bench(() => read.promise)
|
||||
const sessionId = b.runtime.sessions.behavior('s1').sessionId
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
|
||||
} as const
|
||||
const pending = b.root.resolveImage(sessionId, attachment)
|
||||
b.root.releaseSessionImages(sessionId)
|
||||
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
|
||||
await expect(pending).rejects.toThrow('historical image scope was released')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
|
||||
@@ -194,6 +136,7 @@ describe('ConversationService', () => {
|
||||
const bare = new Context()
|
||||
await bare.plugin(ConversationService, {
|
||||
input: new InputHub(bare),
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
}).await()
|
||||
const orphan = bare.get('conversation') as ConversationService
|
||||
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
|
||||
|
||||
@@ -12,12 +12,14 @@ import type {
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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 { createChatStore } from '../src/client/stores.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type {
|
||||
@@ -68,10 +70,10 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -87,20 +89,25 @@ function mount(
|
||||
summaryBlank?: boolean
|
||||
/** Drop the session's summary row entirely (a session the list has not caught up with). */
|
||||
omitSummaryRow?: boolean
|
||||
/** Classify the selected child as a subagent instead of an ordinary fork. */
|
||||
summaryOrigin?: 'subagent'
|
||||
/** A composer block another plugin raised for this session. */
|
||||
composerBlock?: { reason: string }
|
||||
} = {},
|
||||
) {
|
||||
const root = sid('root')
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
|
||||
const childRow = {
|
||||
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
|
||||
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
|
||||
}
|
||||
const listed = options.omitSummaryRow !== true
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: listed ? [root, SID] : [root],
|
||||
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
@@ -113,10 +120,42 @@ function mount(
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const slotCalls: string[] = []
|
||||
/** Owner share handed to the two composer tool-row seats, per render. */
|
||||
const seatOwners: { key: string; owner: unknown }[] = []
|
||||
let pickerOwner: unknown
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
slotCalls.push(key)
|
||||
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
|
||||
seatOwners.push({ key, owner })
|
||||
}
|
||||
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
||||
if (key === 'conversation.session.header') {
|
||||
return (
|
||||
<ConversationSessionHeader
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
views={{
|
||||
list: () => [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
],
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
open={open}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (key === 'conversation.session') {
|
||||
return (
|
||||
<ConversationSession
|
||||
@@ -141,9 +180,6 @@ function mount(
|
||||
}}
|
||||
releaseSessionImages={vi.fn()}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
t={t}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -165,6 +201,7 @@ function mount(
|
||||
addImages={() => null}
|
||||
removeImage={() => {}}
|
||||
draftImages={() => []}
|
||||
resolveSubmitMode={() => 'queue'}
|
||||
toggleCommandMenu={vi.fn()}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
@@ -172,7 +209,12 @@ function mount(
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
t={t}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
renderSlot={((key: string, seatOwner: object) => {
|
||||
// The bar's own seats: recorded so a case can assert what share
|
||||
// each tool-row control received.
|
||||
seatOwners.push({ key, owner: seatOwner })
|
||||
return null
|
||||
}) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
)
|
||||
@@ -198,6 +240,7 @@ function mount(
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useProjection: (() => undefined),
|
||||
useComposerBlock: select => select(options.composerBlock),
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
@@ -207,13 +250,53 @@ function mount(
|
||||
}
|
||||
const view = render(<ConversationRoot {...props} />)
|
||||
return {
|
||||
view, chat, sink, open, retargetWorkspace, session, slotCalls,
|
||||
view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
|
||||
pickerOwner: () => pickerOwner,
|
||||
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('Hero chrome', () => {
|
||||
it('renders the English preview badge through the hero locale seat', () => {
|
||||
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
|
||||
expect(view.getByText('Into the Unknown')).toBeTruthy()
|
||||
expect(view.getByText('Preview')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot resident composer', () => {
|
||||
it('renders the composer inert with the blocker\u2019s own reason', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, {
|
||||
composerBlock: { reason: 'select a model first' },
|
||||
})
|
||||
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
// One disabled textarea with the blocker's placeholder, never a second
|
||||
// tree: the DOM survives the block being raised and cleared.
|
||||
expect(box.disabled).toBe(true)
|
||||
expect(box.placeholder).toBe('select a model first')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.sink).not.toHaveBeenCalled()
|
||||
|
||||
// The model seat stays live. Locking it too would leave the composer
|
||||
// asking for the one thing it prevents — every block this contract has is
|
||||
// cleared by choosing a model.
|
||||
const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
|
||||
expect(seat('conversation.input.model')).toEqual({ locked: false })
|
||||
expect(seat('conversation.input.plan')).toEqual({ locked: true })
|
||||
})
|
||||
|
||||
it('lets the no-workspace posture win over a block', () => {
|
||||
// Picking a workspace is the earlier prerequisite; naming a model first
|
||||
// would send the user somewhere they cannot act yet.
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, {
|
||||
summaryBlank: true,
|
||||
composerBlock: { reason: 'select a model first' },
|
||||
})
|
||||
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(box.disabled).toBe(true)
|
||||
expect(box.placeholder).not.toBe('select a model first')
|
||||
})
|
||||
|
||||
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const box = b.view.getByRole('textbox')
|
||||
@@ -221,8 +304,16 @@ describe('ConversationRoot resident composer', () => {
|
||||
fireEvent.change(box, { target: { value: 'ordinary revised' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised', [])
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue')
|
||||
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(b.view.queryByText('Root')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows hierarchy only for subagents and opens their ordinary owner', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' })
|
||||
const root = b.view.getByRole('button', { name: 'Root' })
|
||||
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(root)
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
@@ -264,7 +355,8 @@ describe('ConversationRoot resident composer', () => {
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('预览版')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
@@ -287,7 +379,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
})
|
||||
|
||||
it('settling phase: a session the list has no row for settles conservatively', () => {
|
||||
@@ -312,7 +404,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
// blank the column for the history round-trip.
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('hero')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -321,7 +413,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const before = b.view.getByRole('textbox')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank. Composer
|
||||
// already sat in the Session scrollport during hero, so the textarea
|
||||
// already sat in the resident scrollport during hero, so the textarea
|
||||
// node and InputHub draft both survive.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
@@ -330,7 +422,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
|
||||
} from '../src/client/input/submission-policy.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('ComposerSubmissionPolicy', () => {
|
||||
it('defaults to Queue and only applies the preference while running', () => {
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
expect(policy.busyEnter.getSnapshot()).toBe(DEFAULT_BUSY_ENTER_BEHAVIOR)
|
||||
expect(policy.resolve(false, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
|
||||
expect(policy.resolve(true, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(true, 'accelerated', true)).toBe('steer')
|
||||
expect(policy.resolve(true, 'enter', false)).toBe('queue')
|
||||
expect(policy.resolve(true, 'accelerated', false)).toBe('queue')
|
||||
|
||||
const changed = vi.fn()
|
||||
policy.busyEnter.subscribe(changed)
|
||||
policy.setBusyEnter('steer')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect(policy.resolve(true, 'enter', true)).toBe('steer')
|
||||
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
|
||||
expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer')
|
||||
})
|
||||
|
||||
it('restores a valid preference and leaves an identical write untouched', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer')
|
||||
const write = vi.spyOn(Storage.prototype, 'setItem')
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
policy.setBusyEnter('steer')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
write.mockRestore()
|
||||
})
|
||||
|
||||
it('uses Queue for invalid, unavailable, or unreadable storage', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid')
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => { throw new Error('blocked') },
|
||||
setItem: vi.fn(),
|
||||
})
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
})
|
||||
|
||||
it('keeps the in-memory preference when persistence throws', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('quota') },
|
||||
})
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
policy.setBusyEnter('steer')
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
})
|
||||
@@ -342,9 +342,11 @@ describe('chat row terminal body', () => {
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
|
||||
@@ -412,6 +414,28 @@ describe('BashRow terminal card', () => {
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands a generic execution error to its original args and full output', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
content: [{ type: 'text', text: 'Error: command aborted' }],
|
||||
isError: true,
|
||||
callView: { card: 'generic', title: 'ls -la', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
expect(row.getAttribute('role')).toBe('button')
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText(/"command": "ls -la"/)).toBeNull()
|
||||
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('IN')).toBeTruthy()
|
||||
expect(view.getByText('OUT')).toBeTruthy()
|
||||
expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -421,12 +445,14 @@ describe('DetailsPanel Output section', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
@@ -451,10 +477,10 @@ describe('DetailsPanel Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,7 +631,10 @@ describe('DetailsPanel Output section', () => {
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
|
||||
{
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
}))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, shared ToolRow
|
||||
* state dots and leading expansion).
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
|
||||
* including several `in_progress` at once, collapse), its TodoDock adapter
|
||||
* (selects the plan off the session snapshot and follows changes), the row's
|
||||
* plan summary (counts plus the two halves of the active summary — the named
|
||||
* task and the `+N` count that parallel work adds, kept apart so the row never
|
||||
* ellipsizes the count away), and the todo_write toolview row (progress summary
|
||||
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
|
||||
* leading expansion).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -17,6 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
|
||||
import { NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
||||
@@ -32,21 +36,73 @@ const LIST: TodoItem[] = [
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
/** A parallel plan: three tasks running at once (concurrent subagents). */
|
||||
const PARALLEL: TodoItem[] = [
|
||||
{ content: '搭骨架', status: 'completed' },
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '读源码', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
describe('planSummary', () => {
|
||||
it('counts done/total and names the single active item with no extra count', () => {
|
||||
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('reports the extra active count separately when several items are in progress', () => {
|
||||
// Parallel work marks several: naming one and hiding the rest would lose
|
||||
// them, and the count stays unjoined so the row cannot ellipsize it.
|
||||
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
|
||||
})
|
||||
|
||||
it('has no hint when nothing is in progress', () => {
|
||||
expect(planSummary([{ content: '都完了', status: 'completed' }]))
|
||||
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('has no hint when the first active item carries no usable content (model JSON)', () => {
|
||||
// Unvalidated args: a missing, mistyped, empty, or whitespace-only content
|
||||
// yields no hint — and no orphan count, even with a second active item to
|
||||
// count. Whitespace-only is the tool's own rejection rule (trimmed
|
||||
// non-empty), and a rejected call keeps its args verbatim.
|
||||
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('is empty-safe', () => {
|
||||
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('starts collapsed with the progress summary visible', () => {
|
||||
it('starts collapsed with the per-status count summary visible', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('任务清单')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('任务')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the completed segment while nothing is done yet', () => {
|
||||
render(<TodoPanel todos={[
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]} t={t} />)
|
||||
expect(screen.getByText('1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText(/已完成/)).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to show one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
@@ -65,17 +121,30 @@ describe('TodoPanel', () => {
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
it('marks every parallel active item, and counts them all in the header', () => {
|
||||
render(<TodoPanel todos={PARALLEL} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
// The old unconditional cap made this list unreachable: three items carry
|
||||
// the in-progress glyph at once, and the header counts all three.
|
||||
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
|
||||
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
|
||||
expect(screen.getByText('跑后台构建')).toBeTruthy()
|
||||
expect(screen.getByText('读源码')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an all-completed list collapses the summary to the done count alone', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 项任务 · 0 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成')).toBeTruthy()
|
||||
expect(screen.queryByText(/进行中|待处理/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,18 +162,20 @@ describe('TodoDock', () => {
|
||||
// Capability absent (no baseline/frame yet) renders nothing.
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ value: LIST }) })
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ value: null }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('registers between the goal and queue entries', () => {
|
||||
it('registers before the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
|
||||
expect(todoDockEntry.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoDockEntry.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,12 +204,30 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the extra active count outside the ellipsized summary text', () => {
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
|
||||
const text = screen.getByText('1/5 已完成 · 写组件')
|
||||
const extra = screen.getByText('+2')
|
||||
// Separate spans: .summary truncates, the count must not travel inside it.
|
||||
expect(text.contains(extra)).toBe(false)
|
||||
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
|
||||
})
|
||||
|
||||
it('omits the active clause when no item is in progress and reads running-call args', () => {
|
||||
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
|
||||
// planSummary yields activeContent null here, but the counts are known good,
|
||||
// so the row drops only the active clause — `?? model.summary` never runs.
|
||||
const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
|
||||
expect(screen.getByText('1/2 已完成')).toBeTruthy()
|
||||
expect(container.textContent).not.toContain('+')
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible through the shared row states', () => {
|
||||
// A running call (no result yet) carries the running state (row sweep).
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
@@ -186,11 +275,13 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('todo_write · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
it('todoToolview injects the toolview declaration directly', () => {
|
||||
expect(todoToolview.name).toBe('todo-toolview')
|
||||
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoToolview.apply({ slots: { register } } as never)
|
||||
expect(todoToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The one-line contract of the ToolRow summary line as CSS text. jsdom has no
|
||||
* layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans
|
||||
* exist but not whether a narrow row still fits on one line; these read the
|
||||
* declarations the layout depends on.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8')
|
||||
/** Declarations only: the sheet's prose names the properties it explains. */
|
||||
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
function declarations(selector: string): string[] {
|
||||
// Anchored at a rule boundary: an unanchored match would silently read a
|
||||
// compound rule that merely contains the selector (`.root:hover .summarySuffix`)
|
||||
// if one ever lands above the base rule.
|
||||
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText)
|
||||
if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`)
|
||||
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('ToolRow.module.css summary line', () => {
|
||||
it('keeps the summary suffix on one line and unshrunk', () => {
|
||||
// `flex: none` stops the box shrinking, not the text wrapping: without
|
||||
// `nowrap`, a row too narrow for title + separator + suffix wraps the `+n`
|
||||
// onto a second line — the exact case the slot exists to survive.
|
||||
expect(declarations('.summarySuffix')).toEqual(expect.arrayContaining([
|
||||
'flex: none',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
})
|
||||
|
||||
it('leaves the truncation to the summary text alone', () => {
|
||||
// The suffix must never ellipsize: a clipped count reads as a smaller
|
||||
// number rather than as missing information.
|
||||
expect(declarations('.summary')).toEqual(expect.arrayContaining([
|
||||
'overflow: hidden',
|
||||
'text-overflow: ellipsis',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis']))
|
||||
})
|
||||
})
|
||||
154
packages/client/ui-conversation/tests/turn-metrics.spec.ts
Normal file
154
packages/client/ui-conversation/tests/turn-metrics.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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 { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
interface StepSpec {
|
||||
seq: number
|
||||
turn: number
|
||||
step: number
|
||||
timing?: AssistantMessageNode['timing']
|
||||
usage?: unknown
|
||||
}
|
||||
|
||||
const assistant = ({ seq, turn, step, timing, usage }: StepSpec): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
...(timing === undefined ? {} : { timing }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
})
|
||||
|
||||
const user = (seq: number): UserMessageNode => ({
|
||||
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text: 'hi' }] as never, source: null,
|
||||
})
|
||||
|
||||
describe('assistantStepReading', () => {
|
||||
it('derives ttft, decode time, and output tokens from a fully recorded step', () => {
|
||||
const reading = assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 6_800 },
|
||||
usage: { outputTokens: 200 },
|
||||
}))
|
||||
expect(reading).toEqual({ ttftMs: 800, decodeMs: 5_000, outputTokens: 200 })
|
||||
})
|
||||
|
||||
it('returns nulls when timing is absent', () => {
|
||||
const reading = assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, usage: { outputTokens: 5 } }))
|
||||
expect(reading).toEqual({ ttftMs: null, decodeMs: null, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it('needs both boundaries for ttft and clamps negative spans to zero', () => {
|
||||
expect(assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: null, firstTokenTime: 1_800, completedTime: 6_800 },
|
||||
}))).toEqual({ ttftMs: null, decodeMs: 5_000, outputTokens: null })
|
||||
expect(assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: null, completedTime: 6_800 },
|
||||
}))).toEqual({ ttftMs: null, decodeMs: null, outputTokens: null })
|
||||
expect(assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 2_000, firstTokenTime: 1_500, completedTime: 1_200 },
|
||||
}))).toEqual({ ttftMs: 0, decodeMs: 0, outputTokens: null })
|
||||
})
|
||||
|
||||
it('rejects non-object, missing, and non-finite usage token counts', () => {
|
||||
const timing = { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 }
|
||||
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: 'weird' })).outputTokens).toBeNull()
|
||||
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: {} })).outputTokens).toBeNull()
|
||||
const nan = assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: Number.NaN } })
|
||||
expect(assistantStepReading(nan).outputTokens).toBeNull()
|
||||
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: -3 } })).outputTokens).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTurnMetrics', () => {
|
||||
it('takes ttft from the lowest step and throughput over all sampled steps', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1),
|
||||
// Out of step order on purpose: the lowest step owns the ttft slot.
|
||||
assistant({
|
||||
seq: 4, turn: 1, step: 2,
|
||||
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
|
||||
usage: { outputTokens: 60 },
|
||||
}),
|
||||
assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
|
||||
usage: { outputTokens: 40 },
|
||||
}),
|
||||
]
|
||||
// 100 tokens over 5s of decode.
|
||||
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 1_200, tokensPerSecond: 20 })
|
||||
})
|
||||
|
||||
it('emits ttft without throughput when no step carries usage', () => {
|
||||
const nodes = [assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_900, completedTime: 3_000 },
|
||||
})]
|
||||
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 900 })
|
||||
})
|
||||
|
||||
it('emits throughput without ttft when only a later step is recorded', () => {
|
||||
const nodes = [
|
||||
assistant({ seq: 2, turn: 1, step: 1 }),
|
||||
assistant({
|
||||
seq: 4, turn: 1, step: 2,
|
||||
timing: { stepStartTime: 10_000, firstTokenTime: 10_500, completedTime: 12_500 },
|
||||
usage: { outputTokens: 30 },
|
||||
}),
|
||||
]
|
||||
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ tokensPerSecond: 15 })
|
||||
})
|
||||
|
||||
it('omits turns with no readings and zero-decode throughput', () => {
|
||||
const nodes = [
|
||||
assistant({ seq: 2, turn: 1, step: 1 }),
|
||||
assistant({
|
||||
seq: 4, turn: 2, step: 1,
|
||||
timing: { stepStartTime: null, firstTokenTime: 5_000, completedTime: 5_000 },
|
||||
usage: { outputTokens: 10 },
|
||||
}),
|
||||
]
|
||||
expect(deriveTurnMetrics(nodes).size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps turns independent and ignores non-assistant nodes', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1),
|
||||
assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_400, completedTime: 2_400 },
|
||||
usage: { outputTokens: 10 },
|
||||
}),
|
||||
user(3),
|
||||
assistant({
|
||||
seq: 4, turn: 2, step: 1,
|
||||
timing: { stepStartTime: 4_000, firstTokenTime: 4_100, completedTime: 6_100 },
|
||||
usage: { outputTokens: 100 },
|
||||
}),
|
||||
]
|
||||
const metrics = deriveTurnMetrics(nodes)
|
||||
expect(metrics.get(1)).toEqual({ ttftMs: 400, tokensPerSecond: 10 })
|
||||
expect(metrics.get(2)).toEqual({ ttftMs: 100, tokensPerSecond: 50 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('footer figure formatters', () => {
|
||||
it('formats latency with one decimal under ten seconds and whole seconds beyond', () => {
|
||||
expect(formatLatencySeconds(840)).toBe('0.8')
|
||||
expect(formatLatencySeconds(1_000)).toBe('1')
|
||||
expect(formatLatencySeconds(9_949)).toBe('9.9')
|
||||
expect(formatLatencySeconds(12_400)).toBe('12')
|
||||
expect(formatLatencySeconds(-5)).toBe('0')
|
||||
})
|
||||
|
||||
it('formats throughput with whole tokens from ten up and one decimal below', () => {
|
||||
expect(formatTokensPerSecond(34.4)).toBe('34')
|
||||
expect(formatTokensPerSecond(9.96)).toBe('10')
|
||||
expect(formatTokensPerSecond(3.14)).toBe('3.1')
|
||||
expect(formatTokensPerSecond(-1)).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
|
||||
import { webCardModel } from '../src/client/contract/web-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
@@ -135,8 +135,7 @@ describe('chat row web body', () => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => {
|
||||
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
|
||||
it('the WebRow collapses to the summary row, expanding to the full search card', () => {
|
||||
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
|
||||
// Collapsed: the summary row alone, no card in the DOM.
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
@@ -205,7 +204,10 @@ describe('DetailsPanel web Output section', () => {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
@@ -235,10 +237,10 @@ describe('DetailsPanel web Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +278,10 @@ describe('web toolview registration', () => {
|
||||
const registered: { key: string; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
for (const _dispose of callback()) { /* exhaust transactional setup */ }
|
||||
return () => undefined
|
||||
},
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
return () => {}
|
||||
@@ -289,7 +295,6 @@ describe('web toolview registration', () => {
|
||||
// One component under both keys, not two thin rows.
|
||||
expect(registered[0]?.component).toBe(WebRow)
|
||||
expect(registered[1]?.component).toBe(WebRow)
|
||||
// The load-order seam the render site depends on.
|
||||
expect(webToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(webToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user