Przeglądaj źródła

fix(writing): 修复磁盘同步滚位与提取提示

磁盘同步 remount 后恢复 scrollTop,避免阅读位置跳动
提取完成状态优先显示具体章节 message
深度思考工作流阶段随内容更新自动滚到底部

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 miesięcy temu
rodzic
commit
7529d9d481

+ 35 - 1
src/components/chat/chat-message.tsx

@@ -873,6 +873,37 @@ function formatThinkingForDisplay(content: string): string {
 function StreamingWorkflowBlock({ content }: { content: string }) {
   const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
   const { title } = useMemo(() => getThinkingBlockMeta(content, true), [content])
+  const scrollRef = useRef<HTMLDivElement>(null)
+  const userScrolledUpRef = useRef(false)
+  const lastScrollTopRef = useRef(0)
+
+  useEffect(() => {
+    const container = scrollRef.current
+    if (!container) return
+    if (!userScrolledUpRef.current) {
+      container.scrollTop = container.scrollHeight
+      lastScrollTopRef.current = container.scrollTop
+    }
+  }, [displayContent])
+
+  useEffect(() => {
+    const container = scrollRef.current
+    if (!container) return
+    lastScrollTopRef.current = container.scrollTop
+    const handleScroll = () => {
+      const threshold = 40
+      const currentScrollTop = container.scrollTop
+      const atBottom = container.scrollHeight - currentScrollTop - container.clientHeight < threshold
+      if (currentScrollTop < lastScrollTopRef.current - 1) {
+        userScrolledUpRef.current = true
+      } else if (atBottom) {
+        userScrolledUpRef.current = false
+      }
+      lastScrollTopRef.current = currentScrollTop
+    }
+    container.addEventListener("scroll", handleScroll)
+    return () => container.removeEventListener("scroll", handleScroll)
+  }, [])
 
   return (
     <div className="w-full min-w-0 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 px-2.5 py-2 min-h-[3rem]">
@@ -880,7 +911,10 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
         <span className="text-sm animate-pulse">📋</span>
         <span className="text-xs font-medium text-blue-700 dark:text-blue-400">{title}</span>
       </div>
-      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
+      <div
+        ref={scrollRef}
+        className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]"
+      >
         {displayContent}
         <span className="animate-pulse text-blue-500">▊</span>
       </div>

+ 15 - 1
src/components/editor/wiki-editor.tsx

@@ -504,6 +504,8 @@ interface WikiEditorProps {
 
 export interface WikiEditorHandle {
   getCurrentMarkdown: () => string | null
+  getImmersiveScrollTop: () => number | null
+  setImmersiveScrollTop: (scrollTop: number) => void
 }
 
 function wrapBareMathBlocks(text: string): string {
@@ -523,6 +525,7 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function
   onHighlightHandled,
 }, ref) {
   const writingTextareaRef = useRef<WritingTextareaHandle>(null)
+  const immersiveScrollRef = useRef<HTMLDivElement>(null)
   // Default to read mode (ReactMarkdown render). Edit mode swaps
   // in Milkdown WYSIWYG. We default to read because:
   //   1. Milkdown's commonmark/gfm preset has no wikilink schema,
@@ -560,6 +563,14 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function
       if (liveBody == null) return null
       return rawBlock + liveBody
     },
+    getImmersiveScrollTop: () => {
+      const el = immersiveScrollRef.current
+      return el ? el.scrollTop : null
+    },
+    setImmersiveScrollTop: (scrollTop: number) => {
+      const el = immersiveScrollRef.current
+      if (el) el.scrollTop = scrollTop
+    },
   }), [rawBlock])
 
   useEffect(() => {
@@ -589,7 +600,10 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function
         </div>
       ) : (
         immersiveWriting ? (
-          <div className="immersive-scroll-container flex h-full w-full flex-col overflow-auto" style={{ 
+          <div
+            ref={immersiveScrollRef}
+            className="immersive-scroll-container flex h-full w-full flex-col overflow-auto"
+            style={{
             scrollbarWidth: "thin",
             scrollbarColor: "oklch(0.75 0 0) transparent"
           }}>

+ 3 - 3
src/components/layout/knowledge-tree.tsx

@@ -1578,11 +1578,11 @@ export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?:
                         {isRunning
                           ? formatImportProgressRunningLabel(task, kindLabel)
                           : task.status === "done"
-                            ? `${kindLabel}提取完成`
+                            ? (task.message || `${kindLabel}提取完成`)
                             : task.status === "error"
-                              ? `${kindLabel}提取失败`
+                              ? (task.message || `${kindLabel}提取失败`)
                               : task.status === "cancelled"
-                                ? `${kindLabel}提取已取消`
+                                ? (task.message || `${kindLabel}提取已取消`)
                                 : task.message ?? ""}
                       </span>
                     </span>

+ 19 - 0
src/components/layout/preview-panel.tsx

@@ -214,6 +214,7 @@ export function PreviewPanel() {
   const [chapterToolbarMoreOpen, setChapterToolbarMoreOpen] = useState(false)
   const [loadedFilePath, setLoadedFilePath] = useState<string | null>(null)
   const [diskSyncEpoch, setDiskSyncEpoch] = useState(0)
+  const pendingScrollRestoreRef = useRef<number | null>(null)
   // Snapshot of what was most recently loaded from disk. Milkdown re-emits
   // `markdownUpdated` on initial parse (before the user types anything),
   // which used to trigger an auto-save that could write back a placeholder
@@ -272,12 +273,30 @@ export function PreviewPanel() {
     rememberLoadedChapter(normalizedPath, diskContent)
     fileContentRef.current = diskContent
     if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
+      const scrollTop = wikiEditorRef.current?.getImmersiveScrollTop()
+      if (scrollTop != null) {
+        pendingScrollRestoreRef.current = scrollTop
+      }
       setFileContent(diskContent)
       setDiskSyncEpoch((epoch) => epoch + 1)
     }
     return true
   }, [rememberLoadedChapter, setFileContent])
 
+  useLayoutEffect(() => {
+    const pending = pendingScrollRestoreRef.current
+    if (pending == null) return
+    pendingScrollRestoreRef.current = null
+    const restore = () => wikiEditorRef.current?.setImmersiveScrollTop(pending)
+    restore()
+    // WritingTextarea autofocus/caret-to-end can scrollIntoView after mount;
+    // re-apply on the next frames so the restored position sticks.
+    requestAnimationFrame(() => {
+      restore()
+      requestAnimationFrame(restore)
+    })
+  }, [diskSyncEpoch, selectedFile])
+
   const syncDiskBeforeAction = useCallback(async () => {
     const path = selectedFileRef.current
     if (!path) return