From 4c77b5f5c5583265f5d7aacf57c2608319aae7b1 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 28 Jan 2026 16:29:17 +0800 Subject: [PATCH] feat: sync the markdown file dirty status --- .../skills/skill-collaboration-manager.ts | 34 +++++++++++++++ .../use-skill-markdown-collaboration.ts | 17 +++++++- .../collaboration/types/collaboration.ts | 1 + .../workflow/skill/file-content-panel.tsx | 1 + .../skill/hooks/use-skill-save-manager.tsx | 41 ++++++++++++++++++- 5 files changed, 90 insertions(+), 4 deletions(-) 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 89d8968953..4c8c78f801 100644 --- a/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/skills/skill-collaboration-manager.ts @@ -21,6 +21,12 @@ type SkillCursorPayload = { end?: number | null } +type SkillFileSavedPayload = { + file_id: string + content?: string + metadata?: Record +} + type SkillDocEntry = { doc: LoroDoc text: ReturnType @@ -47,6 +53,8 @@ class SkillCollaborationManager { private pendingResync = new Set() private cursorByFile = new Map() private cursorEmitter = new EventEmitter() + private fileEmitter = new EventEmitter() + private fileSavedGlobalKey = 'skill_file_saved:all' private handleSkillUpdate = (payload: SkillUpdatePayload) => { if (!payload || !payload.file_id || !payload.update) @@ -80,6 +88,15 @@ class SkillCollaborationManager { if (!update || !update.type) return + if (update.type === 'skill_file_saved') { + const data = update.data as SkillFileSavedPayload | undefined + const fileId = data?.file_id + if (!fileId) + return + this.fileEmitter.emit(this.fileSavedGlobalKey, data) + return + } + if (update.type === 'skill_cursor') { const data = update.data as SkillCursorPayload | undefined const fileId = data?.file_id @@ -139,6 +156,7 @@ class SkillCollaborationManager { this.pendingResync.clear() this.cursorByFile.clear() this.cursorEmitter.removeAllListeners() + this.fileEmitter.removeAllListeners() } this.appId = appId @@ -256,6 +274,10 @@ class SkillCollaborationManager { return off } + onAnyFileSaved(callback: (payload: SkillFileSavedPayload) => void): () => void { + return this.fileEmitter.on(this.fileSavedGlobalKey, callback) + } + isLeader(fileId: string): boolean { return this.leaderByFile.get(fileId) || false } @@ -285,6 +307,18 @@ class SkillCollaborationManager { }) } + emitFileSaved(fileId: string, content: string, metadata?: Record): void { + if (!fileId || !this.socket || !this.socket.connected) { + return + } + + emitWithAuthGuard(this.socket, 'collaboration_event', { + type: 'skill_file_saved', + data: { file_id: fileId, content, metadata }, + timestamp: Date.now(), + }) + } + setActiveFile(appId: string, fileId: string, active: boolean): void { if (!appId || !fileId) return diff --git a/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts b/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts index 923d4666eb..f1427f3e46 100644 --- a/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts +++ b/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts @@ -9,6 +9,7 @@ type UseSkillMarkdownCollaborationProps = { fileId: string | null enabled: boolean initialContent: string + baselineContent: string onLocalChange: (value: string) => void onLeaderSync: () => void } @@ -18,17 +19,24 @@ export const useSkillMarkdownCollaboration = ({ fileId, enabled, initialContent, + baselineContent, onLocalChange, onLeaderSync, }: UseSkillMarkdownCollaborationProps) => { const storeApi = useWorkflowStore() const { eventEmitter } = useEventEmitterContextContext() const suppressNextChangeRef = useRef(null) + // Keep the latest server baseline to avoid marking the editor dirty on initial sync. + const baselineContentRef = useRef(baselineContent) useEffect(() => { suppressNextChangeRef.current = null }, [fileId]) + useEffect(() => { + baselineContentRef.current = baselineContent + }, [baselineContent]) + useEffect(() => { if (!enabled || !fileId) return @@ -39,8 +47,13 @@ export const useSkillMarkdownCollaboration = ({ const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => { suppressNextChangeRef.current = nextText const state = storeApi.getState() - state.setDraftContent(fileId, nextText) - state.pinTab(fileId) + if (nextText === baselineContentRef.current) { + state.clearDraftContent(fileId) + } + else { + state.setDraftContent(fileId, nextText) + state.pinTab(fileId) + } eventEmitter?.emit({ type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, instanceId: fileId, diff --git a/web/app/components/workflow/collaboration/types/collaboration.ts b/web/app/components/workflow/collaboration/types/collaboration.ts index 48e63eb08e..3f31db7e3f 100644 --- a/web/app/components/workflow/collaboration/types/collaboration.ts +++ b/web/app/components/workflow/collaboration/types/collaboration.ts @@ -64,6 +64,7 @@ export type CollaborationEventType | 'app_publish_update' | 'graph_view_active' | 'skill_file_active' + | 'skill_file_saved' | 'skill_cursor' | 'skill_sync_request' | 'skill_resync_request' diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index db86e9739f..8ec7a81735 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -161,6 +161,7 @@ const FileContentPanel: FC = () => { fileId: fileTabId, enabled: canInitCollaboration, initialContent: initialCollaborativeContent, + baselineContent: originalContent, onLocalChange: handleEditorChange, onLeaderSync: handleLeaderSync, }) diff --git a/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx b/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx index 970bd8de72..9633c5bfec 100644 --- a/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx +++ b/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useEventListener } from 'ahooks' import isDeepEqual from 'fast-deep-equal' import * as React from 'react' -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import Toast from '@/app/components/base/toast' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -181,8 +181,9 @@ export const SkillSaveProvider = ({ } const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata) - if (!snapshot) + if (!snapshot) { return { saved: false } + } try { await updateContent.mutateAsync({ @@ -210,6 +211,10 @@ export const SkillSaveProvider = ({ latestState.clearDraftMetadata(fileId) } + if (isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId)) { + skillCollaborationManager.emitFileSaved(fileId, snapshot.content, snapshot.metadata) + } + return { saved: true } } catch (error) { @@ -299,6 +304,38 @@ export const SkillSaveProvider = ({ unregisterFallback, }), [saveAllDirty, saveFile, registerFallback, unregisterFallback]) + useEffect(() => { + if (!appId || !isCollaborationEnabled) + return + + return skillCollaborationManager.onAnyFileSaved((payload) => { + if (!payload?.file_id || typeof payload.content !== 'string') + return + + const fileId = payload.file_id + const queryKey = consoleQuery.appAsset.getFileContent.queryKey({ + input: { params: { appId, nodeId: fileId } }, + }) + const serialized = JSON.stringify({ + content: payload.content, + ...(payload.metadata ? { metadata: payload.metadata } : {}), + }) + const existing = queryClient.getQueryData(queryKey) + queryClient.setQueryData(queryKey, { + ...(existing && typeof existing === 'object' ? existing : {}), + content: serialized, + }) + + const state = storeApi.getState() + state.clearDraftContent(fileId) + + const latestMetadata = state.fileMetadata.get(fileId) + const normalizedLatest = normalizeMetadata(latestMetadata, payload.content) + if (payload.metadata === undefined || isDeepEqual(normalizedLatest, payload.metadata)) + state.clearDraftMetadata(fileId) + }) + }, [appId, isCollaborationEnabled, queryClient, storeApi]) + return ( {children}