feat: add ArtifactSlice and integrate artifact preview into skill editor tabs

Introduce a dedicated Zustand ArtifactSlice to manage artifact selection
state with mutual exclusion against the main file tree. Artifact files
from the sandbox can now be opened as tabs in the skill editor, rendered
via a lightweight ArtifactContentPanel that reuses ReadOnlyFilePreview.
This commit is contained in:
yyh
2026-01-29 17:52:41 +08:00
parent 9d99675a1d
commit 92731bffba
10 changed files with 148 additions and 8 deletions

View File

@ -0,0 +1,54 @@
'use client'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useStore } from '@/app/components/workflow/store'
import { useAppContext } from '@/context/app-context'
import { useSandboxFileDownloadUrl } from '@/service/use-sandbox-file'
import { getArtifactPath } from './constants'
import { getFileExtension } from './utils/file-utils'
import ReadOnlyFilePreview from './viewer/read-only-file-preview'
const ArtifactContentPanel = () => {
const { t } = useTranslation('workflow')
const activeTabId = useStore(s => s.activeTabId)
const { userProfile } = useAppContext()
const sandboxId = userProfile?.id
const path = activeTabId ? getArtifactPath(activeTabId) : undefined
const fileName = path?.split('/').pop() ?? ''
const extension = getFileExtension(fileName)
const { data: ticket, isLoading } = useSandboxFileDownloadUrl(sandboxId, path)
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
<Loading type="area" />
</div>
)
}
if (!ticket?.download_url) {
return (
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
<span className="system-sm-regular">
{t('skillSidebar.loadError')}
</span>
</div>
)
}
return (
<div className="h-full w-full overflow-auto bg-components-panel-bg">
<ReadOnlyFilePreview
downloadUrl={ticket.download_url}
fileName={fileName}
extension={extension}
/>
</div>
)
}
export default React.memo(ArtifactContentPanel)

View File

