fix: simplify code navigation and support Gazebo XML files
Remove redundant code-view cancellation and align button labels. Restore module scrollbar thickness while preserving workspace colors, and support config, SDF and world text files locally and remotely.
This commit is contained in:
@@ -71,7 +71,7 @@ test('does not decorate another file, a hidden/diff editor, stale scope or unrea
|
|||||||
|
|
||||||
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
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 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)))
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(hookSource) === 'openCodeReference')
|
||||||
function consentHarness() {
|
function consentHarness() {
|
||||||
const session = {}
|
const session = {}
|
||||||
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
|
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
|
||||||
@@ -79,19 +79,23 @@ function consentHarness() {
|
|||||||
return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
|
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 () => {
|
test('code navigation opens only when the user selects View in editor', async () => {
|
||||||
const h = consentHarness()
|
const h = consentHarness()
|
||||||
let opened = 0
|
let opened = 0
|
||||||
const open = async () => { opened++ }
|
const open = async () => { opened++ }
|
||||||
assert.equal(opened, 0)
|
assert.equal(opened, 0)
|
||||||
h.cancelCodeReference('answer', 'location')
|
|
||||||
await h.openCodeReference('answer', 'location', open)
|
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(opened, 1)
|
||||||
assert.equal(approved.message().codeReferenceStates.location.status, 'opened')
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy cancelled references can be opened with the same read-only navigation action', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
h.message().codeReferenceStates = { location: { status: 'cancelled' } }
|
||||||
|
let opened = 0
|
||||||
|
await h.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
||||||
@@ -99,8 +103,6 @@ test('duplicate clicks, invalid IDs and busy tasks do not open more files', asyn
|
|||||||
let finish, opened = 0
|
let finish, opened = 0
|
||||||
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
|
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
|
||||||
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
|
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++ })
|
await h.openCodeReference('answer', 'location', async () => { opened++ })
|
||||||
finish()
|
finish()
|
||||||
await first
|
await first
|
||||||
@@ -158,15 +160,23 @@ function text(tree) {
|
|||||||
return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
|
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
|
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})`, () => {
|
for (const dark of [true, false]) test(`code navigation has only a vertically aligned View in editor button (${dark})`, () => {
|
||||||
const clicked = []
|
const clicked = []
|
||||||
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`), onCancel: id => clicked.push(`cancel:${id}`) }
|
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`) }
|
||||||
const buttons = elements(render(props), element => element.type === 'button')
|
const buttons = elements(render(props), element => element.type === 'button')
|
||||||
assert.equal(clicked.length, 0)
|
assert.equal(clicked.length, 0)
|
||||||
assert.deepEqual(buttons.map(text), ['취소', '코드에서 보기'])
|
assert.deepEqual(buttons.map(text), ['코드에서 보기'])
|
||||||
buttons[1].props.onClick()
|
const label = elements(buttons[0], element => element.type === 'span')[0]
|
||||||
|
assert.match(buttons[0].props.className, /items-center.*leading-none/)
|
||||||
|
assert.match(label.props.className, /top-px leading-none/)
|
||||||
|
buttons[0].props.onClick()
|
||||||
assert.deepEqual(clicked, ['open:location'])
|
assert.deepEqual(clicked, ['open:location'])
|
||||||
for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
|
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)
|
for (const status of ['cancelled', 'opened', 'error']) {
|
||||||
|
assert.deepEqual(elements(render({ ...props, states: { location: { status } } }), element => element.type === 'button').map(text), ['코드에서 보기'])
|
||||||
|
}
|
||||||
|
const opening = elements(render({ ...props, states: { location: { status: 'opening' } } }), element => element.type === 'button')[0]
|
||||||
|
assert.equal(opening.props.disabled, true)
|
||||||
|
assert.equal(text(opening), '파일 여는 중...')
|
||||||
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
|
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test('theme synchronizes across windows without writing runtime settings', () =>
|
|||||||
assert.equal(api.useStudioTheme(), 'dark')
|
assert.equal(api.useStudioTheme(), 'dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('module content horizontal scrollbar uses the shared workspace scrollbar style', () => {
|
test('module content scrollbar uses workspace colors without thinner scrollbar dimensions', () => {
|
||||||
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
const scrollClasses = []
|
const scrollClasses = []
|
||||||
function visit(node) {
|
function visit(node) {
|
||||||
@@ -59,5 +59,8 @@ test('module content horizontal scrollbar uses the shared workspace scrollbar st
|
|||||||
}
|
}
|
||||||
visit(source)
|
visit(source)
|
||||||
assert.ok(scrollClasses.length > 0)
|
assert.ok(scrollClasses.length > 0)
|
||||||
for (const classes of scrollClasses) assert.ok(classes.includes('thin-x-scrollbar'))
|
for (const classes of scrollClasses) {
|
||||||
|
assert.ok(classes.includes('module-content-scrollbar'))
|
||||||
|
assert.ok(!classes.includes('thin-x-scrollbar'))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const os = require('node:os')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const { execFile } = require('node:child_process')
|
||||||
|
const { promisify } = require('node:util')
|
||||||
|
const exec = promisify(execFile)
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const placeholder = 'Binary or large file preview is not available.'
|
||||||
|
const xml = '<?xml version="1.0"?>\n<model><name>mkmini_gazebo</name><description>주행 모델</description></model>\n'
|
||||||
|
|
||||||
|
function compile(source, context = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, require, Buffer, ...context })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(run) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-xml-editor-'))
|
||||||
|
try {
|
||||||
|
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) await fs.writeFile(path.join(dir, name), xml)
|
||||||
|
await fs.writeFile(path.join(dir, 'large.config'), 'x'.repeat(1024 * 1024 + 1))
|
||||||
|
await fs.writeFile(path.join(dir, 'model.glb'), Buffer.from([0, 1, 2, 3]))
|
||||||
|
await run(dir)
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('local module and root collectors read small Gazebo XML files without changing binary and size exclusions', async () => {
|
||||||
|
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const functions = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['collectModuleFiles', 'collectRootFiles'].includes(node.name?.text))
|
||||||
|
const pattern = source.statements.find(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(source) === 'textFilePattern')
|
||||||
|
assert.equal(functions.length, 2)
|
||||||
|
assert.ok(pattern)
|
||||||
|
await fixture(async dir => {
|
||||||
|
const api = compile([pattern.getText(source), ...functions.map(node => `export ${node.getText(source)}`)].join('\n'), {
|
||||||
|
readdir: fs.readdir, stat: fs.stat, readFile: fs.readFile,
|
||||||
|
resolve: path.resolve, basename: path.basename,
|
||||||
|
skippedCacheDirs: new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']),
|
||||||
|
findModuleRoot: async () => dir,
|
||||||
|
toRendererModulePath: (name, file, base) => `modules/${name}/${path.relative(base, file)}`,
|
||||||
|
toRendererRootPath: (name, file, base) => `${name}/${path.relative(base, file)}`
|
||||||
|
})
|
||||||
|
for (const result of [await api.collectModuleFiles('NAV2-MKMini'), await api.collectRootFiles(dir, 'examples')]) {
|
||||||
|
const files = Object.fromEntries(Object.entries(result.files).map(([file, content]) => [path.basename(file), content]))
|
||||||
|
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal(files[name], xml)
|
||||||
|
for (const name of ['large.config', 'model.glb']) assert.equal(files[name], placeholder)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote listing, content and search include Gazebo XML while preserving size, NUL and path protections', async () => {
|
||||||
|
const api = compile(await fs.readFile(path.join(root, 'src/main/remoteModuleFiles.ts'), 'utf8'))
|
||||||
|
await fixture(async dir => {
|
||||||
|
const request = async input => JSON.parse((await exec('bash', ['-c', api.remoteModuleFilesScript(dir, input)], { timeout: 10000 })).stdout)
|
||||||
|
const listing = await request({ action: 'list' })
|
||||||
|
assert.ok(listing.filePaths.includes('model.config'))
|
||||||
|
for (const file of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal((await request({ action: 'content', path: file })).content, xml)
|
||||||
|
for (const file of ['large.config', 'model.glb']) assert.equal((await request({ action: 'content', path: file })).content, placeholder)
|
||||||
|
await fs.writeFile(path.join(dir, 'binary.config'), Buffer.from('text\0binary'))
|
||||||
|
assert.equal((await request({ action: 'content', path: 'binary.config' })).content, placeholder)
|
||||||
|
const matches = await request({ action: 'search', query: 'mkmini_gazebo' })
|
||||||
|
assert.deepEqual(matches.map(match => match.file).sort(), ['MODEL.CONFIG', 'model.config', 'model.sdf', 'simulation.world'].sort())
|
||||||
|
await assert.rejects(request({ action: 'content', path: '../outside.config' }), /Invalid module path/)
|
||||||
|
await fs.symlink(path.join(dir, '..'), path.join(dir, 'escape'))
|
||||||
|
await assert.rejects(request({ action: 'content', path: 'escape/outside.config' }), /Invalid module path/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Gazebo model.config, SDF and world files use XML highlighting without treating arbitrary config formats as XML', async () => {
|
||||||
|
const api = compile(await fs.readFile(path.join(root, 'src/renderer/src/features/workspace/workspaceFiles.ts'), 'utf8'))
|
||||||
|
for (const file of ['model.config', 'models/mkmini/model.config', 'models/MODEL.CONFIG', 'model.sdf', 'simulation.world', 'WORLD.SDF', 'package.xml', 'robot.urdf', 'robot.xacro']) assert.equal(api.getEditorLanguage(file), 'xml', file)
|
||||||
|
assert.equal(api.getEditorLanguage('app.config'), 'plaintext')
|
||||||
|
assert.equal(api.getEditorLanguage('control.yaml'), 'yaml')
|
||||||
|
assert.equal(api.getEditorLanguage('model.glb'), 'plaintext')
|
||||||
|
})
|
||||||
+1
-1
@@ -78,7 +78,7 @@ async function readAppDiagnostics(): Promise<{ path: string; content: string }>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker'])
|
const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker'])
|
||||||
const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i
|
const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|sdf|world|config|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i
|
||||||
const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i
|
const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i
|
||||||
const maxImagePreviewBytes = 10 * 1024 * 1024
|
const maxImagePreviewBytes = 10 * 1024 * 1024
|
||||||
const explicitModuleFolders: Record<string, string> = {
|
const explicitModuleFolders: Record<string, string> = {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import base64, json, os, stat, sys
|
|||||||
args = json.loads(base64.b64decode(sys.argv[1]))
|
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||||
root = os.path.realpath(args['root'])
|
root = os.path.realpath(args['root'])
|
||||||
skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'}
|
skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'}
|
||||||
extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'}
|
extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.sdf', '.world', '.config', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'}
|
||||||
placeholder = 'Binary or large file preview is not available.'
|
placeholder = 'Binary or large file preview is not available.'
|
||||||
def content(relative):
|
def content(relative):
|
||||||
if os.path.isabs(relative) or '..' in relative.split('/'):
|
if os.path.isabs(relative) or '..' in relative.split('/'):
|
||||||
|
|||||||
@@ -642,7 +642,6 @@ function LocalPcWorkspaceMockup({
|
|||||||
cancelPlan: cancelAgentPlan,
|
cancelPlan: cancelAgentPlan,
|
||||||
updateCommandExecution: updateAgentCommandExecution,
|
updateCommandExecution: updateAgentCommandExecution,
|
||||||
openCodeReference: openAgentCodeReference,
|
openCodeReference: openAgentCodeReference,
|
||||||
cancelCodeReference: cancelAgentCodeReference,
|
|
||||||
stop: stopAgent,
|
stop: stopAgent,
|
||||||
clear: clearAgentConversation
|
clear: clearAgentConversation
|
||||||
} = useAiAgent({
|
} = useAiAgent({
|
||||||
@@ -4792,7 +4791,6 @@ function LocalPcWorkspaceMockup({
|
|||||||
if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId)
|
if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId)
|
||||||
}}
|
}}
|
||||||
onOpenAgentCodeReference={(messageId, referenceId) => void openAgentCodeReference(messageId, referenceId, openWorkspaceAgentCodeReference)}
|
onOpenAgentCodeReference={(messageId, referenceId) => void openAgentCodeReference(messageId, referenceId, openWorkspaceAgentCodeReference)}
|
||||||
onCancelAgentCodeReference={cancelAgentCodeReference}
|
|
||||||
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
|
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
|
||||||
await applyWorkspaceAgentPlan(plan)
|
await applyWorkspaceAgentPlan(plan)
|
||||||
closeAgentDiffPreviewsForMessage(messageId)
|
closeAgentDiffPreviewsForMessage(messageId)
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { FileCode2, Loader2, X } from 'lucide-react'
|
import { FileCode2, Loader2 } from 'lucide-react'
|
||||||
import { t, useLocaleRender } from '../i18n'
|
import { t, useLocaleRender } from '../i18n'
|
||||||
import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent'
|
import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent'
|
||||||
|
|
||||||
export function AgentCodeReferences({ references, states, dark, busy, onOpen, onCancel }: {
|
export function AgentCodeReferences({ references, states, dark, busy, onOpen }: {
|
||||||
references: AgentCodeReference[]
|
references: AgentCodeReference[]
|
||||||
states?: Record<string, AgentCodeReferenceState>
|
states?: Record<string, AgentCodeReferenceState>
|
||||||
dark: boolean
|
dark: boolean
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
onOpen?: (id: string) => void
|
onOpen?: (id: string) => void
|
||||||
onCancel?: (id: string) => void
|
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
return <div className="grid min-w-0 gap-3">
|
return <div className="grid min-w-0 gap-3">
|
||||||
{references.map(reference => {
|
{references.map(reference => {
|
||||||
const state = states?.[reference.id]
|
const state = states?.[reference.id]
|
||||||
const opening = state?.status === 'opening'
|
const opening = state?.status === 'opening'
|
||||||
const cancelled = state?.status === 'cancelled'
|
|
||||||
return <div key={reference.id} data-agent-code-reference={reference.id} className={['min-w-0 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
return <div key={reference.id} data-agent-code-reference={reference.id} className={['min-w-0 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||||
<div className="flex min-w-0 items-start gap-2">
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
<FileCode2 size={14} className="mt-0.5 shrink-0 text-[#8b5cf6]" />
|
<FileCode2 size={14} className="mt-0.5 shrink-0 text-[#8b5cf6]" />
|
||||||
@@ -27,10 +25,7 @@ export function AgentCodeReferences({ references, states, dark, busy, onOpen, on
|
|||||||
</div>
|
</div>
|
||||||
{state?.error && <p role="alert" className="mt-2 break-words text-[11px] text-[#ef4444]">{t(state.error)}</p>}
|
{state?.error && <p role="alert" className="mt-2 break-words text-[11px] text-[#ef4444]">{t(state.error)}</p>}
|
||||||
<div className="mt-2 flex min-w-0 items-center justify-end gap-2">
|
<div className="mt-2 flex min-w-0 items-center justify-end gap-2">
|
||||||
{cancelled ? <span className={dark ? 'text-white/40' : 'text-[#64748b]'}>{t('취소됨')}</span> : <>
|
<button type="button" disabled={busy || opening || !onOpen} onClick={() => onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold leading-none text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <FileCode2 size={13} className="shrink-0" />}<span className="relative top-px leading-none">{opening ? t('파일 여는 중...') : t('코드에서 보기')}</span></button>
|
||||||
{state?.status !== 'opened' && <button type="button" disabled={busy || opening || !onCancel} onClick={() => onCancel?.(reference.id)} className={['inline-flex h-8 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md border px-3 text-[11px] font-bold disabled:cursor-not-allowed disabled:opacity-40', dark ? 'border-white/15 hover:bg-white/8' : 'border-[#d8dee9] hover:bg-[#e2e8f0]'].join(' ')}><X size={13} />{t('취소')}</button>}
|
|
||||||
<button type="button" disabled={busy || opening || !onOpen} onClick={() => onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <FileCode2 size={13} className="shrink-0" />}<span>{opening ? t('파일 여는 중...') : t('코드에서 보기')}</span></button>
|
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export default function ModuleListView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="thin-x-scrollbar -mt-2 flex min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
|
<div className="module-content-scrollbar -mt-2 flex min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||||||
<div className={['flex h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
<div className={['flex h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||||
<ModuleListToolbar
|
<ModuleListToolbar
|
||||||
dark={dark}
|
dark={dark}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ type WorkspaceQaPanelProps = {
|
|||||||
onRunAgentCommand: (messageId: string, command: AgentCommand) => void
|
onRunAgentCommand: (messageId: string, command: AgentCommand) => void
|
||||||
onRejectAgentCommand: (messageId: string, command: AgentCommand) => void
|
onRejectAgentCommand: (messageId: string, command: AgentCommand) => void
|
||||||
onOpenAgentCodeReference?: (messageId: string, referenceId: string) => void
|
onOpenAgentCodeReference?: (messageId: string, referenceId: string) => void
|
||||||
onCancelAgentCodeReference?: (messageId: string, referenceId: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
|
function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
|
||||||
@@ -146,7 +145,6 @@ export default function WorkspaceQaPanel({
|
|||||||
onApplyAgentPlan,
|
onApplyAgentPlan,
|
||||||
onCancelAgentPlan,
|
onCancelAgentPlan,
|
||||||
onOpenAgentCodeReference,
|
onOpenAgentCodeReference,
|
||||||
onCancelAgentCodeReference,
|
|
||||||
onOpenAgentChange,
|
onOpenAgentChange,
|
||||||
onRunAgentCommand,
|
onRunAgentCommand,
|
||||||
onRejectAgentCommand
|
onRejectAgentCommand
|
||||||
@@ -325,7 +323,6 @@ export default function WorkspaceQaPanel({
|
|||||||
onApply={onApplyAgentPlan}
|
onApply={onApplyAgentPlan}
|
||||||
onCancel={onCancelAgentPlan}
|
onCancel={onCancelAgentPlan}
|
||||||
onOpenCodeReference={onOpenAgentCodeReference}
|
onOpenCodeReference={onOpenAgentCodeReference}
|
||||||
onCancelCodeReference={onCancelAgentCodeReference}
|
|
||||||
onOpenChange={onOpenAgentChange}
|
onOpenChange={onOpenAgentChange}
|
||||||
onRunCommand={onRunAgentCommand}
|
onRunCommand={onRunAgentCommand}
|
||||||
onRejectCommand={onRejectAgentCommand}
|
onRejectCommand={onRejectAgentCommand}
|
||||||
@@ -388,8 +385,7 @@ function AgentMessages({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onRunCommand,
|
onRunCommand,
|
||||||
onRejectCommand,
|
onRejectCommand,
|
||||||
onOpenCodeReference,
|
onOpenCodeReference
|
||||||
onCancelCodeReference
|
|
||||||
}: {
|
}: {
|
||||||
dark: boolean
|
dark: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -406,7 +402,6 @@ function AgentMessages({
|
|||||||
onRunCommand: (messageId: string, command: AgentCommand) => void
|
onRunCommand: (messageId: string, command: AgentCommand) => void
|
||||||
onRejectCommand: (messageId: string, command: AgentCommand) => void
|
onRejectCommand: (messageId: string, command: AgentCommand) => void
|
||||||
onOpenCodeReference?: (messageId: string, referenceId: string) => void
|
onOpenCodeReference?: (messageId: string, referenceId: string) => void
|
||||||
onCancelCodeReference?: (messageId: string, referenceId: string) => void
|
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
if (statusLoading) {
|
if (statusLoading) {
|
||||||
@@ -452,7 +447,7 @@ function AgentMessages({
|
|||||||
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
|
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
|
||||||
{message.plan && (
|
{message.plan && (
|
||||||
<>
|
<>
|
||||||
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={busy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} onCancel={onCancelCodeReference ? id => onCancelCodeReference(message.id, id) : undefined} />}
|
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={busy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
|
||||||
{!!message.plan.evidence?.length && (
|
{!!message.plan.evidence?.length && (
|
||||||
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
|
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
|
||||||
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
|
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
|
||||||
|
|||||||
@@ -459,17 +459,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
: message))
|
: message))
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelCodeReference = (messageId: string, referenceId: string): void => {
|
|
||||||
updateMessages(current => current.map(message => message.id === messageId && message.plan?.codeReferences?.some(reference => reference.id === referenceId) && message.codeReferenceStates?.[referenceId]?.status !== 'opening'
|
|
||||||
? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: { status: 'cancelled' } } }
|
|
||||||
: message))
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
|
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
|
||||||
if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return
|
if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return
|
||||||
const message = messageStateRef.current.messages.find(message => message.id === messageId)
|
const message = messageStateRef.current.messages.find(message => message.id === messageId)
|
||||||
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
|
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
|
||||||
if (!reference || message?.codeReferenceStates?.[referenceId]?.status === 'cancelled') return
|
if (!reference) return
|
||||||
const latestMessageId = messageStateRef.current.messages.at(-1)?.id
|
const latestMessageId = messageStateRef.current.messages.at(-1)?.id
|
||||||
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
|
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
|
||||||
const update = (state: AgentCodeReferenceState): void => {
|
const update = (state: AgentCodeReferenceState): void => {
|
||||||
@@ -522,7 +516,6 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
cancelPlan,
|
cancelPlan,
|
||||||
updateCommandExecution,
|
updateCommandExecution,
|
||||||
openCodeReference,
|
openCodeReference,
|
||||||
cancelCodeReference,
|
|
||||||
stop,
|
stop,
|
||||||
clear
|
clear
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
|
|||||||
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
|
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
|
||||||
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
|
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
|
||||||
if (file.endsWith('.json')) return 'json'
|
if (file.endsWith('.json')) return 'json'
|
||||||
if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml'
|
if (/\.(xml|urdf|xacro|sdf|world)$/i.test(file) || /(^|\/)model\.config$/i.test(file)) return 'xml'
|
||||||
if (file.endsWith('.html')) return 'html'
|
if (file.endsWith('.html')) return 'html'
|
||||||
if (file.endsWith('.css')) return 'css'
|
if (file.endsWith('.css')) return 'css'
|
||||||
if (file.endsWith('.py')) return 'python'
|
if (file.endsWith('.py')) return 'python'
|
||||||
|
|||||||
@@ -294,4 +294,12 @@
|
|||||||
background: rgba(148, 163, 184, 0.95);
|
background: rgba(148, 163, 184, 0.95);
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-content-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(148, 163, 184, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-content-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.95);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user