input-bar.spec.tsx 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858
  1. // @vitest-environment jsdom
  2. // InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
  3. // Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
  4. // semantics (input stays free; primary turns stop), the machine pending lock,
  5. // decoration backdrop, error/notice strips, and the focus-keeping mousedown.
  6. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
  7. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  8. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  9. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  10. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  11. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  12. import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  13. import { SessionInputShell } from '../src/client/input/facade.ts'
  14. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  15. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  16. import { zh } from '../src/client/locales.ts'
  17. afterEach(cleanup)
  18. // jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
  19. // is absent — and the composer measures the caret with one when it restores the
  20. // selection after an edit it performed itself. Every case here runs against a
  21. // zero rect; the reveal case below substitutes its own and restores this one.
  22. const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
  23. Range.prototype.getBoundingClientRect = ZERO_RECT
  24. // Read through the descriptor so the native method is never referenced unbound;
  25. // the reveal case below wraps it to record what it was asked to measure.
  26. const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
  27. .value as (this: Range, node: Node, offset: number) => void
  28. const SCTX = {} as ClientContext
  29. const SID = 's1' as SessionId
  30. function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
  31. return {
  32. sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
  33. pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
  34. openState: 'open', openError: null, hasMore: false, loadingOlder: false,
  35. promptError: null, blank: false, subagent: null, lastAgentError: null,
  36. ...overrides,
  37. }
  38. }
  39. interface BenchOptions {
  40. planEntry?: React.ReactNode
  41. /** The `plan` projection value the standard-kit useProjection serves. */
  42. plan?: { active: boolean; pending: boolean }
  43. modelEntry?: React.ReactNode
  44. /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
  45. lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
  46. permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
  47. draft?: string
  48. running?: boolean
  49. subagent?: Exclude<ConversationSnapshot['subagent'], null>
  50. disabled?: boolean
  51. promptError?: ConversationSnapshot['promptError']
  52. variant?: 'hero' | 'composer'
  53. placeholder?: string
  54. t?: InputBarProps['t']
  55. command?: (line: string) => Promise<boolean>
  56. accessory?: React.ReactNode
  57. overlay?: React.ReactNode
  58. leftItems?: React.ReactNode
  59. rightItems?: React.ReactNode
  60. commandMenuOpen?: boolean
  61. busyEnter?: 'queue' | 'steer'
  62. toggleCommandMenu?: (selection: { start: number; end: number }) => void
  63. }
  64. /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
  65. function bench(over?: BenchOptions) {
  66. const sink = vi.fn()
  67. const lex = over?.lexicon
  68. type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
  69. const shell = new SessionInputShell({
  70. actx: SCTX,
  71. defaultSink: sink,
  72. // Lexicon-only stub: adjudication untouched (undefined slash methods are
  73. // never reached — these benches drive plain-draft flows only).
  74. ...(lex !== undefined
  75. ? {
  76. slash: (() => ({
  77. lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
  78. })) as unknown as NonNullable<ShellDeps['slash']>,
  79. }
  80. : {}),
  81. })
  82. if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
  83. const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
  84. running: over?.running ?? false,
  85. subagent: over?.subagent ?? null,
  86. removed: over?.disabled ?? false,
  87. promptError: over?.promptError ?? null,
  88. }))
  89. const stop = vi.fn()
  90. const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
  91. const slotCalls: { key: string; owner: unknown }[] = []
  92. const renderSlot = ((key: string, owner: object) => {
  93. slotCalls.push({ key, owner })
  94. if (key === 'conversation.input.plan') return over?.planEntry ?? null
  95. if (key === 'conversation.input.model') return over?.modelEntry ?? null
  96. return null
  97. }) as InputBarProps['renderSlot']
  98. const props: InputBarProps = {
  99. sessionId: SID,
  100. SessionProvider: ({ children }) => children(SID),
  101. useSession: bindSnapshotSelector(session),
  102. useSessions: bindSnapshotSelector(createSnapshotStore({
  103. ids: [], byId: {}, current: undefined, phase: 'ready',
  104. subagentsByParent: {}, currentAddress: undefined,
  105. })),
  106. useWorkspaces: bindSnapshotSelector(createSnapshotStore({
  107. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  108. baselinesReady: true, recentWorkspaceId: undefined,
  109. })),
  110. useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
  111. (selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
  112. useInput: bindSnapshotSelector(shell.state),
  113. inputActions: shell.actions,
  114. keyboard: shell,
  115. resolveSubmitMode: (running, gesture, steeringAvailable) => {
  116. if (!running || !steeringAvailable) return 'queue'
  117. const preferred = over?.busyEnter ?? 'queue'
  118. return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue'
  119. },
  120. toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
  121. useNotices: bindSnapshotSelector(shell.notices),
  122. useLexicon: bindSnapshotSelector(shell.lexicon),
  123. useMenuLauncher: bindSnapshotSelector(menuLauncher),
  124. stop,
  125. command: over?.command ?? (() => Promise.resolve(true)),
  126. // Mirrors the real lookup chain (conversation namespace, then common).
  127. t: over?.t ?? makeTranslate(zh, commonZh),
  128. renderSlot,
  129. variant: over?.variant ?? 'composer',
  130. ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
  131. ...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
  132. ...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
  133. ...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
  134. ...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
  135. }
  136. const view = render(<InputBar {...props} />)
  137. const textarea = view.container.querySelector('textarea')!
  138. const stopping = over?.running === true && over.subagent === undefined
  139. const button = view.container.querySelector<HTMLButtonElement>(
  140. `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
  141. )!
  142. return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
  143. }
  144. describe('Enter semantics', () => {
  145. it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
  146. const { textarea, sink } = bench({ draft: 'hello' })
  147. fireEvent.keyDown(textarea, { key: 'Enter' })
  148. expect(sink).toHaveBeenCalledWith('hello', 'queue')
  149. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
  150. expect(sink).toHaveBeenCalledTimes(1)
  151. const empty = bench({ draft: ' ' })
  152. fireEvent.keyDown(empty.textarea, { key: 'Enter' })
  153. expect(empty.sink).not.toHaveBeenCalled()
  154. })
  155. it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
  156. const { textarea, sink } = bench({ draft: 'hello' })
  157. fireEvent.keyDown(textarea, { key: 'a' })
  158. fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
  159. expect(sink).not.toHaveBeenCalled()
  160. })
  161. it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
  162. const { textarea, sink } = bench({ draft: 'hello' })
  163. fireEvent.compositionStart(textarea)
  164. fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
  165. expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
  166. })
  167. it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
  168. const idle = bench({ draft: 'hello' })
  169. fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
  170. expect(idle.sink).toHaveBeenCalledWith('hello', 'queue')
  171. const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
  172. fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
  173. expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer')
  174. const busyMeta = bench({ running: true, draft: 'steer with cmd' })
  175. fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
  176. expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
  177. })
  178. it('platform undo/redo chords route to the machine, never the browser stack', () => {
  179. const { textarea, shell } = bench({ draft: '' })
  180. fireEvent.change(textarea, { target: { value: 'first' } })
  181. fireEvent.change(textarea, { target: { value: 'first second' } })
  182. fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
  183. expect(shell.snapshot.draft).not.toBe('first second')
  184. fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
  185. expect(shell.snapshot.draft).toBe('first second')
  186. })
  187. it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
  188. vi.useFakeTimers()
  189. try {
  190. const { textarea, sink } = bench({ draft: 'hello' })
  191. fireEvent.compositionStart(textarea)
  192. fireEvent.keyDown(textarea, { key: 'Enter' })
  193. expect(sink).not.toHaveBeenCalled()
  194. fireEvent.compositionEnd(textarea)
  195. // Safari delivers the closing keydown before the deferred clear.
  196. fireEvent.keyDown(textarea, { key: 'Enter' })
  197. expect(sink).not.toHaveBeenCalled()
  198. vi.advanceTimersByTime(20)
  199. fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
  200. expect(sink).not.toHaveBeenCalled()
  201. fireEvent.keyDown(textarea, { key: 'Enter' })
  202. expect(sink).toHaveBeenCalledTimes(1)
  203. } finally {
  204. vi.useRealTimers()
  205. }
  206. })
  207. })
  208. describe('running and lock semantics (queue cut 1)', () => {
  209. it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
  210. const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
  211. expect(textarea.disabled).toBe(false) // running no longer locks
  212. fireEvent.change(textarea, { target: { value: '排队消息2' } })
  213. fireEvent.keyDown(textarea, { key: 'Enter' })
  214. expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
  215. expect(button.getAttribute('aria-label')).toBe('停止生成')
  216. fireEvent.click(button)
  217. expect(stop).toHaveBeenCalledTimes(1)
  218. })
  219. it('running plain Enter follows the busy-state Steer preference', () => {
  220. const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
  221. fireEvent.keyDown(textarea, { key: 'Enter' })
  222. expect(sink).toHaveBeenCalledWith('直接插话', 'steer')
  223. })
  224. it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
  225. const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
  226. fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
  227. expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue')
  228. const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
  229. fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
  230. expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
  231. })
  232. it('running subagent primary admits a follow-up instead of exposing Stop', () => {
  233. const { button, sink, stop } = bench({
  234. running: true,
  235. draft: '后续消息',
  236. subagent: {
  237. address: {
  238. parentSessionId: 'parent' as SessionId,
  239. childSessionId: SID,
  240. mode: 'continuable',
  241. },
  242. parentAvailable: true,
  243. },
  244. })
  245. expect(button.getAttribute('aria-label')).toBe('发送消息')
  246. fireEvent.click(button)
  247. expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
  248. expect(stop).not.toHaveBeenCalled()
  249. const empty = bench({
  250. running: true,
  251. subagent: {
  252. address: {
  253. parentSessionId: 'parent' as SessionId,
  254. childSessionId: SID,
  255. mode: 'continuable',
  256. },
  257. parentAvailable: true,
  258. },
  259. })
  260. expect(empty.button.disabled).toBe(true)
  261. })
  262. it('keeps both running subagent Enter gestures on Queue transport', () => {
  263. const subagent = {
  264. address: {
  265. parentSessionId: 'parent' as SessionId,
  266. childSessionId: SID,
  267. mode: 'continuable' as const,
  268. },
  269. parentAvailable: true,
  270. }
  271. const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
  272. fireEvent.keyDown(plain.textarea, { key: 'Enter' })
  273. expect(plain.sink).toHaveBeenCalledWith('plain', 'queue')
  274. const accelerated = bench({ running: true, draft: 'accelerated', subagent })
  275. fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
  276. expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue')
  277. })
  278. it('disabled (session removed) locks the textarea and chrome', () => {
  279. const { textarea, view } = bench({ disabled: true })
  280. expect(textarea.disabled).toBe(true)
  281. expect(textarea.placeholder).toBe('会话不可用')
  282. expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
  283. })
  284. it('idle primary sends and disables on empty draft', () => {
  285. const { button, sink } = bench({ draft: 'go' })
  286. fireEvent.click(button)
  287. expect(sink).toHaveBeenCalledWith('go', 'queue')
  288. const empty = bench()
  289. expect(empty.button.disabled).toBe(true)
  290. })
  291. it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
  292. const first = bench({ disabled: true, draft: 'x' })
  293. act(() => { first.session.set(snapshotOf({ removed: false })) })
  294. const textarea = first.view.container.querySelector('textarea')!
  295. expect(document.activeElement).toBe(textarea)
  296. textarea.blur()
  297. fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
  298. expect(document.activeElement).toBe(textarea)
  299. })
  300. it('typing forwards through the machine (draft state echoes back)', () => {
  301. const { textarea, wiring } = bench()
  302. fireEvent.change(textarea, { target: { value: 'typed' } })
  303. expect(wiring.state.getSnapshot().draft).toBe('typed')
  304. expect((textarea).value).toBe('typed')
  305. })
  306. it('wheel over a non-overflowing draft forwards to the conversation host', () => {
  307. const host = document.createElement('div')
  308. host.setAttribute('data-conversation-scroll', '')
  309. Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
  310. const { view, textarea } = bench()
  311. host.appendChild(view.container)
  312. document.body.appendChild(host)
  313. try {
  314. const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
  315. expect(wheeled).toBe(false) // preventDefault
  316. expect(host.scrollTop).toBe(70)
  317. } finally {
  318. host.remove()
  319. }
  320. })
  321. it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
  322. const host = document.createElement('div')
  323. host.setAttribute('data-conversation-scroll', '')
  324. Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
  325. const { view, textarea } = bench()
  326. host.appendChild(view.container)
  327. document.body.appendChild(host)
  328. const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
  329. Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
  330. Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
  331. let scrollTop = 150
  332. Object.defineProperty(scrollport, 'scrollTop', {
  333. configurable: true,
  334. get: () => scrollTop,
  335. set: (value: number) => { scrollTop = value },
  336. })
  337. try {
  338. // Mid-draft: both directions stay local — host must not move.
  339. expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
  340. expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
  341. expect(host.scrollTop).toBe(40)
  342. // At the bottom edge, further down-scroll forwards to the host.
  343. scrollTop = 300
  344. expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
  345. expect(host.scrollTop).toBe(70)
  346. // At the top edge, further up-scroll forwards to the host.
  347. scrollTop = 0
  348. host.scrollTop = 70
  349. expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
  350. expect(host.scrollTop).toBe(50)
  351. } finally {
  352. host.remove()
  353. }
  354. })
  355. it('the caret layer and the glyph layer ride one scrollport', () => {
  356. const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
  357. const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
  358. const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
  359. // The caret is the textarea's and every visible glyph is the backdrop's, so
  360. // one box has to carry both or an offset can exist in one and not the other.
  361. // jsdom has no layout and loads no stylesheet — which box scrolls is the
  362. // browser scenario's to assert; what is checkable here is that the
  363. // scrollport element holds both layers.
  364. expect(scroll.contains(textarea)).toBe(true)
  365. expect(scroll.contains(backdrop)).toBe(true)
  366. // The glyph layer carries the draft and nothing else: with one scrollport
  367. // it no longer pads its own height to match a second box's scroll extent.
  368. expect(backdrop.textContent).toBe('line\n'.repeat(40))
  369. })
  370. it('an edit the composer performs itself scrolls the caret back into view', async () => {
  371. // Paste and cut suppress the native edit, so no engine reveals the caret
  372. // for them. jsdom has no layout: the rects are stubbed,
  373. // and what is asserted is the arithmetic — minimal scroll, in both
  374. // directions, and nothing at all for a caret already inside the box.
  375. const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
  376. const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
  377. const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
  378. expect(mirror.firstChild).toBeInstanceOf(Text)
  379. scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
  380. // jsdom reports scrollHeight === clientHeight for every element, which is
  381. // the composer's own "nothing to reveal" case; a scrollable box is what
  382. // puts the reveal on the table at all.
  383. Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
  384. Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
  385. Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
  386. onTestFinished(() => {
  387. Range.prototype.getBoundingClientRect = ZERO_RECT
  388. Range.prototype.setStart = NATIVE_SET_START
  389. })
  390. // Which layer the caret is measured against, and at which index: the stub
  391. // records `setStart` so a helper that measured the backdrop instead, or
  392. // always collapsed at 0, fails here rather than only in the browser lane.
  393. let measured: { node: Node; offset: number } | null = null
  394. Range.prototype.setStart = function setStart(node: Node, offset: number): void {
  395. measured = { node, offset }
  396. NATIVE_SET_START.call(this, node, offset)
  397. }
  398. const caretAt = (top: number): void => {
  399. Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
  400. }
  401. const settle = async (): Promise<void> => {
  402. await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
  403. }
  404. // Pasted text lands below the fold: scroll down by exactly the overshoot.
  405. caretAt(500)
  406. fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
  407. await settle()
  408. expect(scroll.scrollTop).toBe(88) // 524 - 436
  409. // Measured on the mirror's own text, at the index the paste left the caret
  410. // (an empty draft's selection start, 0, plus the pasted length).
  411. expect(measured!.node).toBe(mirror.firstChild)
  412. expect(measured!.offset).toBe('pasted'.length)
  413. // A caret already inside the box does not move it.
  414. caretAt(200)
  415. fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
  416. await settle()
  417. expect(scroll.scrollTop).toBe(88)
  418. // Above the fold (a cut can leave it there): scroll back up.
  419. caretAt(60)
  420. fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
  421. await settle()
  422. expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
  423. // A caret straight after a newline has nothing on its line to measure, so
  424. // the newline it just left is measured instead and one line is added.
  425. // chromium reports no client rects at all for the collapsed position.
  426. mirror.style.lineHeight = '24px'
  427. caretAt(500)
  428. fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
  429. await settle()
  430. // The four pastes accumulate at the draft's head, so the caret is at the
  431. // end of what they inserted — and the measured index is the newline before it.
  432. expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
  433. expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
  434. })
  435. it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
  436. // The composer DOM is reused across sessions, so the previous session's
  437. // offset survives while the value swap puts the caret at the new draft's
  438. // end. `preventScroll` keeps the browser from revealing it through the
  439. // conversation scrollport, which leaves the reveal to the effect itself.
  440. const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
  441. const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
  442. const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
  443. onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
  444. scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
  445. Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
  446. Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
  447. Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
  448. Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
  449. // The draft ends in a newline, so the reveal takes the after-newline path
  450. // and needs a resolvable line-height (jsdom computes `normal`).
  451. mirror.style.lineHeight = '24px'
  452. // Which index the effect reveals at, not merely that it scrolled: a
  453. // revealCaret(0) would land the same offset without this.
  454. onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
  455. let measured: { node: Node; offset: number } | null = null
  456. Range.prototype.setStart = function setStart(node: Node, offset: number): void {
  457. measured = { node, offset }
  458. NATIVE_SET_START.call(this, node, offset)
  459. }
  460. const focused: (boolean | undefined)[] = []
  461. textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
  462. textarea.setSelectionRange(textarea.value.length, textarea.value.length)
  463. act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
  464. expect(focused).toEqual([true])
  465. expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
  466. // The draft ends in a newline, so the rule measures that newline: the
  467. // caret's own index is the mirror text's length minus its sentinel.
  468. expect(measured!.node).toBe(mirror.firstChild)
  469. expect(measured!.offset).toBe(textarea.value.length - 1)
  470. })
  471. it('a persisted draft adopted after mount gets its caret revealed too', () => {
  472. // ConversationSession seeds the stored draft in its own mount effect, which
  473. // runs after this component's: the first reveal measures an empty mirror,
  474. // so the draft's arrival has to run it again without reclaiming focus.
  475. const { view, textarea, shell } = bench()
  476. const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
  477. const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
  478. // The restored draft ends in a newline, so the reveal takes the
  479. // after-newline path and needs a resolvable line-height (jsdom says `normal`).
  480. mirror.style.lineHeight = '24px'
  481. onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
  482. scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
  483. Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
  484. Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
  485. Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
  486. Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
  487. const other = document.createElement('input')
  488. document.body.appendChild(other)
  489. onTestFinished(() => { other.remove() })
  490. other.focus()
  491. expect(scroll.scrollTop).toBe(0)
  492. act(() => { shell.setDraft('restored\n'.repeat(40)) })
  493. expect(document.activeElement).toBe(other)
  494. // The caret the machine left at the draft's end, revealed once the draft exists.
  495. expect(textarea.selectionStart).toBe(textarea.value.length)
  496. expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
  497. })
  498. it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
  499. const { textarea } = bench({ disabled: true })
  500. expect(textarea.placeholder).toBe('会话不可用')
  501. const live = bench()
  502. expect(live.textarea.placeholder).toBe('给智能体发消息')
  503. const custom = bench({ placeholder: 'Custom placeholder' })
  504. expect(custom.textarea.placeholder).toBe('Custom placeholder')
  505. })
  506. it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
  507. const active = bench({ plan: { active: true, pending: false } })
  508. expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
  509. // /plan just ran: pending entry already reads as the plan target.
  510. const entering = bench({ plan: { active: false, pending: true } })
  511. expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
  512. // Pending exit: target is default again.
  513. const leaving = bench({ plan: { active: true, pending: true } })
  514. expect(leaving.textarea.placeholder).toBe('给智能体发消息')
  515. // Owner placeholder outranks the plan swap.
  516. const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
  517. expect(custom.textarea.placeholder).toBe('Custom placeholder')
  518. })
  519. })
  520. describe('machine pending lock', () => {
  521. it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
  522. const { view, shell } = bench()
  523. // Drive the machine into submitting through a claim + enter.
  524. act(() => {
  525. shell.setDraft('/goal ')
  526. shell.beginCommand(
  527. {
  528. token: '/goal ',
  529. submit: () => new Promise<never>(() => {}), // never settles: stays submitting
  530. },
  531. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  532. )
  533. shell.submit()
  534. })
  535. expect(shell.snapshot.phase).toBe('submitting')
  536. const textarea = view.container.querySelector('textarea')!
  537. expect(textarea.readOnly).toBe(true)
  538. expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
  539. })
  540. })
  541. describe('decorations', () => {
  542. it('claimed token renders the mirror highlight and the blank-args hint', () => {
  543. // Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
  544. const { view, shell } = bench({ t: makeTranslate({}) })
  545. act(() => {
  546. shell.setDraft('/goal ')
  547. shell.beginCommand(
  548. { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
  549. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  550. )
  551. })
  552. const token = view.container.querySelector('[data-decoration="token"]')
  553. expect(token?.textContent).toBe('/goal ')
  554. expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
  555. // Args typed: the hint disappears, the token highlight stays.
  556. act(() => { shell.setDraft('/goal 发布') })
  557. expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
  558. expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
  559. })
  560. it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
  561. const { view, shell } = bench()
  562. act(() => {
  563. shell.setDraft('/goal ')
  564. shell.beginCommand(
  565. { token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
  566. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  567. )
  568. })
  569. expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
  570. })
  571. it('an inserted reference renders as a chip at its placeholder offset', () => {
  572. const { view, shell } = bench()
  573. act(() => {
  574. shell.setDraft('参考 @w1 内容')
  575. shell.insertReference(
  576. { source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' },
  577. { start: 3, end: 6, draftRev: shell.snapshot.draftRev },
  578. )
  579. })
  580. const chip = view.container.querySelector('[data-decoration="chip"]')
  581. expect(chip?.textContent).toBe('@w1')
  582. expect(shell.snapshot.occurrences).toHaveLength(1)
  583. // The draft carries exactly one placeholder char where the token was.
  584. expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
  585. })
  586. it('a lexicon-matched plain token renders the text-ref mark (decision 21)', () => {
  587. const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
  588. const { view, shell } = bench({ lexicon })
  589. act(() => { shell.setDraft('use /fixture-demo now') })
  590. const mark = view.container.querySelector('[data-decoration="text-ref"]')
  591. expect(mark?.textContent).toBe('/fixture-demo')
  592. // Editing the token out of match shape drops the decoration.
  593. act(() => { shell.setDraft('use /fixture-dem now') })
  594. expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
  595. })
  596. })
  597. describe('insertText (decision 21 scoped event body)', () => {
  598. it('splices plain text over the span and reports success as true', () => {
  599. const { shell } = bench({ draft: '/fix' })
  600. const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
  601. expect(ok).toBe(true)
  602. expect(shell.snapshot.draft).toBe('/fixture-demo ')
  603. expect(shell.snapshot.occurrences).toEqual([])
  604. })
  605. it('a stale draftRev refuses whole: false, draft untouched', () => {
  606. const { shell } = bench({ draft: '/fix' })
  607. const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
  608. act(() => { shell.setDraft('/fixX') })
  609. expect(shell.insertText('/fixture-demo ', span)).toBe(false)
  610. expect(shell.snapshot.draft).toBe('/fixX')
  611. })
  612. })
  613. describe('strips and variants', () => {
  614. it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
  615. const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
  616. expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
  617. expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
  618. })
  619. it('renders the notice strip from the machine notice store', () => {
  620. const { view, shell } = bench()
  621. act(() => { shell.notify('error', '命令失败了') })
  622. expect(view.getByText('命令失败了')).toBeTruthy()
  623. })
  624. it('hero variant adds the hero class and accessory row renders', () => {
  625. const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
  626. expect(view.getByTestId('acc')).toBeTruthy()
  627. expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
  628. })
  629. it('renders overlay anchor and left/right slot items', () => {
  630. const { view } = bench({
  631. overlay: <i data-testid="ov" />,
  632. leftItems: <i data-testid="li" />,
  633. rightItems: <i data-testid="ri" />,
  634. })
  635. expect(view.getByTestId('ov')).toBeTruthy()
  636. expect(view.getByTestId('li')).toBeTruthy()
  637. expect(view.getByTestId('ri')).toBeTruthy()
  638. })
  639. })
  640. describe('command launcher chrome and control seats', () => {
  641. it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries (B ruling)', () => {
  642. const { view, slotCalls } = bench()
  643. expect(view.getByLabelText('命令')).toBeTruthy()
  644. // Capability absent (no projection value): the chip renders nothing.
  645. expect(view.queryByLabelText(/^访问模式/)).toBeNull()
  646. // Every seat dispatched, nothing rendered.
  647. expect(slotCalls.map(c => c.key)).toEqual([
  648. 'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model',
  649. ])
  650. expect(view.queryByLabelText('Plan mode')).toBeNull()
  651. expect(view.queryByLabelText('Model')).toBeNull()
  652. })
  653. it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
  654. const toggleCommandMenu = vi.fn()
  655. const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
  656. textarea.setSelectionRange(2, 7)
  657. const launcher = view.getByLabelText('命令')
  658. expect(launcher.getAttribute('aria-expanded')).toBe('false')
  659. fireEvent.click(launcher)
  660. expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
  661. act(() => { menuLauncher.set('command') })
  662. expect(launcher.getAttribute('aria-expanded')).toBe('true')
  663. })
  664. it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
  665. const command = vi.fn(() => Promise.resolve(true))
  666. const permissions = {
  667. options: [
  668. { value: 'read-only', name: 'read-only' },
  669. { value: 'workspace-write', name: 'workspace-write' },
  670. { value: 'danger-full-access', name: 'danger-full-access' },
  671. ],
  672. currentValue: 'read-only',
  673. }
  674. const { view } = bench({ permissions, command })
  675. const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
  676. // Title-case display is presentation only; the menu ids stay machine names.
  677. expect(trigger.textContent).toBe('Read Only')
  678. expect([...trigger.querySelectorAll('svg')]
  679. .every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
  680. fireEvent.click(trigger)
  681. const items = view.getAllByRole('menuitem')
  682. expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
  683. fireEvent.click(items[1]!)
  684. // Optimistic pick + disable until admission resolves (command stub resolves true).
  685. const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
  686. expect(busy.textContent).toBe('Workspace Write')
  687. expect(busy.disabled).toBe(true)
  688. expect(command).toHaveBeenCalledWith('/permission workspace-write')
  689. await act(async () => {})
  690. expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
  691. })
  692. it('requires explicit risk acknowledgement before submitting Full access', async () => {
  693. const command = vi.fn(() => Promise.resolve(true))
  694. const permissions = {
  695. options: [
  696. { value: 'workspace-write', name: 'workspace-write' },
  697. { value: 'danger-full-access', name: 'danger-full-access' },
  698. ],
  699. currentValue: 'workspace-write',
  700. }
  701. const { view } = bench({ permissions, command })
  702. fireEvent.click(view.getByLabelText(/^访问模式/))
  703. fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
  704. expect(command).not.toHaveBeenCalled()
  705. expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy()
  706. const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
  707. expect(enable.disabled).toBe(true)
  708. fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
  709. expect(enable.disabled).toBe(false)
  710. fireEvent.click(enable)
  711. expect(command).toHaveBeenCalledOnce()
  712. expect(command).toHaveBeenCalledWith('/permission danger-full-access')
  713. expect(view.queryByRole('dialog')).toBeNull()
  714. expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
  715. await act(async () => {})
  716. })
  717. it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
  718. const command = vi.fn(() => Promise.resolve(true))
  719. const permissions = {
  720. options: [
  721. { value: 'workspace-write', name: 'workspace-write' },
  722. { value: 'danger-full-access', name: 'danger-full-access' },
  723. ],
  724. currentValue: 'workspace-write',
  725. }
  726. const { view } = bench({ permissions, command })
  727. const openConfirmation = () => {
  728. fireEvent.click(view.getByLabelText(/^访问模式/))
  729. fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
  730. }
  731. openConfirmation()
  732. fireEvent.click(view.getByRole('checkbox'))
  733. fireEvent.click(view.getByRole('button', { name: '取消' }))
  734. expect(command).not.toHaveBeenCalled()
  735. expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
  736. openConfirmation()
  737. expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
  738. expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
  739. })
  740. it('revokes an open Full access confirmation when the task locks', () => {
  741. const command = vi.fn(() => Promise.resolve(true))
  742. const permissions = {
  743. options: [
  744. { value: 'workspace-write', name: 'workspace-write' },
  745. { value: 'danger-full-access', name: 'danger-full-access' },
  746. ],
  747. currentValue: 'workspace-write',
  748. }
  749. const { view, session } = bench({ permissions, command })
  750. fireEvent.click(view.getByLabelText(/^访问模式/))
  751. fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
  752. fireEvent.click(view.getByRole('checkbox'))
  753. act(() => { session.set(snapshotOf({ removed: true })) })
  754. expect(view.queryByRole('dialog')).toBeNull()
  755. expect(command).not.toHaveBeenCalled()
  756. })
  757. it('resets an open Full access confirmation when switching tasks', () => {
  758. const command = vi.fn(() => Promise.resolve(true))
  759. const permissions = {
  760. options: [
  761. { value: 'workspace-write', name: 'workspace-write' },
  762. { value: 'danger-full-access', name: 'danger-full-access' },
  763. ],
  764. currentValue: 'workspace-write',
  765. }
  766. const { view, props } = bench({ permissions, command })
  767. fireEvent.click(view.getByLabelText(/^访问模式/))
  768. fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
  769. fireEvent.click(view.getByRole('checkbox'))
  770. view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
  771. expect(view.queryByRole('dialog')).toBeNull()
  772. expect(command).not.toHaveBeenCalled()
  773. })
  774. it('a registered entry fills its seat and receives the locked owner prop', () => {
  775. const { view, slotCalls } = bench({
  776. disabled: true,
  777. planEntry: <i data-testid="plan-entry" />,
  778. modelEntry: <i data-testid="model-entry" />,
  779. })
  780. expect(view.getByTestId('plan-entry')).toBeTruthy()
  781. expect(view.getByTestId('model-entry')).toBeTruthy()
  782. // The bar hands its chrome disable state to the filling entry.
  783. expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
  784. cleanup()
  785. const live = bench({ running: true })
  786. expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
  787. })
  788. it('disabled locks the Access chip and command launcher (running does not)', () => {
  789. const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
  790. const { view } = bench({ disabled: true, permissions })
  791. expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
  792. expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
  793. cleanup()
  794. const live = bench({ running: true, permissions })
  795. expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
  796. })
  797. })