feat(sandbox): implement artifacts section UI

- Replace placeholder with functional ArtifactsSection component
- Add ArtifactsTree component for file tree rendering
- Support expand/collapse with lazy loading
- Show blue dot indicator when collapsed with files
- Add empty state card with hint text
- Add download button on file hover
- Add i18n translations (en-US, zh-Hans)
This commit is contained in:
yyh
2026-01-26 15:43:06 +08:00
parent 166b4a5a2b
commit d396d92059
4 changed files with 200 additions and 25 deletions

View File

@ -1,66 +1,109 @@
'use client'
import type { FC } from 'react'
import { RiArrowRightSLine } from '@remixicon/react'
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FolderSpark from '@/app/components/base/icons/src/vender/workflow/FolderSpark'
import { useAppContext } from '@/context/app-context'
import { useDownloadSandboxFile, useSandboxFilesTree } from '@/service/use-sandbox-file'
import { cn } from '@/utils/classnames'
import ArtifactsTree from './artifacts-tree'
type ArtifactsSectionProps = {
className?: string
}
/**
* Artifacts section component for file tree.
* Shows the artifacts folder with badge and navigation arrow.
* Clicking expands to show artifact files from test runs.
* Placeholder implementation - functionality to be added later.
*/
const ArtifactsSection: FC<ArtifactsSectionProps> = ({ className }) => {
const { t } = useTranslation('workflow')
// TODO: Replace with actual data
const badgeText = 'Test Run#3'
const hasNewFiles = true
const { userProfile } = useAppContext()
const sandboxId = userProfile?.id
const [isExpanded, setIsExpanded] = useState(false)
const { data: treeData, hasFiles, isLoading } = useSandboxFilesTree(sandboxId, {
enabled: isExpanded,
})
const downloadMutation = useDownloadSandboxFile(sandboxId)
const handleToggle = useCallback(() => {
setIsExpanded(prev => !prev)
}, [])
const handleDownload = useCallback(async (node: SandboxFileTreeNode) => {
try {
const ticket = await downloadMutation.mutateAsync(node.path)
window.open(ticket.download_url, '_blank')
}
catch (error) {
console.error('Download failed:', error)
}
}, [downloadMutation])
const showBlueDot = !isExpanded && hasFiles
return (
<div className={cn('shrink-0 border-t border-divider-regular p-1', className)}>
<button
type="button"
onClick={handleToggle}
className={cn(
'flex w-full items-center rounded-md py-1 pl-2 pr-1.5',
'hover:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
)}
aria-expanded={isExpanded}
aria-label={t('skillSidebar.artifacts.openArtifacts')}
>
{/* Title section */}
<div className="flex flex-1 items-center gap-1 py-0.5">
{/* Icon */}
<div className="flex size-5 items-center justify-center">
<FolderSpark className="size-4 text-text-secondary" aria-hidden="true" />
</div>
{/* Label */}
<span className="system-sm-semibold uppercase text-text-secondary">
{t('skillSidebar.artifacts.title')}
</span>
{/* Badge */}
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5">
<span className="system-2xs-medium uppercase text-text-tertiary">
{badgeText}
</span>
</div>
</div>
{/* Arrow with indicator */}
<div className="relative">
<RiArrowRightSLine className="size-4 text-text-tertiary" aria-hidden="true" />
{/* Blue dot indicator */}
{hasNewFiles && (
<div className="absolute -right-0.5 top-1/2 size-[7px] -translate-y-1/2 rounded-full border border-white bg-state-accent-solid" />
<div className="relative flex items-center">
{showBlueDot && (
<div className="absolute -left-2 size-[7px] rounded-full border border-white bg-state-accent-solid" />
)}
{isExpanded
? <RiArrowDownSLine className="size-4 text-text-tertiary" aria-hidden="true" />
: <RiArrowRightSLine className="size-4 text-text-tertiary" aria-hidden="true" />}
</div>
</button>
{isExpanded && (
<div className="flex flex-col gap-px">
{isLoading
? (
<div className="px-2.5 py-3">
<div className="h-4 w-full animate-pulse rounded bg-components-panel-bg" />
</div>
)
: hasFiles
? (
<ArtifactsTree
data={treeData}
onDownload={handleDownload}
isDownloading={downloadMutation.isPending}
/>
)
: (
<div className="px-2.5 pb-1.5 pt-0.5">
<div className="rounded-lg bg-background-section p-3">
<p className="system-xs-regular text-text-tertiary">
{t('skillSidebar.artifacts.emptyState')}
</p>
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,130 @@
'use client'
import type { FC } from 'react'
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { RiDownloadLine, RiFile3Fill, RiFolderLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { cn } from '@/utils/classnames'
type ArtifactsTreeProps = {
data: SandboxFileTreeNode[] | undefined
onDownload: (node: SandboxFileTreeNode) => void
isDownloading?: boolean
}
type ArtifactsTreeNodeProps = {
node: SandboxFileTreeNode
depth: number
onDownload: (node: SandboxFileTreeNode) => void
isDownloading?: boolean
}
const ArtifactsTreeNode: FC<ArtifactsTreeNodeProps> = ({
node,
depth,
onDownload,
isDownloading,
}) => {
const [isExpanded, setIsExpanded] = useState(false)
const isFolder = node.node_type === 'folder'
const hasChildren = isFolder && node.children.length > 0
const handleToggle = useCallback(() => {
if (isFolder)
setIsExpanded(prev => !prev)
}, [isFolder])
const handleDownload = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onDownload(node)
}, [node, onDownload])
return (
<div>
<div
role={isFolder ? 'button' : undefined}
tabIndex={isFolder ? 0 : undefined}
onClick={handleToggle}
onKeyDown={isFolder
? (e) => {
if (e.key === 'Enter' || e.key === ' ')
handleToggle()
}
: undefined}
className={cn(
'group flex items-center gap-0 rounded-md py-0.5 pr-1.5',
isFolder && 'cursor-pointer hover:bg-state-base-hover',
!isFolder && 'hover:bg-state-base-hover',
)}
style={{ paddingLeft: `${8 + depth * 20}px` }}
>
<div className="flex size-5 shrink-0 items-center justify-center">
{isFolder
? <RiFolderLine className="size-4 text-text-tertiary" />
: <RiFile3Fill className="size-4 text-text-quaternary" />}
</div>
<span className="system-sm-regular flex-1 truncate px-1 py-0.5 text-text-secondary">
{node.name}
</span>
{!isFolder && (
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100',
'hover:bg-state-base-hover-alt',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-active',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
aria-label={`Download ${node.name}`}
>
<RiDownloadLine className="size-3.5 text-text-tertiary" />
</button>
)}
</div>
{isFolder && isExpanded && hasChildren && (
<div>
{node.children.map(child => (
<ArtifactsTreeNode
key={child.id}
node={child}
depth={depth + 1}
onDownload={onDownload}
isDownloading={isDownloading}
/>
))}
</div>
)}
</div>
)
}
const ArtifactsTree: FC<ArtifactsTreeProps> = ({
data,
onDownload,
isDownloading,
}) => {
if (!data || data.length === 0)
return null
return (
<div className="py-0.5">
{data.map(node => (
<ArtifactsTreeNode
key={node.id}
node={node}
depth={0}
onDownload={onDownload}
isDownloading={isDownloading}
/>
))}
</div>
)
}
export default React.memo(ArtifactsTree)

View File

@ -1069,6 +1069,7 @@
"skillEditor.unsupportedPreview": "This file type is not supported for preview",
"skillSidebar.addFile": "Upload File",
"skillSidebar.addFolder": "New Folder",
"skillSidebar.artifacts.emptyState": "Files generated by the agent during test runs. They may be automatically cleared later.",
"skillSidebar.artifacts.openArtifacts": "Open artifacts",
"skillSidebar.artifacts.title": "Artifacts",
"skillSidebar.dragAction.moveTo": "Move to ",

View File

@ -1061,6 +1061,7 @@
"skillEditor.unsupportedPreview": "该文件类型不支持预览",
"skillSidebar.addFile": "上传文件",
"skillSidebar.addFolder": "新建文件夹",
"skillSidebar.artifacts.emptyState": "代理在测试运行期间生成的文件。这些文件可能会在稍后自动清除。",
"skillSidebar.artifacts.openArtifacts": "打开产物文件夹",
"skillSidebar.artifacts.title": "产物",
"skillSidebar.dragAction.moveTo": "移动到 ",