@ -28,6 +28,20 @@ export const NODE_MENU_TYPE = {
export type NodeMenuType = (typeof NODE_MENU_TYPE)[keyof typeof NODE_MENU_TYPE]
export const ARTIFACT_TAB_PREFIX = 'artifact:' as const
export function isArtifactTab(tabId: string | null): boolean {
return tabId?.startsWith(ARTIFACT_TAB_PREFIX) ?? false
}
export function getArtifactPath(tabId: string): string {
return tabId.slice(ARTIFACT_TAB_PREFIX.length)
}
export function makeArtifactTabId(path: string): string {
return `${ARTIFACT_TAB_PREFIX}${path}`
}
export const SIDEBAR_MIN_WIDTH = 240
export const SIDEBAR_MAX_WIDTH = 480
export const SIDEBAR_DEFAULT_WIDTH = 320

View File

@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { START_TAB_ID } from './constants'
import { getArtifactPath, isArtifactTab, START_TAB_ID } from './constants'
import FileTabItem from './file-tab-item'
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
import StartTabItem from './start-tab-item'
import { getFileExtension } from './utils/file-utils'
const FileTabs = () => {
const { t } = useTranslation('workflow')
@ -38,6 +39,8 @@ const FileTabs = () => {
}, [storeApi])
const closeTab = useCallback((fileId: string) => {
if (isArtifactTab(fileId))
storeApi.getState().clearArtifactSelection()
storeApi.getState().closeTab(fileId)
storeApi.getState().clearDraftContent(fileId)
storeApi.getState().clearFileMetadata(fileId)
@ -74,8 +77,11 @@ const FileTabs = () => {
onClick={handleStartTabClick}
/>
{openTabIds.map((fileId) => {
const node = nodeMap?.get(fileId)
const name = node?.name ?? fileId
const isArtifact = isArtifactTab(fileId)
const node = isArtifact ? undefined : nodeMap?.get(fileId)
const artifactFileName = isArtifact ? getArtifactPath(fileId).split('/').pop() ?? fileId : undefined
const name = isArtifact ? artifactFileName! : (node?.name ?? fileId)
const extension = isArtifact ? getFileExtension(artifactFileName!) : node?.extension
const isActive = activeTabId === fileId
const isDirty = dirtyContents.has(fileId) || dirtyMetadataIds.has(fileId)
const isPreview = previewTabId === fileId
@ -85,7 +91,7 @@ const FileTabs = () => {
key={fileId}
fileId={fileId}
name={name}
extension={node?.extension}
extension={extension}
isActive={isActive}
isDirty={isDirty}
isPreview={isPreview}

View File

@ -6,6 +6,7 @@ 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 { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useAppContext } from '@/context/app-context'
import { useDownloadSandboxFile, useSandboxFilesTree } from '@/service/use-sandbox-file'
import { cn } from '@/utils/classnames'
@ -26,11 +27,17 @@ const ArtifactsSection = ({ className }: ArtifactsSectionProps) => {
const { data: treeData, hasFiles, isLoading } = useSandboxFilesTree(sandboxId)
const downloadMutation = useDownloadSandboxFile(sandboxId)
const storeApi = useWorkflowStore()
const selectedArtifactPath = useStore(s => s.selectedArtifactPath)
const handleToggle = useCallback(() => {
setIsExpanded(prev => !prev)
}, [])
const handleSelect = useCallback((node: SandboxFileTreeNode) => {
storeApi.getState().selectArtifact(node.path)
}, [storeApi])
const handleDownload = useCallback(async (node: SandboxFileTreeNode) => {
try {
const ticket = await downloadMutation.mutateAsync(node.path)
@ -91,6 +98,8 @@ const ArtifactsSection = ({ className }: ArtifactsSectionProps) => {
<ArtifactsTree
data={treeData}
onDownload={handleDownload}
onSelect={handleSelect}
selectedPath={selectedArtifactPath ?? undefined}
isDownloading={downloadMutation.isPending}
/>
)

View File

@ -3,7 +3,7 @@
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import { useEffect } from 'react'
import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
import { isArtifactTab, START_TAB_ID } from '@/app/components/workflow/skill/constants'
import { useWorkflowStore } from '@/app/components/workflow/store'
type UseSyncTreeWithActiveTabOptions = {
@ -32,6 +32,13 @@ export function useSyncTreeWithActiveTab({
if (!tree)
return
if (isArtifactTab(activeTabId)) {
requestAnimationFrame(() => {
tree.deselectAll()
})
return
}
requestAnimationFrame(() => {
const node = tree.get(activeTabId)
if (!node)

View File

@ -33,10 +33,12 @@ export function useTreeNodeHandlers({
)
const openFilePreview = useCallback(() => {
storeApi.getState().clearArtifactSelection()
storeApi.getState().openTab(node.data.id, { pinned: false })
}, [node.data.id, storeApi])
const openFilePinned = useCallback(() => {
storeApi.getState().clearArtifactSelection()
storeApi.getState().openTab(node.data.id, { pinned: true })
}, [node.data.id, storeApi])
@ -89,10 +91,13 @@ export function useTreeNodeHandlers({
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (isFolder)
if (isFolder) {
node.toggle()
else
}
else {
storeApi.getState().clearArtifactSelection()
storeApi.getState().openTab(node.data.id, { pinned: true })
}
}
}, [isFolder, node, storeApi])

View File

@ -2,6 +2,9 @@
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useStore } from '@/app/components/workflow/store'
import ArtifactContentPanel from './artifact-content-panel'
import { isArtifactTab } from './constants'
import ContentArea from './content-area'
import ContentBody from './content-body'
import FileContentPanel from './file-content-panel'
@ -19,6 +22,13 @@ const SkillAutoSaveManager = () => {
return null
}
const ContentRouter = () => {
const activeTabId = useStore(s => s.activeTabId)
if (isArtifactTab(activeTabId))
return <ArtifactContentPanel />
return <FileContentPanel />
}
const SkillMain = () => {
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
@ -36,7 +46,7 @@ const SkillMain = () => {
<ContentArea>
<FileTabs />
<ContentBody>
<FileContentPanel />
<ContentRouter />
</ContentBody>
</ContentArea>
</SkillPageLayout>

View File

@ -0,0 +1,24 @@
import type { StateCreator } from 'zustand'
import type { ArtifactSliceShape, SkillEditorSliceShape } from './types'
import { makeArtifactTabId } from '@/app/components/workflow/skill/constants'
export type { ArtifactSliceShape } from './types'
export const createArtifactSlice: StateCreator<
SkillEditorSliceShape,
[],
[],
ArtifactSliceShape
> = (set, get) => ({
selectedArtifactPath: null,
selectArtifact: (path: string) => {
get().clearSelection()
set({ selectedArtifactPath: path })
get().openTab(makeArtifactTabId(path), { pinned: true })
},
clearArtifactSelection: () => {
set({ selectedArtifactPath: null })
},
})

View File

@ -1,6 +1,7 @@
import type { StateCreator } from 'zustand'
import type { SkillEditorSliceShape } from './types'
import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
import { createArtifactSlice } from './artifact-slice'
import { createClipboardSlice } from './clipboard-slice'
import { createDirtySlice } from './dirty-slice'
import { createFileOperationsMenuSlice } from './file-operations-menu-slice'
@ -9,6 +10,7 @@ import { createMetadataSlice } from './metadata-slice'
import { createTabSlice } from './tab-slice'
import { createUploadSlice } from './upload-slice'
export type { ArtifactSliceShape } from './artifact-slice'
export type { ClipboardSliceShape } from './clipboard-slice'
export type { DirtySliceShape } from './dirty-slice'
export type { FileOperationsMenuSliceShape } from './file-operations-menu-slice'
@ -26,6 +28,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
...createMetadataSlice(...args),
...createFileOperationsMenuSlice(...args),
...createUploadSlice(...args),
...createArtifactSlice(...args),
resetSkillEditor: () => {
const [set] = args
@ -48,6 +51,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
fileTreeSearchTerm: '',
uploadStatus: 'idle',
uploadProgress: { uploaded: 0, total: 0, failed: 0 },
selectedArtifactPath: null,
})
},
})

View File

@ -118,6 +118,12 @@ export type UploadSliceShape = {
resetUpload: () => void
}
export type ArtifactSliceShape = {
selectedArtifactPath: string | null
selectArtifact: (path: string) => void
clearArtifactSelection: () => void
}
export type SkillEditorSliceShape
= TabSliceShape
& FileTreeSliceShape
@ -126,6 +132,7 @@ export type SkillEditorSliceShape
& MetadataSliceShape
& FileOperationsMenuSliceShape
& UploadSliceShape
& ArtifactSliceShape
& {
resetSkillEditor: () => void
}