feat: improve chat input, themed menus and agent proposal cancellation
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
|
||||
function harness() {
|
||||
let layoutEffects = []
|
||||
let effects = []
|
||||
let resizeCallback
|
||||
let disconnected = false
|
||||
const states = []
|
||||
const refs = []
|
||||
let stateIndex = 0
|
||||
let refIndex = 0
|
||||
const submissions = []
|
||||
const input = {
|
||||
style: {}, scrollHeight: 56,
|
||||
getBoundingClientRect: () => ({ width: 300 })
|
||||
}
|
||||
const exports = {}
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||
vm.runInNewContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, {
|
||||
exports,
|
||||
window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) },
|
||||
ResizeObserver: class {
|
||||
constructor(callback) { resizeCallback = callback }
|
||||
observe(element) { assert.equal(element, input) }
|
||||
disconnect() { disconnected = true }
|
||||
},
|
||||
require(name) {
|
||||
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||
if (name === './chatAttachments') return {}
|
||||
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
|
||||
if (name === 'react') return {
|
||||
...React,
|
||||
useState(initial) {
|
||||
const index = stateIndex++
|
||||
if (!(index in states)) states[index] = initial
|
||||
return [states[index], value => { states[index] = value }]
|
||||
},
|
||||
useRef(initial) { return refs[refIndex++] ??= { current: initial } },
|
||||
useEffect(callback) { effects.push(callback) },
|
||||
useLayoutEffect(callback) { layoutEffects.push(callback) }
|
||||
}
|
||||
return require(name)
|
||||
}
|
||||
})
|
||||
function find(node, type) {
|
||||
if (!React.isValidElement(node)) return null
|
||||
if (node.type === type) return node
|
||||
return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean)
|
||||
}
|
||||
function render() {
|
||||
stateIndex = 0
|
||||
refIndex = 0
|
||||
layoutEffects = []
|
||||
effects = []
|
||||
const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) })
|
||||
const textarea = find(tree, 'textarea')
|
||||
textarea.props.ref.current = input
|
||||
layoutEffects.forEach(callback => callback())
|
||||
return textarea
|
||||
}
|
||||
return {
|
||||
input, render, submissions,
|
||||
mountEffects: () => effects.map(callback => callback()),
|
||||
resize: width => resizeCallback([{ contentRect: { width } }]),
|
||||
disconnected: () => disconnected
|
||||
}
|
||||
}
|
||||
|
||||
test('input grows to eight visible lines and scrolls only above the cap', () => {
|
||||
const h = harness()
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '56px')
|
||||
assert.equal(h.input.style.overflowY, 'hidden')
|
||||
h.input.scrollHeight = 100
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '100px')
|
||||
h.input.scrollHeight = 180
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '180px')
|
||||
assert.equal(h.input.style.overflowY, 'hidden')
|
||||
h.input.scrollHeight = 300
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '180px')
|
||||
assert.equal(h.input.style.overflowY, 'auto')
|
||||
})
|
||||
|
||||
test('deleting text and submitting restore the initial height', () => {
|
||||
const h = harness()
|
||||
let textarea = h.render()
|
||||
textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } })
|
||||
h.input.scrollHeight = 80
|
||||
textarea = h.render()
|
||||
assert.equal(h.input.style.height, '80px')
|
||||
let prevented = false
|
||||
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } })
|
||||
assert.equal(prevented, true)
|
||||
assert.equal(h.submissions.length, 1)
|
||||
h.input.scrollHeight = 40
|
||||
textarea = h.render()
|
||||
assert.equal(textarea.props.value, '')
|
||||
assert.equal(h.input.style.height, '56px')
|
||||
assert.equal(h.input.style.overflowY, 'hidden')
|
||||
})
|
||||
|
||||
test('Shift+Enter and IME composition do not submit', () => {
|
||||
const h = harness()
|
||||
let textarea = h.render()
|
||||
textarea.props.onChange({ target: { value: 'question' } })
|
||||
textarea = h.render()
|
||||
const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted')
|
||||
textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault })
|
||||
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault })
|
||||
assert.equal(h.submissions.length, 0)
|
||||
})
|
||||
|
||||
test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => {
|
||||
const h = harness()
|
||||
h.render()
|
||||
const cleanups = h.mountEffects()
|
||||
h.input.scrollHeight = 120
|
||||
h.resize(180)
|
||||
assert.equal(h.input.style.height, '120px')
|
||||
h.input.scrollHeight = 50
|
||||
h.resize(400)
|
||||
assert.equal(h.input.style.height, '56px')
|
||||
cleanups.forEach(cleanup => cleanup?.())
|
||||
assert.equal(h.disconnected(), true)
|
||||
})
|
||||
Reference in New Issue
Block a user