1
0

input-bar.client.spec.tsx 77 KB

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