From bbbf6d5bb8365eaf6b0d23e44c590b5d5feb647e Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Tue, 4 Aug 2026 14:48:59 +0800 Subject: [PATCH] fix(cron): keep scheduled task message editable after inserting a skill (#1223) Co-authored-by: Cursor --- src/pages/Cron/index.tsx | 74 ++++++++-- tests/e2e/cron-skill-picker.spec.ts | 215 ++++++++++++++++++++-------- 2 files changed, 215 insertions(+), 74 deletions(-) diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 5f4e9c26..58e69a71 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -2,7 +2,16 @@ * Cron Page * Manage scheduled tasks */ -import { useEffect, useState, useCallback, useMemo, useRef, type ReactNode, type SelectHTMLAttributes } from 'react'; +import { + useEffect, + useLayoutEffect, + useState, + useCallback, + useMemo, + useRef, + type ReactNode, + type SelectHTMLAttributes, +} from 'react'; import { Plus, Clock, @@ -74,6 +83,10 @@ const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [ type SkillTokenRange = { start: number; end: number }; +// The message textarea grows with its content up to this height, after which it +// scrolls internally and the highlight overlay is scroll-synced to match it. +const CRON_MESSAGE_MAX_HEIGHT = 200; + function getSkillPrefix(skillName: string): string { return `/${skillName} `; } @@ -606,6 +619,7 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial const [skillsLoading, setSkillsLoading] = useState(false); const [skillsError, setSkillsError] = useState(null); const messageRef = useRef(null); + const messageOverlayRef = useRef(null); const skillPickerRef = useRef(null); const [prevOpen, setPrevOpen] = useState(open); @@ -700,16 +714,49 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial }; }, [skillPickerOpen]); - const moveMessageCaretTo = useCallback((position: number) => { + // The highlight overlay is absolutely positioned on top of the textarea and is + // the only visible copy of the text once a skill token exists, so it has to + // follow the textarea's scroll offset or edits below the fold become invisible. + const syncMessageOverlayScroll = useCallback(() => { + const textarea = messageRef.current; + const overlay = messageOverlayRef.current; + if (!textarea || !overlay) return; + overlay.scrollTop = textarea.scrollTop; + overlay.scrollLeft = textarea.scrollLeft; + }, []); + + useLayoutEffect(() => { const textarea = messageRef.current; if (!textarea) return; - textarea.focus(); - textarea.setSelectionRange(position, position); - requestAnimationFrame(() => { - messageRef.current?.focus(); - messageRef.current?.setSelectionRange(position, position); - }); - }, []); + const previousScrollTop = textarea.scrollTop; + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, CRON_MESSAGE_MAX_HEIGHT)}px`; + textarea.scrollTop = previousScrollTop; + // On platforms with classic scrollbars the textarea's scrollbar gutter eats + // into its content width; the overlay has to lose the same width or the two + // copies of the text wrap at different columns. + const overlay = messageOverlayRef.current; + if (overlay) { + overlay.style.paddingRight = `${Math.max(0, textarea.offsetWidth - textarea.clientWidth)}px`; + } + syncMessageOverlayScroll(); + }, [message, open, syncMessageOverlayScroll]); + + const moveMessageCaretTo = useCallback( + (position: number) => { + const textarea = messageRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(position, position); + syncMessageOverlayScroll(); + requestAnimationFrame(() => { + messageRef.current?.focus(); + messageRef.current?.setSelectionRange(position, position); + syncMessageOverlayScroll(); + }); + }, + [syncMessageOverlayScroll], + ); const normalizeMessageSelection = useCallback(() => { if (skillTokenRanges.length === 0) return; @@ -790,9 +837,10 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial messageRef.current?.focus(); const cursorPosition = selectionStart + leadingSpace.length + nextToken.length; messageRef.current?.setSelectionRange(cursorPosition, cursorPosition); + syncMessageOverlayScroll(); }); }, - [message], + [message, syncMessageOverlayScroll], ); const updateSchedule = useCallback( (patch: Partial) => setScheduleForm((prev) => ({ ...prev, ...patch })), @@ -1026,6 +1074,8 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial {skillTokenRanges.length > 0 && (