diff --git a/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts b/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts index 4c8c78f801..6cd1a2476c 100644 --- a/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts @@ -55,6 +55,8 @@ class SkillCollaborationManager { private cursorEmitter = new EventEmitter() private fileEmitter = new EventEmitter() private fileSavedGlobalKey = 'skill_file_saved:all' + private treeEmitter = new EventEmitter() + private treeUpdateKey = 'skill_tree_update:all' private handleSkillUpdate = (payload: SkillUpdatePayload) => { if (!payload || !payload.file_id || !payload.update) @@ -97,6 +99,11 @@ class SkillCollaborationManager { return } + if (update.type === 'skill_tree_update') { + this.treeEmitter.emit(this.treeUpdateKey, update.data) + return + } + if (update.type === 'skill_cursor') { const data = update.data as SkillCursorPayload | undefined const fileId = data?.file_id @@ -157,6 +164,7 @@ class SkillCollaborationManager { this.cursorByFile.clear() this.cursorEmitter.removeAllListeners() this.fileEmitter.removeAllListeners() + this.treeEmitter.removeAllListeners() } this.appId = appId @@ -278,6 +286,12 @@ class SkillCollaborationManager { return this.fileEmitter.on(this.fileSavedGlobalKey, callback) } + onTreeUpdate(appId: string, callback: (payload: Record) => void): () => void { + if (appId) + this.ensureSocket(appId) + return this.treeEmitter.on(this.treeUpdateKey, callback) + } + isLeader(fileId: string): boolean { return this.leaderByFile.get(fileId) || false } @@ -319,6 +333,21 @@ class SkillCollaborationManager { }) } + emitTreeUpdate(appId: string, payload: Record = {}): void { + if (!appId) + return + + const socket = this.ensureSocket(appId) + if (!socket || !socket.connected) + return + + emitWithAuthGuard(socket, 'collaboration_event', { + type: 'skill_tree_update', + data: payload, + timestamp: Date.now(), + }) + } + setActiveFile(appId: string, fileId: string, active: boolean): void { if (!appId || !fileId) return diff --git a/web/app/components/workflow/collaboration/types/collaboration.ts b/web/app/components/workflow/collaboration/types/collaboration.ts index 3f31db7e3f..c8a85f469b 100644 --- a/web/app/components/workflow/collaboration/types/collaboration.ts +++ b/web/app/components/workflow/collaboration/types/collaboration.ts @@ -65,6 +65,7 @@ export type CollaborationEventType | 'graph_view_active' | 'skill_file_active' | 'skill_file_saved' + | 'skill_tree_update' | 'skill_cursor' | 'skill_sync_request' | 'skill_resync_request' diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index f2f8325468..0bb3dcc53c 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -24,6 +24,7 @@ import { usePasteOperation } from '../hooks/use-paste-operation' import { useRootFileDrop } from '../hooks/use-root-file-drop' import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree' import { useSkillShortcuts } from '../hooks/use-skill-shortcuts' +import { useSkillTreeCollaboration } from '../hooks/use-skill-tree-collaboration' import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab' import { isDescendantOf } from '../utils/tree-utils' import DragActionTooltip from './drag-action-tooltip' @@ -107,6 +108,8 @@ const FileTree = ({ className }: FileTreeProps) => { const treeChildren = treeData?.children ?? emptyTreeNodes + useSkillTreeCollaboration() + const { handleRootDragEnter, handleRootDragLeave, diff --git a/web/app/components/workflow/skill/hooks/use-create-operations.ts b/web/app/components/workflow/skill/hooks/use-create-operations.ts index 4073545347..81fe5af92f 100644 --- a/web/app/components/workflow/skill/hooks/use-create-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-create-operations.ts @@ -10,6 +10,7 @@ import { useUploadFileWithPresignedUrl, } from '@/service/use-app-asset' import { prepareSkillUploadFile } from '../utils/skill-upload-utils' +import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration' type UseCreateOperationsOptions = { parentId: string | null @@ -34,6 +35,7 @@ export function useCreateOperations({ const createFolder = useCreateAppAssetFolder() const uploadFile = useUploadFileWithPresignedUrl() const batchUpload = useBatchUpload() + const emitTreeUpdate = useSkillTreeUpdateEmitter() const handleNewFile = useCallback(() => { storeApi.getState().startCreateNode('file', parentId) @@ -80,10 +82,12 @@ export function useCreateOperations({ storeApi.getState().setUploadStatus('partial_error') } finally { + if (progress.uploaded > 0) + emitTreeUpdate() e.target.value = '' onClose() } - }, [appId, uploadFile, onClose, parentId, storeApi]) + }, [appId, uploadFile, onClose, parentId, storeApi, emitTreeUpdate]) const handleFolderChange = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) @@ -152,6 +156,7 @@ export function useCreateOperations({ storeApi.getState().setUploadStatus('success') storeApi.getState().setUploadProgress({ uploaded: files.length, total: files.length, failed: 0 }) + emitTreeUpdate() } catch { storeApi.getState().setUploadStatus('partial_error') @@ -160,7 +165,7 @@ export function useCreateOperations({ e.target.value = '' onClose() } - }, [appId, batchUpload, onClose, parentId, storeApi]) + }, [appId, batchUpload, onClose, parentId, storeApi, emitTreeUpdate]) return { fileInputRef, diff --git a/web/app/components/workflow/skill/hooks/use-file-drop.ts b/web/app/components/workflow/skill/hooks/use-file-drop.ts index 5c6c0f101c..5debadb27d 100644 --- a/web/app/components/workflow/skill/hooks/use-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-file-drop.ts @@ -11,6 +11,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store' import { useUploadFileWithPresignedUrl } from '@/service/use-app-asset' import { ROOT_ID } from '../constants' import { prepareSkillUploadFile } from '../utils/skill-upload-utils' +import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration' type FileDropTarget = { folderId: string | null @@ -23,6 +24,7 @@ export function useFileDrop() { const appId = appDetail?.id || '' const storeApi = useWorkflowStore() const uploadFile = useUploadFileWithPresignedUrl() + const emitTreeUpdate = useSkillTreeUpdateEmitter() const handleDragOver = useCallback((e: React.DragEvent, target: FileDropTarget) => { e.preventDefault() @@ -90,6 +92,7 @@ export function useFileDrop() { ), ) + emitTreeUpdate() Toast.notify({ type: 'success', message: t('skillSidebar.menu.filesUploaded', { count: files.length }), @@ -101,7 +104,7 @@ export function useFileDrop() { message: t('skillSidebar.menu.uploadError'), }) } - }, [appId, uploadFile, t, storeApi]) + }, [appId, uploadFile, t, storeApi, emitTreeUpdate]) return { handleDragOver, diff --git a/web/app/components/workflow/skill/hooks/use-inline-create-node.ts b/web/app/components/workflow/skill/hooks/use-inline-create-node.ts index ec63c6d31d..af78fa1631 100644 --- a/web/app/components/workflow/skill/hooks/use-inline-create-node.ts +++ b/web/app/components/workflow/skill/hooks/use-inline-create-node.ts @@ -14,6 +14,7 @@ import { } from '@/service/use-app-asset' import { getFileExtension, isTextLikeFile } from '../utils/file-utils' import { createDraftTreeNode, insertDraftTreeNode } from '../utils/tree-utils' +import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration' type UseInlineCreateNodeOptions = { treeRef: React.RefObject | null> @@ -38,6 +39,7 @@ export function useInlineCreateNode({ const uploadFile = useUploadFileWithPresignedUrl() const createFolder = useCreateAppAssetFolder() const renameNode = useRenameAppAssetNode() + const emitTreeUpdate = useSkillTreeUpdateEmitter() const pendingCreateId = pendingCreateNode?.id ?? null const pendingCreateType = pendingCreateNode?.nodeType ?? null @@ -71,6 +73,7 @@ export function useInlineCreateNode({ parent_id: pendingCreateParentId, }, }) + emitTreeUpdate() Toast.notify({ type: 'success', message: t('skillSidebar.menu.folderCreated'), @@ -84,6 +87,7 @@ export function useInlineCreateNode({ file, parentId: pendingCreateParentId, }) + emitTreeUpdate() const extension = getFileExtension(trimmedName, createdFile.extension) if (isTextLikeFile(extension)) storeApi.getState().openTab(createdFile.id, { pinned: true }) @@ -110,6 +114,7 @@ export function useInlineCreateNode({ nodeId: id, payload: { name }, }).then(() => { + emitTreeUpdate() Toast.notify({ type: 'success', message: t('skillSidebar.menu.renamed'), @@ -130,6 +135,7 @@ export function useInlineCreateNode({ renameNode, storeApi, t, + emitTreeUpdate, ]) const searchMatch = useCallback( diff --git a/web/app/components/workflow/skill/hooks/use-modify-operations.ts b/web/app/components/workflow/skill/hooks/use-modify-operations.ts index 4d153e2312..860920269a 100644 --- a/web/app/components/workflow/skill/hooks/use-modify-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-modify-operations.ts @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next' import Toast from '@/app/components/base/toast' import { useDeleteAppAssetNode } from '@/service/use-app-asset' import { getAllDescendantFileIds } from '../utils/tree-utils' +import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration' type UseModifyOperationsOptions = { nodeId: string @@ -35,6 +36,7 @@ export function useModifyOperations({ const { t } = useTranslation('workflow') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const deleteNode = useDeleteAppAssetNode() + const emitTreeUpdate = useSkillTreeUpdateEmitter() const handleRename = useCallback(() => { if (treeRef?.current) { @@ -59,6 +61,7 @@ export function useModifyOperations({ : [] await deleteNode.mutateAsync({ appId, nodeId }) + emitTreeUpdate() descendantFileIds.forEach((fileId) => { storeApi.getState().closeTab(fileId) @@ -90,7 +93,7 @@ export function useModifyOperations({ setShowDeleteConfirm(false) onClose() } - }, [appId, nodeId, node?.data?.node_type, deleteNode, storeApi, treeData?.children, onClose, t]) + }, [appId, nodeId, node?.data?.node_type, deleteNode, storeApi, treeData?.children, onClose, t, emitTreeUpdate]) const handleDeleteCancel = useCallback(() => { setShowDeleteConfirm(false) diff --git a/web/app/components/workflow/skill/hooks/use-node-move.ts b/web/app/components/workflow/skill/hooks/use-node-move.ts index ec698e5757..6856a8658c 100644 --- a/web/app/components/workflow/skill/hooks/use-node-move.ts +++ b/web/app/components/workflow/skill/hooks/use-node-move.ts @@ -9,12 +9,14 @@ import { useStore as useAppStore } from '@/app/components/app/store' import Toast from '@/app/components/base/toast' import { useMoveAppAssetNode } from '@/service/use-app-asset' import { toApiParentId } from '../utils/tree-utils' +import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration' export function useNodeMove() { const { t } = useTranslation('workflow') const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' const moveNode = useMoveAppAssetNode() + const emitTreeUpdate = useSkillTreeUpdateEmitter() // Execute move API call - validation is handled by react-arborist's disableDrop callback const executeMoveNode = useCallback(async (nodeId: string, targetFolderId: string | null) => { @@ -25,6 +27,7 @@ export function useNodeMove() { payload: { parent_id: toApiParentId(targetFolderId) }, }) + emitTreeUpdate() Toast.notify({ type: 'success', message: t('skillSidebar.menu.moved'), @@ -36,7 +39,7 @@ export function useNodeMove() { message: t('skillSidebar.menu.moveError'), }) } - }, [appId, moveNode, t]) + }, [appId, moveNode, t, emitTreeUpdate]) return { executeMoveNode, diff --git a/web/app/components/workflow/skill/hooks/use-node-reorder.ts b/web/app/components/workflow/skill/hooks/use-node-reorder.ts index d2a155d454..c256ba2903 100644 --- a/web/app/components/workflow/skill/hooks/use-node-reorder.ts +++ b/web/app/components/workflow/skill/hooks/use-node-reorder.ts @@ -7,12 +7,14 @@ import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Toast from '@/app/components/base/toast' import { useReorderAppAssetNode } from '@/service/use-app-asset' +import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration' export function useNodeReorder() { const { t } = useTranslation('workflow') const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' const reorderNode = useReorderAppAssetNode() + const emitTreeUpdate = useSkillTreeUpdateEmitter() const executeReorderNode = useCallback(async (nodeId: string, afterNodeId: string | null) => { try { @@ -22,6 +24,7 @@ export function useNodeReorder() { payload: { after_node_id: afterNodeId }, }) + emitTreeUpdate() Toast.notify({ type: 'success', message: t('skillSidebar.menu.moved'), @@ -33,7 +36,7 @@ export function useNodeReorder() { message: t('skillSidebar.menu.moveError'), }) } - }, [appId, reorderNode, t]) + }, [appId, reorderNode, t, emitTreeUpdate]) return { executeReorderNode, diff --git a/web/app/components/workflow/skill/hooks/use-paste-operation.ts b/web/app/components/workflow/skill/hooks/use-paste-operation.ts index 74b8f23f27..1e4fda190d 100644 --- a/web/app/components/workflow/skill/hooks/use-paste-operation.ts +++ b/web/app/components/workflow/skill/hooks/use-paste-operation.ts @@ -11,6 +11,7 @@ import Toast from '@/app/components/base/toast' import { useWorkflowStore } from '@/app/components/workflow/store' import { useMoveAppAssetNode } from '@/service/use-app-asset' import { findNodeById, getTargetFolderIdFromSelection, toApiParentId } from '../utils/tree-utils' +import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration' type UsePasteOperationOptions = { treeRef: RefObject | null> @@ -33,6 +34,7 @@ export function usePasteOperation({ const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' const moveNode = useMoveAppAssetNode() + const emitTreeUpdate = useSkillTreeUpdateEmitter() const isPastingRef = useRef(false) const handlePaste = useCallback(async () => { @@ -84,6 +86,7 @@ export function usePasteOperation({ ) storeApi.getState().clearClipboard() + emitTreeUpdate() Toast.notify({ type: 'success', @@ -100,7 +103,7 @@ export function usePasteOperation({ isPastingRef.current = false } } - }, [appId, moveNode, storeApi, t, treeData?.children, treeRef]) + }, [appId, moveNode, storeApi, t, treeData?.children, treeRef, emitTreeUpdate]) useEffect(() => { if (!enabled) diff --git a/web/app/components/workflow/skill/hooks/use-skill-tree-collaboration.ts b/web/app/components/workflow/skill/hooks/use-skill-tree-collaboration.ts new file mode 100644 index 0000000000..4f2ab1e096 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-skill-tree-collaboration.ts @@ -0,0 +1,38 @@ +'use client' + +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect } from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { skillCollaborationManager } from '@/app/components/workflow/collaboration/skills/skill-collaboration-manager' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { consoleQuery } from '@/service/client' + +export const useSkillTreeUpdateEmitter = () => { + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode) + + return useCallback((payload: Record = {}) => { + if (!appId || !isCollaborationEnabled) + return + skillCollaborationManager.emitTreeUpdate(appId, payload) + }, [appId, isCollaborationEnabled]) +} + +export const useSkillTreeCollaboration = () => { + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode) + const queryClient = useQueryClient() + + useEffect(() => { + if (!appId || !isCollaborationEnabled) + return + + return skillCollaborationManager.onTreeUpdate(appId, () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId } } }), + }) + }) + }, [appId, isCollaborationEnabled, queryClient]) +}