diff --git a/web/app/components/workflow/skill/editor/code-editor/plugins/remote-cursors.tsx b/web/app/components/workflow/skill/editor/code-editor/plugins/remote-cursors.tsx new file mode 100644 index 0000000000..6f94bf9c42 --- /dev/null +++ b/web/app/components/workflow/skill/editor/code-editor/plugins/remote-cursors.tsx @@ -0,0 +1,388 @@ +import type { OnMount } from '@monaco-editor/react' +import type { OnlineUser } from '@/app/components/workflow/collaboration/types' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' +import { skillCollaborationManager } from '@/app/components/workflow/collaboration/skills/skill-collaboration-manager' +import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' +import { useAppContext } from '@/context/app-context' + +const CURSOR_THROTTLE_MS = 200 +const CURSOR_TTL_MS = 15000 +const SELECTION_ALPHA = 0.2 + +type MonacoEditor = Parameters[0] +type MonacoModel = Exclude, null> +type MonacoDecorations = Parameters['set']>[0] +type MonacoDecorationsCollection = ReturnType +type MonacoDecoration = MonacoDecorations extends readonly (infer T)[] ? T : never + +type SkillCursorInfo = { + userId: string + start: number + end: number + timestamp: number +} + +type SkillCursorMap = Record + +type CursorOverlayItem = { + userId: string + x: number + y: number + height: number + name: string + color: string +} + +type CursorRenderState = { + positions: CursorOverlayItem[] +} + +type CursorRenderAction + = | { type: 'set', positions: CursorOverlayItem[] } + | { type: 'clear' } + +const cursorRenderReducer = (_state: CursorRenderState, action: CursorRenderAction): CursorRenderState => { + if (action.type === 'clear') + return { positions: [] } + return { positions: action.positions } +} + +const hashUserId = (userId: string): string => { + let hash = 0 + for (let i = 0; i < userId.length; i++) + hash = (hash * 31 + userId.charCodeAt(i)) >>> 0 + return hash.toString(36) +} + +const getSelectionClassName = (userId: string): string => `skill-code-selection-${hashUserId(userId)}` + +const hexToRgba = (hex: string, alpha: number): string => { + const clean = hex.replace('#', '') + if (clean.length !== 6) + return `rgba(0, 0, 0, ${alpha})` + const r = Number.parseInt(clean.slice(0, 2), 16) + const g = Number.parseInt(clean.slice(2, 4), 16) + const b = Number.parseInt(clean.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +const clampOffset = (offset: number, max: number) => Math.max(0, Math.min(offset, max)) + +const getSelectionRange = (model: MonacoModel, start: number, end: number) => { + const maxOffset = model.getValueLength() + const safeStart = clampOffset(start, maxOffset) + const safeEnd = clampOffset(end, maxOffset) + const startPos = model.getPositionAt(Math.min(safeStart, safeEnd)) + const endPos = model.getPositionAt(Math.max(safeStart, safeEnd)) + return { + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + } +} + +type UseSkillCodeCursorsProps = { + editor: MonacoEditor | null + fileId: string | null + enabled: boolean +} + +export const useSkillCodeCursors = ({ editor, fileId, enabled }: UseSkillCodeCursorsProps) => { + const { userProfile } = useAppContext() + const myUserId = userProfile?.id || null + const [cursorMap, setCursorMap] = useState({}) + const [onlineUsers, setOnlineUsers] = useState([]) + const [renderState, dispatchRender] = useReducer(cursorRenderReducer, { positions: [] }) + + const cursorMapRef = useRef({}) + const rafIdRef = useRef(null) + const decorationCollectionRef = useRef(null) + const styleRef = useRef(null) + const pendingCursorRef = useRef<{ start: number, end: number } | null>(null) + const lastCursorRef = useRef<{ start: number, end: number } | null>(null) + const throttleTimerRef = useRef(null) + + const effectiveCursorMap = useMemo(() => (enabled && fileId ? cursorMap : {}), [cursorMap, enabled, fileId]) + + useEffect(() => { + cursorMapRef.current = effectiveCursorMap + }, [effectiveCursorMap]) + + useEffect(() => { + return collaborationManager.onOnlineUsersUpdate(setOnlineUsers) + }, []) + + useEffect(() => { + if (!enabled || !fileId) + return + + return skillCollaborationManager.onCursorUpdate(fileId, (nextCursors) => { + setCursorMap(nextCursors) + }) + }, [enabled, fileId]) + + const onlineUserMap = useMemo(() => { + return onlineUsers.reduce>((acc, user) => { + acc[user.user_id] = user + return acc + }, {}) + }, [onlineUsers]) + + const updateSelectionStyles = useCallback((userIds: string[]) => { + if (typeof document === 'undefined') + return + + if (!styleRef.current) { + const style = document.createElement('style') + style.dataset.skillCodeCursor = 'true' + document.head.appendChild(style) + styleRef.current = style + } + + const uniqueIds = Array.from(new Set(userIds)) + styleRef.current.textContent = uniqueIds.map((userId) => { + const color = getUserColor(userId) + return `.${getSelectionClassName(userId)} { background-color: ${hexToRgba(color, SELECTION_ALPHA)}; }` + }).join('\n') + }, []) + + useEffect(() => { + return () => { + if (styleRef.current) { + styleRef.current.remove() + styleRef.current = null + } + } + }, []) + + const scheduleRecalc = useCallback(() => { + if (rafIdRef.current !== null) + return + + rafIdRef.current = window.requestAnimationFrame(() => { + rafIdRef.current = null + if (!enabled || !fileId || !editor) { + dispatchRender({ type: 'clear' }) + return + } + + const model = editor.getModel() + if (!model) { + dispatchRender({ type: 'clear' }) + return + } + + const now = Date.now() + const positions: CursorOverlayItem[] = [] + Object.values(cursorMapRef.current).forEach((cursor) => { + if (cursor.userId === myUserId) + return + if (now - cursor.timestamp > CURSOR_TTL_MS) + return + + const maxOffset = model.getValueLength() + const endOffset = clampOffset(cursor.end, maxOffset) + const caretPosition = model.getPositionAt(endOffset) + const visible = editor.getScrolledVisiblePosition(caretPosition) + if (!visible) + return + + const user = onlineUserMap[cursor.userId] + positions.push({ + userId: cursor.userId, + x: visible.left, + y: visible.top, + height: visible.height || 20, + name: user?.username || cursor.userId.slice(-4), + color: getUserColor(cursor.userId), + }) + }) + + dispatchRender({ type: 'set', positions }) + }) + }, [editor, enabled, fileId, myUserId, onlineUserMap]) + + useEffect(() => { + scheduleRecalc() + }, [scheduleRecalc, cursorMap, onlineUserMap]) + + useEffect(() => { + if (!enabled || !fileId || !editor) + return + + const disposables = [ + editor.onDidScrollChange(scheduleRecalc), + editor.onDidLayoutChange(scheduleRecalc), + editor.onDidChangeModelContent(scheduleRecalc), + ] + + return () => { + disposables.forEach(disposable => disposable.dispose()) + } + }, [editor, enabled, fileId, scheduleRecalc]) + + useEffect(() => { + if (!editor) { + decorationCollectionRef.current = null + return + } + + decorationCollectionRef.current = editor.createDecorationsCollection() + + return () => { + decorationCollectionRef.current?.clear() + decorationCollectionRef.current = null + } + }, [editor]) + + useEffect(() => { + if (!editor) { + updateSelectionStyles([]) + return + } + + if (!enabled || !fileId) { + decorationCollectionRef.current?.clear() + updateSelectionStyles([]) + return + } + + const model = editor.getModel() + if (!model) + return + + const now = Date.now() + const decorations: MonacoDecoration[] = [] + const activeUserIds: string[] = [] + + Object.values(effectiveCursorMap).forEach((cursor) => { + if (cursor.userId === myUserId) + return + if (now - cursor.timestamp > CURSOR_TTL_MS) + return + if (cursor.start === cursor.end) + return + + activeUserIds.push(cursor.userId) + decorations.push({ + range: getSelectionRange(model, cursor.start, cursor.end), + options: { + inlineClassName: getSelectionClassName(cursor.userId), + }, + }) + }) + + updateSelectionStyles(activeUserIds) + decorationCollectionRef.current?.set(decorations) + }, [editor, enabled, fileId, effectiveCursorMap, myUserId, updateSelectionStyles]) + + useEffect(() => { + if (!enabled || !fileId || !editor) + return + + const flushPending = () => { + const pending = pendingCursorRef.current + pendingCursorRef.current = null + if (!pending) + return + + if (lastCursorRef.current + && lastCursorRef.current.start === pending.start + && lastCursorRef.current.end === pending.end) { + return + } + + lastCursorRef.current = pending + skillCollaborationManager.emitCursorUpdate(fileId, pending) + } + + const scheduleEmit = (cursor: { start: number, end: number }) => { + pendingCursorRef.current = cursor + if (throttleTimerRef.current !== null) + return + + throttleTimerRef.current = window.setTimeout(() => { + throttleTimerRef.current = null + flushPending() + }, CURSOR_THROTTLE_MS) + } + + const emitClear = () => { + if (throttleTimerRef.current !== null) { + window.clearTimeout(throttleTimerRef.current) + throttleTimerRef.current = null + } + pendingCursorRef.current = null + lastCursorRef.current = null + skillCollaborationManager.emitCursorUpdate(fileId, null) + } + + const handleSelectionChange = () => { + const model = editor.getModel() + const selection = editor.getSelection() + if (!model || !selection) + return + + const start = model.getOffsetAt(selection.getStartPosition()) + const end = model.getOffsetAt(selection.getEndPosition()) + scheduleEmit({ start, end }) + } + + const selectionDisposable = editor.onDidChangeCursorSelection(handleSelectionChange) + const focusDisposable = editor.onDidFocusEditorText(handleSelectionChange) + const blurDisposable = editor.onDidBlurEditorText(emitClear) + const blurWidgetDisposable = editor.onDidBlurEditorWidget(emitClear) + + handleSelectionChange() + + return () => { + selectionDisposable.dispose() + focusDisposable.dispose() + blurDisposable.dispose() + blurWidgetDisposable.dispose() + emitClear() + } + }, [editor, enabled, fileId]) + + const overlay = useMemo(() => { + if (!enabled || !fileId || renderState.positions.length === 0) + return null + + return ( + <> + {renderState.positions.map(position => ( +
+
+
+ {position.name} +
+
+ ))} + + ) + }, [enabled, fileId, renderState.positions]) + + return { + overlay, + } +} diff --git a/web/app/components/workflow/skill/editor/code-file-editor.tsx b/web/app/components/workflow/skill/editor/code-file-editor.tsx index 5ad2d0b467..9b6aa12c3b 100644 --- a/web/app/components/workflow/skill/editor/code-file-editor.tsx +++ b/web/app/components/workflow/skill/editor/code-file-editor.tsx @@ -1,37 +1,69 @@ +import type { OnMount } from '@monaco-editor/react' import Editor from '@monaco-editor/react' import * as React from 'react' import Loading from '@/app/components/base/loading' +import { useSkillCodeCursors } from './code-editor/plugins/remote-cursors' type CodeFileEditorProps = { language: string theme: string value: string onChange: (value: string | undefined) => void - onMount: (editor: any, monaco: any) => void + onMount: OnMount + fileId?: string | null + collaborationEnabled?: boolean } -const CodeFileEditor = ({ language, theme, value, onChange, onMount }: CodeFileEditorProps) => { +const CodeFileEditor = ({ + language, + theme, + value, + onChange, + onMount, + fileId, + collaborationEnabled, +}: CodeFileEditorProps) => { + const [editorInstance, setEditorInstance] = React.useState[0] | null>(null) + const { overlay } = useSkillCodeCursors({ + editor: editorInstance, + fileId: fileId ?? null, + enabled: Boolean(collaborationEnabled && fileId), + }) + const handleMount = React.useCallback((editor, monaco) => { + setEditorInstance(editor) + onMount(editor, monaco) + }, [onMount]) + return ( - } - onChange={onChange} - options={{ - minimap: { enabled: false }, - lineNumbersMinChars: 3, - wordWrap: 'on', - unicodeHighlight: { - ambiguousCharacters: false, - }, - stickyScroll: { enabled: false }, - fontSize: 13, - lineHeight: 20, - padding: { top: 12, bottom: 12 }, - }} - onMount={onMount} - /> +
+ } + onChange={onChange} + options={{ + minimap: { enabled: false }, + lineNumbersMinChars: 3, + wordWrap: 'on', + unicodeHighlight: { + ambiguousCharacters: false, + }, + stickyScroll: { enabled: false }, + fontSize: 13, + lineHeight: 20, + padding: { top: 12, bottom: 12 }, + }} + onMount={handleMount} + /> + {overlay + ? ( +
+ {overlay} +
+ ) + : null} +
) } diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index c2ebeaebac..e463143648 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -234,6 +234,8 @@ const FileContentPanel = () => { value={currentContent} onChange={handleCodeCollaborativeChange} onMount={handleEditorDidMount} + fileId={fileTabId} + collaborationEnabled={canInitCollaboration} /> ) : null} diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 7bd5ca4439..69bdfb833f 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3852,11 +3852,6 @@ "count": 4 } }, - "app/components/workflow/skill/editor/code-file-editor.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/workflow/store/workflow/debug/inspect-vars-slice.ts": { "ts/no-explicit-any": { "count": 2