ui(chat): show skill names on tool cards and render sandbox file lists

Skill steps were hard to tell apart in the stream, and list_sandbox_files fell through to raw output. Quote the skill or path in the title and add dedicated cards for read_skill and sandbox listings.
This commit is contained in:
wizardchen
2026-08-28 15:37:35 +08:00
committed by lyingbug
parent 80e2f1806d
commit 8f3b4fe60c
15 changed files with 638 additions and 8 deletions
+24
View File
@@ -335,11 +335,23 @@ const messages = {
"queryKnowledgeGraph": "知识图谱查询",
"readSkill": "读取技能",
"executeSkillScript": "执行技能脚本",
"listSandboxFiles": "列出沙箱文件",
"readSandboxFile": "读取沙箱文件",
"shellExec": "执行沙箱命令",
"dataAnalysis": "数据分析",
"dataSchema": "数据结构",
"databaseQuery": "数据库查询"
},
"skillFiles": {
"heading": "技能文件",
"script": "脚本",
"instructions": "技能说明"
},
"sandboxFiles": {
"found": "找到 {count} 个文件",
"empty": "暂无文件",
"truncated": "列表已截断"
},
"shellExec": {
"workDir": "目录",
"exitCode": "退出码",
@@ -844,11 +856,23 @@ const messages = {
"queryKnowledgeGraph": "Knowledge Graph Query",
"readSkill": "Read Skill",
"executeSkillScript": "Execute Skill Script",
"listSandboxFiles": "List sandbox files",
"readSandboxFile": "Read sandbox file",
"shellExec": "Run sandbox command",
"dataAnalysis": "Data Analysis",
"dataSchema": "Data Schema",
"databaseQuery": "Database Query"
},
"skillFiles": {
"heading": "Skill files",
"script": "script",
"instructions": "Instructions"
},
"sandboxFiles": {
"found": "Found {count} file(s)",
"empty": "No files",
"truncated": "List truncated"
},
"shellExec": {
"workDir": "Directory",
"exitCode": "Exit code",
+12
View File
@@ -5330,11 +5330,23 @@ export default {
queryKnowledgeGraph: 'Knowledge Graph Query',
readSkill: 'Read Skill',
executeSkillScript: 'Execute Skill Script',
listSandboxFiles: 'List sandbox files',
readSandboxFile: 'Read sandbox file',
shellExec: 'Run sandbox command',
dataAnalysis: 'Data Analysis',
dataSchema: 'Data Schema',
databaseQuery: 'Database Query'
},
skillFiles: {
heading: 'Skill files',
script: 'script',
instructions: 'Instructions'
},
sandboxFiles: {
found: 'Found {count} file(s)',
empty: 'No files',
truncated: 'List truncated'
},
shellExec: {
workDir: 'Directory',
exitCode: 'Exit code',
+12
View File
@@ -1308,11 +1308,23 @@ export default {
queryKnowledgeGraph: '지식 그래프 조회',
readSkill: '스킬 읽기',
executeSkillScript: '스킬 스크립트 실행',
listSandboxFiles: '샌드박스 파일 목록',
readSandboxFile: '샌드박스 파일 읽기',
shellExec: '샌드박스 명령 실행',
dataAnalysis: '데이터 분석',
dataSchema: '데이터 구조',
databaseQuery: '데이터베이스 조회'
},
skillFiles: {
heading: '스킬 파일',
script: '스크립트',
instructions: '스킬 안내'
},
sandboxFiles: {
found: '파일 {count}개 발견',
empty: '파일 없음',
truncated: '목록이 잘림'
},
shellExec: {
workDir: '디렉터리',
exitCode: '종료 코드',
+12
View File
@@ -1308,11 +1308,23 @@ export default {
queryKnowledgeGraph: 'Запрос графа знаний',
readSkill: 'Чтение навыка',
executeSkillScript: 'Выполнение скрипта навыка',
listSandboxFiles: 'Список файлов песочницы',
readSandboxFile: 'Чтение файла песочницы',
shellExec: 'Выполнение команды в песочнице',
dataAnalysis: 'Анализ данных',
dataSchema: 'Структура данных',
databaseQuery: 'Запрос к базе данных'
},
skillFiles: {
heading: 'Файлы навыка',
script: 'скрипт',
instructions: 'Инструкции навыка'
},
sandboxFiles: {
found: 'Найдено файлов: {count}',
empty: 'Нет файлов',
truncated: 'Список обрезан'
},
shellExec: {
workDir: 'Каталог',
exitCode: 'Код выхода',
+12
View File
@@ -1310,11 +1310,23 @@ export default {
queryKnowledgeGraph: '知识图谱查询',
readSkill: '读取技能',
executeSkillScript: '执行技能脚本',
listSandboxFiles: '列出沙箱文件',
readSandboxFile: '读取沙箱文件',
shellExec: '执行沙箱命令',
dataAnalysis: '数据分析',
dataSchema: '数据结构',
databaseQuery: '数据库查询'
},
skillFiles: {
heading: '技能文件',
script: '脚本',
instructions: '技能说明'
},
sandboxFiles: {
found: '找到 {count} 个文件',
empty: '暂无文件',
truncated: '列表已截断'
},
shellExec: {
workDir: '目录',
exitCode: '退出码',
+34 -2
View File
@@ -26,7 +26,9 @@ export type DisplayType =
| 'wiki_replace_text'
| 'wiki_rename_page'
| 'wiki_delete_page'
| 'shell_exec';
| 'shell_exec'
| 'list_sandbox_files'
| 'read_skill';
// Search result item
export interface SearchResultItem {
@@ -338,6 +340,34 @@ export interface ShellExecData {
stderr_truncated?: boolean;
}
export interface SandboxFileEntry {
name?: string;
path: string;
size?: number;
modified_at?: string;
}
export interface ListSandboxFilesData {
display_type?: 'list_sandbox_files';
session_id?: string;
path?: string;
root?: string;
entries?: SandboxFileEntry[];
count?: number;
truncated?: boolean;
}
export interface ReadSkillData {
display_type?: 'read_skill';
skill_name?: string;
file_path?: string;
description?: string;
instructions?: string;
content?: string;
files?: string[];
skill_dir?: string;
}
// Union type for all wiki edit data
export type WikiEditData = WikiWritePageData | WikiReplaceTextData | WikiRenamePageData | WikiDeletePageData;
@@ -360,7 +390,9 @@ export type ToolResultData =
| WikiReplaceTextData
| WikiRenamePageData
| WikiDeletePageData
| ShellExecData;
| ShellExecData
| ListSandboxFilesData
| ReadSkillData;
// Action data (from index.vue)
export interface ActionData {
@@ -30,6 +30,13 @@ test('getAgentToolIconName maps sandbox shell tools to the terminal icon', () =>
assert.equal(getAgentToolIconName('shell_exec'), 'terminal')
})
test('getAgentToolIconName maps skill and sandbox file tools', () => {
assert.equal(getAgentToolIconName('read_skill'), 'file')
assert.equal(getAgentToolIconName('list_sandbox_files'), 'folder')
assert.equal(getAgentToolIconName('read_sandbox_file'), 'file')
assert.equal(getAgentToolIconName('execute_skill_script'), 'code')
})
test('getAgentToolIconName maps Wiki tools to semantic search and reading icons', () => {
assert.equal(getAgentToolIconName('wiki_search'), 'search')
assert.equal(getAgentToolIconName('wiki_read_page'), 'file-search')
+7 -1
View File
@@ -39,9 +39,15 @@ export function getAgentToolIconName(
if (toolName.startsWith('mcp_')) {
return 'terminal'
}
if (toolName === 'shell_exec' || toolName === 'list_sandbox_files' || toolName === 'read_sandbox_file') {
if (toolName === 'shell_exec') {
return 'terminal'
}
if (toolName === 'list_sandbox_files') {
return 'folder'
}
if (toolName === 'read_sandbox_file' || toolName === 'read_skill') {
return 'file'
}
if (toolName === 'execute_skill_script') {
return 'code'
}
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
formatToolTitleWithDetail,
getEventSkillName,
getReadSkillTarget,
getSandboxToolPath,
sandboxFileListItems,
skillFileListItems,
skillScriptTitleCommand,
} from './skillToolDisplay.ts'
test('formatToolTitleWithDetail quotes a skill or path detail', () => {
assert.equal(formatToolTitleWithDetail('读取技能', 'pdf-processing'), '读取技能:「pdf-processing」')
assert.equal(formatToolTitleWithDetail('读取技能', ' '), '读取技能')
})
test('getReadSkillTarget prefers skill/file from tool_data over arguments', () => {
assert.equal(
getReadSkillTarget({
arguments: { skill_name: 'old', file_path: 'SKILL.md' },
tool_data: { skill_name: 'pdf-processing', file_path: 'scripts/run.py' },
}),
'pdf-processing/scripts/run.py',
)
assert.equal(
getReadSkillTarget({ arguments: { skill_name: 'brandkit' } }),
'brandkit',
)
})
test('getEventSkillName parses JSON-encoded arguments', () => {
assert.equal(
getEventSkillName({ arguments: '{"skill_name":"smart-charts"}' }),
'smart-charts',
)
})
test('skillScriptTitleCommand drops a duplicated skill prefix', () => {
assert.equal(
skillScriptTitleCommand('pdf-processing', 'pdf-processing/scripts/run.py --fast'),
'scripts/run.py --fast',
)
assert.equal(skillScriptTitleCommand('pdf-processing', 'ls'), 'ls')
})
test('skillFileListItems skips SKILL.md and marks scripts', () => {
const items = skillFileListItems({
files: ['SKILL.md', 'scripts/run.py', 'docs/guide.md', ''],
})
assert.deepEqual(
items.map((item) => ({ path: item.path, isScript: item.isScript })),
[
{ path: 'scripts/run.py', isScript: true },
{ path: 'docs/guide.md', isScript: false },
],
)
})
test('sandboxFileListItems uses paths relative to the artifact root', () => {
const items = sandboxFileListItems({
root: '/workspace/output',
path: '/workspace/output',
entries: [
{ name: 'report.html', path: '/workspace/output/report.html', size: 12, modified_at: '2026-08-28T01:02:03Z' },
{ name: 'chart.png', path: '/workspace/output/charts/chart.png', size: 2048 },
],
})
assert.equal(items[0].name, 'report.html')
assert.equal(items[1].name, 'charts/chart.png')
assert.equal(items[0].size, 12)
})
test('getSandboxToolPath reads the listed directory', () => {
assert.equal(
getSandboxToolPath({ tool_data: { path: '/workspace/output/charts' } }),
'/workspace/output/charts',
)
})
+141
View File
@@ -0,0 +1,141 @@
/** Display helpers for skill and sandbox file tools in the agent stream. */
const SKILL_SCRIPT_EXT = /\.(py|sh|bash|js|ts|rb|pl|php)$/i
const SKILL_MD = /(^|\/)SKILL\.md$/i
type ToolEventLike = {
tool_data?: unknown
arguments?: unknown
}
export type SkillFileListItem = {
name: string
path: string
size?: number
modifiedAt?: string
isScript?: boolean
}
function asRecord(value: unknown): Record<string, unknown> {
if (!value) return {}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
return {}
}
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function stringField(record: Record<string, unknown>, key: string): string {
const value = record[key]
return typeof value === 'string' ? value.trim() : value == null ? '' : String(value).trim()
}
function eventFields(event: ToolEventLike | null | undefined): Record<string, unknown> {
return {
...asRecord(event?.arguments),
...asRecord(event?.tool_data),
}
}
export function isSkillScriptPath(path: string): boolean {
return SKILL_SCRIPT_EXT.test(path)
}
export function formatToolTitleWithDetail(baseTitle: string, detail: string): string {
const trimmed = detail.trim()
if (!trimmed) return baseTitle
return `${baseTitle}:「${trimmed}`
}
export function getEventSkillName(event: ToolEventLike | null | undefined): string {
return stringField(eventFields(event), 'skill_name')
}
export function getReadSkillTarget(event: ToolEventLike | null | undefined): string {
const fields = eventFields(event)
const skill = stringField(fields, 'skill_name')
const file = stringField(fields, 'file_path')
if (skill && file) return `${skill}/${file}`
return skill || file
}
export function getSandboxToolPath(event: ToolEventLike | null | undefined): string {
return stringField(eventFields(event), 'path')
}
export function skillScriptTitleCommand(skillName: string, command: string): string {
const trimmed = command.trim()
if (!trimmed) return ''
if (skillName && trimmed.startsWith(`${skillName}/`)) {
return trimmed.slice(skillName.length + 1)
}
return trimmed
}
function basename(path: string): string {
const trimmed = path.replace(/\/+$/, '')
const parts = trimmed.split('/').filter(Boolean)
return parts[parts.length - 1] || trimmed || path
}
function relativeToRoot(path: string, root: string): string {
if (!root) return path
if (path === root) return '.'
const prefix = root.endsWith('/') ? root : `${root}/`
if (path.startsWith(prefix)) return path.slice(prefix.length)
return path
}
export function skillFileListItems(data: unknown): SkillFileListItem[] {
const record = asRecord(data)
const files = record.files
if (!Array.isArray(files)) return []
return files
.map((file) => String(file || '').trim())
.filter(Boolean)
.filter((path) => !SKILL_MD.test(path))
.map((path) => ({
name: basename(path),
path,
isScript: isSkillScriptPath(path),
}))
}
export function sandboxFileListItems(data: unknown): SkillFileListItem[] {
const record = asRecord(data)
const root = stringField(record, 'root') || stringField(record, 'path')
const entries = record.entries
if (!Array.isArray(entries)) return []
return entries.map((entry) => {
const item = asRecord(entry)
const path = stringField(item, 'path')
const name = stringField(item, 'name') || basename(path)
const sizeRaw = item.size
const size = typeof sizeRaw === 'number' && Number.isFinite(sizeRaw) ? sizeRaw : undefined
return {
name: relativeToRoot(path, root) || name,
path,
size,
modifiedAt: stringField(item, 'modified_at'),
isScript: isSkillScriptPath(path || name),
}
})
}
export function formatSandboxModifiedAt(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ''
const date = new Date(trimmed)
if (Number.isNaN(date.getTime())) return trimmed
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
@@ -59,6 +59,10 @@ test('tool rows use line icon names instead of legacy asset masks', () => {
assert.match(source, /wiki_search: 'agentEditor\.tools\.wikiSearch'/)
assert.match(source, /wiki_read_page: 'agentEditor\.tools\.wikiReadPage'/)
assert.match(source, /wiki_read_source_doc: 'agentStream\.tools\.wikiReadSourceDoc'/)
assert.match(source, /list_sandbox_files: 'agentStream\.tools\.listSandboxFiles'/)
assert.match(source, /read_sandbox_file: 'agentStream\.tools\.readSandboxFile'/)
assert.match(source, /getReadSkillTarget/)
assert.match(source, /tool_name === 'list_sandbox_files'/)
assert.match(source, /toolName === 'get_document_content' \|\| toolName === 'wiki_read_source_doc'/)
assert.doesNotMatch(source, /getToolIcon\(event\.tool_name\)/)
})
@@ -564,6 +564,13 @@ import type { ProtectedFileAccessContext } from '@/utils/protectedFileAccess';
import { unwrapFinalAnswerWrappers, thinkingEqualsAnswer } from '@/utils/finalAnswer';
import { getAgentToolIconName } from '@/utils/agent-tool-icons';
import { getQueryText, getWikiPageText } from '@/utils/agent-tool-display';
import {
formatToolTitleWithDetail,
getEventSkillName,
getReadSkillTarget,
getSandboxToolPath,
skillScriptTitleCommand,
} from '@/utils/skillToolDisplay';
import { previewShellCommand } from '@/utils/shellExecResult';
import type { DisplayType } from '@/types/tool-results';
import { parseWikiToolReferences } from '@/utils/wikiToolReferences';
@@ -631,6 +638,8 @@ const TOOL_NAME_KEYS: Record<string, string> = {
query_knowledge_graph: 'agentStream.tools.queryKnowledgeGraph',
read_skill: 'agentStream.tools.readSkill',
execute_skill_script: 'agentStream.tools.executeSkillScript',
list_sandbox_files: 'agentStream.tools.listSandboxFiles',
read_sandbox_file: 'agentStream.tools.readSandboxFile',
shell_exec: 'agentStream.tools.shellExec',
data_analysis: 'agentStream.tools.dataAnalysis',
data_schema: 'agentStream.tools.dataSchema',
@@ -1074,6 +1083,12 @@ const resolveToolDisplayType = (event: any): DisplayType | undefined => {
if (event?.tool_name === 'shell_exec' || event?.tool_name === 'execute_skill_script') {
return 'shell_exec'
}
if (event?.tool_name === 'list_sandbox_files' && event?.success !== false) {
return 'list_sandbox_files'
}
if (event?.tool_name === 'read_skill' && event?.success !== false) {
return 'read_skill'
}
return undefined
};
@@ -2678,7 +2693,19 @@ const getToolTitle = (event: any): string => {
if (event.tool_name === 'wiki_search' || event.tool_name === 'wiki_read_page') {
return `${getLocalizedToolName(event.tool_name)}...`;
}
if (event.tool_name === 'shell_exec' || event.tool_name === 'execute_skill_script') {
if (event.tool_name === 'read_skill') {
const name = getLocalizedToolName(event.tool_name);
return `${formatToolTitleWithDetail(name, getReadSkillTarget(event))}...`;
}
if (event.tool_name === 'execute_skill_script') {
const name = getLocalizedToolName(event.tool_name);
return `${formatToolTitleWithDetail(name, getEventSkillName(event))}...`;
}
if (event.tool_name === 'list_sandbox_files' || event.tool_name === 'read_sandbox_file') {
const name = getLocalizedToolName(event.tool_name);
return `${formatToolTitleWithDetail(name, getSandboxToolPath(event))}...`;
}
if (event.tool_name === 'shell_exec') {
return t('agentStream.toolStatus.shellExecRunning');
}
const localizedName = getLocalizedToolName(event.tool_name);
@@ -2776,7 +2803,22 @@ const getToolTitle = (event: any): string => {
return pageLabel ? `${baseTitle}:「${sanitizeForDisplay(pageLabel)}` : baseTitle;
}
if (toolName === 'shell_exec' || toolName === 'execute_skill_script') {
if (toolName === 'read_skill') {
return formatToolTitleWithDetail(getToolDescription(event), getReadSkillTarget(event));
}
if (toolName === 'list_sandbox_files' || toolName === 'read_sandbox_file') {
return formatToolTitleWithDetail(getToolDescription(event), getSandboxToolPath(event));
}
if (toolName === 'execute_skill_script') {
const command = previewShellCommand(skillScriptCommandLabel(event))
const baseTitle = formatToolTitleWithDetail(getToolDescription(event), getEventSkillName(event))
const rest = skillScriptTitleCommand(getEventSkillName(event), command)
return rest ? `${baseTitle}${rest}` : baseTitle
}
if (toolName === 'shell_exec') {
const command = previewShellCommand(skillScriptCommandLabel(event))
const baseTitle = getToolDescription(event)
return command ? `${baseTitle}${command}` : baseTitle
@@ -2812,7 +2854,19 @@ const getToolDescription = (event: any): string => {
if (event.tool_name === 'query_understand') {
return t('agentStream.toolStatus.queryUnderstanding');
}
if (event.tool_name === 'shell_exec' || event.tool_name === 'execute_skill_script') {
if (event.tool_name === 'read_skill') {
const name = getLocalizedToolName(event.tool_name);
return `${formatToolTitleWithDetail(name, getReadSkillTarget(event))}...`;
}
if (event.tool_name === 'execute_skill_script') {
const name = getLocalizedToolName(event.tool_name);
return `${formatToolTitleWithDetail(name, getEventSkillName(event))}...`;
}
if (event.tool_name === 'list_sandbox_files' || event.tool_name === 'read_sandbox_file') {
const name = getLocalizedToolName(event.tool_name);
return `${formatToolTitleWithDetail(name, getSandboxToolPath(event))}...`;
}
if (event.tool_name === 'shell_exec') {
return t('agentStream.toolStatus.shellExecRunning');
}
const localizedName = getLocalizedToolName(event.tool_name);
@@ -2845,7 +2899,7 @@ const getToolDescription = (event: any): string => {
return success ? t('agentStream.toolStatus.attachmentParsingDone') : t('agentStream.toolStatus.attachmentParsingFailed');
} else if (toolName === 'query_understand') {
return success ? t('agentStream.toolStatus.queryUnderstandDone') : t('agentStream.toolStatus.calledFailed', { name: getLocalizedToolName(toolName) });
} else if (toolName === 'shell_exec' || toolName === 'execute_skill_script') {
} else if (toolName === 'shell_exec' || toolName === 'execute_skill_script' || toolName === 'read_skill' || toolName === 'list_sandbox_files' || toolName === 'read_sandbox_file') {
const localizedName = getLocalizedToolName(toolName);
return success ? localizedName : t('agentStream.toolStatus.calledFailed', { name: localizedName });
} else {
@@ -53,6 +53,16 @@
:arguments="toolArguments"
/>
<SandboxFilesResult
v-else-if="displayType === 'list_sandbox_files'"
:data="toolData as ListSandboxFilesData"
/>
<ReadSkillResult
v-else-if="displayType === 'read_skill'"
:data="toolData as ReadSkillData"
/>
<!-- Fallback: Display raw output -->
<div v-else class="fallback-output">
<div class="fallback-header">
@@ -83,7 +93,9 @@ import type {
GrepResultsData,
KnowledgeChunksListData,
WikiEditData,
ShellExecData
ShellExecData,
ListSandboxFilesData,
ReadSkillData
} from '@/types/tool-results';
import SearchResults from './tool-results/SearchResults.vue';
@@ -101,6 +113,8 @@ import GrepResults from './tool-results/GrepResults.vue';
import KnowledgeChunksList from './tool-results/KnowledgeChunksList.vue';
import WikiEditResult from './tool-results/WikiEditResult.vue';
import ShellExecResult from './tool-results/ShellExecResult.vue';
import SandboxFilesResult from './tool-results/SandboxFilesResult.vue';
import ReadSkillResult from './tool-results/ReadSkillResult.vue';
interface Props {
displayType?: DisplayType;
@@ -0,0 +1,132 @@
<template>
<div class="read-skill-result">
<p v-if="description" class="read-skill-desc">{{ description }}</p>
<div v-if="files.length" class="read-skill-files">
<div class="read-skill-heading">{{ $t('agentStream.skillFiles.heading') }}</div>
<div class="results-list">
<ResultRow
v-for="(item, index) in files"
:key="item.path || `${item.name}-${index}`"
:index="index + 1"
:title="item.path || item.name"
:meta="item.isScript ? $t('agentStream.skillFiles.script') : ''"
:popup-key="item.path || index"
:show-popup="false"
/>
</div>
</div>
<div v-if="body" class="read-skill-stream">
<div class="read-skill-stream-label">{{ bodyLabel }}</div>
<pre class="read-skill-stream-body">{{ body }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { skillFileListItems } from '@/utils/skillToolDisplay'
import type { ReadSkillData } from '@/types/tool-results'
import ResultRow from './ResultRow.vue'
const props = defineProps<{
data: ReadSkillData | Record<string, unknown>
}>()
const { t } = useI18n()
const record = computed(() => (props.data || {}) as Record<string, unknown>)
const description = computed(() => {
const value = record.value.description
return typeof value === 'string' ? value.trim() : ''
})
const files = computed(() => skillFileListItems(record.value))
const filePath = computed(() => {
const value = record.value.file_path
return typeof value === 'string' ? value.trim() : ''
})
const body = computed(() => {
const content = record.value.content
if (typeof content === 'string' && content.trim()) return content
if (filePath.value) return ''
const instructions = record.value.instructions
return typeof instructions === 'string' ? instructions : ''
})
const bodyLabel = computed(() =>
filePath.value
? filePath.value
: t('agentStream.skillFiles.instructions'),
)
</script>
<style lang="less" scoped>
.read-skill-result {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.read-skill-desc {
margin: 0;
font-size: 12px;
line-height: 1.55;
color: var(--td-text-color-secondary);
}
.read-skill-heading {
margin-bottom: 4px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
color: var(--td-text-color-secondary);
}
.read-skill-stream {
min-width: 0;
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
overflow: hidden;
background: var(--td-bg-color-container);
}
.read-skill-stream-label {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
color: var(--td-text-color-secondary);
background: var(--td-bg-color-secondarycontainer);
border-bottom: 1px solid var(--td-component-stroke);
}
.read-skill-stream-body {
margin: 0;
padding: 10px 12px;
max-height: 280px;
overflow: auto;
font-family: var(--app-font-family-mono);
font-size: 12px;
line-height: 1.55;
color: var(--td-text-color-primary);
white-space: pre-wrap;
word-break: break-word;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
background: var(--td-component-border);
border-radius: 4px;
}
}
</style>
@@ -0,0 +1,89 @@
<template>
<div class="sandbox-files-result">
<div v-if="summary" class="results-summary-text">{{ summary }}</div>
<div v-if="items.length" class="results-list">
<ResultRow
v-for="(item, index) in items"
:key="item.path || `${item.name}-${index}`"
:index="index + 1"
:title="item.name || item.path"
:meta="metaFor(item)"
:popup-key="item.path || index"
:show-popup="false"
/>
</div>
<div v-else class="empty-state">{{ $t('agentStream.sandboxFiles.empty') }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatFileSize } from '@/utils/files'
import {
formatSandboxModifiedAt,
sandboxFileListItems,
type SkillFileListItem,
} from '@/utils/skillToolDisplay'
import type { ListSandboxFilesData } from '@/types/tool-results'
import ResultRow from './ResultRow.vue'
const props = defineProps<{
data: ListSandboxFilesData | Record<string, unknown>
}>()
const { t } = useI18n()
const items = computed(() => sandboxFileListItems(props.data))
const summary = computed(() => {
if (!items.value.length) return ''
const record = (props.data || {}) as Record<string, unknown>
const count = typeof record.count === 'number' ? record.count : items.value.length
const parts = [t('agentStream.sandboxFiles.found', { count })]
if (record.truncated) {
parts.push(t('agentStream.sandboxFiles.truncated'))
}
return parts.join(' · ')
})
function sizeLabel(size?: number): string {
if (size == null || !Number.isFinite(size) || size < 0) return ''
if (size === 0) return '0 B'
return formatFileSize(size)
}
function metaFor(item: SkillFileListItem): string {
const parts = [sizeLabel(item.size), item.modifiedAt ? formatSandboxModifiedAt(item.modifiedAt) : '']
return parts.filter(Boolean).join(' · ')
}
</script>
<style lang="less" scoped>
@import './tool-results.less';
.sandbox-files-result {
display: flex;
flex-direction: column;
gap: 8px;
}
.results-summary-text {
font-size: var(--agent-step-summary-size, 12px);
font-weight: 400;
color: var(--td-text-color-secondary);
line-height: 1.5;
}
.results-list {
display: flex;
flex-direction: column;
min-width: 0;
}
.empty-state {
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-placeholder);
}
</style>