feat: add structured agent tools and improve workspace chat UX

This commit is contained in:
2026-10-19 11:30:23 +09:00
parent 098af30fa7
commit cc589fd8cf
35 changed files with 2831 additions and 247 deletions
+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')
const source = fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx'), 'utf8')
const output = {}
let videos = []
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports: output,
require: name => name === '../../i18n' ? { t: key => key, useLocaleRender() {} }
: name === 'react' ? { ...React, useId: () => 'guide' } : require(name),
URL,
window: { learningVideo: { open: async url => { videos.push(url) } } }
})
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 setup(overrides = {}) {
const toggled = []
const props = {
dark: true, workspaceActive: true, minimized: false, resourceMode: 'material',
materialItems: [{ title: 'SLAM', description: '센서 토픽과 TF 상태를 점검하고 지도를 생성합니다.', concepts: ['TF 점검', '지도 저장'], videoUrl: '' }],
activeMaterialIndex: 0, expandedMaterialIndexes: [0], topics: [], topicDetails: [], overview: '',
resources: [], downloadingResourceIds: [], onToggleMaterial: index => toggled.push(index), ...overrides
}
const tree = output.default(props)
const headers = elements(tree, node => node.type === 'button' && node.props['aria-controls'])
return { tree, headers, toggled }
}
for (const dark of [false, true]) {
test(`only the material header toggles the card (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
assert.equal(headers.length, 1)
const header = headers[0]
assert.equal(header.props.type, 'button')
assert.equal(header.props['aria-expanded'], true)
assert.equal(header.props.disabled, false)
assert.doesNotMatch(header.props.className, /hover:bg/)
assert.match(header.props.className, /focus-visible:ring-2/)
const card = elements(tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.ok(card)
assert.equal(card.props.onClick, undefined)
assert.equal(card.props.onKeyDown, undefined)
assert.equal(card.props.role, undefined)
assert.equal(card.props.tabIndex, undefined)
header.props.onClick()
assert.deepEqual(toggled, [0])
})
test(`description is selectable outside the interactive header (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
const contentId = headers[0].props['aria-controls']
const body = elements(tree, node => node.props.id === contentId)[0]
assert.match(body.props.className, /select-text/)
assert.equal(body.props.onClick, undefined)
assert.equal(elements(headers[0], node => node.props.id === contentId).length, 0)
assert.ok(renderToStaticMarkup(body).includes('센서 토픽과 TF 상태'))
assert.equal(elements(body, node => Boolean(node.props.onClick || node.props.onKeyDown)).length, 0)
assert.deepEqual(toggled, [])
})
test(`header padding and dimensions stay identical when toggled (${dark ? 'dark' : 'light'})`, () => {
const expanded = setup({ dark, expandedMaterialIndexes: [0] })
const collapsed = setup({ dark, expandedMaterialIndexes: [] })
for (const state of [expanded, collapsed]) {
const header = state.headers[0]
const card = elements(state.tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.match(card.props.className, /\bpx-3 pt-3\b/)
assert.doesNotMatch(card.props.className, /px-2\.5|pt-2\.5/)
assert.match(header.props.className, /\bflex h-6 w-full\b/)
}
assert.equal(expanded.headers[0].props.className, collapsed.headers[0].props.className)
})
}
test('future material remains disabled, while previous/current steps keep native keyboard buttons', () => {
const items = ['이전', '현재', '다음'].map(title => ({ title, description: '', videoUrl: '' }))
const { headers } = setup({ materialItems: items, activeMaterialIndex: 1, expandedMaterialIndexes: [1] })
assert.deepEqual(headers.map(header => header.props.disabled), [false, false, true])
assert.equal(headers[2].props.onClick, undefined)
for (const header of headers) {
assert.equal(header.props.type, 'button')
assert.equal(header.props.onKeyDown, undefined)
}
assert.ok(setup({ materialItems: items, allowFutureMaterialSteps: true }).headers.every(header => !header.props.disabled))
})
test('video playback is not nested in the collapse button and does not toggle the card', async () => {
videos = []
const { tree, headers, toggled } = setup({ materialItems: [{ title: 'SLAM', description: '설명', videoUrl: 'https://www.youtube.com/watch?v=test123' }] })
const video = elements(tree, node => node.type === 'button' && node.props.className?.includes('aspect-video'))[0]
assert.ok(video)
assert.equal(elements(headers[0], node => node === video).length, 0)
video.props.onClick({ stopPropagation() {} })
await Promise.resolve()
assert.deepEqual(videos, ['https://www.youtube.com/embed/test123'])
assert.deepEqual(toggled, [])
for (const button of elements(tree, node => node.type === 'button')) {
assert.equal(React.Children.toArray(button.props.children).flatMap(child => elements(child, node => node.type === 'button')).length, 0)
}
})