From 7b32668e1d5c41f1938cc86dd70f8303dfe3195d Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 19 Oct 2026 13:12:04 +0900 Subject: [PATCH] feat: improve chat input, themed menus and agent proposal cancellation --- scripts/test-agent-plan-cancel.cjs | 145 ++++++++++++++++++ scripts/test-agent-response-reveal.cjs | 6 +- scripts/test-ai-response-presentation.cjs | 2 +- scripts/test-chat-clear-confirmation.cjs | 24 +-- scripts/test-chat-context-menu-theme.cjs | 81 ++++++++++ scripts/test-chat-input-autosize.cjs | 135 ++++++++++++++++ scripts/test-chat-text-menu.cjs | 14 +- src/main/chatTextMenu.ts | 25 ++- src/main/index.ts | 4 +- src/preload/index.ts | 4 +- src/renderer/src/App.tsx | 9 +- .../src/components/ChatTextContextMenu.tsx | 106 +++++++++++++ src/renderer/src/components/ChatWidgets.tsx | 63 ++++++-- src/renderer/src/env.d.ts | 2 + .../features/workspace/WorkspaceQaPanel.tsx | 86 ++++------- .../src/features/workspace/useAiAgent.ts | 26 +++- src/renderer/src/i18n/en.json | 1 + src/shared/chatTextMenu.ts | 2 + 18 files changed, 641 insertions(+), 94 deletions(-) create mode 100644 scripts/test-agent-plan-cancel.cjs create mode 100644 scripts/test-chat-context-menu-theme.cjs create mode 100644 scripts/test-chat-input-autosize.cjs create mode 100644 src/renderer/src/components/ChatTextContextMenu.tsx diff --git a/scripts/test-agent-plan-cancel.cjs b/scripts/test-agent-plan-cancel.cjs new file mode 100644 index 0000000..048d5cf --- /dev/null +++ b/scripts/test-agent-plan-cancel.cjs @@ -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(source, globals) { + const exports = {} + vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } + }).outputText, { exports, require, ...globals }) + return exports +} +const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) +const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent') +const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource))) +const proposal = (extra = {}) => ({ + id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready', + plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null, + changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }], + commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] }, + commandExecutions: { build: { status: 'pending' } }, ...extra +}) +function harness(message) { + const session = {} + const messageStateRef = { current: { session, messages: [message] } } + const applyingRef = { current: false } + const abortControllerRef = { current: null } + const saved = [] + const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), { + session, messageStateRef, applyingRef, abortControllerRef, + updateMessages(update) { + const next = update(messageStateRef.current.messages) + messageStateRef.current.messages = next + saved.push(JSON.parse(JSON.stringify(next))) + } + }) + return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] } +} + +test('cancel marks the proposal and unfinished commands without applying files', async () => { + const h = harness(proposal({ applyError: 'old error' })) + assert.equal(h.cancelPlan('proposal'), true) + assert.equal(h.current().applyState, 'cancelled') + assert.equal(h.current().applyError, undefined) + assert.equal(h.current().commandExecutions.build.status, 'rejected') + assert.equal(h.saved.at(-1)[0].applyState, 'cancelled') + let applied = false + await h.applyPlan('proposal', async () => { applied = true }) + assert.equal(applied, false) + assert.equal(h.cancelPlan('proposal'), false) +}) + +test('cancel preserves existing successful command results and rejects failed commands', () => { + const message = proposal() + message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' }) + message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } } + const h = harness(message) + h.cancelPlan('proposal') + assert.equal(h.current().commandExecutions.inspect.status, 'completed') + assert.equal(h.current().commandExecutions.inspect.output, 'done') + assert.equal(h.current().commandExecutions.build.status, 'rejected') + assert.equal(h.current().commandExecutions.build.output, 'build failed') +}) + +for (const applyState of ['applying', 'applied', 'cancelled']) { + test(`cannot cancel a proposal in ${applyState} state`, () => { + const h = harness(proposal({ applyState })) + assert.equal(h.cancelPlan('proposal'), false) + assert.equal(h.saved.length, 0) + }) +} + +test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => { + for (const setup of [ + h => { h.current().commandExecutions.build.status = 'running' }, + h => { h.applyingRef.current = true }, + h => { h.abortControllerRef.current = {} }, + h => { h.messageStateRef.current.session = {} } + ]) { + const h = harness(proposal()) + setup(h) + assert.equal(h.cancelPlan('proposal'), false) + assert.equal(h.saved.length, 0) + } +}) + +test('an apply already started cannot be cancelled and completes normally', async () => { + const h = harness(proposal()) + let finish + const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve })) + assert.equal(h.current().applyState, 'applying') + assert.equal(h.cancelPlan('proposal'), false) + finish() + await applying + assert.equal(h.current().applyState, 'applied') +}) + +test('new module proposals can be cancelled without creating anything', () => { + const message = proposal() + message.plan.changes = [] + message.plan.createModule = { name: 'Example' } + const h = harness(message) + assert.equal(h.cancelPlan('proposal'), true) + assert.equal(h.current().applyState, 'cancelled') +}) + +function elements(root, predicate) { + if (!React.isValidElement(root)) return [] + return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))] +} +function text(root) { + if (typeof root === 'string') return root + return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : '' +} +const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, { + ...require('lucide-react'), t: key => key, useLocaleRender() {}, + ProgressiveChatResponse: () => null, MessageCopyButton: () => null, + agentMessageCopyText: message => message.content +}).AgentMessages + +for (const dark of [true, false]) { + test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => { + let cancelled + const props = { dark, enabled: true, statusLoading: false, pending: false, + messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} } + let tree = renderAgent(props) + let buttons = elements(tree, node => node.type === 'button') + const cancel = buttons.find(button => text(button) === '취소') + const apply = buttons.find(button => text(button) === '변경 적용') + assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply)) + const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0] + assert.match(cancelLabel.props.className, /top-px leading-none/) + cancel.props.onClick() + assert.equal(cancelled, 'proposal') + const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0] + assert.equal(busyCancel.props.disabled, true) + tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] }) + buttons = elements(tree, node => node.type === 'button') + assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false) + assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true) + }) +} diff --git a/scripts/test-agent-response-reveal.cjs b/scripts/test-agent-response-reveal.cjs index 4e0aeae..1002220 100644 --- a/scripts/test-agent-response-reveal.cjs +++ b/scripts/test-agent-response-reveal.cjs @@ -72,7 +72,7 @@ test('long answers are grouped into at most twenty steps and finish within five test('only the newly appended paragraph gets the fade class', () => { const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {} + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {} }) const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.') const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL) @@ -159,7 +159,7 @@ for (const dark of [false, true]) { test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => { let expanded const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, react: { ...React, useEffect() {}, useState(initial) { if (expanded === undefined) expanded = initial return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }] @@ -183,7 +183,7 @@ for (const dark of [false, true]) { test('short answers do not show an unnecessary collapse button', () => { const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] } }) const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true }) diff --git a/scripts/test-ai-response-presentation.cjs b/scripts/test-ai-response-presentation.cjs index 3eed7ea..3ac0d63 100644 --- a/scripts/test-ai-response-presentation.cjs +++ b/scripts/test-ai-response-presentation.cjs @@ -16,7 +16,7 @@ function compile(source, overrides = {}, globals = {}) { } const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {} + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {} }) const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text)) diff --git a/scripts/test-chat-clear-confirmation.cjs b/scripts/test-chat-clear-confirmation.cjs index 2f18f79..58a738b 100644 --- a/scripts/test-chat-clear-confirmation.cjs +++ b/scripts/test-chat-clear-confirmation.cjs @@ -19,6 +19,7 @@ function compile(file, overrides = {}, globals = {}) { const i18n = { t: key => key, useLocaleRender() {} } const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n }) const QaInputBox = () => null +const ChatTextContextMenu = () => null function elements(root, predicate) { if (!React.isValidElement(root)) return [] @@ -36,6 +37,7 @@ function setup(overrides = {}, browser = {}) { '../../components/AppStatusModals': { ConfirmStatusModal }, '../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null }, '../../components/ChatWidgets': { QaInputBox }, + '../../components/ChatTextContextMenu': { ChatTextContextMenu }, 'react-dom': { createPortal: child => child }, react: { ...React, @@ -84,19 +86,18 @@ test('context menu deletion also requests confirmation', () => { const root = harness.render() const area = elements(root, node => Boolean(node.props.onContextMenu))[0] area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 }) - const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0] - clear.props.onClick() + const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0] + menu.props.onClear() assert.equal(harness.cleared(), 0) assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1) }) for (const [selected, inside] of [[false, true], [true, true], [true, false]]) { - test(`native chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => { - let request + test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => { const node = {} const harness = setup({}, { getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null, - chatTextMenu: { show: async options => { request = options; return null } } + chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') } }) const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0] area.props.onContextMenu({ @@ -104,18 +105,21 @@ for (const [selected, inside] of [[false, true], [true, true], [true, false]]) { preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 }) await Promise.resolve() - assert.equal(request.kind, 'message') - assert.equal(request.canCopy, selected && inside) - assert.equal(request.canClear, true) + const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0] + assert.equal(menu.props.menu.kind, 'message') + assert.equal(Boolean(menu.props.menu.text), selected && inside) + assert.equal(menu.props.canClear, true) assert.equal(harness.cleared(), 0) }) } -test('native menu deletion still goes through the shared confirmation dialog', async () => { - const harness = setup({}, { chatTextMenu: { show: async () => 'clear' } }) +test('themed menu deletion still goes through the shared confirmation dialog', async () => { + const harness = setup() const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0] area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} }) await Promise.resolve() + const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0] + menu.props.onClear() assert.equal(harness.cleared(), 0) const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0] assert.ok(modal) diff --git a/scripts/test-chat-context-menu-theme.cjs b/scripts/test-chat-context-menu-theme.cjs new file mode 100644 index 0000000..15bb840 --- /dev/null +++ b/scripts/test-chat-context-menu-theme.cjs @@ -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), /대화내역 삭제/) + }) + } +} diff --git a/scripts/test-chat-input-autosize.cjs b/scripts/test-chat-input-autosize.cjs new file mode 100644 index 0000000..29d0a55 --- /dev/null +++ b/scripts/test-chat-input-autosize.cjs @@ -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) +}) diff --git a/scripts/test-chat-text-menu.cjs b/scripts/test-chat-text-menu.cjs index fe36c4a..815c8bc 100644 --- a/scripts/test-chat-text-menu.cjs +++ b/scripts/test-chat-text-menu.cjs @@ -120,25 +120,25 @@ function elements(root, predicate) { } for (const selected of [false, true]) { - test(`textarea opens the menu with native selection and editability (selected=${selected})`, async () => { + test(`textarea opens the themed menu with a selection snapshot (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 }) } + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, + react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, 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 }, + currentTarget: { value: 'abcdef', 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(options.text, selected ? 'cde' : '') + assert.equal(options.input.disabled, false) + assert.equal(options.selectionStart, 2) assert.equal(prevented, true) assert.equal(stopped, true) }) diff --git a/src/main/chatTextMenu.ts b/src/main/chatTextMenu.ts index 3f691a0..36edbe1 100644 --- a/src/main/chatTextMenu.ts +++ b/src/main/chatTextMenu.ts @@ -1,6 +1,29 @@ import { BrowserWindow, clipboard, Menu } from 'electron' import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron' -import type { ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu' +import type { ChatClipboardAction, ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu' + +function trustedChatFrame(event: IpcMainInvokeEvent): boolean { + if (event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return false + const window = BrowserWindow.fromWebContents(event.sender) + return Boolean(window && !window.isDestroyed()) +} + +export function readChatClipboardState(event: IpcMainInvokeEvent): { hasText: boolean } { + return { hasText: trustedChatFrame(event) && clipboard.readText().length > 0 } +} + +export function performChatClipboardAction(event: IpcMainInvokeEvent, input: ChatClipboardAction): boolean { + if (!trustedChatFrame(event) || !input) return false + if (input.action === 'copy' && typeof input.text === 'string' && input.text.length > 0 && input.text.length <= 5_000_000) { + clipboard.writeText(input.text) + return true + } + if (input.action === 'paste' && clipboard.readText().length > 0) { + event.sender.paste() + return true + } + return false +} export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise | null { if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null diff --git a/src/main/index.ts b/src/main/index.ts index 9bb6e3e..9de3cdc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { appLog } from './appLogging' import { checkDockerStatus } from './dockerStatus' -import { showChatTextMenu } from './chatTextMenu' +import { performChatClipboardAction, readChatClipboardState, showChatTextMenu } from './chatTextMenu' import { cameraRuntimeArtifacts } from './cameraRuntime' import { remoteModuleFilesScript } from './remoteModuleFiles' import { applyAgentFiles, runAgentFileTool } from './agentFileTools' @@ -7037,6 +7037,8 @@ app.whenReady().then(() => { }) ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics()) ipcMain.handle('chat-text:context-menu', showChatTextMenu) + ipcMain.handle('chat-text:clipboard-state', readChatClipboardState) + ipcMain.handle('chat-text:clipboard-action', performChatClipboardAction) ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close()) ipcMain.on('app-quit', () => app.quit()) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) diff --git a/src/preload/index.ts b/src/preload/index.ts index e16879d..e73e07d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -118,7 +118,9 @@ console.info('[lms-preload] launcher auth resolved', { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('chatTextMenu', { - show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options) + show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options), + readClipboardState: () => ipcRenderer.invoke('chat-text:clipboard-state'), + perform: (action: import('../shared/chatTextMenu').ChatClipboardAction) => ipcRenderer.invoke('chat-text:clipboard-action', action) }) contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) contextBridge.exposeInMainWorld('lmsBootSplash', { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2e353c5..6c52553 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -634,6 +634,7 @@ function LocalPcWorkspaceMockup({ persistenceError: agentPersistenceError, requestPlan: requestAgentPlan, applyPlan: applyAgentPlan, + cancelPlan: cancelAgentPlan, updateCommandExecution: updateAgentCommandExecution, stop: stopAgent, clear: clearAgentConversation @@ -3728,7 +3729,7 @@ function LocalPcWorkspaceMockup({ const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record; runtimeSettings: string }): Promise => { if (agentActionControllerRef.current || agentPending || agentApplying) return const message = agentMessages.find(item => item.id === messageId) - if (!message?.plan || !command.operation) return + if (!message?.plan || message.applyState === 'cancelled' || !command.operation) return const controller = new AbortController() agentActionControllerRef.current = controller setAgentActionBusy(true) @@ -3800,7 +3801,7 @@ function LocalPcWorkspaceMockup({ if (agentActionControllerRef.current || agentPending) return const message = agentMessages.find((item) => item.id === messageId) const currentExecution = message?.commandExecutions?.[command.id] - if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return + if (!message?.plan || message.applyState === 'cancelled' || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return const requiresAppliedChanges = message.plan.changes.length > 0 || message.plan.createModule !== null if (requiresAppliedChanges && message.applyState !== 'applied') { onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error') @@ -4743,6 +4744,10 @@ function LocalPcWorkspaceMockup({ onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)} onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)} onRejectAgentCommand={rejectWorkspaceAgentCommand} + onCancelAgentPlan={messageId => { + if (agentActionControllerRef.current || agentPending) return + if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId) + }} onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => { await applyWorkspaceAgentPlan(plan) closeAgentDiffPreviewsForMessage(messageId) diff --git a/src/renderer/src/components/ChatTextContextMenu.tsx b/src/renderer/src/components/ChatTextContextMenu.tsx new file mode 100644 index 0000000..708cd4e --- /dev/null +++ b/src/renderer/src/components/ChatTextContextMenu.tsx @@ -0,0 +1,106 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ClipboardPaste, Copy, Trash2 } from 'lucide-react' +import { t, useLocaleRender } from '../i18n' + +export type ChatTextContextMenuState = { + kind: 'message' | 'input' + x: number + y: number + text: string + input?: HTMLTextAreaElement + selectionStart?: number + selectionEnd?: number +} + +export function ChatTextContextMenu({ dark, menu, canClear = false, onClose, onClear, onPasteText }: { + dark: boolean + menu: ChatTextContextMenuState | null + canClear?: boolean + onClose: () => void + onClear?: () => void + onPasteText?: (text: string, menu: ChatTextContextMenuState) => void +}): React.JSX.Element | null { + useLocaleRender() + const menuRef = useRef(null) + const [clipboardState, setClipboardState] = useState<{ menu: ChatTextContextMenuState; hasText: boolean } | null>(null) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const closeRef = useRef(onClose) + closeRef.current = onClose + + useLayoutEffect(() => { + if (!menu || !menuRef.current) return + const bounds = menuRef.current.getBoundingClientRect() + setPosition({ x: Math.max(8, Math.min(menu.x, window.innerWidth - bounds.width - 8)), y: Math.max(8, Math.min(menu.y, window.innerHeight - bounds.height - 8)) }) + }, [menu]) + + useEffect(() => { + if (!menu) return + let disposed = false + if (menu.kind === 'input') { + const read = Promise.resolve().then(() => window.chatTextMenu?.readClipboardState + ? window.chatTextMenu.readClipboardState() + : navigator.clipboard.readText().then(text => ({ hasText: text.length > 0 }))) + void read.then(state => { if (!disposed) setClipboardState({ menu, hasText: state.hasText }) }) + .catch(() => { if (!disposed) setClipboardState({ menu, hasText: false }) }) + } + const outside = (event: Event): void => { + if (!menuRef.current?.contains(event.target as Node)) closeRef.current() + } + const keydown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { event.preventDefault(); closeRef.current() } + } + const close = (): void => closeRef.current() + window.addEventListener('pointerdown', outside, true) + window.addEventListener('contextmenu', outside) + window.addEventListener('scroll', outside, true) + window.addEventListener('resize', close) + window.addEventListener('blur', close) + window.addEventListener('keydown', keydown) + return () => { + disposed = true + window.removeEventListener('pointerdown', outside, true) + window.removeEventListener('contextmenu', outside) + window.removeEventListener('scroll', outside, true) + window.removeEventListener('resize', close) + window.removeEventListener('blur', close) + window.removeEventListener('keydown', keydown) + } + }, [menu]) + + if (!menu) return null + const pasteEnabled = menu.kind === 'input' && !menu.input?.disabled && !menu.input?.readOnly && clipboardState?.menu === menu && clipboardState.hasText + const itemClass = ['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition disabled:cursor-default disabled:opacity-35', dark ? 'enabled:hover:bg-[#37373d] enabled:hover:text-white' : 'enabled:hover:bg-[#eef1f7] enabled:hover:text-[#111520]'].join(' ') + const copy = async (): Promise => { + if (!menu.text) return + onClose() + if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'copy', text: menu.text }) + else await navigator.clipboard.writeText(menu.text) + } + const paste = async (): Promise => { + if (!pasteEnabled || !menu.input?.isConnected || menu.input.disabled || menu.input.readOnly) return + const input = menu.input + input.focus({ preventScroll: true }) + input.setSelectionRange(menu.selectionStart ?? input.selectionStart, menu.selectionEnd ?? input.selectionEnd) + onClose() + if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'paste' }) + else { + const text = await navigator.clipboard.readText() + if (text) onPasteText?.(text, menu) + } + } + return createPortal( +
event.preventDefault()} onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}> + + {menu.kind === 'input' ? ( + + ) : onClear ? <> +
+ + : null} +
, document.body + ) +} diff --git a/src/renderer/src/components/ChatWidgets.tsx b/src/renderer/src/components/ChatWidgets.tsx index dd2a671..3d163ac 100644 --- a/src/renderer/src/components/ChatWidgets.tsx +++ b/src/renderer/src/components/ChatWidgets.tsx @@ -1,10 +1,11 @@ import { t, useLocaleRender } from '../i18n' import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react' import type React from 'react' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' import { CHAT_ATTACHMENT_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu' export function ChatAvatar({ accessToken, @@ -264,6 +265,22 @@ export function ExpandablePlainMessage({ ) } +function resizeQaInput(input: HTMLTextAreaElement): void { + const styles = window.getComputedStyle(input) + const lineHeight = Number.parseFloat(styles.lineHeight) + const padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom) + const border = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth) + const minHeight = Number.parseFloat(styles.minHeight) + const maxHeight = Math.ceil(lineHeight * 8 + padding + border) + + input.style.height = 'auto' + input.style.overflowY = 'hidden' + const contentHeight = input.scrollHeight + border + input.style.maxHeight = `${maxHeight}px` + input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px` + input.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden' +} + export const QaInputBox = memo(function QaInputBox({ dark, busy, @@ -289,10 +306,29 @@ export const QaInputBox = memo(function QaInputBox({ const [attachments, setAttachments] = useState([]) const [attachmentError, setAttachmentError] = useState(null) const [readingAttachments, setReadingAttachments] = useState(false) + const [contextMenu, setContextMenu] = useState(null) const fileInputRef = useRef(null) + const textareaRef = useRef(null) const attachmentReadRef = useRef(0) const canSubmit = busy || (Boolean(draft.trim()) && !disabled && !readingAttachments) + useLayoutEffect(() => { + if (textareaRef.current) resizeQaInput(textareaRef.current) + }, [draft]) + + useEffect(() => { + const input = textareaRef.current + if (!input) return + let width = input.getBoundingClientRect().width + const observer = new ResizeObserver(([entry]) => { + if (!entry || entry.contentRect.width === width) return + width = entry.contentRect.width + resizeQaInput(input) + }) + observer.observe(input) + return () => observer.disconnect() + }, []) + useEffect(() => { attachmentReadRef.current += 1 if (!attachmentsEnabled) { @@ -317,25 +353,28 @@ export const QaInputBox = memo(function QaInputBox({ } return ( -
+