diff --git a/package-lock.json b/package-lock.json
index ee3cc80..1421f3e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@xterm/xterm": "^6.0.0",
"driver.js": "^1.4.0",
"i18next": "^26.4.2",
+ "lowlight": "^3.3.0",
"lucide-react": "^1.14.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
@@ -5249,6 +5250,15 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/highlight.js": {
+ "version": "11.11.2",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
+ "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -6178,6 +6188,21 @@
"node": ">=8"
}
},
+ "node_modules/lowlight": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
+ "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.0.0",
+ "highlight.js": "~11.11.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
diff --git a/package.json b/package.json
index 32a2909..a22f430 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"@xterm/xterm": "^6.0.0",
"driver.js": "^1.4.0",
"i18next": "^26.4.2",
+ "lowlight": "^3.3.0",
"lucide-react": "^1.14.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
diff --git a/scripts/generate-nanumsquare-css.py b/scripts/generate-nanumsquare-css.py
new file mode 100644
index 0000000..cfdcdfa
--- /dev/null
+++ b/scripts/generate-nanumsquare-css.py
@@ -0,0 +1,85 @@
+"""Regenerate font coverage after replacing the bundled NanumSquare WOFF2 files.
+
+Requires fonttools[woff]. Normal app builds use the checked-in CSS, not Python.
+Run: python3 scripts/generate-nanumsquare-css.py [--check]
+"""
+
+import argparse
+import hashlib
+import unicodedata
+from pathlib import Path
+
+from fontTools.pens.boundsPen import BoundsPen
+from fontTools.ttLib import TTFont
+
+
+ROOT = Path(__file__).resolve().parents[1]
+FONTS = ROOT / "src/renderer/public/assets/fonts"
+OUTPUT = ROOT / "src/renderer/src/styles/nanumSquare.css"
+FACES = [(300, "NanumSquareL"), (400, "NanumSquare"), (700, "NanumSquareB"), (800, "NanumSquareEB")]
+# Hangul fillers are intentionally invisible, like whitespace and controls.
+FILLERS = {0x115F, 0x1160, 0x3164, 0xFFA0}
+
+
+def coverage(font):
+ glyphs = font.getGlyphSet()
+ supported = []
+ for code, name in sorted(font.getBestCmap().items()):
+ pen = BoundsPen(glyphs)
+ glyphs[name].draw(pen)
+ intentional = chr(code).isspace() or unicodedata.category(chr(code)) in {"Cc", "Cf"} or code in FILLERS
+ if pen.bounds is not None or intentional:
+ supported.append(code)
+ return supported
+
+
+def unicode_ranges(codes):
+ ranges = []
+ start = end = codes[0]
+ for code in codes[1:]:
+ if code == end + 1:
+ end = code
+ else:
+ ranges.append((start, end))
+ start = end = code
+ ranges.append((start, end))
+ return [f"U+{start:X}" if start == end else f"U+{start:X}-{end:X}" for start, end in ranges]
+
+
+def generate():
+ sections = [
+ "/* Generated by scripts/generate-nanumsquare-css.py. Do not edit manually.\n"
+ " * Empty glyphs are excluded so the existing system-font fallback can render them.\n"
+ " * Font binaries and visible glyphs are unchanged.\n */"
+ ]
+ for weight, name in FACES:
+ file = FONTS / f"{name}.woff2"
+ with TTFont(file) as font:
+ ranges = unicode_ranges(coverage(font))
+ lines = [", ".join(ranges[index:index + 10]) for index in range(0, len(ranges), 10)]
+ sections.append(
+ f"/* {file.name} sha256: {hashlib.sha256(file.read_bytes()).hexdigest()} */\n"
+ "@font-face {\n"
+ " font-family: 'NanumSquare';\n"
+ f" src: url('/assets/fonts/{file.name}') format('woff2');\n"
+ f" font-weight: {weight};\n"
+ " font-style: normal;\n"
+ " font-display: swap;\n"
+ " unicode-range:\n " + ",\n ".join(lines) + ";\n}"
+ )
+ return "\n\n".join(sections) + "\n"
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args()
+ generated = generate()
+ if args.check:
+ if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated:
+ raise SystemExit("NanumSquare coverage CSS is stale; regenerate it")
+ print("NanumSquare coverage matches all four font files")
+ else:
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+ OUTPUT.write_text(generated, encoding="utf-8")
+ print(f"Generated {OUTPUT.relative_to(ROOT)}")
diff --git a/scripts/test-agent-code-navigation.cjs b/scripts/test-agent-code-navigation.cjs
new file mode 100644
index 0000000..7aefa14
--- /dev/null
+++ b/scripts/test-agent-code-navigation.cjs
@@ -0,0 +1,172 @@
+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 { createHash, webcrypto } = require('node:crypto')
+
+function compile(source, globals = {}, requireModule = require) {
+ const exports = {}
+ vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
+ exports, require: requireModule, Error, crypto: webcrypto, TextEncoder, window: { setTimeout: callback => setTimeout(callback, 1) }, ...globals
+ })
+ return exports
+}
+const tools = compile(fs.readFileSync(require.resolve('../src/shared/agentTools.ts'), 'utf8'))
+const navigation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentCodeNavigation.ts'), 'utf8'), {}, name => name.endsWith('shared/agentTools') ? tools : require(name))
+const content = 'def configure():\n speed = 0.4\n return speed\n'
+const hash = value => createHash('sha256').update(value).digest('hex')
+const reference = { id: 'location', moduleName: 'Control', path: 'src/node.py', startLine: 2, endLine: 3, reason: 'speed config', snippet: ' speed = 0.4\n return speed', expectedHash: hash(content) }
+
+test('unchanged source reveals the verified line range, while unsaved insertions relocate it', async () => {
+ assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content, reference))), { startLine: 2, endLine: 3 })
+ assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange('# unsaved\n' + content, reference))), { startLine: 3, endLine: 4 })
+ assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content.replace(/\n/g, '\r\n'), reference))), { startLine: 2, endLine: 3 })
+})
+
+test('rejects missing, ambiguous or altered code rather than highlighting a guessed line', async () => {
+ for (const changed of [content.replace('0.4', '0.8'), `${content}\n${content}`]) await assert.rejects(navigation.resolveCodeReferenceRange(changed, reference), /위치를 확정/)
+ for (const patch of [{ expectedHash: undefined }, { snippet: '' }, { snippet: '[REDACTED]' }, { snippet: 'one line' }, { path: '../etc/passwd' }, { path: '.env' }, { startLine: 0 }, { endLine: 500 }]) {
+ await assert.rejects(navigation.resolveCodeReferenceRange(content, { ...reference, ...patch }))
+ }
+})
+
+function editorHarness(text = content) {
+ const calls = []
+ const collection = { clear: () => calls.push('clear') }
+ const model = { uri: { path: '/modules/Control/src/node.py' }, getValue: () => text, isDisposed: () => false, getLineMaxColumn: line => text.split('\n')[line - 1].length + 1 }
+ const editor = { getModel: () => model, getDomNode: () => ({ clientWidth: 800, clientHeight: 600 }), layout: () => calls.push('layout'), createDecorationsCollection: value => { calls.push(['decorate', value]); return collection }, revealRangeInCenter: value => calls.push(['reveal', value]), setPosition: value => calls.push(['position', value]), focus: () => calls.push('focus') }
+ const options = { file: 'modules/Control/src/node.py', reference, isCurrent: () => true, getEditor: () => editor, getActiveFile: () => 'modules/Control/src/node.py', isReady: () => true, attempts: 4 }
+ return { model, editor, options, calls, collection }
+}
+
+test('focuses and highlights only after the correct Monaco model has loaded', async () => {
+ const h = editorHarness()
+ let reads = 0
+ h.options.getEditor = () => ++reads < 3 ? null : h.editor
+ assert.equal(await navigation.revealAgentCodeReference(h.options), h.collection)
+ assert.deepEqual(h.calls.map(call => Array.isArray(call) ? call[0] : call), ['layout', 'decorate', 'position', 'reveal', 'focus'])
+ const selection = h.calls.find(call => call[0] === 'reveal')[1]
+ assert.equal(selection.startLineNumber, 2)
+ assert.equal(selection.endLineNumber, 3)
+ assert.equal(h.calls[1][1][0].options.isWholeLine, true)
+})
+
+test('does not decorate another file, a hidden/diff editor, stale scope or unready model', async () => {
+ for (const setup of [
+ h => { h.model.uri.path = '/modules/Other/src/node.py' },
+ h => { h.options.getActiveFile = () => 'other.py' },
+ h => { h.options.isReady = () => false },
+ h => { h.options.isCurrent = () => false },
+ h => { h.editor.getDomNode = () => ({ clientWidth: 0, clientHeight: 0 }) },
+ h => { h.model.isDisposed = () => true }
+ ]) {
+ const h = editorHarness()
+ setup(h)
+ await assert.rejects(navigation.revealAgentCodeReference(h.options))
+ assert.equal(h.calls.length, 0)
+ }
+})
+
+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) && ['openCodeReference', 'cancelCodeReference'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
+function consentHarness() {
+ const session = {}
+ const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
+ const globals = { session, messageStateRef, navigatingRef: { current: false }, applyingRef: { current: false }, abortControllerRef: { current: null }, updateMessages: update => { messageStateRef.current.messages = update(messageStateRef.current.messages) } }
+ return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
+}
+
+test('opening requires explicit approval, cancellation never opens or applies anything', async () => {
+ const h = consentHarness()
+ let opened = 0
+ const open = async () => { opened++ }
+ assert.equal(opened, 0)
+ h.cancelCodeReference('answer', 'location')
+ await h.openCodeReference('answer', 'location', open)
+ assert.equal(opened, 0)
+ assert.equal(h.message().codeReferenceStates.location.status, 'cancelled')
+ const approved = consentHarness()
+ await approved.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
+ assert.equal(opened, 1)
+ assert.equal(approved.message().codeReferenceStates.location.status, 'opened')
+})
+
+test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
+ const h = consentHarness()
+ let finish, opened = 0
+ const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
+ assert.equal(h.message().codeReferenceStates.location.status, 'opening')
+ h.cancelCodeReference('answer', 'location')
+ assert.equal(h.message().codeReferenceStates.location.status, 'opening')
+ await h.openCodeReference('answer', 'location', async () => { opened++ })
+ finish()
+ await first
+ assert.equal(opened, 1)
+ await h.openCodeReference('answer', 'invented', async () => { opened++ })
+ h.globals.applyingRef.current = true
+ await h.openCodeReference('answer', 'location', async () => { opened++ })
+ assert.equal(opened, 1)
+})
+
+test('errors remain visible and late navigation cannot update another conversation', async () => {
+ const h = consentHarness()
+ await h.openCodeReference('answer', 'location', async () => { throw Error('missing file') })
+ assert.equal(h.message().codeReferenceStates.location.status, 'error')
+ assert.equal(h.message().codeReferenceStates.location.error, 'missing file')
+ let finish
+ const pending = h.openCodeReference('answer', 'location', () => new Promise(resolve => { finish = resolve }))
+ const other = { id: 'other' }
+ h.globals.messageStateRef.current = { session: {}, messages: [other] }
+ finish()
+ await pending
+ assert.equal(h.message(), other)
+})
+
+test('a new question cancels pending navigation without leaving its old state stuck opening', async () => {
+ const h = consentHarness()
+ let finish, current
+ const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
+ current = isCurrent
+ return new Promise((_resolve, reject) => { finish = () => reject(Error('cancelled')) })
+ })
+ h.globals.messageStateRef.current.messages.push({ id: 'new-question', role: 'user' })
+ assert.equal(current(), false)
+ finish()
+ await pending
+ assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'error')
+ assert.equal(h.globals.messageStateRef.current.messages.at(-1).id, 'new-question')
+})
+
+test('restored interrupted navigation becomes retryable without reopening any file', () => {
+ const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
+ const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
+ const restored = recoverInterruptedAgentMessages([message])[0]
+ assert.equal(restored.codeReferenceStates.pending.status, 'error')
+ assert.equal(restored.codeReferenceStates.approved.status, 'opened')
+ assert.equal(restored.codeReferenceStates.declined.status, 'cancelled')
+ assert.equal(message.codeReferenceStates.pending.status, 'opening')
+})
+
+function elements(tree, predicate) {
+ if (!React.isValidElement(tree)) return []
+ return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
+}
+function text(tree) {
+ return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
+}
+const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences
+for (const dark of [true, false]) test(`consent controls use cancel-left/open-right and preserve states (${dark})`, () => {
+ const clicked = []
+ const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`), onCancel: id => clicked.push(`cancel:${id}`) }
+ const buttons = elements(render(props), element => element.type === 'button')
+ assert.equal(clicked.length, 0)
+ assert.deepEqual(buttons.map(text), ['취소', '코드에서 보기'])
+ buttons[1].props.onClick()
+ assert.deepEqual(clicked, ['open:location'])
+ for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
+ assert.equal(elements(render({ ...props, states: { location: { status: 'cancelled' } } }), element => element.type === 'button').length, 0)
+ assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
+})
diff --git a/scripts/test-agent-plan-cancel.cjs b/scripts/test-agent-plan-cancel.cjs
index 048d5cf..e93c310 100644
--- a/scripts/test-agent-plan-cancel.cjs
+++ b/scripts/test-agent-plan-cancel.cjs
@@ -118,6 +118,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(
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,
+ AgentWorkDuration: () => null,
agentMessageCopyText: message => message.content
}).AgentMessages
diff --git a/scripts/test-agent-progress.cjs b/scripts/test-agent-progress.cjs
new file mode 100644
index 0000000..747047f
--- /dev/null
+++ b/scripts/test-agent-progress.cjs
@@ -0,0 +1,157 @@
+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')
+
+function compile(source, globals = {}) {
+ const exports = {}
+ vm.runInNewContext(ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
+ }).outputText, { exports, DOMException, Error, ...globals })
+ return exports
+}
+const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
+const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
+const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
+const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
+const plain = value => JSON.parse(JSON.stringify(value))
+const response = (toolRequests = []) => ({ provider: 'QWEN', model: 'fixture', plan: { summary: '분석 완료', explanation: '최종 설명', requestedModules: [], requestedLogs: [], changes: [], commands: [], createModule: null, actionPlan: [], toolRequests } })
+const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
+const tick = () => new Promise(resolve => setImmediate(resolve))
+const result = (extra = {}) => ({ id: 'tool', name: 'files.read', status: 'ok', target: 'fixture', observedAt: new Date().toISOString(), data: '{"exists":true,"complete":true,"content":"PRIVATE_SOURCE"}', truncated: false, ...extra })
+
+function harness(requestApi) {
+ const session = {}
+ let now = 1000
+ class Clock extends Date { static now() { return now } }
+ const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
+ const messageStateRef = { current: { session } }
+ const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
+ ...context, session, messageStateRef, AbortController,
+ Date: Clock,
+ courseId: 'course', weekNumber: 3, pending: false, historyLoading: false,
+ status: { enabled: true }, abortControllerRef: { current: null },
+ setPending: value => { state.pending = value },
+ setRequestStartedAt: value => { state.requestStartedAt = value },
+ setActivity: value => { state.activity = value },
+ setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
+ updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) },
+ onResponseRef: { current: () => { state.notices++ } }, requestApi
+ })
+ return { ...api, state, messageStateRef, advance: ms => { now += ms } }
+}
+
+test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
+ const first = deferred(), tool = deferred(), final = deferred()
+ let calls = 0
+ const h = harness(() => (++calls === 1 ? first.promise : final.promise))
+ const running = h.requestPlan('카메라 FPS 확인', [{ name: 'Camera', files: [] }], [], { executeTool: () => tool.promise })
+ assert.equal(h.state.pending, true)
+ assert.equal(h.state.requestStartedAt, 1000)
+ assert.equal(h.state.steps.length, 0)
+ first.resolve(response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config/camera.yaml' } }]))
+ await tick()
+ assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis'])
+ assert.match(h.state.activity, /files.read 조회 중/)
+ tool.resolve(result())
+ await tick()
+ assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis', 'tool'])
+ assert.equal(h.state.steps[1].path, 'config/camera.yaml')
+ assert.equal(JSON.stringify(h.state.steps).includes('PRIVATE_SOURCE'), false)
+ assert.equal(h.state.messages.length, 1)
+ assert.equal(h.state.saved.flat().some(message => message.progressSteps), false)
+ assert.equal(calls, 2)
+ h.advance(20_900)
+ final.resolve(response())
+ await running
+ assert.equal(h.state.steps.length, 0)
+ assert.equal(h.state.pending, false)
+ assert.equal(h.state.activity, '')
+ assert.equal(h.state.requestStartedAt, null)
+ assert.equal(h.state.messages.length, 2)
+ assert.equal(h.state.messages[1].plan.summary, '분석 완료')
+ assert.equal(h.state.messages[1].durationMs, 20_900)
+ assert.equal(h.state.saved.at(-1)[1].durationMs, 20_900)
+ assert.equal(h.state.notices, 1)
+})
+
+test('errors replace temporary progress with the existing error reply', async () => {
+ let calls = 0
+ const h = harness(async () => {
+ if (++calls === 1) return response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }])
+ throw Error('서버 오류')
+ })
+ await h.requestPlan('확인', [{ name: 'Camera', files: [] }], [], { executeTool: async () => result({ status: 'error' }) })
+ assert.equal(h.state.messages.at(-1).content, '서버 오류')
+ assert.equal(h.state.steps.length, 0)
+ assert.equal(h.state.pending, false)
+ assert.equal(h.state.requestStartedAt, null)
+})
+
+test('stopping clears progress and a late response cannot overwrite a newer request', async () => {
+ const old = deferred(), next = deferred()
+ let calls = 0
+ const h = harness(() => (++calls === 1 ? old.promise : next.promise))
+ const first = h.requestPlan('첫 질문', [], [])
+ h.stop()
+ assert.equal(h.state.requestStartedAt, null)
+ assert.equal(h.state.steps.length, 0)
+ assert.equal(h.state.activity, '')
+ const second = h.requestPlan('다음 질문', [], [])
+ old.resolve(response())
+ await first
+ assert.equal(h.state.pending, true)
+ assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 0)
+ next.resolve(response())
+ await second
+ assert.equal(h.state.pending, false)
+ assert.equal(h.state.steps.length, 0)
+ assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 1)
+})
+
+test('scope changes suppress late progress callbacks', async () => {
+ const tool = deferred()
+ const h = harness(async () => response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }]))
+ const running = h.requestPlan('확인', [], [], { executeTool: () => tool.promise })
+ await tick()
+ h.messageStateRef.current.session = {}
+ const previous = h.state.steps.length
+ h.stop()
+ tool.resolve(result())
+ await running
+ assert.equal(previous, 1)
+ assert.equal(h.state.steps.length, 0)
+ assert.equal(h.state.notices, 0)
+})
+
+test('source context stages report missing and limited modules and empty logs honestly', async () => {
+ const steps = []
+ let calls = 0
+ await context.resolveAgentContext({
+ signal: new AbortController().signal,
+ initialContext: { modules: [{ name: 'Missing', files: [] }, { name: 'Limited', files: [] }], runtimeLogs: [] },
+ onStep: step => steps.push(step),
+ requestPlan: async () => ++calls === 1 ? { ...response(), plan: { ...response().plan, requestedModules: ['Missing', 'Limited'], requestedLogs: ['Missing'] } } : response(),
+ loadRequestedContext: async () => ({ modules: [{ name: 'Missing', files: [], fileContext: { status: 'uninstalled' } }, { name: 'Limited', files: [{ path: 'x', content: 'PRIVATE_SOURCE' }], fileContext: { status: 'limited' } }], runtimeLogs: [] })
+ })
+ assert.deepEqual(plain(steps).map(step => [step.kind, step.status]), [['analysis', 'ok'], ['files', 'missing'], ['files', 'partial'], ['logs', 'empty'], ['analysis', 'ok']])
+ assert.equal(JSON.stringify(steps).includes('PRIVATE_SOURCE'), false)
+})
+
+test('tool stages distinguish missing files, partial reads and counts without exposing raw output', async () => {
+ const steps = []
+ const tools = [
+ { id: 'missing', name: 'files.read', moduleName: 'Camera', args: { path: 'missing.yaml' } },
+ { id: 'partial', name: 'files.read', moduleName: 'Camera', args: { path: 'large.py' } },
+ { id: 'list', name: 'files.list', moduleName: 'Camera' },
+ { id: 'search', name: 'files.search', moduleName: 'Camera', args: { query: 'fps' } }
+ ]
+ let calls = 0
+ await context.resolveAgentContext({
+ signal: new AbortController().signal, initialContext: { modules: [], runtimeLogs: [] }, onStep: step => steps.push(step),
+ requestPlan: async () => response(++calls === 1 ? tools : []),
+ executeTool: async tool => result({ data: JSON.stringify(tool.args?.path === 'missing.yaml' ? { exists: false } : tool.args?.path === 'large.py' ? { complete: false } : tool.name === 'files.list' ? { paths: ['a', 'b'] } : { matches: [] }) })
+ })
+ assert.deepEqual(plain(steps.filter(step => step.kind === 'tool')).map(step => [step.status, step.count]), [['missing', undefined], ['partial', undefined], ['ok', 2], ['ok', 0]])
+})
diff --git a/scripts/test-agent-response-reveal.cjs b/scripts/test-agent-response-reveal.cjs
index 4d3d423..827e2ae 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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}
+ '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
})
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
@@ -168,7 +168,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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {},
+ '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
@@ -192,7 +192,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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {},
+ '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
})
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
diff --git a/scripts/test-agent-work-duration.cjs b/scripts/test-agent-work-duration.cjs
new file mode 100644
index 0000000..67db19e
--- /dev/null
+++ b/scripts/test-agent-work-duration.cjs
@@ -0,0 +1,87 @@
+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 harness(initialNow = 1000) {
+ let now = initialNow, state, effect, interval, cleared = 0
+ const windowEvents = new Map(), documentEvents = new Map()
+ class Clock extends Date { static now() { return now } }
+ const exports = {}
+ vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
+ }).outputText, {
+ exports, Date: Clock,
+ window: {
+ setInterval(callback, delay) { assert.equal(delay, 1000); interval = callback; return 7 },
+ clearInterval(id) { assert.equal(id, 7); interval = undefined; cleared++ },
+ addEventListener: (name, callback) => windowEvents.set(name, callback),
+ removeEventListener: name => windowEvents.delete(name)
+ },
+ document: {
+ addEventListener: (name, callback) => documentEvents.set(name, callback),
+ removeEventListener: name => documentEvents.delete(name)
+ },
+ require(name) {
+ if (name === '../i18n') return { t: (key, values) => key.replace(/\{\{seconds\}\}/g, values.seconds), useLocaleRender() {} }
+ if (name === 'react') return { ...React,
+ useState(initial) { state ??= typeof initial === 'function' ? initial() : initial; return [state, value => { state = value }] },
+ useEffect(callback) { effect = callback }
+ }
+ return require(name)
+ }
+ })
+ return {
+ render: props => renderToStaticMarkup(exports.AgentWorkDuration({ dark: true, ...props })),
+ mount: () => effect(), advance: ms => { now += ms }, tick: () => interval?.(),
+ visible: () => documentEvents.get('visibilitychange')?.(), focus: () => windowEvents.get('focus')?.(),
+ interval: () => interval, cleared: () => cleared,
+ listeners: () => windowEvents.size + documentEvents.size
+ }
+}
+
+test('running duration is based on start time, not the number of interval ticks', () => {
+ const h = harness()
+ assert.match(h.render({ startedAt: 1000 }), /0초 동안 작업 중입니다/)
+ const cleanup = h.mount()
+ h.advance(12_900)
+ h.tick()
+ assert.match(h.render({ startedAt: 1000 }), /12초 동안 작업 중입니다/)
+ h.advance(60_000)
+ h.visible()
+ assert.match(h.render({ startedAt: 1000 }), /72초 동안 작업 중입니다/)
+ h.advance(5_000)
+ h.focus()
+ assert.match(h.render({ startedAt: 1000 }), /77초 동안 작업 중입니다/)
+ assert.equal(h.listeners(), 2)
+ cleanup()
+ assert.equal(h.interval(), undefined)
+ assert.equal(h.listeners(), 0)
+ assert.equal(h.cleared(), 1)
+})
+
+test('returning to a remounted chat shows the full elapsed time immediately', () => {
+ const h = harness(74_000)
+ assert.match(h.render({ startedAt: 1000 }), /73초 동안 작업 중입니다/)
+})
+
+test('completed duration stays fixed and never starts a timer', () => {
+ const h = harness()
+ assert.match(h.render({ startedAt: 1000, durationMs: 20_900 }), /20초 동안 작업함/)
+ assert.equal(h.mount(), undefined)
+ h.advance(60_000)
+ h.tick()
+ assert.match(h.render({ durationMs: 20_900 }), /20초 동안 작업함/)
+ assert.equal(h.interval(), undefined)
+ assert.equal(h.listeners(), 0)
+ assert.match(h.render({ durationMs: 0 }), /0초 동안 작업함/)
+})
+
+test('legacy replies and invalid timing do not invent an elapsed duration', () => {
+ const h = harness()
+ for (const durationMs of [undefined, -1, NaN, Infinity]) assert.equal(h.render({ durationMs }), '')
+ assert.match(h.render({ startedAt: 2000 }), /0초 동안 작업 중입니다/)
+})
diff --git a/scripts/test-ai-response-presentation.cjs b/scripts/test-ai-response-presentation.cjs
index 4cd3357..52b0027 100644
--- a/scripts/test-ai-response-presentation.cjs
+++ b/scripts/test-ai-response-presentation.cjs
@@ -16,19 +16,30 @@ 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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}
+ '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
})
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 ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
+const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
+ '../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
+})
+const { AgentProgressBubble } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentProgressBubble.tsx'), 'utf8'), {
+ '../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} },
+ react: { ...React, useEffect() {}, useRef: () => ({ current: null }) },
+ './AgentWorkDuration': { AgentWorkDuration }
+})
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
t: key => key, useLocaleRender() {},
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse,
ChatImageAttachments,
+ AgentProgressBubble,
+ AgentWorkDuration,
MessageCopyButton: () => null,
+ agentMessageCopyText: message => message.content,
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
})
@@ -76,6 +87,33 @@ test('AI answer forwards reveal timing and collapse settings to the same present
assert.match(html, /전체 답변/)
})
+for (const dark of [true, false]) {
+ test(`agent progress accumulates only during the request and is replaced by the final reply (${dark ? 'dark' : 'light'})`, () => {
+ const progressSteps = [
+ { id: 'analysis-0', kind: 'analysis', status: 'ok', round: 1, requests: 1 },
+ { id: 'tool-0-0', kind: 'tool', name: 'files.list', moduleName: 'Camera', status: 'ok', count: 3 },
+ { id: 'tool-1-0', kind: 'tool', name: 'files.read', moduleName: 'Camera', path: 'config/camera.yaml', status: 'missing' },
+ { id: 'tool-1-1', kind: 'tool', name: 'logs.read', moduleName: 'Camera', status: 'error' },
+ { id: 'tool-1-2', kind: 'tool', name: 'files.search', moduleName: 'Camera', status: 'partial', count: 2 }
+ ]
+ const props = { dark, enabled: true, statusLoading: false, pending: true, activity: '조회 결과 분석 중', progressSteps, messages: [] }
+ const html = renderToStaticMarkup(messages.AgentMessages(props))
+ assert.equal((html.match(/data-agent-progress="true"/g) ?? []).length, 1)
+ assert.match(html, /1차 분석 완료 · 추가 조회 1건/)
+ assert.match(html, /Camera · 파일 목록 · 조회 완료 · 3건/)
+ assert.match(html, /Camera · 파일 읽기 · config\/camera.yaml · 대상 없음/)
+ assert.match(html, /Camera · 로그 조회 · 조회 실패/)
+ assert.match(html, /Camera · 파일 검색 · 일부 결과 · 2건/)
+ assert.match(html, /조회 결과 분석 중/)
+ assert.equal((html.match(/agent-response-fade/g) ?? []).length, 5)
+ const final = renderToStaticMarkup(messages.AgentMessages({ ...props, pending: false, messages: [{ id: 'answer', role: 'assistant', content: '최종 답변입니다.', durationMs: 20_900 }] }))
+ assert.doesNotMatch(final, /data-agent-progress|config\/camera.yaml|진행 상황/)
+ assert.match(final, /최종 답변입니다/)
+ assert.match(final, /20초 동안 작업함/)
+ assert.ok(final.indexOf('20초 동안 작업함') < final.indexOf('최종 답변입니다'))
+ })
+}
+
for (const dark of [false, true]) {
for (const [mode, expected] of [
['AiMessages', /AI에게 질문할
내용을 입력하세요/],
diff --git a/scripts/test-chat-clear-confirmation.cjs b/scripts/test-chat-clear-confirmation.cjs
index ea356d8..7b72a6e 100644
--- a/scripts/test-chat-clear-confirmation.cjs
+++ b/scripts/test-chat-clear-confirmation.cjs
@@ -39,6 +39,9 @@ function setup(overrides = {}, browser = {}) {
'../../components/ChatWidgets': { QaInputBox },
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
'../../components/ChatImageAttachments': {},
+ '../../components/AgentProgressBubble': {},
+ '../../components/AgentWorkDuration': {},
+ '../../components/AgentCodeReferences': {},
'react-dom': { createPortal: child => child },
react: {
...React,
diff --git a/scripts/test-chat-input-autosize.cjs b/scripts/test-chat-input-autosize.cjs
index 3554451..ef748d1 100644
--- a/scripts/test-chat-input-autosize.cjs
+++ b/scripts/test-chat-input-autosize.cjs
@@ -36,6 +36,7 @@ function harness() {
if (name === './chatAttachments') return {}
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
+ if (name === './ChatCodeBlock') return {}
if (name === 'react') return {
...React,
useState(initial) {
diff --git a/scripts/test-chat-markdown.cjs b/scripts/test-chat-markdown.cjs
new file mode 100644
index 0000000..caf3cf5
--- /dev/null
+++ b/scripts/test-chat-markdown.cjs
@@ -0,0 +1,111 @@
+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), clearTimeout, setTimeout, ...globals
+ })
+ return exports
+}
+const i18n = { t: key => key, useLocaleRender() {} }
+const block = compile('../src/renderer/src/components/ChatCodeBlock.tsx', { '../i18n': i18n })
+const { MarkdownMessage } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
+ '../i18n': i18n, './ChatCodeBlock': block, './chatAttachments': {}, './ChatImageAttachments': {}, './ChatTextContextMenu': {}
+})
+const render = (content, dark = true, extra = {}) => renderToStaticMarkup(React.createElement(MarkdownMessage, { content, dark, ...extra }))
+
+for (const dark of [true, false]) {
+ test(`renders numbered/nested/task lists without losing the starting number (${dark})`, () => {
+ const html = render('3. 설정 확인\n - 속도 제한\n - 조향 제한\n4. 결과 확인\n\n- [x] 완료\n- [ ] 대기', dark)
+ assert.match(html, /