| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681 |
- // @vitest-environment jsdom
- import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
- import { act, cleanup, fireEvent, render } from '@testing-library/react'
- import type { Context } from '@deepseek-ai/cordis'
- import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
- import {
- bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot,
- } from '@deepseek-ai/dsh-client-test-runtime'
- import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
- import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
- import type { SessionId } from '@deepseek-ai/dsh-session/types'
- import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
- import { SessionInputShell } from '../src/client/input/facade.ts'
- import type {
- ComposerAttachment, ComposerAttachmentsOwnerProps,
- } 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 { zh } from '../src/client/locales.ts'
- afterEach(cleanup)
- // jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
- // is absent — and the composer measures the caret with one when it restores the
- // selection after an edit it performed itself. Every case here runs against a
- // zero rect; the reveal case below substitutes its own and restores this one.
- const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
- Range.prototype.getBoundingClientRect = ZERO_RECT
- // Read through the descriptor so the native method is never referenced unbound;
- // the reveal case below wraps it to record what it was asked to measure.
- const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
- .value as (this: Range, node: Node, offset: number) => void
- const SCTX = {} as Context
- const SID = 's1' as SessionId
- function snapshotOf(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
- return { ...sessionSnapshot(SID), ...overrides }
- }
- interface BenchOptions {
- planEntry?: React.ReactNode
- /** The `plan` projection value the standard-kit useProjection serves. */
- plan?: { active: boolean; pending: boolean }
- modelEntry?: React.ReactNode
- /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
- lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
- permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: 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
- 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?: SessionSnapshot['queue']
- /** The hub's steer-all face (empty-draft accelerated Enter). */
- steerQueue?: () => void
- variant?: 'hero' | 'composer'
- placeholder?: string
- t?: InputBarProps['t']
- command?: (line: string) => Promise<boolean>
- accessory?: React.ReactNode
- overlay?: React.ReactNode
- leftItems?: React.ReactNode
- rightItems?: React.ReactNode
- attachments?: readonly ComposerAttachment[]
- addImages?: (files: readonly 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): SessionSnapshot['queue'][number] {
- return {
- id: id as never, messageId: `message-${id}` as never, placement: 'queued',
- content: [{ type: 'text', text: id }], preview: id, 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,
- imageIds: 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,
- queue: over?.queue ?? [],
- }))
- type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
- const shell = new SessionInputShell({
- actx: SCTX,
- defaultSink: sink,
- commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` },
- queue: {
- getSnapshot: () => session.getSnapshot().queue,
- subscribe: fn => session.subscribe(fn),
- },
- ...(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: (() => ({
- 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.addImages(over.attachments.map(attachment => attachment.id))
- const stop = vi.fn()
- const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) })
- const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
- const slotCalls: { key: string; owner: unknown }[] = []
- const renderSlot = ((key: string, owner: object) => {
- slotCalls.push({ key, owner })
- if (key === 'conversation.input.plan') return over?.planEntry ?? null
- if (key === 'conversation.input.model') return over?.modelEntry ?? null
- return null
- }) as InputBarProps['renderSlot']
- const props: InputBarProps = {
- sessionId: SID,
- SessionProvider: ({ children }) => children,
- useSession: bindSnapshotSelector(session),
- useSessions: bindSnapshotSelector(createSnapshotStore({
- ids: [], byId: {}, current: undefined, phase: 'ready',
- subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
- })),
- useSessionPendingInteraction: bindSnapshotSelector(
- createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
- ),
- useWorkspaces: bindSnapshotSelector(createSnapshotStore({
- items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
- baselinesReady: true, recentWorkspaceId: undefined,
- })),
- useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
- (selector ?? (v => v))(key === 'permissions'
- ? over?.permissions
- : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)),
- useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
- useInput: bindSnapshotSelector(shell.state),
- inputActions: shell.actions,
- keyboard: shell,
- addImages: over?.addImages ?? (() => null),
- removeImage,
- draftImages: ids => ids.flatMap((id) => {
- const attachment = over?.attachments?.find(candidate => candidate.id === id)
- return attachment === undefined ? [] : [attachment]
- }),
- resolveSubmitMode: (running, gesture, steeringAvailable) => {
- if (!running || !steeringAvailable) return 'queue'
- const preferred = over?.busyEnter ?? 'queue'
- return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue'
- },
- toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
- useNotices: bindSnapshotSelector(shell.notices),
- useLexicon: bindSnapshotSelector(shell.lexicon),
- useMenuLauncher: bindSnapshotSelector(menuLauncher),
- stop,
- command: over?.command ?? (() => Promise.resolve(true)),
- 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 } : {}),
- ...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
- ...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
- ...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
- }
- const view = render(<InputBar {...props} />)
- const textarea = view.container.querySelector('textarea')!
- const sendableDraft = (over?.draft?.trim() ?? '') !== '' || (over?.attachments?.length ?? 0) > 0
- const primaryStops = over?.running === true && over.subagent === undefined
- && (!sendableDraft || over.blocked !== undefined)
- const button = view.container.querySelector<HTMLButtonElement>(
- `button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
- )!
- const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
- return {
- view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
- menuLauncher,
- steerQueue: over?.steerQueue,
- }
- }
- /**
- * Dispatch the native `beforeinput` the composer reads the pre-edit selection
- * from. The DOM event carries no range for a textarea (`getTargetRanges()` is
- * empty there), so the element's own selection plus `inputType` is the signal.
- * The selection each gesture leaves is the engine-observed one: a delete over a
- * selection reports that selection, a caret delete reports the bare caret.
- */
- function beforeInput(el: HTMLTextAreaElement, inputType = 'insertText'): void {
- el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType }))
- }
- 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')
- }
- describe('image draft rail', () => {
- it('collects clipboard files while preserving text from a mixed paste', () => {
- const addImages = vi.fn(() => null)
- const { textarea, shell } = bench({ addImages })
- const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
- fireEvent.paste(textarea, {
- clipboardData: {
- items: [
- { kind: 'string', type: 'text/plain', getAsFile: () => null },
- { kind: 'file', type: 'image/png', getAsFile: () => image },
- ],
- getData: () => '同时粘贴的文字',
- },
- })
- expect(addImages).toHaveBeenCalledWith([image])
- expect(shell.snapshot.draft).toBe('同时粘贴的文字')
- })
- it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
- 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).onAddImages(files) })
- }
- // Count: three at once over a two-image limit → the whole batch refused.
- const overCount = bench({ addImages: 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.addImages).not.toHaveBeenCalled()
- cleanup()
- // Per-file bytes.
- const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
- intake(overFile, [png(1024 * 1024 + 1, 'big.png')])
- expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
- expect(overFile.props.addImages).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({ addImages: 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.addImages).not.toHaveBeenCalled()
- cleanup()
- // Within every limit: the batch passes through to addImages.
- const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
- const fits = png(16, 'fits.png')
- intake(within, [fits])
- expect(within.props.addImages).toHaveBeenCalledWith([fits])
- expect(within.view.queryByRole('alert')).toBeNull()
- })
- it('announces the format problem before any limit when the batch holds a non-image', () => {
- const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
- const result = bench({
- addImages,
- 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).onAddImages(files) })
- expect(addImages).toHaveBeenCalledWith(files)
- expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
- })
- it('projects display-ready limits into the attachment slot', () => {
- const result = bench({
- addImages: 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('announces server attachment rejections as product copy, other codes as developer text', () => {
- const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({
- op: 'send',
- error: { code: 'attachment-error', message: 'raw wire text', details: { 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()
- const other = bench({
- promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } },
- })
- expect(other.view.getByRole('alert').textContent).toContain('boom (internal)')
- })
- it('marks the attachment slot unavailable while the composer is locked', () => {
- const result = bench({ addImages: 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, removeImage } = result
- expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
- const owner = attachmentOwner(result.slotCalls)
- act(() => { owner.onRemoveImage('draft-2' as DraftAttachmentId) })
- expect(removeImage).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(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
- await act(async () => { settle({ kind: 'success' }) })
- await vi.waitFor(() => {
- expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
- })
- })
- it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
- vi.useFakeTimers()
- try {
- const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
- const { view, textarea } = bench({ addImages })
- 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('announces a rejected attachment-slot intake through the same toast', () => {
- const addImages = vi.fn(() => '图片读取服务不可用')
- const result = bench({ addImages })
- act(() => {
- attachmentOwner(result.slotCalls).onAddImages([
- 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 { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
- expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
- })
- it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
- expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
- expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
- expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
- expect(bench({
- running: true,
- queue: [row('q-1')],
- subagent: {
- address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
- parentAvailable: true,
- },
- }).textarea.placeholder).toBe('给智能体发消息')
- expect(bench({
- running: true,
- queue: [row('q-1')],
- placeholder: '上层指定提示',
- }).textarea.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,
- }).textarea.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 },
- }).textarea.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))
- // 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))
- 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))
- 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))
- })
- 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: idle, subagent, plain Enter, empty queue, or steering-only 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()
- // Subagent sessions keep the queue transport (no steering face).
- const subagent = {
- address: {
- parentSessionId: 'parent' as SessionId,
- childSessionId: SID,
- mode: 'continuable' as const,
- },
- parentAvailable: true,
- }
- const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() })
- fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
- expect(child.steerQueue).not.toHaveBeenCalled()
- 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,
- queue: [{ ...row('s-1'), placement: 'steering' }],
- 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(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 route to the machine, never the browser stack', () => {
- const { textarea, shell } = bench({ draft: '' })
- fireEvent.change(textarea, { target: { value: 'first' } })
- fireEvent.change(textarea, { target: { value: 'first second' } })
- fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
- expect(shell.snapshot.draft).not.toBe('first second')
- fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
- expect(shell.snapshot.draft).toBe('first second')
- })
- 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('running switches the primary between Stop and Queue Send with the draft', async () => {
- const { textarea, button, stop, sink } = bench({ running: true, busyEnter: 'steer' })
- expect(textarea.disabled).toBe(false)
- expect(button.getAttribute('aria-label')).toBe('停止生成')
- fireEvent.click(button)
- expect(stop).toHaveBeenCalledTimes(1)
- fireEvent.change(textarea, { target: { value: '排队消息' } })
- expect(button.getAttribute('aria-label')).toBe('发送消息')
- fireEvent.change(textarea, { target: { value: ' ' } })
- expect(button.getAttribute('aria-label')).toBe('停止生成')
- fireEvent.change(textarea, { target: { value: '排队消息2' } })
- expect(button.getAttribute('aria-label')).toBe('发送消息')
- fireEvent.click(button)
- expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
- await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') })
- expect(stop).toHaveBeenCalledTimes(1)
- })
- 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))
- 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.disabled).toBe(true)
- expect(textarea.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))
- })
- 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))
- 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))
- })
- it('running continuable subagent keeps Send beside an independent Stop', () => {
- const { button, interruptButton, textarea, sink, stop } = bench({
- running: true,
- draft: '后续消息',
- subagent: {
- address: {
- parentSessionId: 'parent' as SessionId,
- childSessionId: SID,
- mode: 'continuable',
- },
- parentAvailable: true,
- },
- })
- expect(button.getAttribute('aria-label')).toBe('发送消息')
- expect(interruptButton).not.toBeNull()
- expect(textarea.disabled).toBe(false)
- fireEvent.click(button)
- expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
- fireEvent.click(interruptButton!)
- expect(stop).toHaveBeenCalledTimes(1)
- })
- it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
- const { button, interruptButton, textarea, stop, view } = bench({
- running: true,
- draft: '',
- subagent: {
- address: {
- parentSessionId: 'parent' as SessionId,
- childSessionId: SID,
- mode: 'continuable',
- },
- parentAvailable: false,
- },
- })
- expect(textarea.disabled).toBe(true)
- expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
- expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
- expect(button.getAttribute('aria-label')).toBe('发送消息')
- expect(button.disabled).toBe(true)
- 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',
- },
- parentAvailable: true,
- },
- })
- expect(button.getAttribute('aria-label')).toBe('发送消息')
- expect(interruptButton).toBeNull()
- expect(stop).not.toHaveBeenCalled()
- })
- it('keeps both running subagent Enter gestures on Queue transport', () => {
- const subagent = {
- address: {
- parentSessionId: 'parent' as SessionId,
- childSessionId: SID,
- mode: 'continuable' as const,
- },
- parentAvailable: true,
- }
- const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
- fireEvent.keyDown(plain.textarea, { key: 'Enter' })
- expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue', expect.any(AbortSignal))
- const accelerated = bench({ running: true, draft: 'accelerated', subagent })
- fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
- expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue', expect.any(AbortSignal))
- })
- it('disabled (session removed) locks the textarea and chrome', () => {
- const { textarea, view } = bench({ disabled: true })
- expect(textarea.disabled).toBe(true)
- expect(textarea.placeholder).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))
- const empty = bench()
- expect(empty.button.disabled).toBe(true)
- })
- it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
- const first = bench({ disabled: true, draft: 'x' })
- act(() => { first.session.set(snapshotOf({ removed: false })) })
- const textarea = first.view.container.querySelector('textarea')!
- expect(document.activeElement).toBe(textarea)
- textarea.blur()
- fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
- expect(document.activeElement).toBe(textarea)
- })
- it('typing forwards through the machine (draft state echoes back)', () => {
- const { textarea, wiring } = bench()
- fireEvent.change(textarea, { target: { value: 'typed' } })
- expect(wiring.state.getSnapshot().draft).toBe('typed')
- expect((textarea).value).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('the caret layer and the glyph layer ride one scrollport', () => {
- const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
- const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
- const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
- // The caret is the textarea's and every visible glyph is the backdrop's, so
- // one box has to carry both or an offset can exist in one and not the other.
- // jsdom has no layout and loads no stylesheet — which box scrolls is the
- // browser scenario's to assert; what is checkable here is that the
- // scrollport element holds both layers.
- expect(scroll.contains(textarea)).toBe(true)
- expect(scroll.contains(backdrop)).toBe(true)
- // The glyph layer carries the draft and nothing else — no height padding
- // to a second box's scroll extent.
- expect(backdrop.textContent).toBe('line\n'.repeat(40))
- })
- it('repairs Safari native overflow after the mirror shrinks the draft', () => {
- const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
- const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
- 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
- )
- onTestFinished(() => {
- vendor.mockRestore()
- userAgent.mockRestore()
- })
- const { textarea } = bench({ draft: 'two wrapped lines' })
- const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
- let inputRepaired = false
- let scrollportRepaired = false
- const inputLayouts: string[] = []
- const scrollportLayouts: string[] = []
- Object.defineProperty(textarea, 'clientHeight', {
- configurable: true,
- get: () => textarea.style.height === '29px' ? 29 : 28,
- })
- Object.defineProperty(textarea, 'scrollHeight', {
- configurable: true,
- get: () => inputRepaired ? 28 : 52,
- })
- Object.defineProperty(textarea, 'offsetHeight', {
- configurable: true,
- get: () => {
- inputLayouts.push(textarea.style.height)
- if (textarea.style.height === '') inputRepaired = true
- return textarea.clientHeight
- },
- })
- Object.defineProperty(scrollport, 'clientHeight', {
- configurable: true,
- get: () => {
- if (scrollport.style.height === '53px') return 53
- if (inputRepaired && !scrollportRepaired) return 52
- return 28
- },
- })
- Object.defineProperty(scrollport, 'offsetHeight', {
- configurable: true,
- get: () => {
- scrollportLayouts.push(scrollport.style.height)
- if (scrollport.style.height === '') scrollportRepaired = true
- return scrollport.clientHeight
- },
- })
- textarea.setSelectionRange(5, 5)
- fireEvent.change(textarea, { target: { value: 'one line' } })
- expect(inputLayouts).toEqual(['29px', ''])
- expect(scrollportLayouts).toEqual(['53px', ''])
- expect(textarea.style.height).toBe('')
- expect(scrollport.style.height).toBe('')
- expect(textarea.scrollHeight).toBe(textarea.clientHeight)
- expect(scrollport.clientHeight).toBe(28)
- })
- it('does not force the Safari recovery for another iOS browser', () => {
- const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
- const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
- 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
- )
- onTestFinished(() => {
- vendor.mockRestore()
- userAgent.mockRestore()
- })
- const { textarea } = bench({ draft: 'two wrapped lines' })
- const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
- Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 })
- Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 })
- Object.defineProperty(textarea, 'offsetHeight', {
- configurable: true,
- get: () => { throw new Error('non-Safari browser must not force textarea layout') },
- })
- Object.defineProperty(scrollport, 'offsetHeight', {
- configurable: true,
- get: () => { throw new Error('non-Safari browser must not force scrollport layout') },
- })
- fireEvent.change(textarea, { target: { value: 'one line' } })
- expect(scrollport.style.height).toBe('')
- })
- it('does not read Safari layout while a native edit grows the draft', () => {
- const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
- const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
- 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
- )
- onTestFinished(() => {
- vendor.mockRestore()
- userAgent.mockRestore()
- })
- const { textarea, shell } = bench({ draft: 'one line' })
- Object.defineProperty(textarea, 'clientHeight', {
- configurable: true,
- get: () => { throw new Error('growing Safari input must not read layout') },
- })
- Object.defineProperty(textarea, 'scrollHeight', {
- configurable: true,
- get: () => { throw new Error('growing Safari input must not read layout') },
- })
- fireEvent.change(textarea, { target: { value: 'one line grows' } })
- expect(shell.snapshot.draft).toBe('one line grows')
- })
- it('an edit the composer performs itself scrolls the caret back into view', async () => {
- // Paste and cut suppress the native edit, so no engine reveals the caret
- // for them. jsdom has no layout: the rects are stubbed,
- // and what is asserted is the arithmetic — minimal scroll, in both
- // directions, and nothing at all for a caret already inside the box.
- const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
- const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
- const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
- expect(mirror.firstChild).toBeInstanceOf(Text)
- scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
- // jsdom reports scrollHeight === clientHeight for every element, which is
- // the composer's own "nothing to reveal" case; a scrollable box is what
- // puts the reveal on the table at all.
- Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
- Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
- Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
- onTestFinished(() => {
- Range.prototype.getBoundingClientRect = ZERO_RECT
- Range.prototype.setStart = NATIVE_SET_START
- })
- // Which layer the caret is measured against, and at which index: the stub
- // records `setStart` so a helper that measured the backdrop instead, or
- // always collapsed at 0, fails here rather than only in the browser lane.
- let measured: { node: Node; offset: number } | null = null
- Range.prototype.setStart = function setStart(node: Node, offset: number): void {
- measured = { node, offset }
- NATIVE_SET_START.call(this, node, offset)
- }
- const caretAt = (top: number): void => {
- Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
- }
- const settle = async (): Promise<void> => {
- await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
- }
- // Pasted text lands below the fold: scroll down by exactly the overshoot.
- caretAt(500)
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } })
- await settle()
- expect(scroll.scrollTop).toBe(88) // 524 - 436
- // Measured on the mirror's own text, at the index the paste left the caret
- // (an empty draft's selection start, 0, plus the pasted length).
- expect(measured!.node).toBe(mirror.firstChild)
- expect(measured!.offset).toBe('pasted'.length)
- // A caret already inside the box does not move it.
- caretAt(200)
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } })
- await settle()
- expect(scroll.scrollTop).toBe(88)
- // Above the fold (a cut can leave it there): scroll back up.
- caretAt(60)
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } })
- await settle()
- expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
- // A caret straight after a newline has nothing on its line to measure, so
- // the newline it just left is measured instead and one line is added.
- // chromium reports no client rects at all for the collapsed position.
- mirror.style.lineHeight = '24px'
- caretAt(500)
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } })
- await settle()
- // The four pastes accumulate at the draft's head, so the caret is at the
- // end of what they inserted — and the measured index is the newline before it.
- expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
- expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
- })
- it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
- // The composer DOM is reused across sessions, so the previous session's
- // offset survives while the value swap puts the caret at the new draft's
- // end. `preventScroll` keeps the browser from revealing it through the
- // conversation scrollport, which leaves the reveal to the effect itself.
- const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
- const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
- const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
- onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
- scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
- Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
- Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
- Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
- Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
- // The draft ends in a newline, so the reveal takes the after-newline path
- // and needs a resolvable line-height (jsdom computes `normal`).
- mirror.style.lineHeight = '24px'
- // Which index the effect reveals at, not merely that it scrolled: a
- // revealCaret(0) would land the same offset without this.
- onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
- let measured: { node: Node; offset: number } | null = null
- Range.prototype.setStart = function setStart(node: Node, offset: number): void {
- measured = { node, offset }
- NATIVE_SET_START.call(this, node, offset)
- }
- const focused: (boolean | undefined)[] = []
- textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
- textarea.setSelectionRange(textarea.value.length, textarea.value.length)
- act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
- expect(focused).toEqual([true])
- expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
- // The draft ends in a newline, so the rule measures that newline: the
- // caret's own index is the mirror text's length minus its sentinel.
- expect(measured!.node).toBe(mirror.firstChild)
- expect(measured!.offset).toBe(textarea.value.length - 1)
- })
- it('a persisted draft adopted after mount gets its caret revealed too', () => {
- // ConversationSession seeds the stored draft in its own mount effect, which
- // runs after this component's: the first reveal measures an empty mirror,
- // so the draft's arrival has to run it again without reclaiming focus.
- const { view, textarea, shell } = bench()
- const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
- const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
- // The restored draft ends in a newline, so the reveal takes the
- // after-newline path and needs a resolvable line-height (jsdom says `normal`).
- mirror.style.lineHeight = '24px'
- onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
- scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
- Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
- Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
- Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
- Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
- const other = document.createElement('input')
- document.body.appendChild(other)
- onTestFinished(() => { other.remove() })
- other.focus()
- expect(scroll.scrollTop).toBe(0)
- act(() => { shell.setDraft('restored\n'.repeat(40)) })
- expect(document.activeElement).toBe(other)
- // The caret the machine left at the draft's end, revealed once the draft exists.
- expect(textarea.selectionStart).toBe(textarea.value.length)
- expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
- })
- it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
- const { textarea } = bench({ disabled: true })
- expect(textarea.placeholder).toBe('会话不可用')
- const live = bench()
- expect(live.textarea.placeholder).toBe('给智能体发消息')
- const custom = bench({ placeholder: 'Custom placeholder' })
- expect(custom.textarea.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.disabled).toBe(false)
- expect(textarea.readOnly).toBe(true)
- 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.textarea.placeholder).toBe('描述你的任务以生成计划')
- // /plan just ran: pending entry already reads as the plan target.
- const entering = bench({ plan: { active: false, pending: true } })
- expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
- // Pending exit: target is default again.
- const leaving = bench({ plan: { active: true, pending: true } })
- expect(leaving.textarea.placeholder).toBe('给智能体发消息')
- // Owner placeholder outranks the plan swap.
- const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
- expect(custom.textarea.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(
- {
- 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('textarea')!
- expect(textarea.readOnly).toBe(true)
- expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
- })
- })
- describe('decorations', () => {
- it('claimed token renders the mirror highlight and the blank-args hint', () => {
- // Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
- const { view, shell } = bench({ t: makeTranslate({}) })
- act(() => {
- shell.setDraft('/goal ')
- shell.beginCommand(
- { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
- { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
- )
- })
- const token = view.container.querySelector('[data-decoration="token"]')
- expect(token?.textContent).toBe('/goal ')
- expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
- // Args typed: the hint disappears, the token highlight stays.
- act(() => { shell.setDraft('/goal 发布') })
- expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
- expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
- })
- it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
- const { view, shell } = bench()
- act(() => {
- shell.setDraft('/goal ')
- shell.beginCommand(
- { 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(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
- })
- it('an inserted reference decorates its complete inline display range', () => {
- 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-decoration="chip"]')
- expect(chip?.textContent).toBe('@会话一')
- expect(chip?.getAttribute('data-reference-appearance')).toBe('session')
- expect(chip?.querySelector('svg')).not.toBeNull()
- expect(shell.snapshot.occurrences).toHaveLength(1)
- expect(shell.snapshot.draft).toBe('参考 @会话一 内容')
- expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 4 })
- })
- it('keeps the textarea glyph layer transparent when a structured reference 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 })
- session.set(snapshotOf({ removed: true }))
- })
- const backdrop = view.container.querySelector('[data-input-backdrop]')
- expect(textarea.disabled).toBe(true)
- expect(backdrop?.getAttribute('data-disabled')).toBe('true')
- expect(backdrop?.querySelector('[data-decoration="chip"] svg')).not.toBeNull()
- })
- it('Backspace and Delete remove a reference as one range at its boundaries', () => {
- const reference = {
- source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
- }
- const backspace = bench()
- act(() => {
- backspace.shell.setDraft('前 @w1 后')
- backspace.shell.insertReference(
- reference,
- { start: 2, end: 5, draftRev: backspace.shell.snapshot.draftRev },
- )
- })
- backspace.textarea.setSelectionRange(6, 6)
- fireEvent.keyDown(backspace.textarea, { key: 'Backspace' })
- expect(backspace.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
- const forwardDelete = bench()
- act(() => {
- forwardDelete.shell.setDraft('前 @w1 后')
- forwardDelete.shell.insertReference(
- reference,
- { start: 2, end: 5, draftRev: forwardDelete.shell.snapshot.draftRev },
- )
- })
- forwardDelete.textarea.setSelectionRange(2, 2)
- fireEvent.keyDown(forwardDelete.textarea, { key: 'Delete' })
- expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
- })
- it('typing the trigger char immediately before a reference keeps it structured', () => {
- const { shell, 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 })
- })
- expect(shell.snapshot.draft).toBe('@会话一 ')
- // The inserted char equals the reference's own leading trigger, so the two
- // drafts alone cannot say whether it landed before or after that trigger.
- textarea.setSelectionRange(0, 0)
- act(() => {
- beforeInput(textarea)
- fireEvent.change(textarea, { target: { value: '@@会话一 ' } })
- })
- expect(shell.snapshot.draft).toBe('@@会话一 ')
- expect(shell.snapshot.occurrences).toHaveLength(1)
- expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 1, length: 4 })
- })
- it('a selection-replacing delete before a reference keeps it structured', () => {
- const { shell, textarea } = bench()
- act(() => {
- shell.setDraft('@@w1')
- shell.insertReference({
- source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
- }, { start: 1, end: 4, draftRev: shell.snapshot.draftRev })
- })
- expect(shell.snapshot.draft).toBe('@@会话一 ')
- textarea.setSelectionRange(0, 1)
- act(() => {
- beforeInput(textarea, 'deleteContentBackward')
- fireEvent.change(textarea, { target: { value: '@会话一 ' } })
- })
- expect(shell.snapshot.draft).toBe('@会话一 ')
- expect(shell.snapshot.occurrences).toHaveLength(1)
- expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
- })
- it('a caret Backspace before a reference keeps it structured', () => {
- const { shell, textarea } = bench()
- act(() => {
- shell.setDraft('@@w1')
- shell.insertReference({
- source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
- }, { start: 1, end: 4, draftRev: shell.snapshot.draftRev })
- })
- expect(shell.snapshot.draft).toBe('@@会话一 ')
- // A caret delete reports the bare caret, never the character it removes.
- textarea.setSelectionRange(1, 1)
- act(() => {
- beforeInput(textarea, 'deleteContentBackward')
- fireEvent.change(textarea, { target: { value: '@会话一 ' } })
- })
- expect(shell.snapshot.draft).toBe('@会话一 ')
- expect(shell.snapshot.occurrences).toHaveLength(1)
- expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
- })
- it('a caret Delete before a reference keeps it structured', () => {
- const { shell, textarea } = bench()
- act(() => {
- shell.setDraft('@@w1')
- shell.insertReference({
- source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
- }, { start: 1, end: 4, draftRev: shell.snapshot.draftRev })
- })
- textarea.setSelectionRange(0, 0)
- act(() => {
- beforeInput(textarea, 'deleteContentForward')
- fireEvent.change(textarea, { target: { value: '@会话一 ' } })
- })
- expect(shell.snapshot.draft).toBe('@会话一 ')
- expect(shell.snapshot.occurrences).toHaveLength(1)
- expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
- })
- it('a caret word delete before a reference keeps it structured', () => {
- const { shell, textarea } = bench()
- act(() => {
- shell.setDraft('word @w1')
- shell.insertReference({
- source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
- }, { start: 5, end: 8, draftRev: shell.snapshot.draftRev })
- })
- expect(shell.snapshot.draft).toBe('word @会话一 ')
- // One caret gesture can remove more than one character; the deleted span
- // is whatever the draft lost, never a fixed step.
- textarea.setSelectionRange(5, 5)
- act(() => {
- beforeInput(textarea, 'deleteWordBackward')
- fireEvent.change(textarea, { target: { value: '@会话一 ' } })
- })
- expect(shell.snapshot.draft).toBe('@会话一 ')
- expect(shell.snapshot.occurrences).toHaveLength(1)
- expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
- })
- it('copy and cut expand a partial reference selection to its structured range', () => {
- 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 })
- })
- const setData = vi.fn()
- textarea.setSelectionRange(3, 4)
- fireEvent.copy(textarea, { clipboardData: { setData } })
- expect(setData).toHaveBeenCalledWith('text/plain', '@w1')
- expect(shell.snapshot.draft).toBe('前 @会话一 后')
- textarea.setSelectionRange(3, 4)
- fireEvent.cut(textarea, { clipboardData: { setData } })
- expect(setData).toHaveBeenLastCalledWith('text/plain', '@w1')
- expect(shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
- })
- it('a lexicon-matched plain token renders the text-ref mark', () => {
- 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-decoration="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-decoration="text-ref"]')).toBeNull()
- })
- it('a directory completion renders a folder glyph without changing its plain text', () => {
- const { view, shell } = bench()
- act(() => { shell.setDraft('see @src/components/') })
- const mark = view.container.querySelector('[data-decoration="text-ref"]')
- expect(mark?.textContent).toBe('@src/components/')
- expect(mark?.querySelector('svg')).not.toBeNull()
- expect(shell.snapshot.draft).toBe('see @src/components/')
- })
- it('a plain-text reference keeps its nodes while earlier text shifts its offset', () => {
- const { view, textarea, shell } = bench()
- act(() => { shell.setDraft('see @src/components/ here') })
- const backdrop = view.container.querySelector('[data-input-backdrop]')!
- const mark = backdrop.querySelector('[data-decoration="text-ref"]')!
- const icon = mark.querySelector('svg')!
- act(() => { fireEvent.change(textarea, { target: { value: 'X see @src/components/ here' } }) })
- // Node identity, not text: an offset-derived key remounts the mark and its
- // icon on every keystroke landing ahead of the range.
- expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBe(mark)
- expect(icon.isConnected).toBe(true)
- expect(mark.textContent).toBe('@src/components/')
- // A token edited out of match shape still loses its decoration.
- act(() => { fireEvent.change(textarea, { target: { value: 'X see X@src/components/ here' } }) })
- expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBeNull()
- expect(shell.snapshot.draft).toBe('X see X@src/components/ here')
- })
- })
- 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('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
- vi.useFakeTimers()
- try {
- const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { 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 (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 anchor and left/right slot items', () => {
- const { view } = bench({
- overlay: <i data-testid="ov" />,
- leftItems: <i data-testid="li" />,
- rightItems: <i data-testid="ri" />,
- })
- expect(view.getByTestId('ov')).toBeTruthy()
- expect(view.getByTestId('li')).toBeTruthy()
- expect(view.getByTestId('ri')).toBeTruthy()
- })
- })
- describe('command launcher chrome and control seats', () => {
- it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => {
- const { view, slotCalls } = bench()
- expect(view.getByLabelText('命令')).toBeTruthy()
- // Capability absent (no projection value): the chip renders nothing.
- expect(view.queryByLabelText(/^访问模式/)).toBeNull()
- // Every seat dispatched, nothing rendered.
- expect(slotCalls.map(c => c.key)).toEqual([
- 'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
- ])
- 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, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
- textarea.setSelectionRange(2, 7)
- 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('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
- const command = vi.fn(() => Promise.resolve(true))
- const permissions = {
- options: [
- { value: 'read-only', name: 'read-only' },
- { value: 'workspace-write', name: 'workspace-write' },
- { value: 'danger-full-access', name: 'danger-full-access' },
- ],
- currentValue: 'read-only',
- }
- const { view } = bench({ permissions, command })
- const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
- // Title-case display is presentation only; the menu ids stay machine names.
- expect(trigger.textContent).toBe('Read Only')
- expect([...trigger.querySelectorAll('svg')]
- .every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
- fireEvent.click(trigger)
- const items = view.getAllByRole('menuitem')
- expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
- fireEvent.click(items[1]!)
- // Optimistic pick + disable until admission resolves (command stub resolves true).
- const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
- expect(busy.textContent).toBe('Workspace Write')
- expect(busy.disabled).toBe(true)
- expect(command).toHaveBeenCalledWith('/permission workspace-write')
- await act(async () => {})
- expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
- })
- it('requires explicit risk acknowledgement before submitting Full access', async () => {
- const command = vi.fn(() => Promise.resolve(true))
- const permissions = {
- options: [
- { value: 'workspace-write', name: 'workspace-write' },
- { value: 'danger-full-access', name: 'danger-full-access' },
- ],
- currentValue: 'workspace-write',
- }
- const { view } = bench({ permissions, command })
- fireEvent.click(view.getByLabelText(/^访问模式/))
- fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
- expect(command).not.toHaveBeenCalled()
- expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy()
- const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
- expect(enable.disabled).toBe(true)
- fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
- expect(enable.disabled).toBe(false)
- fireEvent.click(enable)
- expect(command).toHaveBeenCalledOnce()
- expect(command).toHaveBeenCalledWith('/permission danger-full-access')
- expect(view.queryByRole('dialog')).toBeNull()
- expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
- await act(async () => {})
- })
- it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
- const command = vi.fn(() => Promise.resolve(true))
- const permissions = {
- options: [
- { value: 'workspace-write', name: 'workspace-write' },
- { value: 'danger-full-access', name: 'danger-full-access' },
- ],
- currentValue: 'workspace-write',
- }
- const { view } = bench({ permissions, command })
- const openConfirmation = () => {
- fireEvent.click(view.getByLabelText(/^访问模式/))
- fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
- }
- openConfirmation()
- fireEvent.click(view.getByRole('checkbox'))
- fireEvent.click(view.getByRole('button', { name: '取消' }))
- expect(command).not.toHaveBeenCalled()
- expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
- openConfirmation()
- expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
- expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
- })
- it('revokes an open Full access confirmation when the task locks', () => {
- const command = vi.fn(() => Promise.resolve(true))
- const permissions = {
- options: [
- { value: 'workspace-write', name: 'workspace-write' },
- { value: 'danger-full-access', name: 'danger-full-access' },
- ],
- currentValue: 'workspace-write',
- }
- const { view, session } = bench({ permissions, command })
- fireEvent.click(view.getByLabelText(/^访问模式/))
- fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
- fireEvent.click(view.getByRole('checkbox'))
- act(() => { session.set(snapshotOf({ removed: true })) })
- expect(view.queryByRole('dialog')).toBeNull()
- expect(command).not.toHaveBeenCalled()
- })
- it('resets an open Full access confirmation when switching tasks', () => {
- const command = vi.fn(() => Promise.resolve(true))
- const permissions = {
- options: [
- { value: 'workspace-write', name: 'workspace-write' },
- { value: 'danger-full-access', name: 'danger-full-access' },
- ],
- currentValue: 'workspace-write',
- }
- const { view, props } = bench({ permissions, command })
- fireEvent.click(view.getByLabelText(/^访问模式/))
- fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
- fireEvent.click(view.getByRole('checkbox'))
- view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
- expect(view.queryByRole('dialog')).toBeNull()
- expect(command).not.toHaveBeenCalled()
- })
- 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" />,
- modelEntry: <i data-testid="model-entry" />,
- })
- expect(view.getByTestId('plan-entry')).toBeTruthy()
- expect(view.getByTestId('model-entry')).toBeTruthy()
- // The bar hands its chrome disable state to the filling entry.
- const controls = slotCalls.filter(call => call.key !== 'conversation.input.attachments')
- 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 => call.key !== 'conversation.input.attachments')
- expect(liveControls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
- expect(attachmentOwner(live.slotCalls).canAcceptDrop).toBe(true)
- })
- it('disabled locks the Access chip and command launcher (running does not)', () => {
- const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
- const { view } = bench({ disabled: true, permissions })
- expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
- expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
- cleanup()
- const live = bench({ running: true, permissions })
- expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
- })
- })
|