mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
2227 lines
107 KiB
TypeScript
2227 lines
107 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
|
||
// InputBar behavior over the editor + submit-machine wiring: Enter-send
|
||
// semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering,
|
||
// repeat suppression), running semantics (input stays free; continuable
|
||
// children keep Send beside Stop), the machine pending lock, chip decorators,
|
||
// error banners, status strips, and the focus-keeping mousedown. Keyboard
|
||
// gestures dispatch real KeyboardEvents at the contenteditable (the Lexical
|
||
// root listener routes them through the keymap commands); draft writes drive
|
||
// the shell (jsdom's beforeinput lacks the ranges Lexical needs).
|
||
|
||
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||
import './control-row-dom.ts'
|
||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||
import { createPortal } from 'react-dom'
|
||
import { $getRoot, $isTextNode } from 'lexical'
|
||
import {
|
||
allowAllAccess, bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError,
|
||
sessionSnapshot as sessionFixture,
|
||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||
import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
|
||
import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||
import type { Context } from '@deepseek-ai/cordis'
|
||
import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||
import type { SubmitOutcome } from '../src/client/contract/input.ts'
|
||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||
import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
|
||
import type {
|
||
ComposerAttachment, ComposerAttachmentsOwnerProps, DraftFileUploads, InputActivityOwnerProps,
|
||
} from '../src/client/contract/slots.ts'
|
||
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
|
||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||
import { en, zh } from '../src/client/locales.ts'
|
||
|
||
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
|
||
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
|
||
|
||
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
|
||
|
||
const SCTX = {} as Context
|
||
const SID = 's1' as SessionId
|
||
const CONTINUABLE_CHILD = {
|
||
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' as const },
|
||
}
|
||
|
||
function snapshotOf(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||
return { ...sessionFixture(SID), ...overrides }
|
||
}
|
||
|
||
interface BenchOptions {
|
||
planEntry?: React.ReactNode
|
||
/** The `plan` projection value the standard-kit useProjection serves. */
|
||
plan?: { active: boolean; pending: boolean }
|
||
/** The `goal` projection value used only to prove attachment intake remains ordinary. */
|
||
goal?: { phase: 'active'; objective: string }
|
||
modelEntry?: React.ReactNode
|
||
permissionEntry?: React.ReactNode
|
||
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
||
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
||
/** The `imageLimits` projection value (absent = no attachment service). */
|
||
imageLimits?: {
|
||
maxImageBytes: number
|
||
maxImagesPerMessage: number
|
||
maxMessageImageBytes: number
|
||
maxImagePixels: number
|
||
maxImageDimension: number
|
||
mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
|
||
}
|
||
draft?: string
|
||
running?: boolean
|
||
retryableFailure?: SessionSeq
|
||
subagent?: Exclude<SessionSnapshot['subagent'], null>
|
||
disabled?: boolean
|
||
inert?: boolean
|
||
blocked?: { readonly reason: string }
|
||
workspacePickerOpen?: boolean
|
||
onRequestWorkspace?: () => void
|
||
promptError?: SessionSnapshot['promptError']
|
||
/** Authoritative queue rows served to the machine overlay (empty = none). */
|
||
queue?: InboxState['next-turn']
|
||
nextStep?: InboxState['next-step']
|
||
/** The hub's steer-all face (empty-draft accelerated Enter). */
|
||
steerQueue?: () => void
|
||
variant?: 'hero' | 'composer'
|
||
placeholder?: string
|
||
t?: InputBarProps['t']
|
||
accessory?: React.ReactNode
|
||
overlay?: React.ReactNode
|
||
leftItems?: React.ReactNode
|
||
rightItems?: React.ReactNode
|
||
activityEntry?: (owner: InputActivityOwnerProps) => React.ReactNode
|
||
contextPressure?: ContextPressureProjection
|
||
footer?: React.ReactNode
|
||
attachments?: readonly ComposerAttachment[]
|
||
/** Upload states served for file-kind drafts (absent = every file is ready). */
|
||
fileUploads?: DraftFileUploads
|
||
/** Endpoints the Session's access snapshot excludes (absent = every endpoint is allowed). */
|
||
deniedEndpoints?: readonly string[]
|
||
addFiles?: (files: readonly File[], directories?: ReadonlySet<File>) => string | null
|
||
commandMenuOpen?: boolean
|
||
busyEnter?: 'queue' | 'steer'
|
||
toggleCommandMenu?: (selection: { start: number; end: number }) => void
|
||
}
|
||
|
||
/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
|
||
function row(id: string): InboxState['next-turn'][number] {
|
||
return {
|
||
id: id as never, role: 'user', source: { kind: 'user' },
|
||
content: [{ type: 'text', text: id }],
|
||
}
|
||
}
|
||
|
||
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
|
||
function bench(over?: BenchOptions) {
|
||
const sink = vi.fn<(
|
||
text: string,
|
||
attachmentIds: readonly DraftAttachmentId[],
|
||
mode: 'queue' | 'steer',
|
||
signal: AbortSignal,
|
||
) => Promise<SubmitOutcome>>(() => Promise.resolve({ kind: 'success' }))
|
||
const lex = over?.lexicon
|
||
const session = createSnapshotStore<SessionSnapshot>(snapshotOf({
|
||
running: over?.running ?? false,
|
||
subagent: over?.subagent ?? null,
|
||
removed: over?.disabled ?? false,
|
||
promptError: over?.promptError ?? null,
|
||
}))
|
||
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
|
||
const shell = new SessionInputShell({
|
||
actx: SCTX,
|
||
defaultSink: sink,
|
||
commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` },
|
||
inbox: createSnapshotStore<InboxState>({
|
||
'next-turn': over?.queue ?? [],
|
||
'next-step': over?.nextStep ?? [],
|
||
}),
|
||
...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
|
||
// Lexicon-only stub: adjudication untouched (undefined slash methods are
|
||
// never reached — these benches drive plain-draft flows only).
|
||
...(lex !== undefined
|
||
? {
|
||
inputTriggers: (() => ({
|
||
track: () => {},
|
||
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
|
||
})) as unknown as NonNullable<ShellDeps['inputTriggers']>,
|
||
}
|
||
: {}),
|
||
})
|
||
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
|
||
if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id))
|
||
const stop = vi.fn()
|
||
const retry = vi.fn()
|
||
const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) })
|
||
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
|
||
const busyEnter = createSnapshotStore<'queue' | 'steer'>(over?.busyEnter ?? 'queue')
|
||
const stopShortcut = createSnapshotStore<readonly string[]>(['Esc', 'Esc'])
|
||
const slotCalls: { key: string; owner: unknown }[] = []
|
||
const renderSlot = ((key: string, owner: object) => {
|
||
slotCalls.push({ key, owner })
|
||
if (key === 'conversation.input.overlay') return over?.overlay ?? null
|
||
if (key === 'conversation.input.left') return over?.leftItems ?? null
|
||
if (key === 'conversation.input.right') return over?.rightItems ?? null
|
||
if (key === 'conversation.composer.dock') return over?.footer ?? null
|
||
if (key === 'conversation.input.plan') return over?.planEntry ?? null
|
||
if (key === 'conversation.input.permission') return over?.permissionEntry ?? null
|
||
if (key === 'conversation.input.model') return over?.modelEntry ?? null
|
||
if (key === 'conversation.input.activity') return over?.activityEntry?.(owner as InputActivityOwnerProps) ?? null
|
||
return null
|
||
}) as never
|
||
const props: InputBarProps = {
|
||
usePanelInfo: selector => selector({ activePanelId: null }),
|
||
sessionId: SID,
|
||
SessionProvider: ({ children }) => children,
|
||
useSession: bindSnapshotSelector(session),
|
||
useConversation: bindSnapshotSelector(createSnapshotStore(conversationFixture())),
|
||
useSessionStatus: bindSnapshotSelector(createSnapshotStore(new Map())),
|
||
useSessionRetainInfo: () => undefined,
|
||
useResource,
|
||
useSessions: bindSnapshotSelector(createSnapshotStore<SessionListState>({
|
||
ids: [], byId: {}, phase: 'ready',
|
||
projectionsBySession: {},
|
||
})),
|
||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||
})),
|
||
...allowAllAccess,
|
||
useContextMenu: contextMenuHook(),
|
||
useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint),
|
||
useProjection: ((key: string, selector: (v: unknown) => unknown = v => v) =>
|
||
selector(key === 'plan'
|
||
? over?.plan
|
||
: key === 'goal' ? over?.goal
|
||
: key === 'conversationContinuation' ? over?.retryableFailure
|
||
: key === 'inbox' ? { 'next-turn': over?.queue ?? [], 'next-step': over?.nextStep ?? [] }
|
||
: key === 'imageLimits' ? over?.imageLimits
|
||
: key === 'contextPressure' ? over?.contextPressure : undefined)),
|
||
useInput: bindSnapshotSelector(shell.state),
|
||
inputActions: shell.actions,
|
||
keyboard: shell,
|
||
addFiles: over?.addFiles ?? (() => null),
|
||
useFileUploads: bindSnapshotSelector(createSnapshotStore<DraftFileUploads>(over?.fileUploads ?? {})),
|
||
retryFileUpload: undefined,
|
||
removeAttachment,
|
||
resolveDraftAttachments: ids => ids.flatMap((id) => {
|
||
const attachment = over?.attachments?.find(candidate => candidate.id === id)
|
||
return attachment === undefined ? [] : [attachment]
|
||
}),
|
||
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
|
||
useBusyEnter: bindSnapshotSelector(busyEnter),
|
||
retry,
|
||
useStopShortcut: bindSnapshotSelector(stopShortcut),
|
||
useNotices: bindSnapshotSelector(shell.notices),
|
||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||
useMenuLauncher: bindSnapshotSelector(menuLauncher),
|
||
stop,
|
||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||
t: over?.t ?? makeTranslate(zh, commonZh),
|
||
renderSlot,
|
||
variant: over?.variant ?? 'composer',
|
||
...(over?.inert === true ? { disabled: true } : {}),
|
||
...(over?.blocked !== undefined ? { blocked: over.blocked } : {}),
|
||
...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}),
|
||
...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}),
|
||
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
||
...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
|
||
}
|
||
const view = render(<InputBar {...props} />)
|
||
const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
|
||
const sendableDraft = (over?.draft?.trim() ?? '') !== '' || (over?.attachments?.length ?? 0) > 0
|
||
const primaryStops = over?.running === true && over.subagent === undefined
|
||
&& (!sendableDraft || over.blocked !== undefined)
|
||
// A running steer-capable composer (ordinary session or continuable child
|
||
// with its parent online) labels Send by the delivery mode it performs over
|
||
// a plain message draft; idle sessions, one-shot children, locked
|
||
// composers, a child's empty draft, drafts with a pending upload, and `/`
|
||
// command lines keep plain Send (an ordinary running session's empty draft
|
||
// is the Stop seat). The mirror models plain-phase drafts only: cases
|
||
// that claim a command or freeze the machine query the DOM directly.
|
||
const steeringAvailable = over?.subagent === undefined || over.subagent.address.mode === 'continuable'
|
||
const composerLocked = over?.disabled === true || over?.inert === true || over?.blocked !== undefined
|
||
const uploadsPending = (over?.attachments ?? []).some(attachment => attachment.kind === 'file'
|
||
? over?.fileUploads?.[attachment.id]?.status !== 'ready'
|
||
: over?.fileUploads?.[attachment.id] !== undefined && over.fileUploads[attachment.id]?.status !== 'ready')
|
||
const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false)
|
||
const primaryLabel = primaryStops
|
||
? '停止生成'
|
||
: over?.retryableFailure !== undefined && !sendableDraft && over.running !== true && over.subagent === undefined && !composerLocked
|
||
&& !(over.nextStep ?? []).some(message => message.source.kind === 'user')
|
||
? '继续生成'
|
||
: over?.running === true && steeringAvailable && !composerLocked && plainMessageDraft
|
||
? (over.busyEnter === 'steer' ? '插话发送' : '排队发送')
|
||
: '发送消息'
|
||
const button = view.container.querySelector<HTMLButtonElement>(`button[aria-label="${primaryLabel}"]`)!
|
||
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
|
||
return {
|
||
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls,
|
||
menuLauncher, busyEnter, stopShortcut, retry,
|
||
steerQueue: over?.steerQueue,
|
||
get placeholder() { return placeholderOf(view.container) },
|
||
get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' },
|
||
}
|
||
}
|
||
|
||
it('offers the play action only for an empty, idle composer with an eligible failure', async () => {
|
||
const b = bench({ retryableFailure: 2 as SessionSeq })
|
||
expect(b.button.disabled).toBe(false)
|
||
expect(b.button.querySelector('svg')).not.toBeNull()
|
||
fireEvent.click(b.button)
|
||
expect(b.retry).toHaveBeenCalledExactlyOnceWith(2)
|
||
expect(b.sink).not.toHaveBeenCalled()
|
||
await act(async () => { b.shell.setDraft('A new instruction') })
|
||
const send = b.view.getByRole('button', { name: '发送消息' })
|
||
fireEvent.click(send)
|
||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledOnce() })
|
||
expect(b.sink.mock.calls[0]?.[0]).toBe('A new instruction')
|
||
expect(b.retry).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it.each([
|
||
{ running: true }, { attachments: [{ kind: 'image' as const, id: 'image' as DraftAttachmentId, file: new File([], 'x.png'), previewUrl: 'blob:test' }] },
|
||
{ blocked: { reason: 'Select a model' } }, { subagent: CONTINUABLE_CHILD }, { deniedEndpoints: ['session/prompt'] },
|
||
{ nextStep: [row('new instruction')] },
|
||
])('keeps the continue action unavailable with %j', (options) => {
|
||
const b = bench({ ...options, retryableFailure: 2 as SessionSeq })
|
||
expect(b.view.queryByRole('button', { name: '继续生成' })).toBeNull()
|
||
})
|
||
|
||
function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
|
||
for (let i = slotCalls.length - 1; i >= 0; i -= 1) {
|
||
const call = slotCalls[i]
|
||
if (call?.key === 'conversation.input.attachments') return call.owner as ComposerAttachmentsOwnerProps
|
||
}
|
||
throw new Error('attachment slot was not rendered')
|
||
}
|
||
|
||
/** The state's placeholder copy (the textarea.placeholder equivalent; the visible layer renders it only while empty). */
|
||
function placeholderOf(container: HTMLElement): string {
|
||
return container.querySelector('[data-composer-input]')?.getAttribute('data-placeholder') ?? ''
|
||
}
|
||
|
||
/** Whether the composer surface accepts edits right now (setEditable's DOM face). */
|
||
function editableOf(input: HTMLElement): boolean {
|
||
return input.getAttribute('contenteditable') === 'true'
|
||
}
|
||
|
||
/** Write the draft through the shell inside act (the seed path; caret lands at the end). */
|
||
function writeDraft(shell: SessionInputShell, text: string): void {
|
||
act(() => { shell.setDraft(text) })
|
||
}
|
||
|
||
describe('composer focus handoff', () => {
|
||
it.each(['hero', 'composer'] as const)('focuses the %s editor when its card background is pressed', (variant) => {
|
||
const { view, shell, textarea } = bench({ variant })
|
||
writeDraft(shell, 'draft text')
|
||
textarea.blur()
|
||
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
|
||
const card = view.container.querySelector('[data-composer-card]')!
|
||
|
||
expect(fireEvent.mouseDown(card, { button: 0 })).toBe(false)
|
||
expect(document.activeElement).toBe(textarea)
|
||
expect(lexicalFocus).toHaveBeenCalled()
|
||
})
|
||
|
||
it('leaves editor selection, scrollport gestures, controls and canceled presses to their owners', () => {
|
||
const { view, shell, textarea } = bench({
|
||
modelEntry: <button><span>Model</span></button>,
|
||
leftItems: <span onMouseDown={(event) => { event.preventDefault() }}>Canceled</span>,
|
||
})
|
||
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
|
||
const scrollport = view.container.querySelector('[data-input-scroll]')!
|
||
const card = view.container.querySelector('[data-composer-card]')!
|
||
|
||
expect(fireEvent.mouseDown(textarea, { button: 0 })).toBe(true)
|
||
expect(fireEvent.mouseDown(scrollport, { button: 0 })).toBe(true)
|
||
expect(fireEvent.mouseDown(view.getByText('Model'), { button: 0 })).toBe(true)
|
||
expect(fireEvent.mouseDown(view.getByText('Canceled'), { button: 0 })).toBe(false)
|
||
expect(fireEvent.mouseDown(card, { button: 2 })).toBe(true)
|
||
expect(lexicalFocus).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('leaves embedded overlay options, status text and scrollbars with the focused search field', () => {
|
||
const { view, shell } = bench({
|
||
overlay: <div>
|
||
<input aria-label="Filter models" />
|
||
<div role="listbox"><div role="option" aria-selected={false}>Model option</div></div>
|
||
<div>Loading models</div>
|
||
</div>,
|
||
})
|
||
const search = view.getByRole('textbox', { name: 'Filter models' })
|
||
act(() => { search.focus() })
|
||
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
|
||
|
||
for (const target of [
|
||
view.getByRole('option'), view.getByRole('listbox'), view.getByText('Loading models'),
|
||
view.container.querySelector('[data-composer-overlay]')!,
|
||
]) {
|
||
expect(fireEvent.mouseDown(target, { button: 0 })).toBe(true)
|
||
expect(document.activeElement).toBe(search)
|
||
}
|
||
expect(lexicalFocus).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps a focused editor\'s native caret without restoring an older Lexical selection', () => {
|
||
const { view, shell, textarea } = bench()
|
||
act(() => { shell.focus() })
|
||
expect(document.activeElement).toBe(textarea)
|
||
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
|
||
|
||
expect(fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })).toBe(false)
|
||
expect(document.activeElement).toBe(textarea)
|
||
expect(lexicalFocus).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('leaves portaled menu background presses outside the card alone', () => {
|
||
const { view, shell } = bench({ modelEntry: createPortal(<div>Menu background</div>, document.body) })
|
||
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
|
||
|
||
expect(fireEvent.mouseDown(view.getByText('Menu background'), { button: 0 })).toBe(true)
|
||
expect(lexicalFocus).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it.each([{ inert: true }, { disabled: true }, { blocked: { reason: 'unavailable' } }])(
|
||
'does not focus an unavailable composer: %j', (options) => {
|
||
const { view, shell } = bench(options)
|
||
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
|
||
|
||
expect(fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })).toBe(true)
|
||
expect(lexicalFocus).not.toHaveBeenCalled()
|
||
},
|
||
)
|
||
|
||
it('focus() returns the keyboard to the editor through Lexical, not a bare DOM focus', () => {
|
||
const { shell, textarea } = bench()
|
||
writeDraft(shell, 'draft text')
|
||
textarea.blur()
|
||
expect(document.activeElement).not.toBe(textarea)
|
||
|
||
const lexicalFocus = vi.spyOn(shell.editor, 'focus')
|
||
act(() => { shell.focus() })
|
||
expect(document.activeElement).toBe(textarea)
|
||
// Lexical's own focus restores its stored selection; a bare DOM focus would
|
||
// land the caret at the start of the draft. jsdom carries no caret, so the
|
||
// selection itself is asserted in the browser lane.
|
||
expect(lexicalFocus).toHaveBeenCalled()
|
||
})
|
||
|
||
it.each<[string, SubmitOutcome, boolean]>([
|
||
['desktop acceptance', { kind: 'success' }, false],
|
||
['desktop failure', { kind: 'error', text: 'send failed' }, false],
|
||
['mobile acceptance', { kind: 'success' }, true],
|
||
['mobile failure', { kind: 'error', text: 'send failed' }, true],
|
||
])('returns focus within the composer after %s', async (_label, outcome, mobile) => {
|
||
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockReturnValue(mobile)
|
||
onTestFinished(() => { device.mockRestore() })
|
||
const { view, shell, textarea, sink } = bench()
|
||
writeDraft(shell, 'draft text')
|
||
act(() => { shell.focus() })
|
||
let settle!: (outcome: SubmitOutcome) => void
|
||
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(textarea.getAttribute('contenteditable')).toBe('false')
|
||
// A browser moves the keyboard to the body once the focused box stops being editable; jsdom does not.
|
||
act(() => { textarea.blur() })
|
||
expect(document.activeElement).toBe(document.body)
|
||
|
||
await act(async () => { settle(outcome) })
|
||
|
||
expect(textarea.getAttribute('contenteditable')).toBe('true')
|
||
expect(document.activeElement).toBe(mobile ? view.container.querySelector('[data-composer-card]') : textarea)
|
||
})
|
||
|
||
it('leaves the keyboard with a control the user moved to while a message was sending', async () => {
|
||
const { shell, textarea, sink } = bench()
|
||
const other = document.createElement('button')
|
||
document.body.appendChild(other)
|
||
onTestFinished(() => { other.remove() })
|
||
writeDraft(shell, 'draft text')
|
||
act(() => { shell.focus() })
|
||
let settle!: (outcome: SubmitOutcome) => void
|
||
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
act(() => { other.focus() })
|
||
|
||
await act(async () => { settle({ kind: 'success' }) })
|
||
|
||
expect(document.activeElement).toBe(other)
|
||
})
|
||
})
|
||
|
||
describe('composer placeholder visibility', () => {
|
||
it.each([' ', ' ', '\t', '\n'])('hides for whitespace %j and returns after deletion', async (draft) => {
|
||
const { view, shell, textarea, button, sink, props } = bench()
|
||
const placeholder = () => view.container.querySelector('[data-composer-placeholder]')
|
||
expect(placeholder()).not.toBeNull()
|
||
writeDraft(shell, draft)
|
||
expect(placeholder()).toBeNull()
|
||
expect(button.disabled).toBe(true)
|
||
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13 })
|
||
await act(async () => {})
|
||
expect(sink).not.toHaveBeenCalled()
|
||
fireEvent.blur(textarea)
|
||
view.rerender(<InputBar {...props} />)
|
||
fireEvent.focus(textarea)
|
||
expect(placeholder()).toBeNull()
|
||
writeDraft(shell, '')
|
||
expect(placeholder()).not.toBeNull()
|
||
})
|
||
|
||
it('hides for pasted spaces and restores after clearing', async () => {
|
||
const { view, shell, textarea } = bench()
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: { items: [], getData: () => ' ' },
|
||
})
|
||
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(' ') })
|
||
expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
|
||
writeDraft(shell, '')
|
||
expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
|
||
})
|
||
|
||
it('keeps whitespace hidden through composition and rerender', () => {
|
||
const { view, shell, textarea, props } = bench()
|
||
fireEvent.compositionStart(textarea)
|
||
writeDraft(shell, ' ')
|
||
expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
|
||
view.rerender(<InputBar {...props} />)
|
||
fireEvent.compositionEnd(textarea, { data: ' ' })
|
||
expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
|
||
writeDraft(shell, '')
|
||
expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('image draft rail', () => {
|
||
it.each([false, true])('accepts pasted and dropped images for a continuable child (running=%s)', async (running) => {
|
||
const addFiles = vi.fn(() => null)
|
||
const { textarea, shell, slotCalls } = bench({ subagent: CONTINUABLE_CHILD, running, addFiles })
|
||
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: {
|
||
items: [{ kind: 'file', type: image.type, getAsFile: () => image }],
|
||
getData: () => '一起发送的文字',
|
||
},
|
||
})
|
||
expect(addFiles).toHaveBeenCalledWith([image], undefined)
|
||
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('一起发送的文字') })
|
||
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
|
||
act(() => { attachmentOwner(slotCalls).onAddFiles([image]) })
|
||
expect(addFiles).toHaveBeenCalledTimes(2)
|
||
// This fix restores paste/drop independently of the menu's picker policy.
|
||
expect(shell.canPickFiles()).toBe(false)
|
||
})
|
||
|
||
it.each([
|
||
['a generic file', new File(['text'], 'notes.txt', { type: 'text/plain' }), false],
|
||
['an unsupported image', new File(['<svg/>'], 'drawing.svg', { type: 'image/svg+xml' }), false],
|
||
['a directory', new File([], 'folder.png', { type: 'image/png' }), true],
|
||
])('refuses the entire child attachment batch containing %s', (_name, unsupported, directory) => {
|
||
const addFiles = vi.fn(() => null)
|
||
const { slotCalls, view, shell } = bench({ subagent: CONTINUABLE_CHILD, addFiles, draft: '保留草稿' })
|
||
const image = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||
act(() => {
|
||
attachmentOwner(slotCalls).onAddFiles([image, unsupported], directory ? new Set([unsupported]) : undefined)
|
||
})
|
||
expect(addFiles).not.toHaveBeenCalled()
|
||
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||
expect(shell.snapshot.draft).toBe('保留草稿')
|
||
})
|
||
|
||
it.each([
|
||
['a one-shot child', { ...CONTINUABLE_CHILD, address: { ...CONTINUABLE_CHILD.address, mode: 'one-shot' as const } }],
|
||
])('keeps attachment intake closed for %s', (_name, subagent) => {
|
||
const addFiles = vi.fn(() => null)
|
||
const { slotCalls } = bench({ subagent, addFiles })
|
||
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
|
||
act(() => {
|
||
attachmentOwner(slotCalls).onAddFiles([new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })])
|
||
})
|
||
expect(addFiles).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('collects clipboard files while preserving text from a mixed paste', async () => {
|
||
const addFiles = vi.fn(() => null)
|
||
const { textarea, shell } = bench({ addFiles })
|
||
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(addFiles).toHaveBeenCalledWith([image], undefined)
|
||
// The paste lands inside the PASTE_COMMAND update; its commit is a microtask away.
|
||
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') })
|
||
})
|
||
|
||
it('passes known clipboard directories and keeps files whose entry metadata is unavailable', () => {
|
||
const addFiles = vi.fn(() => null)
|
||
const { textarea } = bench({ addFiles })
|
||
const folder = new File([], 'folder with spaces')
|
||
const emptyFile = new File([], 'empty-file')
|
||
const withoutApi = new File([], 'without-api')
|
||
const withoutEntry = new File([], 'without-entry')
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: {
|
||
items: [
|
||
{ kind: 'string', getAsFile: () => null },
|
||
{ kind: 'file', getAsFile: () => folder, webkitGetAsEntry: () => ({ isDirectory: true }) },
|
||
{ kind: 'file', getAsFile: () => emptyFile, webkitGetAsEntry: () => ({ isDirectory: false }) },
|
||
{ kind: 'file', getAsFile: () => withoutApi },
|
||
{ kind: 'file', getAsFile: () => withoutEntry, webkitGetAsEntry: () => null },
|
||
{ kind: 'file', getAsFile: () => null },
|
||
],
|
||
getData: () => '',
|
||
},
|
||
})
|
||
expect(addFiles).toHaveBeenCalledWith([folder, emptyFile, withoutApi, withoutEntry], new Set([folder]))
|
||
})
|
||
|
||
it.each([
|
||
['ordinary session', {}],
|
||
['continuable child', { subagent: CONTINUABLE_CHILD }],
|
||
])('pre-checks projected image limits for %s: whole-batch refusal with product copy', (_name, session) => {
|
||
const limits = {
|
||
maxImageBytes: 1024 * 1024,
|
||
maxImagesPerMessage: 2,
|
||
maxMessageImageBytes: 2 * 1024 * 1024,
|
||
maxImagePixels: 40_000_000,
|
||
maxImageDimension: 2000,
|
||
mediaTypes: ['image/png'] as const,
|
||
}
|
||
const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
|
||
const intake = (result: ReturnType<typeof bench>, files: File[]) => {
|
||
act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) })
|
||
}
|
||
// Count: three at once over a two-image limit → the whole batch refused.
|
||
const overCount = bench({ ...session, addFiles: vi.fn(() => null), imageLimits: limits })
|
||
intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
|
||
expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
|
||
expect(overCount.props.addFiles).not.toHaveBeenCalled()
|
||
cleanup()
|
||
// Per-file bytes.
|
||
const overFile = bench({ ...session, addFiles: vi.fn(() => null), imageLimits: limits })
|
||
intake(overFile, [png(1024 * 1024 + 1, 'big.png')])
|
||
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
|
||
expect(overFile.props.addFiles).not.toHaveBeenCalled()
|
||
cleanup()
|
||
// Aggregate bytes across the existing rail plus the new batch.
|
||
const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
|
||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
|
||
const overTotal = bench({ ...session, addFiles: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
|
||
intake(overTotal, [png(1024 * 1024, 'more.png')])
|
||
expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
|
||
expect(overTotal.props.addFiles).not.toHaveBeenCalled()
|
||
cleanup()
|
||
// Within every limit: the batch passes through to addFiles.
|
||
const within = bench({ ...session, addFiles: vi.fn(() => null), imageLimits: limits })
|
||
const fits = png(16, 'fits.png')
|
||
intake(within, [fits])
|
||
expect(within.props.addFiles).toHaveBeenCalledWith([fits], undefined)
|
||
expect(within.view.queryByRole('alert')).toBeNull()
|
||
})
|
||
|
||
it('announces the format problem before any limit when the batch holds a non-image', () => {
|
||
const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||
const result = bench({
|
||
addFiles,
|
||
imageLimits: {
|
||
maxImageBytes: 8,
|
||
maxImagesPerMessage: 1,
|
||
maxMessageImageBytes: 8,
|
||
maxImagePixels: 40_000_000,
|
||
maxImageDimension: 2000,
|
||
mediaTypes: ['image/png'] as const,
|
||
},
|
||
})
|
||
// Oversized AND over-count AND wrong type: the format rejection wins.
|
||
const files = [
|
||
new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
|
||
new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
|
||
]
|
||
act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) })
|
||
expect(addFiles).toHaveBeenCalledWith(files, undefined)
|
||
expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||
})
|
||
|
||
it('projects display-ready limits into the attachment slot', () => {
|
||
const result = bench({
|
||
addFiles: vi.fn(() => null),
|
||
imageLimits: {
|
||
maxImageBytes: 5 * 1024 * 1024,
|
||
maxImagesPerMessage: 20,
|
||
maxMessageImageBytes: 100 * 1024 * 1024,
|
||
maxImagePixels: 40_000_000,
|
||
maxImageDimension: 2000,
|
||
mediaTypes: ['image/png'] as const,
|
||
},
|
||
})
|
||
expect(attachmentOwner(result.slotCalls).dropLimits).toEqual({ count: 20, size: '5MB' })
|
||
})
|
||
|
||
it('forwards dropped directories to addFiles and announces its refusal', () => {
|
||
const addFiles = vi.fn((_files: readonly File[], directories?: ReadonlySet<File>) =>
|
||
directories !== undefined && directories.size > 0 ? '只有桌面端支持添加文件夹,浏览器里请添加单个文件' : null)
|
||
const result = bench({ addFiles })
|
||
const folder = new File([], 'project')
|
||
const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' })
|
||
act(() => { attachmentOwner(result.slotCalls).onAddFiles([folder, note], new Set([folder])) })
|
||
expect(addFiles).toHaveBeenCalledWith([folder, note], new Set([folder]))
|
||
expect(result.view.getByRole('alert').textContent).toContain('只有桌面端支持添加文件夹')
|
||
})
|
||
|
||
it('announces server attachment rejections as product copy, other codes as developer text', () => {
|
||
const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({
|
||
op: 'send',
|
||
error: new RemoteError('session/attachment-invalid', 'raw wire text', { reason }),
|
||
})
|
||
const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') })
|
||
expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
|
||
cleanup()
|
||
const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') })
|
||
expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)')
|
||
cleanup()
|
||
// A subagent refusal uses the same product copy for the same reason.
|
||
const subagent = bench({
|
||
promptError: {
|
||
op: 'send',
|
||
error: new RemoteError('subagent/attachment-invalid', 'raw wire text', {
|
||
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
|
||
}),
|
||
},
|
||
})
|
||
expect(subagent.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
|
||
cleanup()
|
||
const other = bench({
|
||
promptError: { op: 'send', error: new RemoteError('gateway/internal', 'boom', {}) },
|
||
})
|
||
expect(other.view.getByRole('alert').textContent).toContain('boom (gateway/internal)')
|
||
})
|
||
|
||
it('marks the attachment slot unavailable while the composer is locked', () => {
|
||
const result = bench({ addFiles: vi.fn(() => null), inert: true })
|
||
expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false)
|
||
})
|
||
|
||
it('sends an image-only draft and exposes removal through the attachment slot', async () => {
|
||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||
const extra = new File([Uint8Array.of(2)], 'extra.png', { type: 'image/png' })
|
||
const attachments = [
|
||
{ kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' },
|
||
{ kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' },
|
||
]
|
||
const result = bench({ attachments })
|
||
const { view, textarea, sink, removeAttachment } = result
|
||
expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
|
||
const owner = attachmentOwner(result.slotCalls)
|
||
act(() => { owner.onRemoveAttachment('draft-2' as DraftAttachmentId) })
|
||
expect(removeAttachment).toHaveBeenCalledWith('draft-2')
|
||
let settle!: (outcome: SubmitOutcome) => void
|
||
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
// The rail keeps the sent image, and Send shows progress, until the Host accepts the message.
|
||
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
|
||
const sending = view.getByRole('button', { name: '正在发送消息' }) as HTMLButtonElement
|
||
expect(sending.disabled).toBe(true)
|
||
expect(sending.getAttribute('aria-busy')).toBe('true')
|
||
expect(sending.hasAttribute('data-sending')).toBe(true)
|
||
await act(async () => { settle({ kind: 'success' }) })
|
||
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
|
||
expect(view.getByRole('button', { name: '发送消息' }).getAttribute('aria-busy')).toBe('false')
|
||
})
|
||
|
||
it('keeps an image-only draft in the rail when its admission fails', async () => {
|
||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||
const attachments = [
|
||
{ kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' },
|
||
]
|
||
const result = bench({ attachments })
|
||
const { textarea, sink } = result
|
||
let fail!: (outcome: SubmitOutcome) => void
|
||
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { fail = resolve }))
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
|
||
await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) })
|
||
await vi.waitFor(() => {
|
||
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
|
||
})
|
||
expect(result.view.getByRole('alert').textContent).toContain('图片发送失败')
|
||
})
|
||
|
||
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||
const { view, textarea } = bench({ addFiles })
|
||
const paste = () => {
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: {
|
||
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }],
|
||
getData: () => '',
|
||
},
|
||
})
|
||
}
|
||
paste()
|
||
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||
act(() => { vi.advanceTimersByTime(4000) })
|
||
expect(view.queryByRole('alert')).toBeNull()
|
||
// The identical rejection re-announces: the toast is keyed per show.
|
||
paste()
|
||
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('keeps text editable but refuses attachment-slot, picker and paste intake without permission', () => {
|
||
const added = vi.fn(() => null)
|
||
const result = bench({ deniedEndpoints: ['fileUploads/upload'], addFiles: added, draft: 'text stays editable' })
|
||
const attachment = attachmentOwner(result.slotCalls)
|
||
expect(attachment.canAcceptDrop).toBe(false)
|
||
const file = new File(['note'], 'note.txt')
|
||
attachment.onAddFiles([file])
|
||
const picker = result.view.container.querySelector<HTMLInputElement>('input[type=file]')!
|
||
expect(picker.disabled).toBe(true)
|
||
fireEvent.change(picker, { target: { files: [file] } })
|
||
fireEvent.paste(result.textarea, { clipboardData: {
|
||
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => file }], getData: () => '',
|
||
} })
|
||
expect(added).not.toHaveBeenCalled()
|
||
expect(result.textarea.getAttribute('contenteditable')).toBe('true')
|
||
})
|
||
|
||
it('announces a rejected attachment-slot intake through the same toast', () => {
|
||
const addFiles = vi.fn(() => '图片读取服务不可用')
|
||
const result = bench({ addFiles })
|
||
act(() => {
|
||
attachmentOwner(result.slotCalls).onAddFiles([
|
||
new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }),
|
||
])
|
||
})
|
||
expect(result.view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
||
})
|
||
})
|
||
|
||
describe('Enter semantics', () => {
|
||
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
|
||
const { placeholder } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
|
||
expect(placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
||
expect(bench({
|
||
running: true,
|
||
queue: [row('q-1')],
|
||
subagent: {
|
||
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
|
||
},
|
||
}).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
||
})
|
||
|
||
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
|
||
expect(bench({ running: true }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
|
||
expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
|
||
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
|
||
expect(bench({
|
||
running: true,
|
||
queue: [row('q-1')],
|
||
placeholder: '上层指定提示',
|
||
}).placeholder).toBe('上层指定提示')
|
||
// The command menu owns Enter while open: neither the hint nor the
|
||
// gesture may claim the chord.
|
||
expect(bench({
|
||
running: true,
|
||
queue: [row('q-1')],
|
||
commandMenuOpen: true,
|
||
}).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
|
||
// The steer hint intentionally outranks the plan placeholder: while it
|
||
// shows, the whole-queue gesture is genuinely available in plan mode.
|
||
expect(bench({
|
||
running: true,
|
||
queue: [row('q-1')],
|
||
plan: { active: true, pending: false },
|
||
}).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
||
})
|
||
|
||
it('an open command menu withholds the whole-queue steering gesture', () => {
|
||
const steerQueue = vi.fn()
|
||
const { textarea, sink } = bench({
|
||
running: true,
|
||
queue: [row('q-1')],
|
||
commandMenuOpen: true,
|
||
steerQueue,
|
||
})
|
||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
||
expect(steerQueue).not.toHaveBeenCalled()
|
||
expect(sink).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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', [], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
// The submitting-phase lock, not draft emptiness, suppresses the repeat:
|
||
// the draft is still uncleared while the sink round-trip is in flight.
|
||
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(sink).toHaveBeenCalledTimes(1)
|
||
const empty = bench({ draft: ' ' })
|
||
fireEvent.keyDown(empty.textarea, { key: 'Enter' })
|
||
expect(empty.sink).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
|
||
const { textarea, sink } = bench({ draft: 'hello' })
|
||
fireEvent.keyDown(textarea, { key: 'a' })
|
||
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
||
expect(sink).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
|
||
const { textarea, sink } = bench({ draft: 'hello' })
|
||
fireEvent.compositionStart(textarea)
|
||
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
||
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
|
||
})
|
||
|
||
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', expect.any(AbortSignal), expect.any(Function))
|
||
|
||
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', expect.any(AbortSignal), expect.any(Function))
|
||
|
||
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', expect.any(AbortSignal), expect.any(Function))
|
||
})
|
||
|
||
it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
|
||
const steerQueue = vi.fn()
|
||
const queue = [row('q-1'), row('q-2')]
|
||
const meta = bench({ running: true, queue, steerQueue })
|
||
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
|
||
expect(meta.steerQueue).toHaveBeenCalledTimes(1)
|
||
expect(meta.sink).not.toHaveBeenCalled()
|
||
|
||
const ctrl = bench({ running: true, queue, steerQueue: vi.fn() })
|
||
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
|
||
expect(ctrl.steerQueue).toHaveBeenCalledTimes(1)
|
||
expect(ctrl.sink).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('queue steering stays gated by activity, gesture, capability, and queued rows', () => {
|
||
// Idle: the gesture falls through to the machine's empty-draft no-op.
|
||
const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
|
||
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
|
||
expect(idle.steerQueue).not.toHaveBeenCalled()
|
||
expect(idle.sink).not.toHaveBeenCalled()
|
||
|
||
// Plain Enter never steers the queue, even under the busy Steer preference.
|
||
const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() })
|
||
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
|
||
expect(plain.steerQueue).not.toHaveBeenCalled()
|
||
expect(plain.sink).not.toHaveBeenCalled()
|
||
|
||
// Continuable children expose the same steering face as ordinary Sessions.
|
||
const subagent = {
|
||
address: {
|
||
parentSessionId: 'parent' as SessionId,
|
||
childSessionId: SID,
|
||
mode: 'continuable' as const,
|
||
},
|
||
}
|
||
const childSteerQueue = vi.fn()
|
||
const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: childSteerQueue })
|
||
fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
|
||
expect(childSteerQueue).toHaveBeenCalledTimes(1)
|
||
expect(child.sink).not.toHaveBeenCalled()
|
||
|
||
// No queued rows: the empty draft stays a no-op.
|
||
const none = bench({ running: true, steerQueue: vi.fn() })
|
||
fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true })
|
||
expect(none.steerQueue).not.toHaveBeenCalled()
|
||
expect(none.sink).not.toHaveBeenCalled()
|
||
|
||
// Pending steering rows are not the queue: nothing to flush.
|
||
const steering = bench({
|
||
running: true,
|
||
nextStep: [row('s-1')],
|
||
steerQueue: vi.fn(),
|
||
})
|
||
fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
|
||
expect(steering.steerQueue).not.toHaveBeenCalled()
|
||
expect(steering.sink).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('draft content outranks the queue: accelerated Enter steers the draft only', () => {
|
||
const steerQueue = vi.fn()
|
||
const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
|
||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
|
||
expect(sink).toHaveBeenCalledWith('插话', [], 'steer', expect.any(AbortSignal), expect.any(Function))
|
||
expect(steerQueue).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => {
|
||
const { textarea, sink } = bench({ running: true, queue: [row('q-1')] })
|
||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
||
expect(sink).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('platform undo/redo chords drive the Lexical history stack, never the browser one', () => {
|
||
const { textarea, shell } = bench({ draft: '' })
|
||
vi.useFakeTimers()
|
||
onTestFinished(() => { vi.useRealTimers() })
|
||
writeDraft(shell, 'first')
|
||
vi.advanceTimersByTime(1100) // beyond the history merge window: a separate undo step
|
||
act(() => {
|
||
shell.editor.update(() => {
|
||
const first = $getRoot().getAllTextNodes()[0]
|
||
if ($isTextNode(first)) first.select(5, 5).insertText(' second')
|
||
}, { discrete: true })
|
||
})
|
||
expect(shell.snapshot.draft).toBe('first second')
|
||
// Lexical's chord detection reads keyCode (90 = z); a history restore
|
||
// commits on the next flush (setEditorState defers inside the command
|
||
// update).
|
||
const flush = (): void => { act(() => { shell.editor.update(() => {}, { discrete: true }) }) }
|
||
fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true })
|
||
flush()
|
||
expect(shell.snapshot.draft).toBe('first')
|
||
fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true, shiftKey: true })
|
||
flush()
|
||
expect(shell.snapshot.draft).toBe('first second')
|
||
})
|
||
|
||
it('a paste inside the typing merge window is its own undo step', async () => {
|
||
const { textarea, shell } = bench({ draft: '' })
|
||
vi.useFakeTimers()
|
||
onTestFinished(() => { vi.useRealTimers() })
|
||
writeDraft(shell, 'typed')
|
||
vi.advanceTimersByTime(1100) // separate the seed from the typing step
|
||
act(() => {
|
||
shell.editor.update(() => {
|
||
const first = $getRoot().getAllTextNodes()[0]
|
||
if ($isTextNode(first)) first.select(5, 5).insertText(' one')
|
||
}, { discrete: true })
|
||
})
|
||
// No timer advance: the paste lands inside the history merge window, and
|
||
// PASTE_TAG still gives it its own undo entry. The paste commits inside
|
||
// the PASTE_COMMAND update, a microtask away.
|
||
fireEvent.paste(textarea, {
|
||
clipboardData: { items: [], getData: () => ' two' },
|
||
})
|
||
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('typed one two') })
|
||
const flush = (): void => { act(() => { shell.editor.update(() => {}, { discrete: true }) }) }
|
||
fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true })
|
||
flush()
|
||
expect(shell.snapshot.draft).toBe('typed one')
|
||
})
|
||
|
||
it('caret-only commits advance neither draftRev nor the published state', () => {
|
||
const { shell } = bench({ draft: 'hello' })
|
||
const before = shell.snapshot
|
||
act(() => {
|
||
shell.editor.update(() => {
|
||
const first = $getRoot().getAllTextNodes()[0]
|
||
if ($isTextNode(first)) first.select(1, 1)
|
||
}, { discrete: true })
|
||
})
|
||
expect(shell.snapshot).toBe(before) // publish skipped: the snapshot reference is unchanged
|
||
act(() => {
|
||
shell.editor.update(() => {
|
||
const first = $getRoot().getAllTextNodes()[0]
|
||
if ($isTextNode(first)) first.select(1, 1).insertText('x')
|
||
}, { discrete: true })
|
||
})
|
||
expect(shell.snapshot.draftRev).toBe(before.draftRev + 1)
|
||
expect(shell.snapshot.draft).toBe('hxello')
|
||
})
|
||
|
||
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const { textarea, sink } = bench({ draft: 'hello' })
|
||
fireEvent.compositionStart(textarea)
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(sink).not.toHaveBeenCalled()
|
||
fireEvent.compositionEnd(textarea)
|
||
// Safari delivers the closing keydown before the deferred clear.
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(sink).not.toHaveBeenCalled()
|
||
vi.advanceTimersByTime(20)
|
||
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
|
||
expect(sink).not.toHaveBeenCalled()
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(sink).toHaveBeenCalledTimes(1)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('running and lock semantics', () => {
|
||
it.each([
|
||
{ messages: zh, label: '停止生成', trigger: 'hover' },
|
||
{ messages: en, label: 'Stop generating', trigger: 'focus' },
|
||
])('shows the registered Stop sequence on $trigger and removes it when unavailable', ({ messages, label, trigger }) => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const { view, stopShortcut } = bench({
|
||
running: true,
|
||
subagent: { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' } },
|
||
t: makeTranslate(messages, {}),
|
||
})
|
||
const stop = view.getByRole('button', { name: label })
|
||
if (trigger === 'hover') fireEvent.pointerEnter(stop)
|
||
else fireEvent.focus(stop)
|
||
act(() => { vi.advanceTimersByTime(500) })
|
||
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe(`${label} Esc Esc`)
|
||
act(() => { stopShortcut.set([]) })
|
||
expect(view.getByRole('tooltip').textContent).toBe(label)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
// The docked seat is the containing block for fixed descendants, so control
|
||
// tooltips render under the document body rather than inside the composer.
|
||
it('renders every control tooltip outside the composer subtree', () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const { view } = bench({ running: true })
|
||
const shown: string[] = []
|
||
for (const control of view.container.querySelectorAll('button')) {
|
||
fireEvent.pointerEnter(control)
|
||
act(() => { vi.advanceTimersByTime(500) })
|
||
const tooltip = view.queryByRole('tooltip')
|
||
if (tooltip !== null) {
|
||
shown.push(control.getAttribute('aria-label') ?? '')
|
||
expect(view.container.contains(tooltip)).toBe(false)
|
||
expect(document.body.contains(tooltip)).toBe(true)
|
||
}
|
||
fireEvent.pointerLeave(control)
|
||
act(() => { vi.advanceTimersByTime(500) })
|
||
}
|
||
expect(shown).toEqual(expect.arrayContaining(['添加文件或调用指令', '停止生成']))
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('dismisses the Stop tooltip when an empty composer becomes idle', () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const { button, view, session } = bench({ running: true })
|
||
fireEvent.pointerEnter(button)
|
||
act(() => { vi.advanceTimersByTime(500) })
|
||
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe('停止生成 Esc Esc')
|
||
|
||
// Disabling a hovered native button need not deliver mouseleave.
|
||
act(() => { session.set(snapshotOf({ running: false })) })
|
||
expect(button.disabled).toBe(true)
|
||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||
expect(view.queryByRole('tooltip')).toBeNull()
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('running switches the primary between Stop and Queue Send with the draft', async () => {
|
||
const { textarea, button, stop, sink, shell } = bench({ running: true })
|
||
expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
|
||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||
fireEvent.click(button)
|
||
expect(stop).toHaveBeenCalledTimes(1)
|
||
|
||
writeDraft(shell, '排队消息')
|
||
expect(button.getAttribute('aria-label')).toBe('排队发送')
|
||
writeDraft(shell, ' ')
|
||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||
writeDraft(shell, '排队消息2')
|
||
expect(button.getAttribute('aria-label')).toBe('排队发送')
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') })
|
||
expect(stop).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('running keeps Stop beside the sending control until the Host accepts the message', async () => {
|
||
const { view, textarea, stop, sink } = bench({ running: true, draft: '排队消息' })
|
||
let settle!: (outcome: SubmitOutcome) => void
|
||
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
const sending = view.getByRole('button', { name: '正在发送消息' }) as HTMLButtonElement
|
||
expect(sending.disabled).toBe(true)
|
||
fireEvent.click(view.getByRole('button', { name: '停止生成' }))
|
||
expect(stop).toHaveBeenCalledTimes(1)
|
||
|
||
await act(async () => { settle({ kind: 'success' }) })
|
||
|
||
expect(view.queryByRole('button', { name: '正在发送消息' })).toBeNull()
|
||
expect(view.getAllByRole('button', { name: '停止生成' })).toHaveLength(1)
|
||
})
|
||
|
||
it('running Send follows the busy-state Steer preference and labels the delivery', () => {
|
||
const { button, sink } = bench({ running: true, busyEnter: 'steer', draft: '按钮插话' })
|
||
expect(button.getAttribute('aria-label')).toBe('插话发送')
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('按钮插话', [], 'steer', expect.any(AbortSignal), expect.any(Function))
|
||
})
|
||
|
||
it('running Send relabels when the busy-state preference changes live', () => {
|
||
const { button, busyEnter, sink } = bench({ running: true, draft: '跟随设置' })
|
||
expect(button.getAttribute('aria-label')).toBe('排队发送')
|
||
act(() => { busyEnter.set('steer') })
|
||
expect(button.getAttribute('aria-label')).toBe('插话发送')
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('跟随设置', [], 'steer', expect.any(AbortSignal), expect.any(Function))
|
||
})
|
||
|
||
it('running Send keeps the plain label for a slash line and a claimed command', () => {
|
||
// An unclaimed `/` line adjudicates on submit; a claimed command executes
|
||
// instead of delivering a message. Neither click is a Queue/Steer delivery.
|
||
const slash = bench({ running: true, busyEnter: 'steer', draft: '/goal inspect' })
|
||
expect(slash.button.getAttribute('aria-label')).toBe('发送消息')
|
||
expect(slash.button.disabled).toBe(false)
|
||
|
||
const claimed = bench({ running: true, busyEnter: 'steer' })
|
||
act(() => {
|
||
claimed.shell.setDraft('/goal ')
|
||
claimed.shell.beginCommand(
|
||
{ name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) },
|
||
{ start: 0, end: 6, draftRev: claimed.shell.snapshot.draftRev },
|
||
)
|
||
})
|
||
expect(claimed.shell.snapshot.phase).toBe('claimed')
|
||
const button = claimed.view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')
|
||
expect(button).not.toBeNull()
|
||
expect(claimed.view.container.querySelector('button[aria-label="插话发送"]')).toBeNull()
|
||
})
|
||
|
||
it('running Send keeps the plain label while a file upload is still pending', () => {
|
||
const file = { kind: 'file' as const, id: 'file-1' as DraftAttachmentId, file: new File(['x'], 'note.txt', { type: 'text/plain' }) }
|
||
const pending = bench({
|
||
running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
|
||
fileUploads: { [file.id]: { status: 'uploading', loaded: 0 } },
|
||
})
|
||
expect(pending.button.getAttribute('aria-label')).toBe('发送消息')
|
||
expect(pending.button.disabled).toBe(true)
|
||
|
||
// A failed upload holds the same gate until it is retried or removed.
|
||
const failed = bench({
|
||
running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
|
||
fileUploads: { [file.id]: { status: 'error', message: 'upload failed' } },
|
||
})
|
||
expect(failed.button.getAttribute('aria-label')).toBe('发送消息')
|
||
expect(failed.button.disabled).toBe(true)
|
||
|
||
const ready = bench({
|
||
running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
|
||
fileUploads: { [file.id]: { status: 'ready', receiptId: 'receipt-1' as never, file: { kind: 'file' } as never } },
|
||
})
|
||
expect(ready.button.getAttribute('aria-label')).toBe('插话发送')
|
||
expect(ready.button.disabled).toBe(false)
|
||
})
|
||
|
||
it('running Send waits for an uploading image and treats an image without an upload as ready', () => {
|
||
const image = {
|
||
kind: 'image' as const,
|
||
id: 'image-1' as DraftAttachmentId,
|
||
file: new File(['x'], 'shot.png', { type: 'image/png' }),
|
||
previewUrl: 'blob:shot',
|
||
}
|
||
const uploading = bench({
|
||
running: true, busyEnter: 'steer', draft: '带图', attachments: [image],
|
||
fileUploads: { [image.id]: { status: 'uploading', loaded: 0 } },
|
||
})
|
||
expect(uploading.button.getAttribute('aria-label')).toBe('发送消息')
|
||
expect(uploading.button.disabled).toBe(true)
|
||
|
||
// A subagent continuation's image has no upload and travels inline.
|
||
const inline = bench({ running: true, busyEnter: 'steer', draft: '带图', attachments: [image] })
|
||
expect(inline.button.getAttribute('aria-label')).toBe('插话发送')
|
||
expect(inline.button.disabled).toBe(false)
|
||
})
|
||
|
||
it('explains on Enter why uploads hold the send, naming a Host refusal', () => {
|
||
const limits = {
|
||
maxImageBytes: 1024 * 1024,
|
||
maxImagesPerMessage: 2,
|
||
maxMessageImageBytes: 2 * 1024 * 1024,
|
||
maxImagePixels: 40_000_000,
|
||
maxImageDimension: 2000,
|
||
mediaTypes: ['image/png'] as const,
|
||
}
|
||
const image = {
|
||
kind: 'image' as const,
|
||
id: 'image-1' as DraftAttachmentId,
|
||
file: new File(['x'], 'wide.png', { type: 'image/png' }),
|
||
previewUrl: 'blob:wide',
|
||
}
|
||
const enterWith = (upload: NonNullable<BenchOptions['fileUploads']>[string]): string | null => {
|
||
const result = bench({ draft: '带图', attachments: [image], fileUploads: { [image.id]: upload }, imageLimits: limits })
|
||
fireEvent.keyDown(result.textarea, { key: 'Enter' })
|
||
expect(result.sink).not.toHaveBeenCalled()
|
||
const text = result.view.getByRole('alert').textContent
|
||
cleanup()
|
||
return text
|
||
}
|
||
expect(enterWith({ status: 'uploading', loaded: 0 })).toContain('文件还在上传,请等待上传完成后发送')
|
||
expect(enterWith({ status: 'error', message: 'offline' })).toContain('上传失败,点击重试')
|
||
const refusal = '图片宽高不能超过 2000px,请缩小后重试'
|
||
expect(enterWith({ status: 'error', message: 'too wide', reason: 'IMAGE_DIMENSION_TOO_LARGE' })).toContain(refusal)
|
||
// The attachments slot receives the same localized explanation for its thumbnail.
|
||
const owner = attachmentOwner(bench({ attachments: [image], imageLimits: limits }).slotCalls)
|
||
expect(owner.uploadFailureText('IMAGE_DIMENSION_TOO_LARGE')).toBe(refusal)
|
||
})
|
||
|
||
it('idle Send keeps the plain label regardless of the busy-state preference', () => {
|
||
const { button, sink } = bench({ busyEnter: 'steer', draft: '空闲发送' })
|
||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('空闲发送', [], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
})
|
||
|
||
it('running treats an attachment-only draft as Send', async () => {
|
||
const attachment = {
|
||
kind: 'image' as const,
|
||
id: 'draft-1' as DraftAttachmentId,
|
||
file: new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }),
|
||
previewUrl: 'blob:pixel',
|
||
}
|
||
const { button, sink } = bench({ running: true, attachments: [attachment] })
|
||
expect(button.getAttribute('aria-label')).toBe('排队发送')
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') })
|
||
})
|
||
|
||
it('running blocked composer keeps Stop with a retained draft', () => {
|
||
const { button, sink, stop, textarea } = bench({
|
||
running: true,
|
||
draft: '保留的草稿',
|
||
blocked: { reason: '请选择可用模型' },
|
||
placeholder: '请选择可用模型',
|
||
})
|
||
expect(textarea.getAttribute('aria-disabled')).toBe('true')
|
||
expect(textarea.getAttribute('data-placeholder')).toBe('请选择可用模型')
|
||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||
expect(button.disabled).toBe(false)
|
||
fireEvent.click(button)
|
||
expect(stop).toHaveBeenCalledTimes(1)
|
||
expect(sink).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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', expect.any(AbortSignal), expect.any(Function))
|
||
})
|
||
|
||
it('a running composer whose access excludes prompting and stopping offers neither Stop nor Send', () => {
|
||
const { view, stop } = bench({
|
||
running: true,
|
||
// The bar is handed a live Stop handler all the same, so what withholds the action is the access
|
||
// answer and not a missing face.
|
||
deniedEndpoints: ['session/prompt', 'session/cancel'],
|
||
blocked: { reason: '只读查看:本页由 owner 分享,无法发送消息' },
|
||
placeholder: '只读查看:本页由 owner 分享,无法发送消息',
|
||
})
|
||
expect(view.container.querySelector('button[aria-label="停止生成"]')).toBeNull()
|
||
expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull()
|
||
expect(stop).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('a running composer whose access excludes only prompting keeps Stop but offers no busy Send', () => {
|
||
const empty = bench({ running: true, deniedEndpoints: ['session/prompt'] })
|
||
expect(empty.view.getByLabelText('停止生成')).toBeTruthy()
|
||
cleanup()
|
||
// A draft would make the primary action the busy Send, which the snapshot excludes.
|
||
const drafted = bench({ running: true, deniedEndpoints: ['session/prompt'], draft: '排到下一轮' })
|
||
expect(drafted.view.container.querySelector('button[aria-label="排队发送"]')).toBeNull()
|
||
})
|
||
|
||
it('an idle composer whose access excludes prompting offers no Send', () => {
|
||
const { view } = bench({ deniedEndpoints: ['session/prompt'], draft: 'hello' })
|
||
expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull()
|
||
})
|
||
|
||
it('a composer whose access has no answer yet keeps its ordinary Send', () => {
|
||
const { view, props } = bench({ draft: 'hello' })
|
||
// Without a bound Session the Session-scope Hook answers undefined, which denies nothing.
|
||
view.rerender(<InputBar {...props} useAccess={() => undefined} />)
|
||
expect(view.getByLabelText('发送消息')).toBeTruthy()
|
||
})
|
||
|
||
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', expect.any(AbortSignal), expect.any(Function))
|
||
|
||
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', expect.any(AbortSignal), expect.any(Function))
|
||
})
|
||
|
||
it('running continuable subagent keeps Send beside an independent Stop', () => {
|
||
const { button, interruptButton, textarea, sink, stop, view, slotCalls, shell } = bench({
|
||
running: true,
|
||
draft: '后续消息',
|
||
subagent: {
|
||
address: {
|
||
parentSessionId: 'parent' as SessionId,
|
||
childSessionId: SID,
|
||
mode: 'continuable',
|
||
},
|
||
},
|
||
})
|
||
expect(button.getAttribute('aria-label')).toBe('排队发送')
|
||
expect(interruptButton).not.toBeNull()
|
||
expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
|
||
expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(true)
|
||
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
|
||
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
|
||
onTestFinished(() => { click.mockRestore() })
|
||
expect(shell.canPickFiles()).toBe(false)
|
||
expect(shell.pickFiles()).toBe(false)
|
||
expect(click).not.toHaveBeenCalled()
|
||
click.mockRestore()
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
fireEvent.click(interruptButton!)
|
||
expect(stop).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('running continuable subagent Send follows the Steer preference like an ordinary session', () => {
|
||
const subagent = {
|
||
address: {
|
||
parentSessionId: 'parent' as SessionId,
|
||
childSessionId: SID,
|
||
mode: 'continuable' as const,
|
||
},
|
||
}
|
||
const { button, sink } = bench({ running: true, busyEnter: 'steer', draft: '子代理插话', subagent })
|
||
expect(button.getAttribute('aria-label')).toBe('插话发送')
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('子代理插话', [], 'steer', expect.any(AbortSignal), expect.any(Function))
|
||
|
||
// No draft: the child has no Stop seat to fall back to, so its disabled
|
||
// Send keeps the plain label instead of naming a delivery it cannot make.
|
||
const empty = bench({ running: true, busyEnter: 'steer', subagent })
|
||
expect(empty.button.getAttribute('aria-label')).toBe('发送消息')
|
||
expect(empty.button.disabled).toBe(true)
|
||
})
|
||
|
||
it.each([
|
||
['active plan', { plan: { active: true, pending: false } }],
|
||
['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }],
|
||
])('%s keeps ordinary generic-file intake enabled', (_name, projection) => {
|
||
const added = vi.fn(() => null)
|
||
const { view, slotCalls, shell } = bench({ ...projection, addFiles: added })
|
||
expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(false)
|
||
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
|
||
// The menu's File row opens the hidden input through the bound picker.
|
||
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
|
||
onTestFinished(() => { click.mockRestore() })
|
||
expect(shell.canPickFiles()).toBe(true)
|
||
expect(shell.pickFiles()).toBe(true)
|
||
expect(click).toHaveBeenCalledOnce()
|
||
click.mockRestore()
|
||
})
|
||
|
||
it.each([
|
||
['removed session', { disabled: true }],
|
||
['inert composer', { inert: true }],
|
||
['blocked composer', { blocked: { reason: 'waiting' } }],
|
||
])('%s hides and refuses the File action', (_name, state) => {
|
||
const { shell, view } = bench({ ...state, addFiles: () => null })
|
||
const input = view.container.querySelector<HTMLInputElement>('input[type="file"]')!
|
||
const open = vi.spyOn(input, 'click')
|
||
expect(shell.canPickFiles()).toBe(false)
|
||
expect(shell.pickFiles()).toBe(false)
|
||
expect(open).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('file action availability follows mount and live composer state', () => {
|
||
const { props, view, shell } = bench({ addFiles: () => null })
|
||
expect(shell.canPickFiles()).toBe(true)
|
||
view.rerender(<InputBar {...props} disabled />)
|
||
expect(shell.canPickFiles()).toBe(false)
|
||
view.rerender(<InputBar {...props} />)
|
||
expect(shell.canPickFiles()).toBe(true)
|
||
view.unmount()
|
||
expect(shell.canPickFiles()).toBe(false)
|
||
expect(shell.pickFiles()).toBe(false)
|
||
})
|
||
|
||
it('keeps input and Stop available for a continuable child without parent availability', () => {
|
||
const { button, interruptButton, textarea, stop } = bench({
|
||
running: true,
|
||
draft: 'continue',
|
||
subagent: CONTINUABLE_CHILD,
|
||
})
|
||
expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
|
||
expect(button.disabled).toBe(false)
|
||
expect(interruptButton?.disabled).toBe(false)
|
||
fireEvent.click(interruptButton!)
|
||
expect(stop).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('running one-shot subagent never exposes Stop', () => {
|
||
const { button, interruptButton, stop } = bench({
|
||
running: true,
|
||
draft: '不可停止',
|
||
subagent: {
|
||
address: {
|
||
parentSessionId: 'parent' as SessionId,
|
||
childSessionId: SID,
|
||
mode: 'one-shot',
|
||
},
|
||
},
|
||
})
|
||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||
expect(interruptButton).toBeNull()
|
||
expect(stop).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('applies the ordinary Queue/Steer preference to a running continuable child', () => {
|
||
const subagent = {
|
||
address: {
|
||
parentSessionId: 'parent' as SessionId,
|
||
childSessionId: SID,
|
||
mode: 'continuable' as const,
|
||
},
|
||
}
|
||
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
|
||
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
|
||
expect(plain.sink).toHaveBeenCalledWith('plain', [], 'steer', expect.any(AbortSignal), expect.any(Function))
|
||
|
||
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
|
||
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
|
||
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'steer', expect.any(AbortSignal), expect.any(Function))
|
||
|
||
const opposite = bench({ running: true, busyEnter: 'steer', draft: 'opposite', subagent })
|
||
fireEvent.keyDown(opposite.textarea, { key: 'Enter', metaKey: true })
|
||
expect(opposite.sink).toHaveBeenCalledWith('opposite', [], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
})
|
||
|
||
it('disabled (session removed) locks the textarea and chrome', () => {
|
||
const { textarea, view } = bench({ disabled: true })
|
||
expect(textarea.getAttribute('aria-disabled')).toBe('true')
|
||
expect(placeholderOf(view.container)).toBe('会话不可用')
|
||
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
|
||
})
|
||
|
||
it('idle primary sends and disables on empty draft', () => {
|
||
const { button, sink } = bench({ draft: 'go' })
|
||
fireEvent.click(button)
|
||
expect(sink).toHaveBeenCalledWith('go', [], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
const empty = bench()
|
||
expect(empty.button.disabled).toBe(true)
|
||
})
|
||
|
||
it('unlock refocuses the surface; mousedown on the button keeps focus', () => {
|
||
const first = bench({ disabled: true, draft: 'x' })
|
||
const textarea = first.view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
|
||
const focused: (boolean | undefined)[] = []
|
||
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
||
act(() => { first.session.set(snapshotOf({ removed: false })) })
|
||
expect(focused).toEqual([true])
|
||
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
|
||
expect(focused).toEqual([true, true])
|
||
})
|
||
|
||
it('a programmatic draft write echoes back through the state and the editor DOM', () => {
|
||
const { textarea, wiring, shell } = bench()
|
||
writeDraft(shell, 'typed')
|
||
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
||
expect(textarea.textContent).toBe('typed')
|
||
})
|
||
|
||
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 })
|
||
const { view, textarea } = bench()
|
||
host.appendChild(view.container)
|
||
document.body.appendChild(host)
|
||
try {
|
||
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
|
||
expect(wheeled).toBe(false) // preventDefault
|
||
expect(host.scrollTop).toBe(70)
|
||
} finally {
|
||
host.remove()
|
||
}
|
||
})
|
||
|
||
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)
|
||
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(scrollport, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value: number) => { scrollTop = value },
|
||
})
|
||
try {
|
||
// Mid-draft: both directions stay local — host must not move.
|
||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
|
||
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
|
||
expect(host.scrollTop).toBe(40)
|
||
// At the bottom edge, further down-scroll forwards to the host.
|
||
scrollTop = 300
|
||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
|
||
expect(host.scrollTop).toBe(70)
|
||
// At the top edge, further up-scroll forwards to the host.
|
||
scrollTop = 0
|
||
host.scrollTop = 70
|
||
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
|
||
expect(host.scrollTop).toBe(50)
|
||
} finally {
|
||
host.remove()
|
||
}
|
||
})
|
||
|
||
it('one editable surface rides the scrollport (no mirror or backdrop layers)', () => {
|
||
const { view, textarea } = bench({ draft: 'line\n'.repeat(40).trimEnd() })
|
||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||
expect(scroll.contains(textarea)).toBe(true)
|
||
// The three-layer stack is gone: the editable surface owns glyphs, caret,
|
||
// and height at once.
|
||
expect(view.container.querySelector('[data-input-backdrop]')).toBeNull()
|
||
expect(view.container.querySelector('[data-input-mirror]')).toBeNull()
|
||
// DOM textContent joins paragraphs without separators; the projection
|
||
// carries the newlines.
|
||
expect(textarea.textContent).toBe('line'.repeat(40))
|
||
expect(bench({ draft: 'a\nb' }).shell.snapshot.draft).toBe('a\nb')
|
||
})
|
||
|
||
it('a session switch refocuses the editable surface with preventScroll', () => {
|
||
const { view, textarea, props } = bench({ draft: 'line one' })
|
||
const focused: (boolean | undefined)[] = []
|
||
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
||
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
||
expect(focused).toEqual([true])
|
||
})
|
||
|
||
it.each([
|
||
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false },
|
||
{ name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false },
|
||
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false },
|
||
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false },
|
||
{ name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true },
|
||
{ name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true },
|
||
])('composer focus and submission respect device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => {
|
||
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
|
||
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
|
||
Object.defineProperty(navigator, name, { configurable: true, value })
|
||
onTestFinished(() => {
|
||
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
|
||
else Object.defineProperty(navigator, name, descriptor)
|
||
})
|
||
}
|
||
vi.resetModules()
|
||
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
|
||
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
|
||
onTestFinished(() => { device.mockRestore() })
|
||
const outside = document.createElement('input')
|
||
document.body.append(outside)
|
||
onTestFinished(() => { outside.remove() })
|
||
outside.focus()
|
||
|
||
const { view, textarea, props, shell, button, sink } = bench({ draft: 'draft' })
|
||
expect(document.activeElement).toBe(automatic ? textarea : outside)
|
||
act(() => { outside.focus() })
|
||
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
||
expect(document.activeElement).toBe(automatic ? textarea : outside)
|
||
act(() => { outside.focus() })
|
||
act(() => { view.rerender(<InputBar {...props} disabled />) })
|
||
act(() => { view.rerender(<InputBar {...props} />) })
|
||
expect(document.activeElement).toBe(automatic ? textarea : outside)
|
||
|
||
act(() => { outside.focus() })
|
||
fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })
|
||
expect(document.activeElement).toBe(textarea)
|
||
|
||
writeDraft(shell, '')
|
||
expect(document.activeElement).toBe(textarea)
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
expect(sink).not.toHaveBeenCalled()
|
||
expect(document.activeElement).toBe(textarea)
|
||
|
||
for (const gesture of ['click', 'enter'] as const) {
|
||
writeDraft(shell, `send by ${gesture}`)
|
||
act(() => { textarea.focus() })
|
||
if (gesture === 'click') {
|
||
fireEvent.mouseDown(button, { button: 0 })
|
||
fireEvent.click(button)
|
||
} else {
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
}
|
||
await act(async () => {})
|
||
expect(sink).toHaveBeenLastCalledWith(`send by ${gesture}`, [], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||
expect(shell.snapshot.draft).toBe('')
|
||
expect(document.activeElement === textarea).toBe(automatic)
|
||
}
|
||
|
||
writeDraft(shell, '/goal ')
|
||
act(() => {
|
||
shell.beginCommand(
|
||
{ name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'error', text: 'Missing goal' }) },
|
||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||
)
|
||
textarea.focus()
|
||
})
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
await act(async () => {})
|
||
expect(shell.snapshot.phase).toBe('claimed')
|
||
expect(shell.snapshot.draft).toBe('/goal ')
|
||
expect(document.activeElement).toBe(textarea)
|
||
})
|
||
|
||
it('a persisted draft adopted after mount does not steal focus from another control', () => {
|
||
const { shell } = bench()
|
||
const other = document.createElement('input')
|
||
document.body.appendChild(other)
|
||
onTestFinished(() => { other.remove() })
|
||
other.focus()
|
||
act(() => { shell.setDraft('restored\n'.repeat(40)) })
|
||
expect(document.activeElement).toBe(other)
|
||
expect(shell.snapshot.draft).toBe('restored\n'.repeat(40))
|
||
})
|
||
|
||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||
expect(bench({ disabled: true }).placeholder).toBe('会话不可用')
|
||
const live = bench()
|
||
expect(live.placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
|
||
const custom = bench({ placeholder: 'Custom placeholder' })
|
||
expect(custom.placeholder).toBe('Custom placeholder')
|
||
})
|
||
|
||
it('the inert textarea opens the Workspace picker by pointer or keyboard', () => {
|
||
const onRequestWorkspace = vi.fn()
|
||
const { view, textarea } = bench({
|
||
inert: true,
|
||
workspacePickerOpen: false,
|
||
onRequestWorkspace,
|
||
placeholder: '选择一个工作区开始',
|
||
})
|
||
expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
|
||
expect(editableOf(textarea)).toBe(false)
|
||
expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
|
||
expect(textarea.getAttribute('aria-expanded')).toBe('false')
|
||
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
|
||
|
||
fireEvent.click(textarea)
|
||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||
fireEvent.keyDown(textarea, { key: ' ' })
|
||
expect(onRequestWorkspace).toHaveBeenCalledTimes(3)
|
||
|
||
// The WHOLE capsule is the pick target, and its pointerdown never reaches
|
||
// the document — the open picker's outside-close must not race the reopen.
|
||
const card = view.container.querySelector('[data-composer-card]') as HTMLElement
|
||
fireEvent.click(card)
|
||
expect(onRequestWorkspace).toHaveBeenCalledTimes(4)
|
||
const onDocumentPointerDown = vi.fn()
|
||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||
try {
|
||
fireEvent.pointerDown(card)
|
||
} finally {
|
||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||
}
|
||
expect(onDocumentPointerDown).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
||
const active = bench({ plan: { active: true, pending: false } })
|
||
expect(active.placeholder).toBe('描述你的任务以生成计划')
|
||
// /plan just ran: pending entry already reads as the plan target.
|
||
const entering = bench({ plan: { active: false, pending: true } })
|
||
expect(entering.placeholder).toBe('描述你的任务以生成计划')
|
||
// Pending exit: target is default again.
|
||
const leaving = bench({ plan: { active: true, pending: true } })
|
||
expect(leaving.placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
|
||
// Owner placeholder outranks the plan swap.
|
||
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
||
expect(custom.placeholder).toBe('Custom placeholder')
|
||
})
|
||
})
|
||
|
||
describe('machine pending lock', () => {
|
||
it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
|
||
const { view, shell } = bench()
|
||
// Drive the machine into submitting through a claim + enter.
|
||
act(() => {
|
||
shell.setDraft('/goal ')
|
||
shell.beginCommand(
|
||
{
|
||
name: 'goal',
|
||
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')
|
||
const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
|
||
expect(editableOf(textarea)).toBe(false)
|
||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('decorations', () => {
|
||
/** The claimed command chip's host element. */
|
||
function commandChipOf(container: HTMLElement): HTMLElement | null {
|
||
return container.querySelector('[data-composer-command]')
|
||
}
|
||
|
||
it('a claim renders the leading command chip and sets the blank-args hint variable', () => {
|
||
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
|
||
const { view, shell, textarea } = bench({ t: makeTranslate({}) })
|
||
act(() => {
|
||
shell.setDraft('/goal ')
|
||
shell.beginCommand(
|
||
{ name: 'goal', token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||
)
|
||
})
|
||
expect(commandChipOf(view.container)?.textContent).toBe('/goal')
|
||
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('目标内容'))
|
||
// Args typed: the hint disappears, the chip stays.
|
||
act(() => { shell.actions.insertText('发布', shell.actions.captureInsertion()) })
|
||
expect(shell.snapshot.draft).toBe('/goal 发布')
|
||
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
|
||
expect(commandChipOf(view.container)).not.toBeNull()
|
||
})
|
||
|
||
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
|
||
const { shell, textarea } = bench()
|
||
act(() => {
|
||
shell.setDraft('/goal ')
|
||
shell.beginCommand(
|
||
{ name: 'goal', token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||
)
|
||
})
|
||
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
|
||
})
|
||
|
||
it('the hint lookup keys on the claim name, so a localized claim token keeps the locale entry', () => {
|
||
const { shell, textarea } = bench()
|
||
act(() => {
|
||
shell.setDraft('/目标 ')
|
||
shell.beginCommand(
|
||
{ name: 'goal', token: '/目标 ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||
{ start: 0, end: 4, draftRev: shell.snapshot.draftRev },
|
||
)
|
||
})
|
||
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
|
||
})
|
||
|
||
it('suppresses placeholders throughout native composition, including a temporarily empty draft', async () => {
|
||
const { shell, textarea, view } = bench()
|
||
expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
|
||
fireEvent.compositionStart(textarea)
|
||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||
act(() => { shell.setDraft('z') })
|
||
act(() => { shell.setDraft('') })
|
||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||
fireEvent.compositionEnd(textarea, { data: '' })
|
||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||
await act(async () => {})
|
||
expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
|
||
|
||
act(() => {
|
||
shell.setDraft('/目标 ')
|
||
shell.beginCommand(
|
||
{ name: 'goal', token: '/目标 ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||
{ start: 0, end: 4, draftRev: shell.snapshot.draftRev },
|
||
)
|
||
})
|
||
fireEvent.compositionStart(textarea)
|
||
act(() => { shell.setDraft('/目标 z') })
|
||
act(() => { shell.setDraft('/目标 ') })
|
||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||
fireEvent.compositionEnd(textarea, { data: '这' })
|
||
act(() => { shell.setDraft('/目标 这') })
|
||
await act(async () => {})
|
||
expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
|
||
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
|
||
})
|
||
|
||
it('an inserted reference renders a real chip capsule with its icon and label', () => {
|
||
const { view, shell } = bench()
|
||
const reference = {
|
||
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
|
||
}
|
||
act(() => {
|
||
shell.setDraft('参考 @w1 内容')
|
||
shell.insertReference(
|
||
reference,
|
||
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
|
||
)
|
||
})
|
||
const chip = view.container.querySelector('[data-composer-chip]')
|
||
expect(chip?.textContent).toBe('会话一')
|
||
expect(chip?.querySelector('svg')).not.toBeNull()
|
||
expect(chip?.getAttribute('contenteditable')).toBe('false')
|
||
expect(shell.snapshot.occurrences).toHaveLength(1)
|
||
// The draft IS the clipboard projection; the label lives in the chip DOM.
|
||
expect(shell.snapshot.draft).toBe('参考 @w1 内容')
|
||
expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 3 })
|
||
})
|
||
|
||
it('keeps the chip decorator mounted when the session becomes disabled', () => {
|
||
const { view, shell, session, textarea } = bench()
|
||
act(() => {
|
||
shell.setDraft('@w1')
|
||
shell.insertReference({
|
||
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
||
}, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
|
||
})
|
||
const chip = view.container.querySelector('[data-composer-chip]')
|
||
expect(chip?.querySelector('svg')).not.toBeNull()
|
||
act(() => { session.set(snapshotOf({ removed: true })) })
|
||
expect(textarea.getAttribute('aria-disabled')).toBe('true')
|
||
expect(view.container.querySelector('[data-composer-chip]')).toBe(chip)
|
||
})
|
||
|
||
it('a chip leaves the document as one unit (keyboard deletion is asserted in the browser lane)', () => {
|
||
// jsdom lacks the non-standard Selection.modify Lexical's character
|
||
// deletion crosses nodes with, so the Backspace/Delete gesture itself is
|
||
// an e2e assertion; the structural unit — one span covering the chip
|
||
// removes the whole occurrence — is checkable here.
|
||
const { shell } = bench()
|
||
act(() => {
|
||
shell.setDraft('前 @w1 后')
|
||
shell.insertReference({
|
||
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
||
}, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
|
||
})
|
||
expect(shell.snapshot.occurrences).toHaveLength(1)
|
||
act(() => {
|
||
shell.editor.update(() => {
|
||
expect($replaceDetectSpanWithText({ start: 2, end: 3 }, '')).toBe(true)
|
||
}, { discrete: true })
|
||
})
|
||
expect(shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
|
||
})
|
||
|
||
it('copy and cut expand a selected chip to its clipboard projection natively', async () => {
|
||
const { shell, textarea } = bench()
|
||
act(() => {
|
||
shell.setDraft('前 @w1 后')
|
||
shell.insertReference({
|
||
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
||
}, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
|
||
// Select the chip plus its flanking spaces: detect [1, 4).
|
||
shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
|
||
})
|
||
const setData = vi.fn()
|
||
fireEvent.copy(textarea, { clipboardData: { setData, getData: () => '' } })
|
||
expect(setData).toHaveBeenCalledWith('text/plain', ' @w1 ')
|
||
expect(shell.snapshot.draft).toBe('前 @w1 后')
|
||
|
||
act(() => {
|
||
shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
|
||
})
|
||
fireEvent.cut(textarea, { clipboardData: { setData, getData: () => '' } })
|
||
await vi.waitFor(() => {
|
||
expect(shell.snapshot).toMatchObject({ draft: '前后', occurrences: [] })
|
||
})
|
||
expect(setData).toHaveBeenLastCalledWith('text/plain', ' @w1 ')
|
||
})
|
||
|
||
it('a lexicon-matched plain token renders the text-ref node', () => {
|
||
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
|
||
const { view, shell } = bench({ lexicon })
|
||
act(() => { shell.setDraft('use /fixture-demo now') })
|
||
const mark = view.container.querySelector('[data-composer-text-ref]')
|
||
expect(mark?.textContent).toBe('/fixture-demo')
|
||
// Editing the token out of match shape drops the decoration.
|
||
act(() => { shell.setDraft('use /fixture-dem now') })
|
||
expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
|
||
})
|
||
|
||
it('a directory completion decorates color-only: literal text, no icon seat', () => {
|
||
const { view, shell } = bench()
|
||
act(() => { shell.setDraft('see @src/components/') })
|
||
const mark = view.container.querySelector('[data-composer-text-ref]')
|
||
expect(mark?.textContent).toBe('@src/components/')
|
||
// The trigger character marks editable text; the domain icon is the
|
||
// settled chip's alone.
|
||
expect(mark?.getAttribute('data-ref-appearance')).toBeNull()
|
||
expect(shell.snapshot.draft).toBe('see @src/components/')
|
||
})
|
||
|
||
it('a pasted path decorates as one whole file or folder token', async () => {
|
||
const { view, shell, textarea } = bench()
|
||
const marks = () => [...view.container.querySelectorAll('[data-composer-text-ref]')].map(el => el.textContent)
|
||
const text = 'read @"/a/nv/SM100 ISA.html" and @src/deep/file.txt then @src/deep/ first'
|
||
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => text } })
|
||
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(text) })
|
||
expect(marks()).toEqual(['@"/a/nv/SM100 ISA.html"', '@src/deep/file.txt', '@src/deep/'])
|
||
})
|
||
|
||
it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {
|
||
const { view, shell } = bench()
|
||
act(() => { shell.setDraft('see @src/components/ here') })
|
||
const mark = view.container.querySelector('[data-composer-text-ref]')!
|
||
// Type ahead through the node API (the transaction path typing takes).
|
||
act(() => {
|
||
shell.editor.update(() => {
|
||
const first = $getRoot().getAllTextNodes()[0]
|
||
if ($isTextNode(first)) first.spliceText(0, 0, 'X ')
|
||
}, { discrete: true })
|
||
})
|
||
// Node identity, not text: the entity node survives edits ahead of it.
|
||
expect(view.container.querySelector('[data-composer-text-ref]')).toBe(mark)
|
||
expect(mark.textContent).toBe('@src/components/')
|
||
expect(shell.snapshot.draft).toBe('X see @src/components/ here')
|
||
// A token edited out of match shape still loses its decoration.
|
||
act(() => { shell.setDraft('X see X@src/components/ here') })
|
||
expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('insertText (scoped event body)', () => {
|
||
it('splices plain text over the span and reports success as true', () => {
|
||
const { shell } = bench({ draft: '/fix' })
|
||
const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
|
||
expect(ok).toBe(true)
|
||
expect(shell.snapshot.draft).toBe('/fixture-demo ')
|
||
expect(shell.snapshot.occurrences).toEqual([])
|
||
})
|
||
|
||
it('a stale draftRev refuses whole: false, draft untouched', () => {
|
||
const { shell } = bench({ draft: '/fix' })
|
||
const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
|
||
act(() => { shell.setDraft('/fixX') })
|
||
expect(shell.insertText('/fixture-demo ', span)).toBe(false)
|
||
expect(shell.snapshot.draft).toBe('/fixX')
|
||
})
|
||
})
|
||
|
||
describe('strips and variants', () => {
|
||
it.each([zh, en])('shows localized guidance when another writer owns the Session', (dictionary) => {
|
||
const send = bench({
|
||
promptError: {
|
||
op: 'send',
|
||
error: new RemoteError('session/writer-held', 'internal writer diagnostic', { sessionId: SID }),
|
||
},
|
||
t: makeTranslate(dictionary, commonZh),
|
||
})
|
||
expect(send.view.getByRole('alert').textContent).toBe(dictionary['error.sessionInUse'])
|
||
})
|
||
|
||
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const send = bench({
|
||
promptError: { op: 'send', error: new RemoteError('session/agent-busy', 'boom', { reason: 'boom' }) },
|
||
})
|
||
// The toast body-portals (transformed ancestors must not trap it), so
|
||
// queries go through the view's document-bound helpers.
|
||
expect(send.view.getByRole('alert').textContent).toContain('boom (session/agent-busy)')
|
||
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||
act(() => { vi.advanceTimersByTime(4000) })
|
||
expect(send.view.queryByRole('alert')).toBeNull()
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('announces an error notice from the machine store as a fading toast', () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const { view, shell } = bench()
|
||
act(() => { shell.notify('error', '命令失败了') })
|
||
expect(view.getByRole('alert').textContent).toContain('命令失败了')
|
||
expect(view.queryByRole('status')).toBeNull()
|
||
act(() => { vi.advanceTimersByTime(4000) })
|
||
expect(view.queryByRole('alert')).toBeNull()
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('renders an information notice from the machine store as a status strip', () => {
|
||
const { view, shell } = bench()
|
||
act(() => { shell.notify('info', '命令完成了') })
|
||
expect(view.getByRole('status').textContent).toBe('命令完成了')
|
||
expect(view.queryByRole('alert')).toBeNull()
|
||
})
|
||
|
||
it('hero variant adds the hero class and accessory row renders', () => {
|
||
const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
|
||
expect(view.getByTestId('acc')).toBeTruthy()
|
||
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
||
})
|
||
|
||
it('renders overlay, left/right, and footer slots at their layout positions', () => {
|
||
const { view } = bench({
|
||
overlay: <i data-testid="ov" />,
|
||
leftItems: <i data-testid="li" />,
|
||
rightItems: <i data-testid="ri" />,
|
||
footer: <i data-testid="foot" />,
|
||
})
|
||
expect(view.getByTestId('ov')).toBeTruthy()
|
||
expect(view.getByTestId('li')).toBeTruthy()
|
||
expect(view.getByTestId('ri')).toBeTruthy()
|
||
expect(view.getByTestId('foot')).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
describe('command launcher chrome and control seats', () => {
|
||
it('renders the command launcher and dispatches every empty control seat', () => {
|
||
const { view, slotCalls } = bench()
|
||
expect(view.getByLabelText('添加文件或调用指令')).toBeTruthy()
|
||
// Capability absent (no permission slot entry): the chip renders nothing.
|
||
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
||
// Every seat dispatched, nothing rendered (render passes may repeat; the
|
||
// seat set is the contract).
|
||
expect([...new Set(slotCalls.map(c => c.key))]).toEqual([
|
||
'conversation.input.overlay', 'conversation.input.attachments',
|
||
'conversation.input.permission', 'conversation.input.plan', 'conversation.input.left',
|
||
'conversation.input.right', 'conversation.input.model', 'conversation.input.activity',
|
||
'conversation.composer.dock',
|
||
])
|
||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||
expect(view.queryByLabelText('Model')).toBeNull()
|
||
})
|
||
|
||
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
|
||
const toggleCommandMenu = vi.fn()
|
||
const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
|
||
act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
|
||
const launcher = view.getByLabelText('添加文件或调用指令')
|
||
expect(launcher.getAttribute('aria-expanded')).toBe('false')
|
||
fireEvent.click(launcher)
|
||
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
|
||
act(() => { menuLauncher.set('command') })
|
||
expect(launcher.getAttribute('aria-expanded')).toBe('true')
|
||
})
|
||
|
||
it('follows the access answers over a running Session, and restores the actions when they return', () => {
|
||
const { view, props } = bench({ running: true })
|
||
// While the page may stop the running Session, the primary action is its Stop.
|
||
expect(view.getByLabelText('停止生成')).toBeTruthy()
|
||
|
||
// The access snapshot now excludes the Session's writes: the bar offers neither Stop nor Send.
|
||
view.rerender(<InputBar {...props} useAccess={() => false} />)
|
||
expect(view.queryByLabelText('停止生成')).toBeNull()
|
||
expect(view.queryByLabelText('发送消息')).toBeNull()
|
||
|
||
// The answers return: the bar offers the action the page holds again.
|
||
view.rerender(<InputBar {...props} />)
|
||
expect(view.getByLabelText('停止生成')).toBeTruthy()
|
||
})
|
||
|
||
it('opening the command menu from the button puts the keyboard in the editor first', () => {
|
||
const toggleCommandMenu = vi.fn()
|
||
const { view, textarea } = bench({ toggleCommandMenu })
|
||
// Tab to the button and activate it: the keyboard is on the button, and the
|
||
// menu is a combobox whose arrows live on the editor.
|
||
textarea.blur()
|
||
expect(document.activeElement).not.toBe(textarea)
|
||
fireEvent.click(view.getByLabelText('添加文件或调用指令'))
|
||
expect(document.activeElement).toBe(textarea)
|
||
expect(toggleCommandMenu).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||
const { view, slotCalls } = bench({
|
||
disabled: true,
|
||
planEntry: <i data-testid="plan-entry" />,
|
||
permissionEntry: <i data-testid="permission-entry" />,
|
||
modelEntry: <i data-testid="model-entry" />,
|
||
})
|
||
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
||
expect(view.getByTestId('permission-entry')).toBeTruthy()
|
||
expect(view.getByTestId('model-entry')).toBeTruthy()
|
||
// The bar hands its chrome disable state to the filling entry.
|
||
const controlKeys = new Set([
|
||
'conversation.input.permission', 'conversation.input.plan', 'conversation.input.model',
|
||
])
|
||
const controls = slotCalls.filter(call => controlKeys.has(call.key))
|
||
expect(controls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
||
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
|
||
cleanup()
|
||
const live = bench({ running: true })
|
||
const liveControls = live.slotCalls.filter(call => controlKeys.has(call.key))
|
||
expect(liveControls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||
expect(attachmentOwner(live.slotCalls).canAcceptDrop).toBe(true)
|
||
})
|
||
|
||
it('disabled locks the command launcher while running does not', () => {
|
||
const { view } = bench({ disabled: true })
|
||
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
|
||
cleanup()
|
||
const live = bench({ running: true })
|
||
expect((live.view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(false)
|
||
})
|
||
})
|
||
|
||
it('lets a toolbar activity replace accessories without replacing the draft editor or send action', () => {
|
||
const { view } = bench({ draft: 'keep this draft', modelEntry: <button>model choice</button>,
|
||
activityEntry: owner => <>
|
||
<button onClick={() => { owner.onActiveChange(true) }}>expand activity</button>
|
||
<button onClick={() => { owner.onActiveChange(false) }}>close activity</button>
|
||
</>,
|
||
})
|
||
const editor = view.getByRole('textbox')
|
||
fireEvent.click(view.getByRole('button', { name: 'expand activity' }))
|
||
expect(view.queryByRole('button', { name: 'model choice' })).toBeNull()
|
||
expect(view.getByRole('textbox')).toBe(editor)
|
||
expect(editor.textContent).toBe('keep this draft')
|
||
expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy()
|
||
fireEvent.click(view.getByRole('button', { name: 'close activity' }))
|
||
expect(view.getByRole('button', { name: 'model choice' })).toBeTruthy()
|
||
})
|
||
|
||
it('places context usage below the composer and hides it until the activity closes', () => {
|
||
const { view } = bench({ draft: 'draft', contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||
activityEntry: owner => <>
|
||
<button onClick={() => { owner.onActiveChange(true) }}>microphone</button>
|
||
<button onClick={() => { owner.onActiveChange(false) }}>close activity</button>
|
||
</>,
|
||
})
|
||
const meter = view.getByRole('button', { name: '上下文已用 25%' })
|
||
const microphone = view.getByRole('button', { name: 'microphone' })
|
||
expect(microphone.compareDocumentPosition(meter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||
fireEvent.click(meter)
|
||
expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy()
|
||
fireEvent.click(microphone)
|
||
expect(view.queryByRole('dialog', { name: '上下文已用' })).toBeNull()
|
||
expect(view.queryByRole('button', { name: '上下文已用 25%' })).toBeNull()
|
||
expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy()
|
||
fireEvent.click(view.getByRole('button', { name: 'close activity' }))
|
||
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
|
||
expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy()
|
||
expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy()
|
||
})
|