Files
leon-ai--leon/app/src/js/tool-ui-handler.js
T
2026-07-13 12:44:08 +08:00

1011 lines
27 KiB
JavaScript

const COMMAND_OUTPUT_MAX_DISPLAY_CHARS = 80_000
const ANSI_ESCAPE_PATTERN =
// eslint-disable-next-line no-control-regex
/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g
/**
* Tool UI Handler
* Renders tool executions as expandable activity cards.
*/
export default class ToolUIHandler {
constructor(feedElement, scrollDownCallback, formatMessageCallback) {
this.feed = feedElement
this.scrollDown = scrollDownCallback
this.formatMessage = formatMessageCallback
this.toolGroups = new Map()
}
/**
* Handle tool output messages with interactive activity cards.
*/
handleToolOutput(data) {
if (data.activityType === 'agent_skill') {
return this.handleAgentSkillActivity(data)
}
const {
toolkitName,
toolName,
toolGroupId,
answer,
replaceMessageId,
key
} = data
if (replaceMessageId) {
this.replaceToolMessage(replaceMessageId, data)
return
}
const answerKey = key ? key.split('.').pop() : 'unknown'
const groupId = toolGroupId || `${toolkitName}_${toolName}_${Date.now()}`
const mode =
data.toolDisplayMode === 'activity_card' ? 'activity_card' : 'legacy'
let toolGroupContainer = this.toolGroups.get(groupId)
if (!toolGroupContainer) {
toolGroupContainer = this.createToolGroupContainer({
groupId,
toolkitName,
toolName,
answerKey,
mode,
data
})
this.toolGroups.set(groupId, toolGroupContainer)
}
if (toolGroupContainer.mode === 'activity_card') {
this.updateActivityCard(toolGroupContainer, data, answerKey)
} else {
this.addLegacyToolMessage(toolGroupContainer, answer)
}
this.scrollDown()
return {
groupId,
isNewGroup: toolGroupContainer.isNew
}
}
handleAgentSkillActivity(data) {
const agentSkill = data.agentSkill || {}
const skillId = agentSkill.id || agentSkill.name || 'agent-skill'
const groupId = data.toolGroupId || `agent_skill_${skillId}_${Date.now()}`
let activityContainer = this.toolGroups.get(groupId)
if (!activityContainer) {
activityContainer = this.createAgentSkillActivityCard({
groupId,
agentSkill
})
this.toolGroups.set(groupId, activityContainer)
}
this.updateAgentSkillActivityCard(activityContainer, data)
this.scrollDown()
return {
groupId,
isNewGroup: activityContainer.isNew
}
}
createToolGroupContainer(params) {
return params.mode === 'activity_card'
? this.createActivityCardContainer(params)
: this.createLegacyToolGroupContainer(params)
}
getRemixIconClass(iconName) {
const fallbackIconName = 'ri-magic-line'
if (typeof iconName !== 'string') {
return fallbackIconName
}
const trimmedIconName = iconName.trim()
if (!trimmedIconName || trimmedIconName.includes(' ')) {
return fallbackIconName
}
return trimmedIconName.startsWith('ri-')
? trimmedIconName
: `ri-${trimmedIconName}`
}
getToolActivityIconClass(data) {
return this.getRemixIconClass(data.toolIconName || data.toolkitIconName)
}
/**
* Create the legacy shell-like tool group container.
*/
createLegacyToolGroupContainer({
groupId,
toolkitName,
toolName,
answerKey
}) {
const groupContainer = document.createElement('div')
groupContainer.className = 'tool-group-container'
groupContainer.setAttribute('data-tool-group-id', groupId)
const toolHeader = document.createElement('button')
toolHeader.className = 'tool-header'
toolHeader.setAttribute('type', 'button')
toolHeader.innerHTML = `
<i class="ri-terminal-line tool-icon"></i>
<span class="tool-name">${toolkitName} toolkit → ${toolName}${answerKey}</span>
<i class="ri-arrow-down-s-line expand-icon"></i>
`
const toolContent = document.createElement('div')
toolContent.className = 'tool-content'
const shellOutput = document.createElement('div')
shellOutput.className = 'shell-output'
toolContent.appendChild(shellOutput)
groupContainer.appendChild(toolHeader)
groupContainer.appendChild(toolContent)
this.addExpandCollapseHandler(toolHeader, toolContent)
this.feed.appendChild(groupContainer)
return {
mode: 'legacy',
container: groupContainer,
toolHeader,
toolContent,
shellOutput,
isNew: true
}
}
createActivityCardContainer({
groupId,
toolkitName,
toolName,
answerKey,
data
}) {
const groupContainer = document.createElement('div')
groupContainer.className = 'tool-group-container tool-activity-card'
groupContainer.setAttribute('data-tool-group-id', groupId)
const toolHeader = document.createElement('button')
toolHeader.className = 'tool-header tool-activity-header'
toolHeader.setAttribute('type', 'button')
const heading = document.createElement('div')
heading.className = 'tool-activity-heading'
const icon = document.createElement('i')
icon.className = `${this.getToolActivityIconClass(data)} tool-icon`
const titleBlock = document.createElement('div')
titleBlock.className = 'tool-activity-title-block'
const title = document.createElement('span')
title.className = 'tool-title'
title.textContent = data.stepLabel || this.humanizeFunctionName(answerKey)
const subtitle = document.createElement('span')
subtitle.className = 'tool-subtitle'
subtitle.textContent = `${toolkitName} toolkit • ${toolName}`
titleBlock.appendChild(title)
titleBlock.appendChild(subtitle)
heading.appendChild(icon)
heading.appendChild(titleBlock)
const meta = document.createElement('div')
meta.className = 'tool-activity-meta'
const statusChip = document.createElement('span')
statusChip.className = 'tool-status-chip running'
statusChip.textContent = 'Running'
const expandIcon = document.createElement('i')
expandIcon.className = 'ri-arrow-down-s-line expand-icon'
meta.appendChild(statusChip)
meta.appendChild(expandIcon)
toolHeader.appendChild(heading)
toolHeader.appendChild(meta)
const toolContent = document.createElement('div')
toolContent.className = 'tool-content'
const summary = document.createElement('div')
summary.className = 'tool-activity-summary'
summary.textContent = 'Preparing tool activity...'
const sections = document.createElement('div')
sections.className = 'tool-activity-sections'
const inputPanel = this.createActivityPanel(
'Input',
'Parameters sent to the function'
)
const outputPanel = this.createActivityPanel(
'Result',
'What the function returned'
)
sections.appendChild(inputPanel.panel)
sections.appendChild(outputPanel.panel)
const rawDetails = document.createElement('details')
rawDetails.className = 'tool-raw-details'
const rawSummary = document.createElement('summary')
rawSummary.textContent = 'Raw data'
const rawContent = document.createElement('div')
rawContent.className = 'tool-raw-content'
rawDetails.appendChild(rawSummary)
rawDetails.appendChild(rawContent)
toolContent.appendChild(summary)
toolContent.appendChild(sections)
toolContent.appendChild(rawDetails)
groupContainer.appendChild(toolHeader)
groupContainer.appendChild(toolContent)
this.addExpandCollapseHandler(toolHeader, toolContent)
this.feed.appendChild(groupContainer)
return {
mode: 'activity_card',
container: groupContainer,
toolHeader,
toolContent,
summary,
title,
subtitle,
statusChip,
inputBody: inputPanel.body,
outputBody: outputPanel.body,
rawContent,
rawDetails,
rawInput: null,
rawOutput: null,
commandOutputText: '',
preparationLog: [],
isNew: true
}
}
createAgentSkillActivityCard({ groupId, agentSkill }) {
const groupContainer = document.createElement('div')
groupContainer.className =
'tool-group-container tool-activity-card agent-skill-activity-card'
groupContainer.setAttribute('data-tool-group-id', groupId)
const skillName = agentSkill.name || agentSkill.id || 'Agent Skill'
const skillPath = agentSkill.skillPath || 'SKILL.md'
const toolHeader = document.createElement('button')
toolHeader.className = 'tool-header tool-activity-header'
toolHeader.setAttribute('type', 'button')
const heading = document.createElement('div')
heading.className = 'tool-activity-heading'
const icon = document.createElement('i')
icon.className = 'ri-book-ai-line tool-icon'
const titleBlock = document.createElement('div')
titleBlock.className = 'tool-activity-title-block'
const title = document.createElement('span')
title.className = 'tool-title'
title.textContent = `Agent Skill: ${skillName}`
const subtitle = document.createElement('span')
subtitle.className = 'tool-subtitle'
subtitle.textContent = skillPath
subtitle.setAttribute('title', skillPath)
titleBlock.appendChild(title)
titleBlock.appendChild(subtitle)
heading.appendChild(icon)
heading.appendChild(titleBlock)
const meta = document.createElement('div')
meta.className = 'tool-activity-meta'
const statusChip = document.createElement('span')
statusChip.className = 'tool-status-chip selected'
statusChip.textContent = 'In use'
const expandIcon = document.createElement('i')
expandIcon.className = 'ri-arrow-down-s-line expand-icon'
meta.appendChild(statusChip)
meta.appendChild(expandIcon)
toolHeader.appendChild(heading)
toolHeader.appendChild(meta)
const toolContent = document.createElement('div')
toolContent.className = 'tool-content'
const summary = document.createElement('div')
summary.className = 'tool-activity-summary'
summary.textContent = 'Following SKILL.md instructions for this step.'
const sections = document.createElement('div')
sections.className = 'tool-activity-sections single'
const skillPanel = this.createActivityPanel(
'Skill',
'Agent Skill context for this step'
)
sections.appendChild(skillPanel.panel)
toolContent.appendChild(summary)
toolContent.appendChild(sections)
groupContainer.appendChild(toolHeader)
groupContainer.appendChild(toolContent)
this.addExpandCollapseHandler(toolHeader, toolContent)
this.feed.appendChild(groupContainer)
return {
mode: 'agent_skill',
container: groupContainer,
title,
subtitle,
statusChip,
skillBody: skillPanel.body,
isNew: true
}
}
/**
* Create a titled content panel used by the activity card sections.
*/
createActivityPanel(title, description) {
const panel = document.createElement('section')
panel.className = 'tool-activity-panel'
const heading = document.createElement('div')
heading.className = 'tool-panel-heading'
const titleElement = document.createElement('span')
titleElement.className = 'tool-panel-title'
titleElement.textContent = title
const descriptionElement = document.createElement('span')
descriptionElement.className = 'tool-panel-description'
descriptionElement.textContent = description
const body = document.createElement('div')
body.className = 'tool-panel-body'
heading.appendChild(titleElement)
heading.appendChild(descriptionElement)
panel.appendChild(heading)
panel.appendChild(body)
return {
panel,
body
}
}
/**
* Add expand/collapse functionality to tool header.
*/
addExpandCollapseHandler(toolHeader, toolContent) {
toolHeader.addEventListener('click', () => {
const isExpanded = toolContent.classList.contains('expanded')
const expandIcon = toolHeader.querySelector('.expand-icon')
if (isExpanded) {
toolContent.classList.remove('expanded')
expandIcon?.classList.remove('rotated')
} else {
toolContent.classList.add('expanded')
expandIcon?.classList.add('rotated')
}
})
}
/**
* Update the activity card with the latest input/output state.
*/
updateActivityCard(toolGroupContainer, data, answerKey) {
const title =
data.stepLabel ||
this.humanizeFunctionName(data.functionName || answerKey)
toolGroupContainer.title.textContent = title
if (data.functionName) {
const functionLabel = this.humanizeFunctionName(data.functionName)
toolGroupContainer.subtitle.textContent =
`${data.toolkitName} toolkit • ${data.toolName}${functionLabel}`
}
if (data.toolPhase === 'input') {
toolGroupContainer.summary.textContent =
data.stepLabel || data.functionName
? `Preparing ${title.toLowerCase()}...`
: 'Preparing tool activity...'
this.setStatusChip(toolGroupContainer.statusChip, 'running')
const parsedInput = this.parseToolInput(data.toolInput)
toolGroupContainer.rawInput = parsedInput ?? data.toolInput ?? null
this.renderValuePreview(
toolGroupContainer.inputBody,
parsedInput ?? data.toolInput,
'No input'
)
if (!toolGroupContainer.rawOutput) {
this.renderPlaceholder(
toolGroupContainer.outputBody,
'Waiting for function output...'
)
}
}
if (data.toolPhase === 'preparation') {
const progressMessage = data.message || data.answer || ''
this.setStatusChip(toolGroupContainer.statusChip, 'running')
if (progressMessage) {
toolGroupContainer.summary.textContent = progressMessage
toolGroupContainer.preparationLog.push(progressMessage)
this.renderPreparationLog(
toolGroupContainer.outputBody,
toolGroupContainer.preparationLog
)
toolGroupContainer.rawOutput = {
preparation: toolGroupContainer.preparationLog
}
}
}
if (data.toolPhase === 'output_delta') {
const outputDelta = data.outputDelta || data.answer || ''
this.setStatusChip(toolGroupContainer.statusChip, 'running')
if (outputDelta) {
toolGroupContainer.summary.textContent = 'Receiving command output...'
toolGroupContainer.commandOutputText = this.mergeCommandOutput(
toolGroupContainer.commandOutputText,
outputDelta
)
this.renderCommandOutputLog(
toolGroupContainer.outputBody,
toolGroupContainer.commandOutputText
)
toolGroupContainer.rawOutput = {
output: toolGroupContainer.commandOutputText
}
}
}
if (data.toolPhase === 'output') {
const isError = data.status === 'error'
const isObserved = data.status === 'observed'
toolGroupContainer.summary.textContent =
data.message ||
(isError
? 'The function failed.'
: isObserved
? 'The function returned an observation.'
: 'The function completed.')
this.setStatusChip(
toolGroupContainer.statusChip,
isError ? 'error' : isObserved ? 'observed' : 'success'
)
const outputPayload = {
message: data.message,
output: data.output
}
toolGroupContainer.rawOutput = outputPayload
this.renderOutputPreview(toolGroupContainer.outputBody, data)
}
this.renderRawData(toolGroupContainer)
if (toolGroupContainer.isNew) {
toolGroupContainer.isNew = false
}
}
updateAgentSkillActivityCard(activityContainer, data) {
const agentSkill = data.agentSkill || {}
const skillName = agentSkill.name || agentSkill.id || 'Agent Skill'
const skillPath = agentSkill.skillPath || ''
activityContainer.title.textContent = `Agent Skill: ${skillName}`
activityContainer.subtitle.textContent = skillPath || 'SKILL.md'
activityContainer.subtitle.setAttribute(
'title',
skillPath || 'SKILL.md'
)
this.setStatusChip(activityContainer.statusChip, 'selected')
this.renderValuePreview(
activityContainer.skillBody,
{
name: skillName,
description: agentSkill.description || '',
root_path: agentSkill.rootPath || '',
skill_path: skillPath
},
'No Agent Skill metadata'
)
if (activityContainer.isNew) {
activityContainer.isNew = false
}
}
/**
* Add a tool message to the legacy shell output.
*/
addLegacyToolMessage(toolGroupContainer, message) {
const messageElement = document.createElement('div')
messageElement.className = 'shell-message'
const formattedMessage = this.formatMessage(message)
messageElement.innerHTML = `<span class="shell-prompt">></span> ${formattedMessage}`
toolGroupContainer.shellOutput.appendChild(messageElement)
if (toolGroupContainer.isNew) {
toolGroupContainer.isNew = false
}
}
/**
* Render the output preview block.
*/
renderOutputPreview(container, data) {
container.innerHTML = ''
if (data.message) {
const summary = document.createElement('div')
summary.className = 'tool-output-summary'
summary.innerHTML = this.formatMessage(data.message)
container.appendChild(summary)
}
if (
typeof data.output === 'undefined' ||
data.output === null ||
(typeof data.output === 'object' &&
!Array.isArray(data.output) &&
Object.keys(data.output).length === 0)
) {
if (!data.message) {
this.renderPlaceholder(container, 'No output')
}
return
}
const preview = this.buildValueNode(data.output)
container.appendChild(preview)
}
/**
* Render preparation progress before the final tool output is available.
*/
renderPreparationLog(container, messages) {
container.innerHTML = ''
if (!messages.length) {
this.renderPlaceholder(container, 'Waiting for function output...')
return
}
const list = document.createElement('ul')
list.className = 'tool-value-list'
messages.forEach((message) => {
const item = document.createElement('li')
item.textContent = message
list.appendChild(item)
})
container.appendChild(list)
}
/**
* Merge terminal output chunks while respecting carriage-return progress
* updates used by CLI tools to repaint the current line.
*/
mergeCommandOutput(currentOutput, nextChunk) {
const cleanChunk = this.cleanCommandOutputChunk(nextChunk)
if (!cleanChunk) {
return currentOutput || ''
}
let output = currentOutput || ''
let replaceCurrentLine = false
for (const char of cleanChunk) {
if (char === '\r') {
replaceCurrentLine = true
continue
}
if (char === '\n') {
output += '\n'
replaceCurrentLine = false
continue
}
if (replaceCurrentLine) {
const lineStartIndex = output.lastIndexOf('\n') + 1
output = output.slice(0, lineStartIndex)
replaceCurrentLine = false
}
output += char
}
if (output.length <= COMMAND_OUTPUT_MAX_DISPLAY_CHARS) {
return output
}
return output.slice(output.length - COMMAND_OUTPUT_MAX_DISPLAY_CHARS)
}
/**
* Strip terminal control sequences that do not render usefully in HTML.
*/
cleanCommandOutputChunk(chunk) {
const withoutAnsi = String(chunk || '').replace(ANSI_ESCAPE_PATTERN, '')
const output = []
for (const char of withoutAnsi) {
if (char === '\b') {
output.pop()
continue
}
output.push(char)
}
return output.join('')
}
/**
* Render live command output in the result panel.
*/
renderCommandOutputLog(container, outputText) {
container.innerHTML = ''
if (!outputText) {
this.renderPlaceholder(container, 'Waiting for function output...')
return
}
const output = document.createElement('pre')
output.className = 'tool-command-output'
output.textContent = outputText
container.appendChild(output)
output.scrollTop = output.scrollHeight
}
/**
* Render a value preview block.
*/
renderValuePreview(container, value, emptyLabel) {
container.innerHTML = ''
if (
value === null ||
typeof value === 'undefined' ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length === 0)
) {
this.renderPlaceholder(container, emptyLabel)
return
}
container.appendChild(this.buildValueNode(value))
}
/**
* Render an empty state placeholder.
*/
renderPlaceholder(container, text) {
container.innerHTML = ''
const placeholder = document.createElement('div')
placeholder.className = 'tool-empty-state'
placeholder.textContent = text
container.appendChild(placeholder)
}
/**
* Build the most appropriate preview node for a value.
*/
buildValueNode(value) {
if (Array.isArray(value)) {
return this.buildArrayNode(value)
}
if (value && typeof value === 'object') {
return this.buildObjectNode(value)
}
return this.buildScalarNode(value)
}
/**
* Build a list preview for array values.
*/
buildArrayNode(value) {
const list = document.createElement('ul')
list.className = 'tool-value-list'
value.slice(0, 8).forEach((item) => {
const itemElement = document.createElement('li')
itemElement.appendChild(this.buildScalarNode(this.stringifyCompact(item)))
list.appendChild(itemElement)
})
if (value.length > 8) {
const more = document.createElement('li')
more.className = 'tool-empty-state'
more.textContent = `+${value.length - 8} more item(s)`
list.appendChild(more)
}
return list
}
/**
* Build a key/value preview for object values.
*/
buildObjectNode(value) {
const entries = Object.entries(value)
if (entries.length === 0) {
const empty = document.createElement('div')
empty.className = 'tool-empty-state'
empty.textContent = 'No data'
return empty
}
const wrapper = document.createElement('div')
wrapper.className = 'tool-kv-list'
entries.slice(0, 8).forEach(([key, itemValue]) => {
const row = document.createElement('div')
row.className = 'tool-kv-row'
const keyElement = document.createElement('span')
keyElement.className = 'tool-kv-key'
keyElement.textContent = this.humanizeFunctionName(key)
const valueElement = document.createElement('div')
valueElement.className = 'tool-kv-value'
valueElement.appendChild(
this.buildScalarNode(this.stringifyCompact(itemValue))
)
row.appendChild(keyElement)
row.appendChild(valueElement)
wrapper.appendChild(row)
})
if (entries.length > 8) {
const more = document.createElement('div')
more.className = 'tool-empty-state'
more.textContent = `+${entries.length - 8} more field(s)`
wrapper.appendChild(more)
}
return wrapper
}
/**
* Build a formatted scalar value preview.
*/
buildScalarNode(value) {
const wrapper = document.createElement('div')
wrapper.className = 'tool-rich-text'
wrapper.innerHTML = this.formatMessage(
typeof value === 'string' ? value : String(value ?? '')
)
return wrapper
}
/**
* Render raw input/output payloads behind a disclosure block.
*/
renderRawData(toolGroupContainer) {
const payload = {}
if (toolGroupContainer.rawInput !== null) {
payload['input'] = toolGroupContainer.rawInput
}
if (toolGroupContainer.rawOutput !== null) {
payload['result'] = toolGroupContainer.rawOutput
}
if (Object.keys(payload).length === 0) {
toolGroupContainer.rawDetails.removeAttribute('open')
toolGroupContainer.rawContent.innerHTML = ''
return
}
toolGroupContainer.rawContent.innerHTML = this.formatMessage(
JSON.stringify(payload, null, 2)
)
}
/**
* Parse tool input JSON when possible.
*/
parseToolInput(toolInput) {
if (typeof toolInput !== 'string' || !toolInput.trim()) {
return null
}
try {
return JSON.parse(toolInput)
} catch {
return toolInput
}
}
/**
* Update the status chip for the activity card.
*/
setStatusChip(chip, status) {
chip.classList.remove('running', 'success', 'error', 'selected', 'observed')
if (status === 'error') {
chip.classList.add('error')
chip.textContent = 'Failed'
return
}
if (status === 'success') {
chip.classList.add('success')
chip.textContent = 'Done'
return
}
if (status === 'selected') {
chip.classList.add('selected')
chip.textContent = 'In use'
return
}
if (status === 'observed') {
chip.classList.add('observed')
chip.textContent = 'Observed'
return
}
chip.classList.add('running')
chip.textContent = 'Running'
}
/**
* Humanize function and field names for display.
*/
humanizeFunctionName(value) {
return String(value || 'unknown')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\w/, (char) => char.toUpperCase())
}
/**
* Produce a compact preview string for nested values.
*/
stringifyCompact(value) {
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) {
return '(empty)'
}
return trimmed.length > 180 ? `${trimmed.slice(0, 177)}...` : trimmed
}
if (value === null || typeof value === 'undefined') {
return '(empty)'
}
if (typeof value === 'object') {
try {
const serialized = JSON.stringify(value)
return serialized.length > 180
? `${serialized.slice(0, 177)}...`
: serialized
} catch {
return String(value)
}
}
return String(value)
}
/**
* Replace a tool message (for progress updates, etc.).
*/
replaceToolMessage(replaceMessageId, newData) {
const existingMessage = document.querySelector(
`[data-message-id="${replaceMessageId}"]`
)
if (existingMessage && existingMessage.closest('.tool-group-container')) {
const formattedMessage = this.formatMessage(newData.answer)
existingMessage.innerHTML = `<span class="shell-prompt">></span> ${formattedMessage}`
} else {
this.handleToolOutput(newData)
}
}
/**
* Get tool group info for saving to localStorage.
*/
getToolGroupInfo(groupId, toolkitName, toolName, message) {
return {
originalString: `[TOOL_OUTPUT:${groupId}] ${toolkitName}${toolName}`,
messageId: `tool-${groupId}`,
formattedMessage: this.formatMessage(message)
}
}
/**
* Check if a message is a tool output marker.
*/
static isToolOutputMarker(messageString) {
return messageString && messageString.startsWith('[TOOL_OUTPUT:')
}
/**
* Clear all tool groups.
*/
clearToolGroups() {
this.toolGroups.clear()
}
/**
* Get the number of active tool groups.
*/
getToolGroupCount() {
return this.toolGroups.size
}
/**
* Get a specific tool group by ID.
*/
getToolGroup(groupId) {
return this.toolGroups.get(groupId)
}
/**
* Remove a tool group.
*/
removeToolGroup(groupId) {
const toolGroup = this.toolGroups.get(groupId)
if (toolGroup) {
toolGroup.container.remove()
this.toolGroups.delete(groupId)
}
}
}