input-bar.client.spec.tsx 78 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675
  1. // @vitest-environment jsdom
  2. // InputBar behavior over the editor + submit-machine wiring: Enter-send
  3. // semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering,
  4. // repeat suppression), running semantics (input stays free; continuable
  5. // children keep Send beside Stop), the machine pending lock, chip decorators,
  6. // error banners, status strips, and the focus-keeping mousedown. Keyboard
  7. // gestures dispatch real KeyboardEvents at the contenteditable (the Lexical
  8. // root listener routes them through the keymap commands); draft writes drive
  9. // the shell (jsdom's beforeinput lacks the ranges Lexical needs).
  10. import type { InboxState } from '@deepseek-ai/dsh-agent/types'
  11. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  12. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
  13. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  14. import { $getRoot, $isTextNode } from 'lexical'
  15. import {
  16. bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError,
  17. sessionSnapshot as sessionFixture,
  18. } from '@deepseek-ai/dsh-client-test-runtime'
  19. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  20. import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
  21. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  22. import type { Context } from '@deepseek-ai/cordis'
  23. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  24. import type { SubmitOutcome } from '../src/client/contract/input.ts'
  25. import { SessionInputShell } from '../src/client/input/facade.ts'
  26. import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
  27. import type {
  28. ComposerAttachment, ComposerAttachmentsOwnerProps, DraftFileUploads,
  29. } from '../src/client/contract/slots.ts'
  30. import type { DraftAttachmentId } from '../src/client/contract/input.ts'
  31. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  32. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  33. import { en, zh } from '../src/client/locales.ts'
  34. // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  35. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
  36. afterEach(cleanup)
  37. // jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
  38. // is absent — and the composer measures the caret with one when it restores the
  39. // selection after an edit it performed itself. Every case here runs against a
  40. // zero rect; the reveal case below substitutes its own and restores this one.
  41. const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
  42. Range.prototype.getBoundingClientRect = ZERO_RECT
  43. const SCTX = {} as Context
  44. const SID = 's1' as SessionId
  45. function snapshotOf(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
  46. return { ...sessionFixture(SID), ...overrides }
  47. }
  48. interface BenchOptions {
  49. planEntry?: React.ReactNode
  50. /** The `plan` projection value the standard-kit useProjection serves. */
  51. plan?: { active: boolean; pending: boolean }
  52. /** The `goal` projection value used only to prove attachment intake remains ordinary. */
  53. goal?: { phase: 'active'; objective: string }
  54. modelEntry?: React.ReactNode
  55. permissionEntry?: React.ReactNode
  56. /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
  57. lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
  58. /** The `imageLimits` projection value (absent = no attachment service). */
  59. imageLimits?: {
  60. maxImageBytes: number
  61. maxImagesPerMessage: number
  62. maxMessageImageBytes: number
  63. maxImagePixels: number
  64. maxImageDimension: number
  65. mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
  66. }
  67. draft?: string
  68. running?: boolean
  69. subagent?: Exclude<SessionSnapshot['subagent'], null>
  70. disabled?: boolean
  71. inert?: boolean
  72. blocked?: { readonly reason: string }
  73. workspacePickerOpen?: boolean
  74. onRequestWorkspace?: () => void
  75. promptError?: SessionSnapshot['promptError']
  76. /** Authoritative queue rows served to the machine overlay (empty = none). */
  77. queue?: InboxState['next-turn']
  78. nextStep?: InboxState['next-step']
  79. /** The hub's steer-all face (empty-draft accelerated Enter). */
  80. steerQueue?: () => void
  81. variant?: 'hero' | 'composer'
  82. placeholder?: string
  83. t?: InputBarProps['t']
  84. accessory?: React.ReactNode
  85. overlay?: React.ReactNode
  86. leftItems?: React.ReactNode
  87. rightItems?: React.ReactNode
  88. footer?: React.ReactNode
  89. attachments?: readonly ComposerAttachment[]
  90. /** Upload states served for file-kind drafts (absent = every file is ready). */
  91. fileUploads?: DraftFileUploads
  92. addFiles?: (files: readonly File[]) => string | null
  93. commandMenuOpen?: boolean
  94. busyEnter?: 'queue' | 'steer'
  95. toggleCommandMenu?: (selection: { start: number; end: number }) => void
  96. }
  97. /** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
  98. function row(id: string): InboxState['next-turn'][number] {
  99. return {
  100. id: id as never, role: 'user', source: { kind: 'user' },
  101. content: [{ type: 'text', text: id }],
  102. }
  103. }
  104. /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
  105. function bench(over?: BenchOptions) {
  106. const sink = vi.fn<(
  107. text: string,
  108. attachmentIds: readonly DraftAttachmentId[],
  109. mode: 'queue' | 'steer',
  110. signal: AbortSignal,
  111. ) => Promise<SubmitOutcome>>(() => Promise.resolve({ kind: 'success' }))
  112. const lex = over?.lexicon
  113. const session = createSnapshotStore<SessionSnapshot>(snapshotOf({
  114. running: over?.running ?? false,
  115. subagent: over?.subagent ?? null,
  116. removed: over?.disabled ?? false,
  117. promptError: over?.promptError ?? null,
  118. }))
  119. type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
  120. const shell = new SessionInputShell({
  121. actx: SCTX,
  122. defaultSink: sink,
  123. commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` },
  124. inbox: createSnapshotStore<InboxState>({
  125. 'next-turn': over?.queue ?? [],
  126. 'next-step': over?.nextStep ?? [],
  127. }),
  128. ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
  129. // Lexicon-only stub: adjudication untouched (undefined slash methods are
  130. // never reached — these benches drive plain-draft flows only).
  131. ...(lex !== undefined
  132. ? {
  133. inputTriggers: (() => ({
  134. track: () => {},
  135. lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
  136. })) as unknown as NonNullable<ShellDeps['inputTriggers']>,
  137. }
  138. : {}),
  139. })
  140. if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
  141. if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id))
  142. const stop = vi.fn()
  143. const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) })
  144. const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
  145. const busyEnter = createSnapshotStore<'queue' | 'steer'>(over?.busyEnter ?? 'queue')
  146. const slotCalls: { key: string; owner: unknown }[] = []
  147. const renderSlot = ((key: string, owner: object) => {
  148. slotCalls.push({ key, owner })
  149. if (key === 'conversation.input.overlay') return over?.overlay ?? null
  150. if (key === 'conversation.input.left') return over?.leftItems ?? null
  151. if (key === 'conversation.input.right') return over?.rightItems ?? null
  152. if (key === 'conversation.composer.dock') return over?.footer ?? null
  153. if (key === 'conversation.input.plan') return over?.planEntry ?? null
  154. if (key === 'conversation.input.permission') return over?.permissionEntry ?? null
  155. if (key === 'conversation.input.model') return over?.modelEntry ?? null
  156. return null
  157. }) as never
  158. const props: InputBarProps = {
  159. usePanelInfo: selector => selector({ activePanelId: null }),
  160. sessionId: SID,
  161. SessionProvider: ({ children }) => children,
  162. useSession: bindSnapshotSelector(session),
  163. useConversation: bindSnapshotSelector(createSnapshotStore(conversationFixture())),
  164. useSessionStatus: bindSnapshotSelector(createSnapshotStore(new Map())),
  165. useSessionRetainInfo: () => undefined,
  166. useResource,
  167. useSessions: bindSnapshotSelector(createSnapshotStore<SessionListState>({
  168. ids: [], byId: {}, phase: 'ready',
  169. subagentsByParent: {}, jobsBySession: {},
  170. })),
  171. useWorkspaces: bindSnapshotSelector(createSnapshotStore({
  172. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  173. })),
  174. useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
  175. (selector ?? (v => v))(key === 'plan'
  176. ? over?.plan
  177. : key === 'goal' ? over?.goal
  178. : key === 'imageLimits' ? over?.imageLimits : undefined)),
  179. useInput: bindSnapshotSelector(shell.state),
  180. inputActions: shell.actions,
  181. keyboard: shell,
  182. addFiles: over?.addFiles ?? (() => null),
  183. useFileUploads: bindSnapshotSelector(createSnapshotStore<DraftFileUploads>(over?.fileUploads ?? {})),
  184. retryFileUpload: undefined,
  185. removeAttachment,
  186. resolveDraftAttachments: ids => ids.flatMap((id) => {
  187. const attachment = over?.attachments?.find(candidate => candidate.id === id)
  188. return attachment === undefined ? [] : [attachment]
  189. }),
  190. toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
  191. useBusyEnter: bindSnapshotSelector(busyEnter),
  192. useNotices: bindSnapshotSelector(shell.notices),
  193. useLexicon: bindSnapshotSelector(shell.lexicon),
  194. useMenuLauncher: bindSnapshotSelector(menuLauncher),
  195. stop,
  196. // Mirrors the real lookup chain (conversation namespace, then common).
  197. t: over?.t ?? makeTranslate(zh, commonZh),
  198. renderSlot,
  199. variant: over?.variant ?? 'composer',
  200. ...(over?.inert === true ? { disabled: true } : {}),
  201. ...(over?.blocked !== undefined ? { blocked: over.blocked } : {}),
  202. ...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}),
  203. ...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}),
  204. ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
  205. ...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
  206. }
  207. const view = render(<InputBar {...props} />)
  208. const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
  209. const sendableDraft = (over?.draft?.trim() ?? '') !== '' || (over?.attachments?.length ?? 0) > 0
  210. const primaryStops = over?.running === true && over.subagent === undefined
  211. && (!sendableDraft || over.blocked !== undefined)
  212. // A running steer-capable composer (ordinary session or continuable child
  213. // with its parent online) labels Send by the delivery mode it performs over
  214. // a plain message draft; idle sessions, one-shot children, locked
  215. // composers, a child's empty draft, drafts with a pending upload, and `/`
  216. // command lines keep plain Send (an ordinary running session's empty draft
  217. // is the Stop seat). The mirror models plain-phase drafts only: cases
  218. // that claim a command or freeze the machine query the DOM directly.
  219. const steeringAvailable = over?.subagent === undefined || over.subagent.address.mode === 'continuable'
  220. const composerLocked = over?.disabled === true || over?.inert === true || over?.blocked !== undefined
  221. || (over?.subagent?.address.mode === 'continuable' && over.subagent.parentAvailable !== true)
  222. const uploadsPending = (over?.attachments ?? []).some(attachment =>
  223. attachment.kind === 'file' && over?.fileUploads?.[attachment.id]?.status !== 'ready')
  224. const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false)
  225. const primaryLabel = primaryStops
  226. ? '停止生成'
  227. : over?.running === true && steeringAvailable && !composerLocked && plainMessageDraft
  228. ? (over.busyEnter === 'steer' ? '插话发送' : '排队发送')
  229. : '发送消息'
  230. const button = view.container.querySelector<HTMLButtonElement>(`button[aria-label="${primaryLabel}"]`)!
  231. const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
  232. return {
  233. view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls,
  234. menuLauncher, busyEnter,
  235. steerQueue: over?.steerQueue,
  236. get placeholder() { return placeholderOf(view.container) },
  237. get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' },
  238. }
  239. }
  240. function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
  241. for (let i = slotCalls.length - 1; i >= 0; i -= 1) {
  242. const call = slotCalls[i]
  243. if (call?.key === 'conversation.input.attachments') return call.owner as ComposerAttachmentsOwnerProps
  244. }
  245. throw new Error('attachment slot was not rendered')
  246. }
  247. /** The state's placeholder copy (the textarea.placeholder equivalent; the visible layer renders it only while empty). */
  248. function placeholderOf(container: HTMLElement): string {
  249. return container.querySelector('[data-composer-input]')?.getAttribute('data-placeholder') ?? ''
  250. }
  251. /** Whether the composer surface accepts edits right now (setEditable's DOM face). */
  252. function editableOf(input: HTMLElement): boolean {
  253. return input.getAttribute('contenteditable') === 'true'
  254. }
  255. /** Write the draft through the shell inside act (the seed path; caret lands at the end). */
  256. function writeDraft(shell: SessionInputShell, text: string): void {
  257. act(() => { shell.setDraft(text) })
  258. }
  259. describe('composer focus handoff', () => {
  260. it('focus() returns the keyboard to the editor through Lexical, not a bare DOM focus', () => {
  261. const { shell, textarea } = bench()
  262. writeDraft(shell, 'draft text')
  263. textarea.blur()
  264. expect(document.activeElement).not.toBe(textarea)
  265. const lexicalFocus = vi.spyOn(shell.editor, 'focus')
  266. act(() => { shell.focus() })
  267. expect(document.activeElement).toBe(textarea)
  268. // Lexical's own focus restores its stored selection; a bare DOM focus would
  269. // land the caret at the start of the draft. jsdom carries no caret, so the
  270. // selection itself is asserted in the browser lane.
  271. expect(lexicalFocus).toHaveBeenCalled()
  272. })
  273. })
  274. describe('composer placeholder visibility', () => {
  275. it.each([' ', ' ', '\t', '\n'])('hides for whitespace %j and returns after deletion', async (draft) => {
  276. const { view, shell, textarea, button, sink, props } = bench()
  277. const placeholder = () => view.container.querySelector('[data-composer-placeholder]')
  278. expect(placeholder()).not.toBeNull()
  279. writeDraft(shell, draft)
  280. expect(placeholder()).toBeNull()
  281. expect(button.disabled).toBe(true)
  282. fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13 })
  283. await act(async () => {})
  284. expect(sink).not.toHaveBeenCalled()
  285. fireEvent.blur(textarea)
  286. view.rerender(<InputBar {...props} />)
  287. fireEvent.focus(textarea)
  288. expect(placeholder()).toBeNull()
  289. writeDraft(shell, '')
  290. expect(placeholder()).not.toBeNull()
  291. })
  292. it('hides for pasted spaces and restores after clearing', async () => {
  293. const { view, shell, textarea } = bench()
  294. fireEvent.paste(textarea, {
  295. clipboardData: { items: [], getData: () => ' ' },
  296. })
  297. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(' ') })
  298. expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
  299. writeDraft(shell, '')
  300. expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
  301. })
  302. it('keeps whitespace hidden through composition and rerender', () => {
  303. const { view, shell, textarea, props } = bench()
  304. fireEvent.compositionStart(textarea)
  305. writeDraft(shell, ' ')
  306. expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
  307. view.rerender(<InputBar {...props} />)
  308. fireEvent.compositionEnd(textarea, { data: ' ' })
  309. expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
  310. writeDraft(shell, '')
  311. expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
  312. })
  313. })
  314. describe('image draft rail', () => {
  315. it('collects clipboard files while preserving text from a mixed paste', async () => {
  316. const addFiles = vi.fn(() => null)
  317. const { textarea, shell } = bench({ addFiles })
  318. const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
  319. fireEvent.paste(textarea, {
  320. clipboardData: {
  321. items: [
  322. { kind: 'string', type: 'text/plain', getAsFile: () => null },
  323. { kind: 'file', type: 'image/png', getAsFile: () => image },
  324. ],
  325. getData: () => '同时粘贴的文字',
  326. },
  327. })
  328. expect(addFiles).toHaveBeenCalledWith([image])
  329. // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away.
  330. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') })
  331. })
  332. it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
  333. const limits = {
  334. maxImageBytes: 1024 * 1024,
  335. maxImagesPerMessage: 2,
  336. maxMessageImageBytes: 2 * 1024 * 1024,
  337. maxImagePixels: 40_000_000,
  338. maxImageDimension: 2000,
  339. mediaTypes: ['image/png'] as const,
  340. }
  341. const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
  342. const intake = (result: ReturnType<typeof bench>, files: File[]) => {
  343. act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) })
  344. }
  345. // Count: three at once over a two-image limit → the whole batch refused.
  346. const overCount = bench({ addFiles: vi.fn(() => null), imageLimits: limits })
  347. intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
  348. expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
  349. expect(overCount.props.addFiles).not.toHaveBeenCalled()
  350. cleanup()
  351. // Per-file bytes.
  352. const overFile = bench({ addFiles: vi.fn(() => null), imageLimits: limits })
  353. intake(overFile, [png(1024 * 1024 + 1, 'big.png')])
  354. expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
  355. expect(overFile.props.addFiles).not.toHaveBeenCalled()
  356. cleanup()
  357. // Aggregate bytes across the existing rail plus the new batch.
  358. const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
  359. const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
  360. const overTotal = bench({ addFiles: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
  361. intake(overTotal, [png(1024 * 1024, 'more.png')])
  362. expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
  363. expect(overTotal.props.addFiles).not.toHaveBeenCalled()
  364. cleanup()
  365. // Within every limit: the batch passes through to addFiles.
  366. const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits })
  367. const fits = png(16, 'fits.png')
  368. intake(within, [fits])
  369. expect(within.props.addFiles).toHaveBeenCalledWith([fits])
  370. expect(within.view.queryByRole('alert')).toBeNull()
  371. })
  372. it('announces the format problem before any limit when the batch holds a non-image', () => {
  373. const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
  374. const result = bench({
  375. addFiles,
  376. imageLimits: {
  377. maxImageBytes: 8,
  378. maxImagesPerMessage: 1,
  379. maxMessageImageBytes: 8,
  380. maxImagePixels: 40_000_000,
  381. maxImageDimension: 2000,
  382. mediaTypes: ['image/png'] as const,
  383. },
  384. })
  385. // Oversized AND over-count AND wrong type: the format rejection wins.
  386. const files = [
  387. new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
  388. new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
  389. ]
  390. act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) })
  391. expect(addFiles).toHaveBeenCalledWith(files)
  392. expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
  393. })
  394. it('projects display-ready limits into the attachment slot', () => {
  395. const result = bench({
  396. addFiles: vi.fn(() => null),
  397. imageLimits: {
  398. maxImageBytes: 5 * 1024 * 1024,
  399. maxImagesPerMessage: 20,
  400. maxMessageImageBytes: 100 * 1024 * 1024,
  401. maxImagePixels: 40_000_000,
  402. maxImageDimension: 2000,
  403. mediaTypes: ['image/png'] as const,
  404. },
  405. })
  406. expect(attachmentOwner(result.slotCalls).dropLimits).toEqual({ count: 20, size: '5MB' })
  407. })
  408. it('announces server attachment rejections as product copy, other codes as developer text', () => {
  409. const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({
  410. op: 'send',
  411. error: new RemoteError('session/attachment-invalid', 'raw wire text', { reason }),
  412. })
  413. const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') })
  414. expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
  415. cleanup()
  416. const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') })
  417. expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)')
  418. cleanup()
  419. // A subagent refusal uses the same product copy for the same reason.
  420. const subagent = bench({
  421. promptError: {
  422. op: 'send',
  423. error: new RemoteError('subagent/attachment-invalid', 'raw wire text', {
  424. reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
  425. }),
  426. },
  427. })
  428. expect(subagent.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
  429. cleanup()
  430. const other = bench({
  431. promptError: { op: 'send', error: new RemoteError('gateway/internal', 'boom', {}) },
  432. })
  433. expect(other.view.getByRole('alert').textContent).toContain('boom (gateway/internal)')
  434. })
  435. it('marks the attachment slot unavailable while the composer is locked', () => {
  436. const result = bench({ addFiles: vi.fn(() => null), inert: true })
  437. expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false)
  438. })
  439. it('sends an image-only draft and exposes removal through the attachment slot', async () => {
  440. const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
  441. const extra = new File([Uint8Array.of(2)], 'extra.png', { type: 'image/png' })
  442. const attachments = [
  443. { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' },
  444. { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' },
  445. ]
  446. const result = bench({ attachments })
  447. const { view, textarea, sink, removeAttachment } = result
  448. expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
  449. const owner = attachmentOwner(result.slotCalls)
  450. act(() => { owner.onRemoveAttachment('draft-2' as DraftAttachmentId) })
  451. expect(removeAttachment).toHaveBeenCalledWith('draft-2')
  452. let settle!: (outcome: SubmitOutcome) => void
  453. sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
  454. fireEvent.keyDown(textarea, { key: 'Enter' })
  455. expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal))
  456. // Optimistic commit: the rail clears at submit, before the admission settles.
  457. expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
  458. await act(async () => { settle({ kind: 'success' }) })
  459. expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
  460. })
  461. it('returns an image-only draft to the rail when its admission fails', async () => {
  462. const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
  463. const attachments = [
  464. { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' },
  465. ]
  466. const result = bench({ attachments })
  467. const { textarea, sink } = result
  468. let fail!: (outcome: SubmitOutcome) => void
  469. sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { fail = resolve }))
  470. fireEvent.keyDown(textarea, { key: 'Enter' })
  471. expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
  472. await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) })
  473. await vi.waitFor(() => {
  474. expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
  475. })
  476. expect(result.view.getByRole('alert').textContent).toContain('图片发送失败')
  477. })
  478. it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
  479. vi.useFakeTimers()
  480. try {
  481. const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
  482. const { view, textarea } = bench({ addFiles })
  483. const paste = () => {
  484. fireEvent.paste(textarea, {
  485. clipboardData: {
  486. items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }],
  487. getData: () => '',
  488. },
  489. })
  490. }
  491. paste()
  492. expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
  493. act(() => { vi.advanceTimersByTime(4000) })
  494. expect(view.queryByRole('alert')).toBeNull()
  495. // The identical rejection re-announces: the toast is keyed per show.
  496. paste()
  497. expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
  498. } finally {
  499. vi.useRealTimers()
  500. }
  501. })
  502. it('announces a rejected attachment-slot intake through the same toast', () => {
  503. const addFiles = vi.fn(() => '图片读取服务不可用')
  504. const result = bench({ addFiles })
  505. act(() => {
  506. attachmentOwner(result.slotCalls).onAddFiles([
  507. new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }),
  508. ])
  509. })
  510. expect(result.view.getByRole('alert').textContent).toContain('图片读取服务不可用')
  511. })
  512. })
  513. describe('Enter semantics', () => {
  514. it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
  515. const { placeholder } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
  516. expect(placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
  517. expect(bench({
  518. running: true,
  519. queue: [row('q-1')],
  520. subagent: {
  521. address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
  522. parentAvailable: true,
  523. },
  524. }).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
  525. })
  526. it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
  527. expect(bench({ running: true }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
  528. expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
  529. expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
  530. expect(bench({
  531. running: true,
  532. queue: [row('q-1')],
  533. placeholder: '上层指定提示',
  534. }).placeholder).toBe('上层指定提示')
  535. // The command menu owns Enter while open: neither the hint nor the
  536. // gesture may claim the chord.
  537. expect(bench({
  538. running: true,
  539. queue: [row('q-1')],
  540. commandMenuOpen: true,
  541. }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
  542. // The steer hint intentionally outranks the plan placeholder: while it
  543. // shows, the whole-queue gesture is genuinely available in plan mode.
  544. expect(bench({
  545. running: true,
  546. queue: [row('q-1')],
  547. plan: { active: true, pending: false },
  548. }).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
  549. })
  550. it('an open command menu withholds the whole-queue steering gesture', () => {
  551. const steerQueue = vi.fn()
  552. const { textarea, sink } = bench({
  553. running: true,
  554. queue: [row('q-1')],
  555. commandMenuOpen: true,
  556. steerQueue,
  557. })
  558. fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
  559. expect(steerQueue).not.toHaveBeenCalled()
  560. expect(sink).not.toHaveBeenCalled()
  561. })
  562. it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
  563. const { textarea, sink } = bench({ draft: 'hello' })
  564. fireEvent.keyDown(textarea, { key: 'Enter' })
  565. expect(sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal))
  566. // The submitting-phase lock, not draft emptiness, suppresses the repeat:
  567. // the draft is still uncleared while the sink round-trip is in flight.
  568. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
  569. fireEvent.keyDown(textarea, { key: 'Enter' })
  570. expect(sink).toHaveBeenCalledTimes(1)
  571. const empty = bench({ draft: ' ' })
  572. fireEvent.keyDown(empty.textarea, { key: 'Enter' })
  573. expect(empty.sink).not.toHaveBeenCalled()
  574. })
  575. it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
  576. const { textarea, sink } = bench({ draft: 'hello' })
  577. fireEvent.keyDown(textarea, { key: 'a' })
  578. fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
  579. expect(sink).not.toHaveBeenCalled()
  580. })
  581. it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
  582. const { textarea, sink } = bench({ draft: 'hello' })
  583. fireEvent.compositionStart(textarea)
  584. fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
  585. expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
  586. })
  587. it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
  588. const idle = bench({ draft: 'hello' })
  589. fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
  590. expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal))
  591. const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
  592. fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
  593. expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer', expect.any(AbortSignal))
  594. const busyMeta = bench({ running: true, draft: 'steer with cmd' })
  595. fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
  596. expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer', expect.any(AbortSignal))
  597. })
  598. it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
  599. const steerQueue = vi.fn()
  600. const queue = [row('q-1'), row('q-2')]
  601. const meta = bench({ running: true, queue, steerQueue })
  602. fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
  603. expect(meta.steerQueue).toHaveBeenCalledTimes(1)
  604. expect(meta.sink).not.toHaveBeenCalled()
  605. const ctrl = bench({ running: true, queue, steerQueue: vi.fn() })
  606. fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
  607. expect(ctrl.steerQueue).toHaveBeenCalledTimes(1)
  608. expect(ctrl.sink).not.toHaveBeenCalled()
  609. })
  610. it('queue steering stays gated by activity, gesture, capability, and queued rows', () => {
  611. // Idle: the gesture falls through to the machine's empty-draft no-op.
  612. const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
  613. fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
  614. expect(idle.steerQueue).not.toHaveBeenCalled()
  615. expect(idle.sink).not.toHaveBeenCalled()
  616. // Plain Enter never steers the queue, even under the busy Steer preference.
  617. const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() })
  618. fireEvent.keyDown(plain.textarea, { key: 'Enter' })
  619. expect(plain.steerQueue).not.toHaveBeenCalled()
  620. expect(plain.sink).not.toHaveBeenCalled()
  621. // Continuable children expose the same steering face as ordinary Sessions.
  622. const subagent = {
  623. address: {
  624. parentSessionId: 'parent' as SessionId,
  625. childSessionId: SID,
  626. mode: 'continuable' as const,
  627. },
  628. parentAvailable: true,
  629. }
  630. const childSteerQueue = vi.fn()
  631. const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: childSteerQueue })
  632. fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
  633. expect(childSteerQueue).toHaveBeenCalledTimes(1)
  634. expect(child.sink).not.toHaveBeenCalled()
  635. // No queued rows: the empty draft stays a no-op.
  636. const none = bench({ running: true, steerQueue: vi.fn() })
  637. fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true })
  638. expect(none.steerQueue).not.toHaveBeenCalled()
  639. expect(none.sink).not.toHaveBeenCalled()
  640. // Pending steering rows are not the queue: nothing to flush.
  641. const steering = bench({
  642. running: true,
  643. nextStep: [row('s-1')],
  644. steerQueue: vi.fn(),
  645. })
  646. fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
  647. expect(steering.steerQueue).not.toHaveBeenCalled()
  648. expect(steering.sink).not.toHaveBeenCalled()
  649. })
  650. it('draft content outranks the queue: accelerated Enter steers the draft only', () => {
  651. const steerQueue = vi.fn()
  652. const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
  653. fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
  654. expect(sink).toHaveBeenCalledWith('插话', [], 'steer', expect.any(AbortSignal))
  655. expect(steerQueue).not.toHaveBeenCalled()
  656. })
  657. it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => {
  658. const { textarea, sink } = bench({ running: true, queue: [row('q-1')] })
  659. fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
  660. expect(sink).not.toHaveBeenCalled()
  661. })
  662. it('platform undo/redo chords drive the Lexical history stack, never the browser one', () => {
  663. const { textarea, shell } = bench({ draft: '' })
  664. vi.useFakeTimers()
  665. onTestFinished(() => { vi.useRealTimers() })
  666. writeDraft(shell, 'first')
  667. vi.advanceTimersByTime(1100) // beyond the history merge window: a separate undo step
  668. act(() => {
  669. shell.editor.update(() => {
  670. const first = $getRoot().getAllTextNodes()[0]
  671. if ($isTextNode(first)) first.select(5, 5).insertText(' second')
  672. }, { discrete: true })
  673. })
  674. expect(shell.snapshot.draft).toBe('first second')
  675. // Lexical's chord detection reads keyCode (90 = z); a history restore
  676. // commits on the next flush (setEditorState defers inside the command
  677. // update).
  678. const flush = (): void => { act(() => { shell.editor.update(() => {}, { discrete: true }) }) }
  679. fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true })
  680. flush()
  681. expect(shell.snapshot.draft).toBe('first')
  682. fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true, shiftKey: true })
  683. flush()
  684. expect(shell.snapshot.draft).toBe('first second')
  685. })
  686. it('a paste inside the typing merge window is its own undo step', async () => {
  687. const { textarea, shell } = bench({ draft: '' })
  688. vi.useFakeTimers()
  689. onTestFinished(() => { vi.useRealTimers() })
  690. writeDraft(shell, 'typed')
  691. vi.advanceTimersByTime(1100) // separate the seed from the typing step
  692. act(() => {
  693. shell.editor.update(() => {
  694. const first = $getRoot().getAllTextNodes()[0]
  695. if ($isTextNode(first)) first.select(5, 5).insertText(' one')
  696. }, { discrete: true })
  697. })
  698. // No timer advance: the paste lands inside the history merge window, and
  699. // PASTE_TAG still gives it its own undo entry. The paste commits inside
  700. // the PASTE_COMMAND update, a microtask away.
  701. fireEvent.paste(textarea, {
  702. clipboardData: { items: [], getData: () => ' two' },
  703. })
  704. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('typed one two') })
  705. const flush = (): void => { act(() => { shell.editor.update(() => {}, { discrete: true }) }) }
  706. fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true })
  707. flush()
  708. expect(shell.snapshot.draft).toBe('typed one')
  709. })
  710. it('caret-only commits advance neither draftRev nor the published state', () => {
  711. const { shell } = bench({ draft: 'hello' })
  712. const before = shell.snapshot
  713. act(() => {
  714. shell.editor.update(() => {
  715. const first = $getRoot().getAllTextNodes()[0]
  716. if ($isTextNode(first)) first.select(1, 1)
  717. }, { discrete: true })
  718. })
  719. expect(shell.snapshot).toBe(before) // publish skipped: the snapshot reference is unchanged
  720. act(() => {
  721. shell.editor.update(() => {
  722. const first = $getRoot().getAllTextNodes()[0]
  723. if ($isTextNode(first)) first.select(1, 1).insertText('x')
  724. }, { discrete: true })
  725. })
  726. expect(shell.snapshot.draftRev).toBe(before.draftRev + 1)
  727. expect(shell.snapshot.draft).toBe('hxello')
  728. })
  729. it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
  730. vi.useFakeTimers()
  731. try {
  732. const { textarea, sink } = bench({ draft: 'hello' })
  733. fireEvent.compositionStart(textarea)
  734. fireEvent.keyDown(textarea, { key: 'Enter' })
  735. expect(sink).not.toHaveBeenCalled()
  736. fireEvent.compositionEnd(textarea)
  737. // Safari delivers the closing keydown before the deferred clear.
  738. fireEvent.keyDown(textarea, { key: 'Enter' })
  739. expect(sink).not.toHaveBeenCalled()
  740. vi.advanceTimersByTime(20)
  741. fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
  742. expect(sink).not.toHaveBeenCalled()
  743. fireEvent.keyDown(textarea, { key: 'Enter' })
  744. expect(sink).toHaveBeenCalledTimes(1)
  745. } finally {
  746. vi.useRealTimers()
  747. }
  748. })
  749. })
  750. describe('running and lock semantics', () => {
  751. it('dismisses the Stop tooltip when an empty composer becomes idle', () => {
  752. vi.useFakeTimers()
  753. try {
  754. const { button, view, session } = bench({ running: true })
  755. fireEvent.mouseEnter(button)
  756. act(() => { vi.advanceTimersByTime(500) })
  757. expect(view.getByRole('tooltip').textContent).toBe('停止生成')
  758. // Disabling a hovered native button need not deliver mouseleave.
  759. act(() => { session.set(snapshotOf({ running: false })) })
  760. expect(button.disabled).toBe(true)
  761. expect(button.getAttribute('aria-label')).toBe('发送消息')
  762. expect(view.queryByRole('tooltip')).toBeNull()
  763. } finally {
  764. vi.useRealTimers()
  765. }
  766. })
  767. it('running switches the primary between Stop and Queue Send with the draft', async () => {
  768. const { textarea, button, stop, sink, shell } = bench({ running: true })
  769. expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
  770. expect(button.getAttribute('aria-label')).toBe('停止生成')
  771. fireEvent.click(button)
  772. expect(stop).toHaveBeenCalledTimes(1)
  773. writeDraft(shell, '排队消息')
  774. expect(button.getAttribute('aria-label')).toBe('排队发送')
  775. writeDraft(shell, ' ')
  776. expect(button.getAttribute('aria-label')).toBe('停止生成')
  777. writeDraft(shell, '排队消息2')
  778. expect(button.getAttribute('aria-label')).toBe('排队发送')
  779. fireEvent.click(button)
  780. expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
  781. await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') })
  782. expect(stop).toHaveBeenCalledTimes(1)
  783. })
  784. it('running Send follows the busy-state Steer preference and labels the delivery', () => {
  785. const { button, sink } = bench({ running: true, busyEnter: 'steer', draft: '按钮插话' })
  786. expect(button.getAttribute('aria-label')).toBe('插话发送')
  787. fireEvent.click(button)
  788. expect(sink).toHaveBeenCalledWith('按钮插话', [], 'steer', expect.any(AbortSignal))
  789. })
  790. it('running Send relabels when the busy-state preference changes live', () => {
  791. const { button, busyEnter, sink } = bench({ running: true, draft: '跟随设置' })
  792. expect(button.getAttribute('aria-label')).toBe('排队发送')
  793. act(() => { busyEnter.set('steer') })
  794. expect(button.getAttribute('aria-label')).toBe('插话发送')
  795. fireEvent.click(button)
  796. expect(sink).toHaveBeenCalledWith('跟随设置', [], 'steer', expect.any(AbortSignal))
  797. })
  798. it('running Send keeps the plain label for a slash line and a claimed command', () => {
  799. // An unclaimed `/` line adjudicates on submit; a claimed command executes
  800. // instead of delivering a message. Neither click is a Queue/Steer delivery.
  801. const slash = bench({ running: true, busyEnter: 'steer', draft: '/goal inspect' })
  802. expect(slash.button.getAttribute('aria-label')).toBe('发送消息')
  803. expect(slash.button.disabled).toBe(false)
  804. const claimed = bench({ running: true, busyEnter: 'steer' })
  805. act(() => {
  806. claimed.shell.setDraft('/goal ')
  807. claimed.shell.beginCommand(
  808. { name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) },
  809. { start: 0, end: 6, draftRev: claimed.shell.snapshot.draftRev },
  810. )
  811. })
  812. expect(claimed.shell.snapshot.phase).toBe('claimed')
  813. const button = claimed.view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')
  814. expect(button).not.toBeNull()
  815. expect(claimed.view.container.querySelector('button[aria-label="插话发送"]')).toBeNull()
  816. })
  817. it('running Send keeps the plain label while a file upload is still pending', () => {
  818. const file = { kind: 'file' as const, id: 'file-1' as DraftAttachmentId, file: new File(['x'], 'note.txt', { type: 'text/plain' }) }
  819. const pending = bench({
  820. running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
  821. fileUploads: { [file.id]: { status: 'uploading', loaded: 0 } },
  822. })
  823. expect(pending.button.getAttribute('aria-label')).toBe('发送消息')
  824. expect(pending.button.disabled).toBe(true)
  825. // A failed upload holds the same gate until it is retried or removed.
  826. const failed = bench({
  827. running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
  828. fileUploads: { [file.id]: { status: 'error', message: 'upload failed' } },
  829. })
  830. expect(failed.button.getAttribute('aria-label')).toBe('发送消息')
  831. expect(failed.button.disabled).toBe(true)
  832. const ready = bench({
  833. running: true, busyEnter: 'steer', draft: '带附件', attachments: [file],
  834. fileUploads: { [file.id]: { status: 'ready', receiptId: 'receipt-1' as never, file: { kind: 'file' } as never } },
  835. })
  836. expect(ready.button.getAttribute('aria-label')).toBe('插话发送')
  837. expect(ready.button.disabled).toBe(false)
  838. })
  839. it('idle Send keeps the plain label regardless of the busy-state preference', () => {
  840. const { button, sink } = bench({ busyEnter: 'steer', draft: '空闲发送' })
  841. expect(button.getAttribute('aria-label')).toBe('发送消息')
  842. fireEvent.click(button)
  843. expect(sink).toHaveBeenCalledWith('空闲发送', [], 'queue', expect.any(AbortSignal))
  844. })
  845. it('running treats an attachment-only draft as Send', async () => {
  846. const attachment = {
  847. kind: 'image' as const,
  848. id: 'draft-1' as DraftAttachmentId,
  849. file: new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }),
  850. previewUrl: 'blob:pixel',
  851. }
  852. const { button, sink } = bench({ running: true, attachments: [attachment] })
  853. expect(button.getAttribute('aria-label')).toBe('排队发送')
  854. fireEvent.click(button)
  855. expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal))
  856. await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') })
  857. })
  858. it('running blocked composer keeps Stop with a retained draft', () => {
  859. const { button, sink, stop, textarea } = bench({
  860. running: true,
  861. draft: '保留的草稿',
  862. blocked: { reason: '请选择可用模型' },
  863. placeholder: '请选择可用模型',
  864. })
  865. expect(textarea.getAttribute('aria-disabled')).toBe('true')
  866. expect(textarea.getAttribute('data-placeholder')).toBe('请选择可用模型')
  867. expect(button.getAttribute('aria-label')).toBe('停止生成')
  868. expect(button.disabled).toBe(false)
  869. fireEvent.click(button)
  870. expect(stop).toHaveBeenCalledTimes(1)
  871. expect(sink).not.toHaveBeenCalled()
  872. })
  873. it('running plain Enter follows the busy-state Steer preference', () => {
  874. const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
  875. fireEvent.keyDown(textarea, { key: 'Enter' })
  876. expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal))
  877. })
  878. it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
  879. const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
  880. fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
  881. expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue', expect.any(AbortSignal))
  882. const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
  883. fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
  884. expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue', expect.any(AbortSignal))
  885. })
  886. it('running continuable subagent keeps Send beside an independent Stop', () => {
  887. const { button, interruptButton, textarea, sink, stop, view, slotCalls, shell } = bench({
  888. running: true,
  889. draft: '后续消息',
  890. subagent: {
  891. address: {
  892. parentSessionId: 'parent' as SessionId,
  893. childSessionId: SID,
  894. mode: 'continuable',
  895. },
  896. parentAvailable: true,
  897. },
  898. })
  899. expect(button.getAttribute('aria-label')).toBe('排队发送')
  900. expect(interruptButton).not.toBeNull()
  901. expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
  902. expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(true)
  903. expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
  904. const click = vi.spyOn(HTMLInputElement.prototype, 'click')
  905. onTestFinished(() => { click.mockRestore() })
  906. expect(shell.canPickFiles()).toBe(false)
  907. expect(shell.pickFiles()).toBe(false)
  908. expect(click).not.toHaveBeenCalled()
  909. click.mockRestore()
  910. fireEvent.click(button)
  911. expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
  912. fireEvent.click(interruptButton!)
  913. expect(stop).toHaveBeenCalledTimes(1)
  914. })
  915. it('running continuable subagent Send follows the Steer preference like an ordinary session', () => {
  916. const subagent = {
  917. address: {
  918. parentSessionId: 'parent' as SessionId,
  919. childSessionId: SID,
  920. mode: 'continuable' as const,
  921. },
  922. parentAvailable: true,
  923. }
  924. const { button, sink } = bench({ running: true, busyEnter: 'steer', draft: '子代理插话', subagent })
  925. expect(button.getAttribute('aria-label')).toBe('插话发送')
  926. fireEvent.click(button)
  927. expect(sink).toHaveBeenCalledWith('子代理插话', [], 'steer', expect.any(AbortSignal))
  928. // No draft: the child has no Stop seat to fall back to, so its disabled
  929. // Send keeps the plain label instead of naming a delivery it cannot make.
  930. const empty = bench({ running: true, busyEnter: 'steer', subagent })
  931. expect(empty.button.getAttribute('aria-label')).toBe('发送消息')
  932. expect(empty.button.disabled).toBe(true)
  933. })
  934. it.each([
  935. ['active plan', { plan: { active: true, pending: false } }],
  936. ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }],
  937. ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => {
  938. const added = vi.fn(() => null)
  939. const { view, slotCalls, shell } = bench({ ...projection, addFiles: added })
  940. expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(false)
  941. expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
  942. // The menu's File row opens the hidden input through the bound picker.
  943. const click = vi.spyOn(HTMLInputElement.prototype, 'click')
  944. onTestFinished(() => { click.mockRestore() })
  945. expect(shell.canPickFiles()).toBe(true)
  946. expect(shell.pickFiles()).toBe(true)
  947. expect(click).toHaveBeenCalledOnce()
  948. click.mockRestore()
  949. })
  950. it.each([
  951. ['removed session', { disabled: true }],
  952. ['inert composer', { inert: true }],
  953. ['blocked composer', { blocked: { reason: 'waiting' } }],
  954. ])('%s hides and refuses the File action', (_name, state) => {
  955. const { shell, view } = bench({ ...state, addFiles: () => null })
  956. const input = view.container.querySelector<HTMLInputElement>('input[type="file"]')!
  957. const open = vi.spyOn(input, 'click')
  958. expect(shell.canPickFiles()).toBe(false)
  959. expect(shell.pickFiles()).toBe(false)
  960. expect(open).not.toHaveBeenCalled()
  961. })
  962. it('file action availability follows mount and live composer state', () => {
  963. const { props, view, shell } = bench({ addFiles: () => null })
  964. expect(shell.canPickFiles()).toBe(true)
  965. view.rerender(<InputBar {...props} disabled />)
  966. expect(shell.canPickFiles()).toBe(false)
  967. view.rerender(<InputBar {...props} />)
  968. expect(shell.canPickFiles()).toBe(true)
  969. view.unmount()
  970. expect(shell.canPickFiles()).toBe(false)
  971. expect(shell.pickFiles()).toBe(false)
  972. })
  973. it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
  974. const { button, interruptButton, textarea, stop, view } = bench({
  975. running: true,
  976. draft: '',
  977. subagent: {
  978. address: {
  979. parentSessionId: 'parent' as SessionId,
  980. childSessionId: SID,
  981. mode: 'continuable',
  982. },
  983. parentAvailable: false,
  984. },
  985. })
  986. expect(textarea.getAttribute('aria-disabled')).toBe('true')
  987. expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
  988. expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
  989. expect(button.getAttribute('aria-label')).toBe('发送消息')
  990. expect(button.disabled).toBe(true)
  991. expect(interruptButton?.disabled).toBe(false)
  992. fireEvent.click(interruptButton!)
  993. expect(stop).toHaveBeenCalledTimes(1)
  994. })
  995. it('running one-shot subagent never exposes Stop', () => {
  996. const { button, interruptButton, stop } = bench({
  997. running: true,
  998. draft: '不可停止',
  999. subagent: {
  1000. address: {
  1001. parentSessionId: 'parent' as SessionId,
  1002. childSessionId: SID,
  1003. mode: 'one-shot',
  1004. },
  1005. parentAvailable: true,
  1006. },
  1007. })
  1008. expect(button.getAttribute('aria-label')).toBe('发送消息')
  1009. expect(interruptButton).toBeNull()
  1010. expect(stop).not.toHaveBeenCalled()
  1011. })
  1012. it('applies the ordinary Queue/Steer preference to a running continuable child', () => {
  1013. const subagent = {
  1014. address: {
  1015. parentSessionId: 'parent' as SessionId,
  1016. childSessionId: SID,
  1017. mode: 'continuable' as const,
  1018. },
  1019. parentAvailable: true,
  1020. }
  1021. const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
  1022. fireEvent.keyDown(plain.textarea, { key: 'Enter' })
  1023. expect(plain.sink).toHaveBeenCalledWith('plain', [], 'steer', expect.any(AbortSignal))
  1024. const accelerated = bench({ running: true, draft: 'accelerated', subagent })
  1025. fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
  1026. expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'steer', expect.any(AbortSignal))
  1027. const opposite = bench({ running: true, busyEnter: 'steer', draft: 'opposite', subagent })
  1028. fireEvent.keyDown(opposite.textarea, { key: 'Enter', metaKey: true })
  1029. expect(opposite.sink).toHaveBeenCalledWith('opposite', [], 'queue', expect.any(AbortSignal))
  1030. })
  1031. it('disabled (session removed) locks the textarea and chrome', () => {
  1032. const { textarea, view } = bench({ disabled: true })
  1033. expect(textarea.getAttribute('aria-disabled')).toBe('true')
  1034. expect(placeholderOf(view.container)).toBe('会话不可用')
  1035. expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
  1036. })
  1037. it('idle primary sends and disables on empty draft', () => {
  1038. const { button, sink } = bench({ draft: 'go' })
  1039. fireEvent.click(button)
  1040. expect(sink).toHaveBeenCalledWith('go', [], 'queue', expect.any(AbortSignal))
  1041. const empty = bench()
  1042. expect(empty.button.disabled).toBe(true)
  1043. })
  1044. it('unlock refocuses the surface; mousedown on the button keeps focus', () => {
  1045. const first = bench({ disabled: true, draft: 'x' })
  1046. const textarea = first.view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
  1047. const focused: (boolean | undefined)[] = []
  1048. textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
  1049. act(() => { first.session.set(snapshotOf({ removed: false })) })
  1050. expect(focused).toEqual([true])
  1051. fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
  1052. expect(focused).toEqual([true, true])
  1053. })
  1054. it('a programmatic draft write echoes back through the state and the editor DOM', () => {
  1055. const { textarea, wiring, shell } = bench()
  1056. writeDraft(shell, 'typed')
  1057. expect(wiring.state.getSnapshot().draft).toBe('typed')
  1058. expect(textarea.textContent).toBe('typed')
  1059. })
  1060. it('wheel over a non-overflowing draft forwards to the conversation host', () => {
  1061. const host = document.createElement('div')
  1062. host.setAttribute('data-conversation-scroll', '')
  1063. Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
  1064. const { view, textarea } = bench()
  1065. host.appendChild(view.container)
  1066. document.body.appendChild(host)
  1067. try {
  1068. const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
  1069. expect(wheeled).toBe(false) // preventDefault
  1070. expect(host.scrollTop).toBe(70)
  1071. } finally {
  1072. host.remove()
  1073. }
  1074. })
  1075. it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
  1076. const host = document.createElement('div')
  1077. host.setAttribute('data-conversation-scroll', '')
  1078. Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
  1079. const { view, textarea } = bench()
  1080. host.appendChild(view.container)
  1081. document.body.appendChild(host)
  1082. const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
  1083. Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
  1084. Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
  1085. let scrollTop = 150
  1086. Object.defineProperty(scrollport, 'scrollTop', {
  1087. configurable: true,
  1088. get: () => scrollTop,
  1089. set: (value: number) => { scrollTop = value },
  1090. })
  1091. try {
  1092. // Mid-draft: both directions stay local — host must not move.
  1093. expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
  1094. expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
  1095. expect(host.scrollTop).toBe(40)
  1096. // At the bottom edge, further down-scroll forwards to the host.
  1097. scrollTop = 300
  1098. expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
  1099. expect(host.scrollTop).toBe(70)
  1100. // At the top edge, further up-scroll forwards to the host.
  1101. scrollTop = 0
  1102. host.scrollTop = 70
  1103. expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
  1104. expect(host.scrollTop).toBe(50)
  1105. } finally {
  1106. host.remove()
  1107. }
  1108. })
  1109. it('one editable surface rides the scrollport (no mirror or backdrop layers)', () => {
  1110. const { view, textarea } = bench({ draft: 'line\n'.repeat(40).trimEnd() })
  1111. const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
  1112. expect(scroll.contains(textarea)).toBe(true)
  1113. // The three-layer stack is gone: the editable surface owns glyphs, caret,
  1114. // and height at once.
  1115. expect(view.container.querySelector('[data-input-backdrop]')).toBeNull()
  1116. expect(view.container.querySelector('[data-input-mirror]')).toBeNull()
  1117. // DOM textContent joins paragraphs without separators; the projection
  1118. // carries the newlines.
  1119. expect(textarea.textContent).toBe('line'.repeat(40))
  1120. expect(bench({ draft: 'a\nb' }).shell.snapshot.draft).toBe('a\nb')
  1121. })
  1122. it('a session switch refocuses the editable surface with preventScroll', () => {
  1123. const { view, textarea, props } = bench({ draft: 'line one' })
  1124. const focused: (boolean | undefined)[] = []
  1125. textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
  1126. act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
  1127. expect(focused).toEqual([true])
  1128. })
  1129. it('a persisted draft adopted after mount does not steal focus from another control', () => {
  1130. const { shell } = bench()
  1131. const other = document.createElement('input')
  1132. document.body.appendChild(other)
  1133. onTestFinished(() => { other.remove() })
  1134. other.focus()
  1135. act(() => { shell.setDraft('restored\n'.repeat(40)) })
  1136. expect(document.activeElement).toBe(other)
  1137. expect(shell.snapshot.draft).toBe('restored\n'.repeat(40))
  1138. })
  1139. it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
  1140. expect(bench({ disabled: true }).placeholder).toBe('会话不可用')
  1141. const live = bench()
  1142. expect(live.placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
  1143. const custom = bench({ placeholder: 'Custom placeholder' })
  1144. expect(custom.placeholder).toBe('Custom placeholder')
  1145. })
  1146. it('the inert textarea opens the Workspace picker by pointer or keyboard', () => {
  1147. const onRequestWorkspace = vi.fn()
  1148. const { view, textarea } = bench({
  1149. inert: true,
  1150. workspacePickerOpen: false,
  1151. onRequestWorkspace,
  1152. placeholder: '选择一个工作区开始',
  1153. })
  1154. expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
  1155. expect(editableOf(textarea)).toBe(false)
  1156. expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
  1157. expect(textarea.getAttribute('aria-expanded')).toBe('false')
  1158. expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
  1159. fireEvent.click(textarea)
  1160. fireEvent.keyDown(textarea, { key: 'Enter' })
  1161. fireEvent.keyDown(textarea, { key: ' ' })
  1162. expect(onRequestWorkspace).toHaveBeenCalledTimes(3)
  1163. // The WHOLE capsule is the pick target, and its pointerdown never reaches
  1164. // the document — the open picker's outside-close must not race the reopen.
  1165. const card = view.container.querySelector('[data-composer-card]') as HTMLElement
  1166. fireEvent.click(card)
  1167. expect(onRequestWorkspace).toHaveBeenCalledTimes(4)
  1168. const onDocumentPointerDown = vi.fn()
  1169. document.addEventListener('pointerdown', onDocumentPointerDown)
  1170. try {
  1171. fireEvent.pointerDown(card)
  1172. } finally {
  1173. document.removeEventListener('pointerdown', onDocumentPointerDown)
  1174. }
  1175. expect(onDocumentPointerDown).not.toHaveBeenCalled()
  1176. })
  1177. it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
  1178. const active = bench({ plan: { active: true, pending: false } })
  1179. expect(active.placeholder).toBe('描述你的任务以生成计划')
  1180. // /plan just ran: pending entry already reads as the plan target.
  1181. const entering = bench({ plan: { active: false, pending: true } })
  1182. expect(entering.placeholder).toBe('描述你的任务以生成计划')
  1183. // Pending exit: target is default again.
  1184. const leaving = bench({ plan: { active: true, pending: true } })
  1185. expect(leaving.placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
  1186. // Owner placeholder outranks the plan swap.
  1187. const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
  1188. expect(custom.placeholder).toBe('Custom placeholder')
  1189. })
  1190. })
  1191. describe('machine pending lock', () => {
  1192. it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
  1193. const { view, shell } = bench()
  1194. // Drive the machine into submitting through a claim + enter.
  1195. act(() => {
  1196. shell.setDraft('/goal ')
  1197. shell.beginCommand(
  1198. {
  1199. name: 'goal',
  1200. token: '/goal ',
  1201. submit: () => new Promise<never>(() => {}), // never settles: stays submitting
  1202. },
  1203. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  1204. )
  1205. shell.submit()
  1206. })
  1207. expect(shell.snapshot.phase).toBe('submitting')
  1208. const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
  1209. expect(editableOf(textarea)).toBe(false)
  1210. expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
  1211. })
  1212. })
  1213. describe('decorations', () => {
  1214. /** The claim-token styled leaf (the transform's inline warn color). */
  1215. function tokenSpanOf(container: HTMLElement): HTMLElement | null {
  1216. return container.querySelector('[data-lexical-text][style*="warn-label"]')
  1217. }
  1218. it('claimed token styles the leading leaf and sets the blank-args hint variable', () => {
  1219. // Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
  1220. const { view, shell, textarea } = bench({ t: makeTranslate({}) })
  1221. act(() => {
  1222. shell.setDraft('/goal ')
  1223. shell.beginCommand(
  1224. { name: 'goal', token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
  1225. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  1226. )
  1227. shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh
  1228. })
  1229. expect(tokenSpanOf(view.container)?.textContent).toBe('/goal ')
  1230. expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('目标内容'))
  1231. // Args typed: the hint disappears, the token style stays.
  1232. act(() => { shell.setDraft('/goal 发布') })
  1233. act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
  1234. expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
  1235. expect(tokenSpanOf(view.container)).not.toBeNull()
  1236. })
  1237. it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
  1238. const { shell, textarea } = bench()
  1239. act(() => {
  1240. shell.setDraft('/goal ')
  1241. shell.beginCommand(
  1242. { name: 'goal', token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
  1243. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  1244. )
  1245. })
  1246. expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
  1247. })
  1248. it('the hint lookup keys on the claim name, so a localized claim token keeps the locale entry', () => {
  1249. const { shell, textarea } = bench()
  1250. act(() => {
  1251. shell.setDraft('/目标 ')
  1252. shell.beginCommand(
  1253. { name: 'goal', token: '/目标 ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
  1254. { start: 0, end: 4, draftRev: shell.snapshot.draftRev },
  1255. )
  1256. })
  1257. expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
  1258. })
  1259. it('suppresses placeholders throughout native composition, including a temporarily empty draft', async () => {
  1260. const { shell, textarea, view } = bench()
  1261. expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
  1262. fireEvent.compositionStart(textarea)
  1263. expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
  1264. act(() => { shell.setDraft('z') })
  1265. act(() => { shell.setDraft('') })
  1266. expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
  1267. fireEvent.compositionEnd(textarea, { data: '' })
  1268. expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
  1269. await act(async () => {})
  1270. expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
  1271. act(() => {
  1272. shell.setDraft('/目标 ')
  1273. shell.beginCommand(
  1274. { name: 'goal', token: '/目标 ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
  1275. { start: 0, end: 4, draftRev: shell.snapshot.draftRev },
  1276. )
  1277. })
  1278. fireEvent.compositionStart(textarea)
  1279. act(() => { shell.setDraft('/目标 z') })
  1280. act(() => { shell.setDraft('/目标 ') })
  1281. expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
  1282. fireEvent.compositionEnd(textarea, { data: '这' })
  1283. act(() => { shell.setDraft('/目标 这') })
  1284. await act(async () => {})
  1285. expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
  1286. expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
  1287. })
  1288. it('an inserted reference renders a real chip capsule with its icon and label', () => {
  1289. const { view, shell } = bench()
  1290. const reference = {
  1291. source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
  1292. }
  1293. act(() => {
  1294. shell.setDraft('参考 @w1 内容')
  1295. shell.insertReference(
  1296. reference,
  1297. { start: 3, end: 6, draftRev: shell.snapshot.draftRev },
  1298. )
  1299. })
  1300. const chip = view.container.querySelector('[data-composer-chip]')
  1301. expect(chip?.textContent).toBe('会话一')
  1302. expect(chip?.querySelector('svg')).not.toBeNull()
  1303. expect(chip?.getAttribute('contenteditable')).toBe('false')
  1304. expect(shell.snapshot.occurrences).toHaveLength(1)
  1305. // The draft IS the clipboard projection; the label lives in the chip DOM.
  1306. expect(shell.snapshot.draft).toBe('参考 @w1 内容')
  1307. expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 3 })
  1308. })
  1309. it('keeps the chip decorator mounted when the session becomes disabled', () => {
  1310. const { view, shell, session, textarea } = bench()
  1311. act(() => {
  1312. shell.setDraft('@w1')
  1313. shell.insertReference({
  1314. source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
  1315. }, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
  1316. })
  1317. const chip = view.container.querySelector('[data-composer-chip]')
  1318. expect(chip?.querySelector('svg')).not.toBeNull()
  1319. act(() => { session.set(snapshotOf({ removed: true })) })
  1320. expect(textarea.getAttribute('aria-disabled')).toBe('true')
  1321. expect(view.container.querySelector('[data-composer-chip]')).toBe(chip)
  1322. })
  1323. it('a chip leaves the document as one unit (keyboard deletion is asserted in the browser lane)', () => {
  1324. // jsdom lacks the non-standard Selection.modify Lexical's character
  1325. // deletion crosses nodes with, so the Backspace/Delete gesture itself is
  1326. // an e2e assertion; the structural unit — one span covering the chip
  1327. // removes the whole occurrence — is checkable here.
  1328. const { shell } = bench()
  1329. act(() => {
  1330. shell.setDraft('前 @w1 后')
  1331. shell.insertReference({
  1332. source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
  1333. }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
  1334. })
  1335. expect(shell.snapshot.occurrences).toHaveLength(1)
  1336. act(() => {
  1337. shell.editor.update(() => {
  1338. expect($replaceDetectSpanWithText({ start: 2, end: 3 }, '')).toBe(true)
  1339. }, { discrete: true })
  1340. })
  1341. expect(shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
  1342. })
  1343. it('copy and cut expand a selected chip to its clipboard projection natively', async () => {
  1344. const { shell, textarea } = bench()
  1345. act(() => {
  1346. shell.setDraft('前 @w1 后')
  1347. shell.insertReference({
  1348. source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
  1349. }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
  1350. // Select the chip plus its flanking spaces: detect [1, 4).
  1351. shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
  1352. })
  1353. const setData = vi.fn()
  1354. fireEvent.copy(textarea, { clipboardData: { setData, getData: () => '' } })
  1355. expect(setData).toHaveBeenCalledWith('text/plain', ' @w1 ')
  1356. expect(shell.snapshot.draft).toBe('前 @w1 后')
  1357. act(() => {
  1358. shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
  1359. })
  1360. fireEvent.cut(textarea, { clipboardData: { setData, getData: () => '' } })
  1361. await vi.waitFor(() => {
  1362. expect(shell.snapshot).toMatchObject({ draft: '前后', occurrences: [] })
  1363. })
  1364. expect(setData).toHaveBeenLastCalledWith('text/plain', ' @w1 ')
  1365. })
  1366. it('a lexicon-matched plain token renders the text-ref node', () => {
  1367. const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
  1368. const { view, shell } = bench({ lexicon })
  1369. act(() => { shell.setDraft('use /fixture-demo now') })
  1370. const mark = view.container.querySelector('[data-composer-text-ref]')
  1371. expect(mark?.textContent).toBe('/fixture-demo')
  1372. // Editing the token out of match shape drops the decoration.
  1373. act(() => { shell.setDraft('use /fixture-dem now') })
  1374. expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
  1375. })
  1376. it('a directory completion decorates color-only: literal text, no icon seat', () => {
  1377. const { view, shell } = bench()
  1378. act(() => { shell.setDraft('see @src/components/') })
  1379. const mark = view.container.querySelector('[data-composer-text-ref]')
  1380. expect(mark?.textContent).toBe('@src/components/')
  1381. // The trigger character marks editable text; the domain icon is the
  1382. // settled chip's alone.
  1383. expect(mark?.getAttribute('data-ref-appearance')).toBeNull()
  1384. expect(shell.snapshot.draft).toBe('see @src/components/')
  1385. })
  1386. it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {
  1387. const { view, shell } = bench()
  1388. act(() => { shell.setDraft('see @src/components/ here') })
  1389. const mark = view.container.querySelector('[data-composer-text-ref]')!
  1390. // Type ahead through the node API (the transaction path typing takes).
  1391. act(() => {
  1392. shell.editor.update(() => {
  1393. const first = $getRoot().getAllTextNodes()[0]
  1394. if ($isTextNode(first)) first.spliceText(0, 0, 'X ')
  1395. }, { discrete: true })
  1396. })
  1397. // Node identity, not text: the entity node survives edits ahead of it.
  1398. expect(view.container.querySelector('[data-composer-text-ref]')).toBe(mark)
  1399. expect(mark.textContent).toBe('@src/components/')
  1400. expect(shell.snapshot.draft).toBe('X see @src/components/ here')
  1401. // A token edited out of match shape still loses its decoration.
  1402. act(() => { shell.setDraft('X see X@src/components/ here') })
  1403. expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
  1404. })
  1405. })
  1406. describe('insertText (scoped event body)', () => {
  1407. it('splices plain text over the span and reports success as true', () => {
  1408. const { shell } = bench({ draft: '/fix' })
  1409. const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
  1410. expect(ok).toBe(true)
  1411. expect(shell.snapshot.draft).toBe('/fixture-demo ')
  1412. expect(shell.snapshot.occurrences).toEqual([])
  1413. })
  1414. it('a stale draftRev refuses whole: false, draft untouched', () => {
  1415. const { shell } = bench({ draft: '/fix' })
  1416. const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
  1417. act(() => { shell.setDraft('/fixX') })
  1418. expect(shell.insertText('/fixture-demo ', span)).toBe(false)
  1419. expect(shell.snapshot.draft).toBe('/fixX')
  1420. })
  1421. })
  1422. describe('strips and variants', () => {
  1423. it.each([zh, en])('shows localized guidance when another writer owns the Session', (dictionary) => {
  1424. const send = bench({
  1425. promptError: {
  1426. op: 'send',
  1427. error: new RemoteError('session/writer-held', 'internal writer diagnostic', { sessionId: SID }),
  1428. },
  1429. t: makeTranslate(dictionary, commonZh),
  1430. })
  1431. expect(send.view.getByRole('alert').textContent).toBe(dictionary['error.sessionInUse'])
  1432. })
  1433. it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
  1434. vi.useFakeTimers()
  1435. try {
  1436. const send = bench({
  1437. promptError: { op: 'send', error: new RemoteError('session/agent-busy', 'boom', { reason: 'boom' }) },
  1438. })
  1439. // The toast body-portals (transformed ancestors must not trap it), so
  1440. // queries go through the view's document-bound helpers.
  1441. expect(send.view.getByRole('alert').textContent).toContain('boom (session/agent-busy)')
  1442. expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
  1443. act(() => { vi.advanceTimersByTime(4000) })
  1444. expect(send.view.queryByRole('alert')).toBeNull()
  1445. } finally {
  1446. vi.useRealTimers()
  1447. }
  1448. })
  1449. it('announces an error notice from the machine store as a fading toast', () => {
  1450. vi.useFakeTimers()
  1451. try {
  1452. const { view, shell } = bench()
  1453. act(() => { shell.notify('error', '命令失败了') })
  1454. expect(view.getByRole('alert').textContent).toContain('命令失败了')
  1455. expect(view.queryByRole('status')).toBeNull()
  1456. act(() => { vi.advanceTimersByTime(4000) })
  1457. expect(view.queryByRole('alert')).toBeNull()
  1458. } finally {
  1459. vi.useRealTimers()
  1460. }
  1461. })
  1462. it('renders an information notice from the machine store as a status strip', () => {
  1463. const { view, shell } = bench()
  1464. act(() => { shell.notify('info', '命令完成了') })
  1465. expect(view.getByRole('status').textContent).toBe('命令完成了')
  1466. expect(view.queryByRole('alert')).toBeNull()
  1467. })
  1468. it('hero variant adds the hero class and accessory row renders', () => {
  1469. const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
  1470. expect(view.getByTestId('acc')).toBeTruthy()
  1471. expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
  1472. })
  1473. it('renders overlay, left/right, and footer slots at their layout positions', () => {
  1474. const { view } = bench({
  1475. overlay: <i data-testid="ov" />,
  1476. leftItems: <i data-testid="li" />,
  1477. rightItems: <i data-testid="ri" />,
  1478. footer: <i data-testid="foot" />,
  1479. })
  1480. expect(view.getByTestId('ov')).toBeTruthy()
  1481. expect(view.getByTestId('li')).toBeTruthy()
  1482. expect(view.getByTestId('ri')).toBeTruthy()
  1483. expect(view.getByTestId('foot')).toBeTruthy()
  1484. })
  1485. })
  1486. describe('command launcher chrome and control seats', () => {
  1487. it('renders the command launcher and dispatches every empty control seat', () => {
  1488. const { view, slotCalls } = bench()
  1489. expect(view.getByLabelText('添加文件或调用指令')).toBeTruthy()
  1490. // Capability absent (no permission slot entry): the chip renders nothing.
  1491. expect(view.queryByLabelText(/^访问模式/)).toBeNull()
  1492. // Every seat dispatched, nothing rendered (render passes may repeat; the
  1493. // seat set is the contract).
  1494. expect([...new Set(slotCalls.map(c => c.key))]).toEqual([
  1495. 'conversation.input.overlay', 'conversation.input.attachments',
  1496. 'conversation.input.permission', 'conversation.input.plan', 'conversation.input.left',
  1497. 'conversation.input.right', 'conversation.input.model',
  1498. 'conversation.composer.dock',
  1499. ])
  1500. expect(view.queryByLabelText('Plan mode')).toBeNull()
  1501. expect(view.queryByLabelText('Model')).toBeNull()
  1502. })
  1503. it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
  1504. const toggleCommandMenu = vi.fn()
  1505. const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
  1506. act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
  1507. const launcher = view.getByLabelText('添加文件或调用指令')
  1508. expect(launcher.getAttribute('aria-expanded')).toBe('false')
  1509. fireEvent.click(launcher)
  1510. expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
  1511. act(() => { menuLauncher.set('command') })
  1512. expect(launcher.getAttribute('aria-expanded')).toBe('true')
  1513. })
  1514. it('opening the command menu from the button puts the keyboard in the editor first', () => {
  1515. const toggleCommandMenu = vi.fn()
  1516. const { view, textarea } = bench({ toggleCommandMenu })
  1517. // Tab to the button and activate it: the keyboard is on the button, and the
  1518. // menu is a combobox whose arrows live on the editor.
  1519. textarea.blur()
  1520. expect(document.activeElement).not.toBe(textarea)
  1521. fireEvent.click(view.getByLabelText('添加文件或调用指令'))
  1522. expect(document.activeElement).toBe(textarea)
  1523. expect(toggleCommandMenu).toHaveBeenCalledTimes(1)
  1524. })
  1525. it('a registered entry fills its seat and receives the locked owner prop', () => {
  1526. const { view, slotCalls } = bench({
  1527. disabled: true,
  1528. planEntry: <i data-testid="plan-entry" />,
  1529. permissionEntry: <i data-testid="permission-entry" />,
  1530. modelEntry: <i data-testid="model-entry" />,
  1531. })
  1532. expect(view.getByTestId('plan-entry')).toBeTruthy()
  1533. expect(view.getByTestId('permission-entry')).toBeTruthy()
  1534. expect(view.getByTestId('model-entry')).toBeTruthy()
  1535. // The bar hands its chrome disable state to the filling entry.
  1536. const controlKeys = new Set([
  1537. 'conversation.input.permission', 'conversation.input.plan', 'conversation.input.model',
  1538. ])
  1539. const controls = slotCalls.filter(call => controlKeys.has(call.key))
  1540. expect(controls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
  1541. expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
  1542. cleanup()
  1543. const live = bench({ running: true })
  1544. const liveControls = live.slotCalls.filter(call => controlKeys.has(call.key))
  1545. expect(liveControls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
  1546. expect(attachmentOwner(live.slotCalls).canAcceptDrop).toBe(true)
  1547. })
  1548. it('disabled locks the command launcher while running does not', () => {
  1549. const { view } = bench({ disabled: true })
  1550. expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
  1551. cleanup()
  1552. const live = bench({ running: true })
  1553. expect((live.view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(false)
  1554. })
  1555. })