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
+145
View File
@@ -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)
})
}
+3 -3
View File
@@ -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', () => { test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { 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 document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL) 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'})`, () => { test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { 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) { react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }] 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', () => { test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { 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, () => {}] } react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
}) })
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true }) const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
+1 -1
View File
@@ -16,7 +16,7 @@ function compile(source, overrides = {}, globals = {}) {
} }
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), { 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 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)) const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
+14 -10
View File
@@ -19,6 +19,7 @@ function compile(file, overrides = {}, globals = {}) {
const i18n = { t: key => key, useLocaleRender() {} } const i18n = { t: key => key, useLocaleRender() {} }
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n }) const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
const QaInputBox = () => null const QaInputBox = () => null
const ChatTextContextMenu = () => null
function elements(root, predicate) { function elements(root, predicate) {
if (!React.isValidElement(root)) return [] if (!React.isValidElement(root)) return []
@@ -36,6 +37,7 @@ function setup(overrides = {}, browser = {}) {
'../../components/AppStatusModals': { ConfirmStatusModal }, '../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null }, '../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox }, '../../components/ChatWidgets': { QaInputBox },
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
'react-dom': { createPortal: child => child }, 'react-dom': { createPortal: child => child },
react: { react: {
...React, ...React,
@@ -84,19 +86,18 @@ test('context menu deletion also requests confirmation', () => {
const root = harness.render() const root = harness.render()
const area = elements(root, node => Boolean(node.props.onContextMenu))[0] const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 }) 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] const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
clear.props.onClick() menu.props.onClear()
assert.equal(harness.cleared(), 0) assert.equal(harness.cleared(), 0)
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1) assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
}) })
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) { 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 () => { test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
let request
const node = {} const node = {}
const harness = setup({}, { const harness = setup({}, {
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null, 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] const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
area.props.onContextMenu({ area.props.onContextMenu({
@@ -104,18 +105,21 @@ for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
}) })
await Promise.resolve() await Promise.resolve()
assert.equal(request.kind, 'message') const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
assert.equal(request.canCopy, selected && inside) assert.equal(menu.props.menu.kind, 'message')
assert.equal(request.canClear, true) assert.equal(Boolean(menu.props.menu.text), selected && inside)
assert.equal(menu.props.canClear, true)
assert.equal(harness.cleared(), 0) assert.equal(harness.cleared(), 0)
}) })
} }
test('native menu deletion still goes through the shared confirmation dialog', async () => { test('themed menu deletion still goes through the shared confirmation dialog', async () => {
const harness = setup({}, { chatTextMenu: { show: async () => 'clear' } }) const harness = setup()
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0] const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} }) area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
await Promise.resolve() await Promise.resolve()
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
menu.props.onClear()
assert.equal(harness.cleared(), 0) assert.equal(harness.cleared(), 0)
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0] const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.ok(modal) assert.ok(modal)
+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), /대화내역 삭제/)
})
}
}
+135
View File
@@ -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)
})
+7 -7
View File
@@ -120,25 +120,25 @@ function elements(root, predicate) {
} }
for (const selected of [false, true]) { 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 options
let prevented = false let prevented = false
let stopped = false let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', { const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null },
react: { ...React, useState: initial => [initial, () => {}], useEffect() {}, useRef: () => ({ current: 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 } } } }) }, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} }) const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
const textarea = elements(tree, node => node.type === 'textarea')[0] const textarea = elements(tree, node => node.type === 'textarea')[0]
textarea.props.onContextMenu({ 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 } preventDefault() { prevented = true }, stopPropagation() { stopped = true }
}) })
await Promise.resolve() await Promise.resolve()
assert.equal(options.kind, 'input') assert.equal(options.kind, 'input')
assert.equal(options.canCopy, selected) assert.equal(options.text, selected ? 'cde' : '')
assert.equal(options.canPaste, true) assert.equal(options.input.disabled, false)
assert.equal(options.labels.paste, '붙여넣기') assert.equal(options.selectionStart, 2)
assert.equal(prevented, true) assert.equal(prevented, true)
assert.equal(stopped, true) assert.equal(stopped, true)
}) })
+24 -1
View File
@@ -1,6 +1,29 @@
import { BrowserWindow, clipboard, Menu } from 'electron' import { BrowserWindow, clipboard, Menu } from 'electron'
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } 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<ChatTextMenuAction> | null { export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
+3 -1
View File
@@ -1,7 +1,7 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { appLog } from './appLogging' import { appLog } from './appLogging'
import { checkDockerStatus } from './dockerStatus' import { checkDockerStatus } from './dockerStatus'
import { showChatTextMenu } from './chatTextMenu' import { performChatClipboardAction, readChatClipboardState, showChatTextMenu } from './chatTextMenu'
import { cameraRuntimeArtifacts } from './cameraRuntime' import { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles' import { remoteModuleFilesScript } from './remoteModuleFiles'
import { applyAgentFiles, runAgentFileTool } from './agentFileTools' import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
@@ -7037,6 +7037,8 @@ app.whenReady().then(() => {
}) })
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics()) ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
ipcMain.handle('chat-text:context-menu', showChatTextMenu) 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('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
ipcMain.on('app-quit', () => app.quit()) ipcMain.on('app-quit', () => app.quit())
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
+3 -1
View File
@@ -118,7 +118,9 @@ console.info('[lms-preload] launcher auth resolved', {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('chatTextMenu', { 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('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('lmsBootSplash', { contextBridge.exposeInMainWorld('lmsBootSplash', {
+7 -2
View File
@@ -634,6 +634,7 @@ function LocalPcWorkspaceMockup({
persistenceError: agentPersistenceError, persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan, requestPlan: requestAgentPlan,
applyPlan: applyAgentPlan, applyPlan: applyAgentPlan,
cancelPlan: cancelAgentPlan,
updateCommandExecution: updateAgentCommandExecution, updateCommandExecution: updateAgentCommandExecution,
stop: stopAgent, stop: stopAgent,
clear: clearAgentConversation clear: clearAgentConversation
@@ -3728,7 +3729,7 @@ function LocalPcWorkspaceMockup({
const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => { const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => {
if (agentActionControllerRef.current || agentPending || agentApplying) return if (agentActionControllerRef.current || agentPending || agentApplying) return
const message = agentMessages.find(item => item.id === messageId) 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() const controller = new AbortController()
agentActionControllerRef.current = controller agentActionControllerRef.current = controller
setAgentActionBusy(true) setAgentActionBusy(true)
@@ -3800,7 +3801,7 @@ function LocalPcWorkspaceMockup({
if (agentActionControllerRef.current || agentPending) return if (agentActionControllerRef.current || agentPending) return
const message = agentMessages.find((item) => item.id === messageId) const message = agentMessages.find((item) => item.id === messageId)
const currentExecution = message?.commandExecutions?.[command.id] 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 const requiresAppliedChanges = message.plan.changes.length > 0 || message.plan.createModule !== null
if (requiresAppliedChanges && message.applyState !== 'applied') { if (requiresAppliedChanges && message.applyState !== 'applied') {
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error') onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
@@ -4743,6 +4744,10 @@ function LocalPcWorkspaceMockup({
onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)} onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)}
onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)} onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)}
onRejectAgentCommand={rejectWorkspaceAgentCommand} onRejectAgentCommand={rejectWorkspaceAgentCommand}
onCancelAgentPlan={messageId => {
if (agentActionControllerRef.current || agentPending) return
if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId)
}}
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => { onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
await applyWorkspaceAgentPlan(plan) await applyWorkspaceAgentPlan(plan)
closeAgentDiffPreviewsForMessage(messageId) closeAgentDiffPreviewsForMessage(messageId)
@@ -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<HTMLDivElement>(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<void> => {
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<void> => {
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(
<div ref={menuRef} role="menu" aria-label={t('질의응답')} className={[
'fixed z-[160] w-[160px] overflow-hidden rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'
].join(' ')} style={{ left: position.x, top: position.y }} onMouseDown={event => event.preventDefault()} onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}>
<button type="button" role="menuitem" disabled={!menu.text} onClick={() => void copy().catch(error => console.error('[chat] copy failed', error))} className={itemClass}><Copy size={13} />{t('복사')}</button>
{menu.kind === 'input' ? (
<button type="button" role="menuitem" disabled={!pasteEnabled} onClick={() => void paste().catch(error => console.error('[chat] paste failed', error))} className={itemClass}><ClipboardPaste size={13} />{t('붙여넣기')}</button>
) : onClear ? <>
<div role="separator" className={['my-1 border-t', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')} />
<button type="button" role="menuitem" disabled={!canClear} onClick={() => { onClose(); onClear() }} className={itemClass}><Trash2 size={13} />{t('대화내역 삭제')}</button>
</> : null}
</div>, document.body
)
}
+54 -9
View File
@@ -1,10 +1,11 @@
import { t, useLocaleRender } from '../i18n' import { t, useLocaleRender } from '../i18n'
import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react' import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react'
import type React from '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 { CHAT_ATTACHMENT_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu'
export function ChatAvatar({ export function ChatAvatar({
accessToken, 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({ export const QaInputBox = memo(function QaInputBox({
dark, dark,
busy, busy,
@@ -289,10 +306,29 @@ export const QaInputBox = memo(function QaInputBox({
const [attachments, setAttachments] = useState<ChatAttachment[]>([]) const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [attachmentError, setAttachmentError] = useState<string | null>(null) const [attachmentError, setAttachmentError] = useState<string | null>(null)
const [readingAttachments, setReadingAttachments] = useState(false) const [readingAttachments, setReadingAttachments] = useState(false)
const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const attachmentReadRef = useRef(0) const attachmentReadRef = useRef(0)
const canSubmit = busy || (Boolean(draft.trim()) && !disabled && !readingAttachments) 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(() => { useEffect(() => {
attachmentReadRef.current += 1 attachmentReadRef.current += 1
if (!attachmentsEnabled) { if (!attachmentsEnabled) {
@@ -317,25 +353,28 @@ export const QaInputBox = memo(function QaInputBox({
} }
return ( return (
<div className={['m-3 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')}> <div className={['m-3 shrink-0 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}> <div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}>
<textarea <textarea
ref={textareaRef}
rows={2} rows={2}
spellCheck={false} spellCheck={false}
autoCorrect="off" autoCorrect="off"
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onContextMenu={(event) => { onContextMenu={(event) => {
if (!window.chatTextMenu) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const input = event.currentTarget const input = event.currentTarget
void window.chatTextMenu.show({ setContextMenu({
kind: 'input', kind: 'input',
canCopy: input.selectionEnd > input.selectionStart, x: event.clientX,
canPaste: !input.disabled && !input.readOnly, y: event.clientY,
labels: { copy: t('복사'), paste: t('붙여넣기') } text: input.value.slice(input.selectionStart, input.selectionEnd),
}).catch((error) => console.error('[chat] input menu failed', error)) input,
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd
})
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -345,7 +384,7 @@ export const QaInputBox = memo(function QaInputBox({
}} }}
placeholder={t("질문하세요...")} placeholder={t("질문하세요...")}
className={[ className={[
'min-h-[56px] min-w-0 flex-1 resize-none bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium', 'box-border min-h-[56px] min-w-0 flex-1 self-start resize-none overflow-y-hidden bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]' dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]'
].join(' ')} ].join(' ')}
/> />
@@ -364,6 +403,12 @@ export const QaInputBox = memo(function QaInputBox({
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />} {busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
</button> </button>
</div> </div>
<ChatTextContextMenu dark={dark} menu={contextMenu} onClose={() => setContextMenu(null)} onPasteText={(text, menu) => {
const start = menu.selectionStart ?? draft.length
const end = menu.selectionEnd ?? start
setDraft(current => current.slice(0, start) + text + current.slice(end))
window.requestAnimationFrame(() => textareaRef.current?.setSelectionRange(start + text.length, start + text.length))
}} />
{attachmentsEnabled && attachments.length > 0 && ( {attachmentsEnabled && attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2"> <div className="flex flex-wrap gap-1.5 pt-2">
{attachments.map((file, index) => ( {attachments.map((file, index) => (
+2
View File
@@ -69,6 +69,8 @@ interface Window {
} }
chatTextMenu?: { chatTextMenu?: {
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction> show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
readClipboardState: () => Promise<{ hasText: boolean }>
perform: (action: import('../../shared/chatTextMenu').ChatClipboardAction) => Promise<boolean>
} }
appDiagnostics?: { appDiagnostics?: {
read: () => Promise<{ path: string; content: string }> read: () => Promise<{ path: string; content: string }>
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { ConfirmStatusModal } from '../../components/AppStatusModals' import { ConfirmStatusModal } from '../../components/AppStatusModals'
import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse' import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments' import type { ChatAttachment } from '../../components/chatAttachments'
@@ -56,6 +57,7 @@ type WorkspaceQaPanelProps = {
onSubmit: (content: string, attachments?: ChatAttachment[]) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
onClearConversation: () => void onClearConversation: () => void
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
onCancelAgentPlan: (messageId: string) => void
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
onRunAgentCommand: (messageId: string, command: AgentCommand) => void onRunAgentCommand: (messageId: string, command: AgentCommand) => void
onRejectAgentCommand: (messageId: string, command: AgentCommand) => void onRejectAgentCommand: (messageId: string, command: AgentCommand) => void
@@ -127,12 +129,13 @@ export default function WorkspaceQaPanel({
onSubmit, onSubmit,
onClearConversation, onClearConversation,
onApplyAgentPlan, onApplyAgentPlan,
onCancelAgentPlan,
onOpenAgentChange, onOpenAgentChange,
onRunAgentCommand, onRunAgentCommand,
onRejectAgentCommand onRejectAgentCommand
}: WorkspaceQaPanelProps): React.JSX.Element { }: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender() useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null) const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false) const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1] const lastMessage = activeMessages[activeMessages.length - 1]
@@ -140,6 +143,7 @@ export default function WorkspaceQaPanel({
useEffect(() => { useEffect(() => {
setClearConfirmationOpen(false) setClearConfirmationOpen(false)
setContextMenu(null)
}, [qaMode, workspaceActive, minimized]) }, [qaMode, workspaceActive, minimized])
const requestClearConversation = (): void => { const requestClearConversation = (): void => {
@@ -153,17 +157,6 @@ export default function WorkspaceQaPanel({
} }
}, [agentEnabled, agentStatusLoading, onModeChange, qaMode, teacherQaDisabled]) }, [agentEnabled, agentStatusLoading, onModeChange, qaMode, teacherQaDisabled])
useEffect(() => {
if (!contextMenu) return
const closeContextMenu = (): void => setContextMenu(null)
window.addEventListener('click', closeContextMenu)
window.addEventListener('contextmenu', closeContextMenu)
return () => {
window.removeEventListener('click', closeContextMenu)
window.removeEventListener('contextmenu', closeContextMenu)
}
}, [contextMenu])
useEffect(() => { useEffect(() => {
if (minimized || qaMessagesLoading) return if (minimized || qaMessagesLoading) return
const target = workspaceChatScrollRef.current const target = workspaceChatScrollRef.current
@@ -219,19 +212,7 @@ export default function WorkspaceQaPanel({
const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode) const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode)
? selection.toString() ? selection.toString()
: '' : ''
if (window.chatTextMenu) { setContextMenu({ kind: 'message', x: event.clientX, y: event.clientY, text })
setContextMenu(null)
void window.chatTextMenu.show({
kind: 'message',
canCopy: text.length > 0,
canClear: !clearDisabled,
labels: { copy: t('복사'), clear: t('대화내역 삭제') }
}).then((action) => {
if (action === 'clear') requestClearConversation()
}).catch((error) => console.error('[chat] message menu failed', error))
return
}
setContextMenu({ x: event.clientX, y: event.clientY, text })
} }
return ( return (
@@ -321,6 +302,7 @@ export default function WorkspaceQaPanel({
busy={agentPending || agentActionBusy} busy={agentPending || agentActionBusy}
messages={agentMessages} messages={agentMessages}
onApply={onApplyAgentPlan} onApply={onApplyAgentPlan}
onCancel={onCancelAgentPlan}
onOpenChange={onOpenAgentChange} onOpenChange={onOpenAgentChange}
onRunCommand={onRunAgentCommand} onRunCommand={onRunAgentCommand}
onRejectCommand={onRejectAgentCommand} onRejectCommand={onRejectAgentCommand}
@@ -335,31 +317,7 @@ export default function WorkspaceQaPanel({
/> />
)} )}
</div> </div>
{contextMenu && ( <ChatTextContextMenu dark={dark} menu={contextMenu} canClear={!clearDisabled} onClose={() => setContextMenu(null)} onClear={requestClearConversation} />
<div
className={['fixed z-[140] min-w-[150px] overflow-hidden rounded-md border py-1 shadow-[0_18px_42px_rgba(0,0,0,0.28)]', dark ? 'border-white/10 bg-[#2b2b2b]' : 'border-[#d5dbe7] bg-white'].join(' ')}
style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
disabled={!contextMenu.text}
className={['flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 enabled:hover:bg-white/10' : 'text-[#334155] enabled:hover:bg-[#f1f5f9]'].join(' ')}
onClick={() => {
const text = contextMenu.text
setContextMenu(null)
if (text) void navigator.clipboard.writeText(text).catch((error) => console.error('[chat] copy failed', error))
}}
><Copy size={13} />{t('복사')}</button>
<button
type="button"
disabled={clearDisabled}
className={['block w-full px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 hover:bg-white/10' : 'text-[#334155] hover:bg-[#f1f5f9]'].join(' ')}
onClick={requestClearConversation}
>{t("대화내역 삭제")}</button>
</div>
)}
<QaInputBox <QaInputBox
dark={dark} dark={dark}
busy={inputBusy} busy={inputBusy}
@@ -400,6 +358,7 @@ function AgentMessages({
busy, busy,
messages, messages,
onApply, onApply,
onCancel,
onOpenChange, onOpenChange,
onRunCommand, onRunCommand,
onRejectCommand onRejectCommand
@@ -412,6 +371,7 @@ function AgentMessages({
busy?: boolean busy?: boolean
messages: AgentConversationMessage[] messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void onApply: (messageId: string, plan: AgentPlan) => void
onCancel: (messageId: string) => void
onOpenChange: (messageId: string, change: AgentFileChange) => void onOpenChange: (messageId: string, change: AgentFileChange) => void
onRunCommand: (messageId: string, command: AgentCommand) => void onRunCommand: (messageId: string, command: AgentCommand) => void
onRejectCommand: (messageId: string, command: AgentCommand) => void onRejectCommand: (messageId: string, command: AgentCommand) => void
@@ -504,7 +464,7 @@ function AgentMessages({
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const } const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const }
const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start' const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start'
const finished = execution.status === 'completed' || execution.status === 'failed' const finished = execution.status === 'completed' || execution.status === 'failed'
const policyRejected = execution.status === 'rejected' const policyRejected = message.applyState === 'cancelled' || execution.status === 'rejected'
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? '')) || (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
const waitingForChanges = (message.plan!.changes.length > 0 || message.plan!.createModule !== null) && message.applyState !== 'applied' const waitingForChanges = (message.plan!.changes.length > 0 || message.plan!.createModule !== null) && message.applyState !== 'applied'
return ( return (
@@ -526,7 +486,9 @@ function AgentMessages({
) : null} ) : null}
{execution.error ? <p className="mt-2 text-[11px] font-bold text-[#ef4444]">{execution.error}</p> : null} {execution.error ? <p className="mt-2 text-[11px] font-bold text-[#ef4444]">{execution.error}</p> : null}
<div className="mt-2 flex items-center justify-end gap-2"> <div className="mt-2 flex items-center justify-end gap-2">
{execution.status === 'pending' ? <> {message.applyState === 'cancelled' ? (
<span className={['text-[11px] font-bold', dark ? 'text-white/40' : 'text-[#64748b]'].join(' ')}>{execution.status === 'completed' ? t("실행 완료") : t("취소됨")}</span>
) : execution.status === 'pending' ? <>
<button type="button" onClick={() => onRejectCommand(message.id, command)} className={['inline-flex h-7 items-center gap-1 rounded px-2 text-[11px] font-bold transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white' : 'text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}><X size={12} />{t("거부")}</button> <button type="button" onClick={() => onRejectCommand(message.id, command)} className={['inline-flex h-7 items-center gap-1 rounded px-2 text-[11px] font-bold transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white' : 'text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}><X size={12} />{t("거부")}</button>
<button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("승인")}</button> <button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("승인")}</button>
</> : execution.status === 'running' ? ( </> : execution.status === 'running' ? (
@@ -581,15 +543,27 @@ function AgentMessages({
) : null} ) : null}
{message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null} {message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null}
{message.plan.changes.length > 0 || message.plan.createModule ? ( {message.plan.changes.length > 0 || message.plan.createModule ? (
<div className="flex min-w-0 items-center gap-2">
{message.applyState !== 'applied' && message.applyState !== 'cancelled' && (
<button <button
type="button" type="button"
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')} disabled={busy || message.applyState === 'applying' || Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)} onClick={() => onCancel(message.id)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]" className={['inline-flex h-8 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md border px-3 text-[11px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40', dark ? 'border-white/15 text-white/65 hover:bg-white/8' : 'border-[#d8dee9] text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}
> >
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />} <X size={13} className="shrink-0" /><span className="relative top-px leading-none">{t("취소")}</span>
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button> </button>
)}
<button
type="button"
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)}
className="inline-flex h-8 min-w-0 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
>
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'cancelled' ? <X size={13} /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />}
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'cancelled' ? t("취소됨") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button>
</div>
) : null} ) : null}
</> </>
)} )}
@@ -77,7 +77,7 @@ export type AgentConversationMessage = {
model?: string model?: string
revealStartedAt?: number revealStartedAt?: number
plan?: AgentPlan plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error' applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled'
applyError?: string applyError?: string
commandExecutions?: Record<string, AgentCommandExecution> commandExecutions?: Record<string, AgentCommandExecution>
usage?: { usage?: {
@@ -336,8 +336,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => { const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
const message = messages.find((item) => item.id === messageId) if (messageStateRef.current.session !== session) return
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return const message = messageStateRef.current.messages.find((item) => item.id === messageId)
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled') return
if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return
applyingRef.current = true applyingRef.current = true
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item)) updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
@@ -362,6 +363,24 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
} }
const cancelPlan = (messageId: string): boolean => {
if (messageStateRef.current.session !== session || applyingRef.current || abortControllerRef.current) return false
const message = messageStateRef.current.messages.find((item) => item.id === messageId)
if (!message?.plan || (!message.plan.changes.length && !message.plan.createModule)
|| message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled'
|| Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')) return false
updateMessages(current => current.map(item => item.id === messageId ? {
...item,
applyState: 'cancelled',
applyError: undefined,
commandExecutions: Object.fromEntries(item.plan!.commands.map(command => {
const execution = item.commandExecutions?.[command.id]
return [command.id, execution?.status === 'completed' ? execution : { ...execution, status: 'rejected' as const, error: undefined }]
}))
} : item))
return true
}
const stop = (): void => { const stop = (): void => {
abortControllerRef.current?.abort() abortControllerRef.current?.abort()
abortControllerRef.current = null abortControllerRef.current = null
@@ -410,6 +429,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
persistenceError: persistenceError ?? historyError, persistenceError: persistenceError ?? historyError,
requestPlan, requestPlan,
applyPlan, applyPlan,
cancelPlan,
updateCommandExecution, updateCommandExecution,
stop, stop,
clear clear
+1
View File
@@ -601,6 +601,7 @@
"추가 중": "Adding", "추가 중": "Adding",
"추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.", "추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.",
"취소": "Cancel", "취소": "Cancel",
"취소됨": "Cancelled",
"카테고리": "Category", "카테고리": "Category",
"칸": " spaces", "칸": " spaces",
"커리큘럼": "Curriculum", "커리큘럼": "Curriculum",
+2
View File
@@ -7,3 +7,5 @@ export type ChatTextMenuOptions = {
} }
export type ChatTextMenuAction = 'clear' | null export type ChatTextMenuAction = 'clear' | null
export type ChatClipboardAction = { action: 'copy'; text: string } | { action: 'paste' }