feat: improve agent responses and workspace UI

Add live agent progress, elapsed timing, verified code navigation and rich Markdown rendering. Preserve NanumSquare styling with missing glyph fallback, fix platform availability filtering and unify workspace scrollbar and visualization controls.
This commit is contained in:
2026-10-19 17:06:18 +09:00
parent af21fa0510
commit 8b501266ff
33 changed files with 2231 additions and 98 deletions
+85
View File
@@ -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)}")
+172
View File
@@ -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)
})
+1
View File
@@ -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
+157
View File
@@ -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]])
})
+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', () => {
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 })
+87
View File
@@ -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초 동안 작업 중입니다/)
})
+39 -1
View File
@@ -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에게 질문할<br\/>내용을 입력하세요/],
+3
View File
@@ -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,
+1
View File
@@ -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) {
+111
View File
@@ -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, /<ol start="3" class="[^"]*list-decimal/)
assert.match(html, /<ul class="[^"]*list-disc/)
assert.match(html, /contains-task-list list-none/)
assert.match(html, /task-list-item list-none/)
assert.equal((html.match(/type="checkbox"/g) ?? []).length, 2)
})
test(`renders GFM tables with keyboard-scrollable framing and column alignment (${dark})`, () => {
const html = render('| 모듈 | 속도 |\n| --- | ---: |\n| Control | `0.3 m/s` |', dark)
assert.match(html, /data-chat-table/)
assert.match(html, /tabindex="0" role="region" aria-label="표"/)
assert.match(html, /overflow-x-auto/)
assert.match(html, /<table class="[^"]*table-auto/)
assert.match(html, /text-align:right/)
assert.match(html, /chat-inline-code/)
})
test(`renders fenced Python with language, token colors, copy control and preserved indentation (${dark})`, () => {
const html = render('```python\ndef speed():\n return 0.3\n```', dark)
assert.match(html, /data-chat-code-block/)
assert.ok(html.includes(`data-code-theme="${dark ? 'dark' : 'light'}"`))
assert.match(html, />Python<\/span>/)
assert.match(html, /aria-label="코드 복사"/)
assert.match(html, /hljs-keyword/)
assert.match(html, /hljs-number/)
assert.match(html, /\n <span/)
assert.match(html, /max-h-80 max-w-full overflow-auto/)
assert.match(html, /whitespace-pre font-mono/)
assert.doesNotMatch(html, /chat-inline-code/)
})
}
test('unlabelled and unknown languages remain code blocks rather than inline code', () => {
for (const language of ['', 'unknown-language']) {
const html = render('```' + language + '\n unformatted <value>\n```')
assert.match(html, /data-chat-code-block/)
assert.ok(html.includes(language || '일반 텍스트'))
assert.ok(html.includes(' unformatted &lt;value&gt;'))
assert.doesNotMatch(html, /hljs-/)
}
})
test('HTML-looking code is rendered as escaped text, never live elements', () => {
const html = render('```html\n<script>window.injected = true</script>\n<img src=x onerror="window.injected=true">\n```')
assert.doesNotMatch(html, /<script\b|<img\b/)
assert.match(html, /&lt;/)
assert.match(html, /window.injected/)
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatCodeBlock.tsx'), 'utf8')
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
})
test('only newly revealed code and tables receive the fade class', () => {
const markdown = '설명\n\n```yaml\nspeed: 0.3\n```\n\n| 키 | 값 |\n| --- | --- |\n| speed | 0.3 |'
const html = render(markdown, true, { fadeFrom: markdown.indexOf('| 키') })
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
assert.match(html, /overflow-x-auto rounded-md agent-response-fade/)
})
test('oversized code skips tokenization but still preserves all content', () => {
const source = '# comment\n'.repeat(6000)
const html = renderToStaticMarkup(React.createElement(block.ChatCodeBlock, { source, language: 'python', dark: true }))
assert.doesNotMatch(html, /hljs-comment/)
assert.equal((html.match(/# comment/g) ?? []).length, 6000)
})
function elements(tree, predicate) {
if (!React.isValidElement(tree)) return []
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
}
for (const fails of [false, true]) test(`code copy preserves the full source and reports clipboard ${fails ? 'failure' : 'success'}`, async () => {
let state = 'idle', copied, timer
const api = compile('../src/renderer/src/components/ChatCodeBlock.tsx', {
'../i18n': i18n,
react: { ...React, useState: () => [state, value => { state = value }], useMemo: fn => fn(), useRef: () => ({ current: null }), useEffect() {} }
}, { navigator: { clipboard: { writeText: async text => { if (fails) throw Error('denied'); copied = text } } }, setTimeout: callback => { timer = callback; return 1 }, clearTimeout() {} })
const source = '\t# 한글 주석\n speed = 0.3\n\n'
const renderBlock = () => api.ChatCodeBlock({ source, language: 'python', dark: true })
const button = elements(renderBlock(), element => element.type === 'button')[0]
let stopped = false
button.props.onClick({ stopPropagation() { stopped = true } })
await new Promise(resolve => setImmediate(resolve))
assert.equal(stopped, true)
assert.equal(state, fails ? 'error' : 'copied')
if (!fails) assert.equal(copied, source)
assert.equal(elements(renderBlock(), element => element.type === 'button')[0].props['aria-label'], fails ? '코드를 복사하지 못했습니다.' : '복사됨')
timer()
assert.equal(state, 'idle')
})
+1 -1
View File
@@ -125,7 +125,7 @@ for (const selected of [false, true]) {
let prevented = false
let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null },
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, './ChatCodeBlock': {},
react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) }
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
+61
View File
@@ -0,0 +1,61 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const crypto = require('node:crypto')
const postcss = require('postcss')
const root = path.resolve(__dirname, '..')
const source = fs.readFileSync(path.join(root, 'src/renderer/src/styles/nanumSquare.css'), 'utf8')
const css = postcss.parse(source)
const faces = []
css.walkAtRules('font-face', rule => {
faces.push(Object.fromEntries(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node.value])))
})
function codepoints(range) {
const result = new Set()
let previous = -1
for (const entry of range.split(',')) {
const match = entry.trim().match(/^U\+([0-9A-F]+)(?:-([0-9A-F]+))?$/)
assert.ok(match, `Invalid unicode-range: ${entry}`)
const start = parseInt(match[1], 16)
const end = parseInt(match[2] ?? match[1], 16)
assert.ok(start > previous && end >= start && end <= 0x10ffff)
for (let code = start; code <= end; code++) result.add(code)
previous = end
}
return result
}
test('coverage is tied to the original bundled font bytes for all four weights', () => {
assert.equal(faces.length, 4)
assert.deepEqual(faces.map(face => face['font-weight']), ['300', '400', '700', '800'])
for (const face of faces) {
assert.equal(face['font-family'], "'NanumSquare'")
assert.equal(face['font-display'], 'swap')
const filename = face.src.match(/\/([^/]+\.woff2)'/)[1]
const hash = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'src/renderer/public/assets/fonts', filename))).digest('hex')
assert.ok(source.includes(`${filename} sha256: ${hash}`), 'Regenerate coverage if a font changes')
}
})
for (const face of faces) {
test(`weight ${face['font-weight']} preserves visible Hangul and lets blank glyphs fall back`, () => {
const included = codepoints(face['unicode-range'])
assert.equal([...included].filter(code => code >= 0xac00 && code <= 0xd7a3).length, 2479)
for (const char of '가모한글학생SLAM0123 →') assert.ok(included.has(char.codePointAt(0)), `Keep ${char}`)
for (const char of ['몯', '힣', '\u2022']) assert.equal(included.has(char.codePointAt(0)), false, `Let ${char} use fallback`)
for (const code of [0x20, 0x3000, 0x3164]) assert.ok(included.has(code), 'Preserve intentional spacing')
})
}
test('UI keeps its original default font and native fallback without Neo', () => {
const entry = fs.readFileSync(path.join(root, 'src/renderer/src/index.css'), 'utf8')
assert.match(entry, /@import "\.\/styles\/nanumSquare\.css"/)
assert.match(entry, /font-family: 'NanumSquare', Inter, system-ui, sans-serif/)
assert.doesNotMatch(entry + source, /NanumSquareNeo/)
const directFaces = []
postcss.parse(entry).walkAtRules('font-face', rule => directFaces.push(rule))
assert.equal(directFaces.length, 0, 'Unrestricted duplicate faces would suppress fallback')
})
+86
View File
@@ -0,0 +1,86 @@
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 source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const names = ['isRemotePlatformStatusPending', 'filteredPlatformOptions', 'platformFilterCounts']
const declarations = new Map()
function visit(node) {
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(source))) {
declarations.set(node.name.getText(source), node.getText(source))
}
ts.forEachChild(node, visit)
}
visit(source)
for (const name of names) assert.ok(declarations.has(name), `missing ${name}`)
const filterScript = ts.transpileModule(`${names.map(name => `const ${declarations.get(name)}`).join('\n')}
result = { counts: platformFilterCounts, ids: filteredPlatformOptions.map(platform => platform.id) }`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 }
}).outputText
const controlSource = fs.readFileSync(require.resolve('../src/renderer/src/features/platforms/platformControl.ts'), 'utf8')
const control = {}
vm.runInNewContext(ts.transpileModule(controlSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, {
exports: control,
require: name => {
assert.equal(name, '../../config/appConfig')
return { LMS_API_BASE_URL: 'http://unused.invalid' }
}
})
function classify(platformOptions, checkedIds = ['mini'], platformFilter = 'all') {
const context = {
platformOptions, platformFilter,
checkedRemotePlatformStatusIds: new Set(checkedIds),
isPlatformUnavailable: control.isPlatformUnavailable
}
vm.runInNewContext(filterScript, context)
return JSON.parse(JSON.stringify(context.result))
}
const simulation = { id: 'sim', platformKey: 'futuredrive-local-pc', state: 'ready', status: '사용 가능', action: '시작' }
const mini = { id: 'mini', platformKey: 'mkmini', state: 'ready', status: '사용 가능', action: '제어권 요청', connection: { headscaleOnline: true, agentHealthy: true } }
test('disconnected ready platform counts as unavailable and appears only in that tab', () => {
const disconnected = { ...mini, status: '연결 끊김', connection: { headscaleOnline: false, agentHealthy: false } }
const platforms = [simulation, disconnected]
assert.deepEqual(classify(platforms), { counts: { all: 2, available: 1, unavailable: 1 }, ids: ['sim', 'mini'] })
assert.deepEqual(classify(platforms, ['mini'], 'available').ids, ['sim'])
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
})
test('offline network, unhealthy agent and explicit unavailability all exclude ready platforms', () => {
for (const patch of [
{ connection: { headscaleOnline: false, agentHealthy: true } },
{ connection: { headscaleOnline: true, agentHealthy: false } },
{ connection: undefined, status: '연결 끊김' },
{ connection: undefined, status: '연결 불가' },
{ connection: undefined, action: '연결 불가' }
]) {
const platforms = [simulation, { ...mini, ...patch }]
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
}
})
test('pending remote status remains in All without prematurely entering either availability tab', () => {
const platforms = [simulation, mini]
assert.deepEqual(classify(platforms, []).counts, { all: 2, available: 1, unavailable: 0 })
assert.deepEqual(classify(platforms, [], 'available').ids, ['sim'])
assert.deepEqual(classify(platforms, [], 'unavailable').ids, [])
})
test('reconnecting moves a platform back to available without changing All', () => {
assert.deepEqual(classify([simulation, mini]).counts, { all: 2, available: 2, unavailable: 0 })
assert.deepEqual(classify([simulation, mini], ['mini'], 'available').ids, ['sim', 'mini'])
assert.deepEqual(classify([simulation, mini], ['mini'], 'unavailable').ids, [])
})
test('locked, requested and reserved platform classifications are preserved', () => {
for (const state of ['locked', 'request', 'reserved']) {
const platforms = [simulation, { ...mini, state }]
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
}
})
+15
View File
@@ -46,3 +46,18 @@ test('theme synchronizes across windows without writing runtime settings', () =>
events.get('storage')({ key: null })
assert.equal(api.useStudioTheme(), 'dark')
})
test('module content horizontal scrollbar uses the shared workspace scrollbar style', () => {
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const scrollClasses = []
function visit(node) {
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer && ts.isStringLiteral(node.initializer)) {
const classes = node.initializer.text.split(/\s+/)
if (classes.includes('overflow-x-auto')) scrollClasses.push(classes)
}
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(scrollClasses.length > 0)
for (const classes of scrollClasses) assert.ok(classes.includes('thin-x-scrollbar'))
})