feat: add structured agent tools and improve workspace chat UX
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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 compile(file, overrides = {}, globals = {}) {
|
||||
const exports = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||
return exports
|
||||
}
|
||||
|
||||
function nativeMenu() {
|
||||
let template
|
||||
let close
|
||||
let clipboard = ''
|
||||
let copies = 0
|
||||
let pastes = 0
|
||||
const frame = {}
|
||||
const sender = { mainFrame: frame, isDestroyed: () => false, copy() { copies++ }, paste() { pastes++ } }
|
||||
const event = { sender, senderFrame: frame }
|
||||
const { showChatTextMenu } = compile('../src/main/chatTextMenu.ts', {
|
||||
electron: {
|
||||
BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) },
|
||||
clipboard: { readText: () => clipboard },
|
||||
Menu: { buildFromTemplate(items) {
|
||||
template = items
|
||||
return { popup(options) { close = options.callback } }
|
||||
} }
|
||||
}
|
||||
})
|
||||
return {
|
||||
show: options => showChatTextMenu(event, options),
|
||||
items: () => template,
|
||||
close: () => close(),
|
||||
setClipboard: text => { clipboard = text },
|
||||
copies: () => copies, pastes: () => pastes, event
|
||||
}
|
||||
}
|
||||
|
||||
const labels = { copy: '복사', paste: '붙여넣기', clear: '대화내역 삭제' }
|
||||
|
||||
for (const canCopy of [false, true]) {
|
||||
test(`message copy is enabled only with a selection (selected=${canCopy})`, async () => {
|
||||
const menu = nativeMenu()
|
||||
const result = menu.show({ kind: 'message', canCopy, canClear: true, labels })
|
||||
assert.equal(menu.items()[0].enabled, canCopy)
|
||||
assert.equal(menu.items()[0].label, '복사')
|
||||
assert.equal(menu.items().some(item => item.label === '붙여넣기'), false)
|
||||
menu.items()[0].click()
|
||||
assert.equal(await result, null)
|
||||
assert.equal(menu.copies(), canCopy ? 1 : 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const clipboard of ['', '복사해 둔 내용']) {
|
||||
test(`input paste availability is read from the OS clipboard (empty=${!clipboard})`, async () => {
|
||||
const menu = nativeMenu()
|
||||
menu.setClipboard(clipboard)
|
||||
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
|
||||
assert.equal(menu.items().length, 2)
|
||||
assert.equal(menu.items()[0].enabled, false)
|
||||
assert.equal(menu.items()[1].label, '붙여넣기')
|
||||
assert.equal(menu.items()[1].enabled, Boolean(clipboard))
|
||||
menu.items()[1].click()
|
||||
await result
|
||||
assert.equal(menu.pastes(), clipboard ? 1 : 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('read-only input never allows paste, even with clipboard text', async () => {
|
||||
const menu = nativeMenu()
|
||||
menu.setClipboard('text')
|
||||
const result = menu.show({ kind: 'input', canCopy: true, canPaste: false, labels })
|
||||
assert.equal(menu.items()[1].enabled, false)
|
||||
menu.items()[1].click()
|
||||
await result
|
||||
assert.equal(menu.pastes(), 0)
|
||||
})
|
||||
|
||||
test('paste rechecks an emptied clipboard before editing the input', async () => {
|
||||
const menu = nativeMenu()
|
||||
menu.setClipboard('text')
|
||||
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
|
||||
assert.equal(menu.items()[1].enabled, true)
|
||||
menu.setClipboard('')
|
||||
menu.items()[1].click()
|
||||
await result
|
||||
assert.equal(menu.pastes(), 0)
|
||||
})
|
||||
|
||||
test('delete selection returns a confirmation request rather than deleting data', async () => {
|
||||
const menu = nativeMenu()
|
||||
const result = menu.show({ kind: 'message', canCopy: false, canClear: true, labels })
|
||||
menu.items().find(item => item.label === '대화내역 삭제').click()
|
||||
assert.equal(await result, 'clear')
|
||||
})
|
||||
|
||||
test('closing a menu without choosing an action does nothing', async () => {
|
||||
const menu = nativeMenu()
|
||||
const result = menu.show({ kind: 'message', canCopy: true, labels })
|
||||
menu.close()
|
||||
assert.equal(await result, null)
|
||||
assert.equal(menu.copies(), 0)
|
||||
})
|
||||
|
||||
test('untrusted frames and invalid menu targets cannot open a native menu', () => {
|
||||
const menu = nativeMenu()
|
||||
assert.equal(menu.show({ kind: 'unknown', canCopy: true, labels }), null)
|
||||
menu.event.senderFrame = {}
|
||||
assert.equal(menu.show({ kind: 'input', canCopy: true, labels }), null)
|
||||
})
|
||||
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return []
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`textarea opens the menu with native selection and editability (selected=${selected})`, async () => {
|
||||
let options
|
||||
let prevented = false
|
||||
let stopped = false
|
||||
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
|
||||
react: { ...React, useState: initial => [initial, () => {}], useEffect() {}, useRef: () => ({ current: null }) }
|
||||
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
|
||||
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
|
||||
const textarea = elements(tree, node => node.type === 'textarea')[0]
|
||||
textarea.props.onContextMenu({
|
||||
currentTarget: { selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
|
||||
preventDefault() { prevented = true }, stopPropagation() { stopped = true }
|
||||
})
|
||||
await Promise.resolve()
|
||||
assert.equal(options.kind, 'input')
|
||||
assert.equal(options.canCopy, selected)
|
||||
assert.equal(options.canPaste, true)
|
||||
assert.equal(options.labels.paste, '붙여넣기')
|
||||
assert.equal(prevented, true)
|
||||
assert.equal(stopped, true)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user