feat: improve chat input, themed menus and agent proposal cancellation

This commit is contained in:
2026-10-19 13:12:04 +09:00
parent 105342fbf3
commit 7b32668e1d
18 changed files with 641 additions and 94 deletions
+81
View File
@@ -0,0 +1,81 @@
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')
const { renderToStaticMarkup } = require('react-dom/server')
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
}
test('custom menu clipboard IPC validates the main frame and rechecks paste availability', () => {
let clipboard = ''
let pastes = 0
const frame = {}
const sender = { mainFrame: frame, isDestroyed: () => false, paste() { pastes++ } }
const event = { sender, senderFrame: frame }
const native = compile('../src/main/chatTextMenu.ts', {
electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard, writeText: text => { clipboard = text } } }
})
assert.equal(native.readChatClipboardState(event).hasText, false)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'selected text' }), true)
assert.equal(clipboard, 'selected text')
assert.equal(native.readChatClipboardState(event).hasText, true)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), true)
assert.equal(pastes, 1)
clipboard = ''
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(pastes, 1)
event.senderFrame = {}
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'blocked' }), false)
assert.equal(native.readChatClipboardState(event).hasText, false)
event.senderFrame = frame
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: '' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'x'.repeat(5_000_001) }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'unknown' }), false)
})
function renderMenu({ dark, kind = 'message', text = '', hasText = false, readOnly = false }) {
const menu = { kind, x: 20, y: 30, text, input: { readOnly, disabled: false } }
let state = 0
const { ChatTextContextMenu } = compile('../src/renderer/src/components/ChatTextContextMenu.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} },
'react-dom': { createPortal: child => child },
react: { ...React, useEffect() {}, useLayoutEffect() {}, useRef: initial => ({ current: initial }), useState() { return [state++ === 0 ? { menu, hasText } : { x: 20, y: 30 }, () => {}] } }
}, { document: { body: {} } })
return ChatTextContextMenu({ dark, menu, onClose() {}, onClear() {}, canClear: true })
}
for (const dark of [true, false]) {
test(`chat menu uses the terminal menu surface and item metrics (${dark ? 'dark' : 'light'})`, () => {
const tree = renderMenu({ dark, text: 'selected' })
const html = renderToStaticMarkup(tree)
assert.match(html, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(html, dark ? /enabled:hover:bg-\[#37373d\]/ : /enabled:hover:bg-\[#eef1f7\]/)
assert.match(html, /rounded-md border py-1 text-\[12px\] font-medium/)
assert.match(html, /flex h-8 w-full cursor-pointer/)
assert.match(html, /role="menu"/)
assert.match(html, /role="menuitem"/)
assert.match(html, /대화내역 삭제/)
assert.doesNotMatch(html, /붙여넣기/)
})
}
for (const hasText of [true, false]) {
for (const readOnly of [true, false]) {
test(`paste disabled state follows clipboard and input editability (clipboard=${hasText}, readOnly=${readOnly})`, () => {
const tree = renderMenu({ dark: true, kind: 'input', hasText, readOnly })
const buttons = React.Children.toArray(tree.props.children).filter(child => child.type === 'button')
assert.equal(buttons[0].props.disabled, true)
assert.equal(buttons[1].props.disabled, !hasText || readOnly)
assert.doesNotMatch(renderToStaticMarkup(tree), /대화내역 삭제/)
})
}
}