فهرست منبع

fix(sidebar): 修复提取中显示并移除失效活动栏

去AI味计入运行中并排到列表顶部,已结束提取只保留最近 3 条。
删除无入口的 ActivityPanel,启动不再恢复 ingest-queue。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 ماه پیش
والد
کامیت
d1488b5d85

+ 0 - 10
src/App.tsx

@@ -98,16 +98,6 @@ function App() {
     if (!isTauri()) return
     if (!isCurrentProject(proj)) return
 
-    try {
-      const { restoreQueue } = await import("@/lib/ingest-queue")
-      if (!isCurrentProject(proj)) return
-      await restoreQueue(proj.id, proj.path)
-    } catch (err) {
-      console.error("恢复摄取队列失败:", err)
-    }
-
-    if (!isCurrentProject(proj)) return
-
     try {
       const { restoreQueue: restoreDedupQueue } = await import("@/lib/dedup-queue")
       await restoreDedupQueue(proj.id, proj.path)

+ 0 - 439
src/components/layout/activity-panel.tsx

@@ -1,439 +0,0 @@
-import { useState, useEffect, useRef, useCallback } from "react"
-import {
-  ChevronUp, ChevronDown, Loader2, CheckCircle2, AlertCircle,
-  FileText, Users, Lightbulb, BookOpen, GitMerge, BarChart3, HelpCircle, Layout,
-  RotateCcw, X, Clock,
-} from "lucide-react"
-import { useActivityStore, type ActivityItem } from "@/stores/activity-store"
-import { useWikiStore } from "@/stores/wiki-store"
-import { useFileSyncStore } from "@/stores/file-sync-store"
-import { normalizePath, getFileName, isAbsolutePath } from "@/lib/path-utils"
-import { getQueue, getQueueSummary, retryTask, cancelTask, cancelAllTasks, type IngestTask } from "@/lib/ingest-queue"
-import {
-  ignoreFileChangeTask,
-  rescanProjectFiles,
-  retryFileChangeTask,
-  type FileChangeTask,
-} from "@/commands/file-sync"
-import { useTranslation } from "react-i18next"
-
-const FILE_TYPE_ICONS: Record<string, typeof FileText> = {
-  sources: BookOpen,
-  entities: Users,
-  concepts: Lightbulb,
-  queries: HelpCircle,
-  synthesis: GitMerge,
-  comparisons: BarChart3,
-}
-
-const FILE_TYPE_LABELS: Record<string, string> = {
-  sources: "来源",
-  entities: "实体",
-  concepts: "概念",
-  queries: "查询",
-  synthesis: "综合",
-  comparisons: "对比",
-}
-
-function getFileTypeInfo(path: string): { icon: typeof FileText; type: string } {
-  for (const [dir, icon] of Object.entries(FILE_TYPE_ICONS)) {
-    if (path.includes(`/${dir}/`) || path.startsWith(`wiki/${dir}/`)) {
-      return { icon, type: FILE_TYPE_LABELS[dir] ?? dir }
-    }
-  }
-  if (path.includes("index.md")) return { icon: Layout, type: "索引" }
-  if (path.includes("log.md")) return { icon: FileText, type: "日志" }
-  return { icon: FileText, type: "文件" }
-}
-
-export function ActivityPanel() {
-  const { t } = useTranslation()
-  const items = useActivityStore((s) => s.items)
-  const clearDone = useActivityStore((s) => s.clearDone)
-  const project = useWikiStore((s) => s.project)
-  const fileSyncTasks = useFileSyncStore((s) => s.tasks)
-  const setFileSyncTasks = useFileSyncStore((s) => s.setTasks)
-  const fileSyncError = useFileSyncStore((s) => s.lastError)
-  const [expanded, setExpanded] = useState(false)
-  const [queueTasks, setQueueTasks] = useState<IngestTask[]>([])
-  const prevRunningRef = useRef(0)
-
-  const runningCount = items.filter((i) => i.status === "running").length
-  const hasItems = items.length > 0
-
-  // Poll queue state
-  useEffect(() => {
-    const interval = setInterval(() => {
-      setQueueTasks([...getQueue()])
-    }, 1000)
-    return () => clearInterval(interval)
-  }, [])
-
-  const queueSummary = getQueueSummary()
-  const hasQueue = queueSummary.total > 0
-  const hasFileSync = fileSyncTasks.length > 0 || Boolean(fileSyncError)
-  const fileSyncPending = fileSyncTasks.filter((t) => t.status === "pending").length
-  const fileSyncProcessing = fileSyncTasks.filter((t) => t.status === "processing").length
-  const fileSyncFailed = fileSyncTasks.filter((t) => t.status === "failed").length
-
-  // All hooks must be before any conditional return.
-  // retryTask / cancelTask / cancelAllTasks all operate on the currently
-  // active project implicitly (via module-scoped state in ingest-queue.ts)
-  // — they take NO projectPath argument. An earlier version passed one in
-  // and the extra arg silently became "taskId", making retry a no-op for
-  // every failed task. Keep this minimal.
-  const handleIngestRetry = useCallback((taskId: string) => {
-    if (!project) return
-    retryTask(taskId)
-  }, [project])
-
-  const handleIngestCancel = useCallback((taskId: string) => {
-    if (!project) return
-    cancelTask(taskId)
-  }, [project])
-
-  const handleCancelAll = useCallback(() => {
-    if (!project) return
-    const activeCount = queueSummary.pending + queueSummary.processing
-    if (activeCount === 0) return
-    if (!window.confirm(
-      t("activity.cancelAllConfirm", { count: activeCount }),
-    )) return
-    cancelAllTasks()
-  }, [project, queueSummary.pending, queueSummary.processing, t])
-
-  const handleFileSyncRescan = useCallback(() => {
-    if (!project) return
-    rescanProjectFiles(project.id, normalizePath(project.path), useWikiStore.getState().sourceWatchConfig)
-      .then((result) => {
-        setFileSyncTasks(result.queue.tasks)
-        useFileSyncStore.getState().setLastError(null)
-      })
-      .catch((err) => useFileSyncStore.getState().setLastError(String(err)))
-  }, [project, setFileSyncTasks])
-
-  const handleFileSyncRetry = useCallback((taskId: string) => {
-    if (!project) return
-    retryFileChangeTask(project.id, normalizePath(project.path), taskId)
-      .then((queue) => {
-        setFileSyncTasks(queue.tasks)
-        useFileSyncStore.getState().setLastError(null)
-      })
-      .catch((err) => useFileSyncStore.getState().setLastError(String(err)))
-  }, [project, setFileSyncTasks])
-
-  const handleFileSyncIgnore = useCallback((taskId: string) => {
-    if (!project) return
-    ignoreFileChangeTask(project.id, normalizePath(project.path), taskId)
-      .then((queue) => {
-        setFileSyncTasks(queue.tasks)
-        useFileSyncStore.getState().setLastError(null)
-      })
-      .catch((err) => useFileSyncStore.getState().setLastError(String(err)))
-  }, [project, setFileSyncTasks])
-
-  // Auto-expand when a new task starts running
-  useEffect(() => {
-    if (runningCount > 0 && prevRunningRef.current === 0) {
-      setExpanded(true)
-    }
-    if ((hasQueue || hasFileSync) && !expanded) {
-      setExpanded(true)
-    }
-    prevRunningRef.current = runningCount
-  }, [runningCount, hasQueue, hasFileSync, expanded])
-
-  if (!hasItems && !hasQueue && !hasFileSync) return null
-
-  const latestItem = items[0]
-
-  // Build status text
-  let statusText = ""
-  if (queueSummary.processing > 0 || queueSummary.pending > 0) {
-    const done = queueSummary.total - queueSummary.pending - queueSummary.processing
-    statusText = t("activity.queueStatus", { done, total: queueSummary.total })
-    if (queueSummary.failed > 0) statusText += t("activity.failedSuffix", { count: queueSummary.failed })
-  } else if (runningCount > 0) {
-    statusText = t("activity.processing", { title: latestItem?.title ?? "..." })
-  } else if (queueSummary.failed > 0) {
-    statusText = t("activity.taskFailed", { count: queueSummary.failed })
-  } else if (fileSyncProcessing > 0 || fileSyncPending > 0) {
-    statusText = t("activity.fileSyncPendingStatus", { count: fileSyncProcessing + fileSyncPending })
-  } else if (fileSyncFailed > 0) {
-    statusText = t("activity.fileSyncFailedStatus", { count: fileSyncFailed })
-  } else if (fileSyncError) {
-    statusText = t("activity.fileSyncFailed")
-  } else {
-    statusText = t("activity.done", { title: latestItem?.title ?? t("activity.allTasksDone") })
-  }
-
-  const isActive = runningCount > 0 || queueSummary.processing > 0 || queueSummary.pending > 0 || fileSyncProcessing > 0 || fileSyncPending > 0
-
-  return (
-    <div className="border-t bg-muted/30">
-      <button
-        onClick={() => setExpanded(!expanded)}
-        className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent/50"
-      >
-        {isActive ? (
-          <Loader2 className="h-3 w-3 animate-spin shrink-0" />
-        ) : queueSummary.failed > 0 || fileSyncFailed > 0 || fileSyncError ? (
-          <AlertCircle className="h-3 w-3 shrink-0 text-destructive" />
-        ) : (
-          <CheckCircle2 className="h-3 w-3 shrink-0 text-emerald-500" />
-        )}
-        <span className="flex-1 truncate text-left">{statusText}</span>
-        {expanded ? (
-          <ChevronDown className="h-3 w-3 shrink-0" />
-        ) : (
-          <ChevronUp className="h-3 w-3 shrink-0" />
-        )}
-      </button>
-
-      {expanded && (
-        <div className="max-h-64 overflow-y-auto border-t">
-          {hasFileSync && (
-            <div className="border-b border-border/50 px-3 py-1.5">
-              <div className="mb-1 flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
-                <span>{t("activity.fileSync")}</span>
-                <button
-                  onClick={handleFileSyncRescan}
-                  className="rounded px-1.5 py-0.5 text-[10px] hover:bg-accent hover:text-foreground"
-                  title={t("activity.rescanTitle")}
-                >
-                  {t("activity.rescan")}
-                </button>
-              </div>
-              {fileSyncError && (
-                <div className="mb-1 truncate text-[10px] text-destructive">{fileSyncError}</div>
-              )}
-              {fileSyncTasks.map((task) => (
-                <FileSyncRow
-                  key={task.id}
-                  task={task}
-                  onRetry={handleFileSyncRetry}
-                  onIgnore={handleFileSyncIgnore}
-                />
-              ))}
-            </div>
-          )}
-
-          {/* Queue progress bar */}
-          {hasQueue && (queueSummary.processing > 0 || queueSummary.pending > 0) && (
-            <div className="px-3 py-1.5 border-b border-border/50">
-              <div className="flex items-center justify-between text-[10px] text-muted-foreground mb-1 gap-2">
-                <span>{t("activity.ingestQueue")}</span>
-                <span className="flex-1 text-right">
-                  {t("activity.completedProgress", {
-                    done: queueSummary.total - queueSummary.pending - queueSummary.processing,
-                    total: queueSummary.total,
-                  })}
-                </span>
-                {queueSummary.pending + queueSummary.processing >= 2 && (
-                  <button
-                    onClick={handleCancelAll}
-                    className="rounded px-1.5 py-0.5 text-[10px] text-destructive hover:bg-destructive/10"
-                    title={t("activity.cancelAllTitle")}
-                  >
-                    {t("activity.cancelAll")}
-                  </button>
-                )}
-              </div>
-              <div className="h-1.5 rounded-full bg-muted overflow-hidden">
-                <div
-                  className="h-full rounded-full bg-primary transition-all"
-                  style={{ width: `${((queueSummary.total - queueSummary.pending - queueSummary.processing) / Math.max(queueSummary.total, 1)) * 100}%` }}
-                />
-              </div>
-            </div>
-          )}
-
-          {/* Queue tasks */}
-          {queueTasks.filter((t) => t.status === "processing").map((task) => (
-            <QueueRow key={task.id} task={task} onRetry={handleIngestRetry} onCancel={handleIngestCancel} />
-          ))}
-          {queueTasks.filter((t) => t.status === "pending").map((task) => (
-            <QueueRow key={task.id} task={task} onRetry={handleIngestRetry} onCancel={handleIngestCancel} />
-          ))}
-          {queueTasks.filter((t) => t.status === "failed").map((task) => (
-            <QueueRow key={task.id} task={task} onRetry={handleIngestRetry} onCancel={handleIngestCancel} />
-          ))}
-
-          {/* Activity items */}
-          {items.map((item) => {
-            // Find matching queue task for cancel button
-            const matchingTask = item.status === "running"
-              ? queueTasks.find((t) => t.status === "processing" && getFileName(t.sourcePath) === item.title)
-              : undefined
-            return (
-              <ActivityRow
-                key={item.id}
-                item={item}
-                onCancel={matchingTask ? () => handleIngestCancel(matchingTask.id) : undefined}
-              />
-            )
-          })}
-          {items.some((i) => i.status !== "running") && (
-            <button
-              onClick={clearDone}
-              className="w-full px-3 py-1 text-center text-[10px] text-muted-foreground hover:underline"
-            >
-              {t("activity.clearCompleted")}
-            </button>
-          )}
-        </div>
-      )}
-    </div>
-  )
-}
-
-function QueueRow({ task, onRetry, onCancel }: { task: IngestTask; onRetry: (id: string) => void; onCancel: (id: string) => void }) {
-  const { t } = useTranslation()
-  const fileName = getFileName(task.sourcePath)
-
-  return (
-    <div className="px-3 py-2 text-xs border-b border-border/50">
-      <div className="flex items-center gap-2">
-        <div className="shrink-0">
-          {task.status === "processing" && <Loader2 className="h-3 w-3 animate-spin text-primary" />}
-          {task.status === "pending" && <Clock className="h-3 w-3 text-muted-foreground" />}
-          {task.status === "failed" && <AlertCircle className="h-3 w-3 text-destructive" />}
-        </div>
-        <div className="min-w-0 flex-1">
-          <div className="font-medium truncate">{fileName}</div>
-          {task.folderContext && (
-            <div className="text-[10px] text-muted-foreground/70 truncate">{task.folderContext}</div>
-          )}
-          {task.status === "failed" && task.error && (
-            <div className="text-[10px] text-destructive mt-0.5 truncate">{task.error}</div>
-          )}
-        </div>
-        <div className="flex items-center gap-1 shrink-0">
-          {task.status === "failed" && (
-            <button
-              onClick={() => onRetry(task.id)}
-              className="p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
-              title={t("activity.retry")}
-            >
-              <RotateCcw className="h-3 w-3" />
-            </button>
-          )}
-          {(task.status === "pending" || task.status === "processing") && (
-            <button
-              onClick={() => onCancel(task.id)}
-              className="p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive"
-              title={t("activity.cancel")}
-            >
-              <X className="h-3 w-3" />
-            </button>
-          )}
-        </div>
-      </div>
-    </div>
-  )
-}
-
-function FileSyncRow({ task, onRetry, onIgnore }: { task: FileChangeTask; onRetry: (id: string) => void; onIgnore: (id: string) => void }) {
-  const { t } = useTranslation()
-  const fileName = getFileName(task.path)
-  const kindLabel = task.kind.charAt(0).toUpperCase() + task.kind.slice(1)
-
-  return (
-    <div className="py-1.5 text-xs">
-      <div className="flex items-center gap-2">
-        <div className="shrink-0">
-          {task.status === "processing" && <Loader2 className="h-3 w-3 animate-spin text-primary" />}
-          {task.status === "pending" && <Clock className="h-3 w-3 text-muted-foreground" />}
-          {task.status === "failed" && <AlertCircle className="h-3 w-3 text-destructive" />}
-        </div>
-        <div className="min-w-0 flex-1">
-          <div className="truncate font-medium">{fileName}</div>
-          <div className="truncate text-[10px] text-muted-foreground/70">{kindLabel} - {task.path}</div>
-          {task.status === "failed" && task.error && (
-            <div className="mt-0.5 truncate text-[10px] text-destructive">{task.error}</div>
-          )}
-        </div>
-        {task.status === "failed" && (
-          <div className="flex shrink-0 items-center gap-1">
-            <button
-              onClick={() => onRetry(task.id)}
-              className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
-              title={t("activity.retry")}
-            >
-              <RotateCcw className="h-3 w-3" />
-            </button>
-            <button
-              onClick={() => onIgnore(task.id)}
-              className="rounded p-0.5 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
-              title={t("activity.ignore")}
-            >
-              <X className="h-3 w-3" />
-            </button>
-          </div>
-        )}
-      </div>
-    </div>
-  )
-}
-
-function ActivityRow({ item, onCancel }: { item: ActivityItem; onCancel?: () => void }) {
-  const { t } = useTranslation()
-  const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
-  const project = useWikiStore((s) => s.project)
-
-  function handleFileClick(filePath: string) {
-    if (!project) return
-    const pp = normalizePath(project.path)
-    const fullPath = isAbsolutePath(filePath)
-      ? normalizePath(filePath)
-      : `${pp}/${filePath}`
-    setSelectedFile(fullPath)
-  }
-
-  return (
-    <div className="px-3 py-2 text-xs border-b border-border/50 last:border-b-0">
-      <div className="flex items-start gap-2">
-        <div className="mt-0.5 shrink-0">
-          {item.status === "running" && <Loader2 className="h-3 w-3 animate-spin text-primary" />}
-          {item.status === "done" && <CheckCircle2 className="h-3 w-3 text-emerald-500" />}
-          {item.status === "error" && <AlertCircle className="h-3 w-3 text-destructive" />}
-        </div>
-        <div className="min-w-0 flex-1">
-          <div className="font-medium">{item.title}</div>
-          <div className="text-muted-foreground mt-0.5">{item.detail}</div>
-        </div>
-        {item.status === "running" && onCancel && (
-          <button
-            onClick={onCancel}
-            className="shrink-0 p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive"
-            title={t("activity.cancel")}
-          >
-            <X className="h-3 w-3" />
-          </button>
-        )}
-      </div>
-
-      {/* File list with types */}
-      {item.filesWritten.length > 0 && item.status === "done" && (
-        <div className="mt-1.5 ml-5 flex flex-col gap-0.5">
-          {item.filesWritten.map((filePath) => {
-            const { icon: Icon, type } = getFileTypeInfo(filePath)
-            const fileName = getFileName(filePath)
-            return (
-              <button
-                key={filePath}
-                type="button"
-                onClick={() => handleFileClick(filePath)}
-                className="flex items-center gap-1.5 rounded px-1 py-0.5 text-left text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
-              >
-                <Icon className="h-3 w-3 shrink-0" />
-                <span className="text-[10px] font-medium text-muted-foreground/70 w-14 shrink-0">{type}</span>
-                <span className="truncate">{fileName}</span>
-              </button>
-            )
-          })}
-        </div>
-      )}
-    </div>
-  )
-}

+ 0 - 2
src/components/layout/app-layout.tsx

@@ -4,7 +4,6 @@ import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh"
 import { IconSidebar } from "./icon-sidebar"
 import { SidebarPanel } from "./sidebar-panel"
 import { ContentArea } from "./content-area"
-import { ActivityPanel } from "./activity-panel"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { ErrorBoundary } from "@/components/error-boundary"
 import { clampSidebarWidth } from "@/lib/workspace-layout"
@@ -262,7 +261,6 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
               <div className="min-h-0 flex-1 overflow-hidden">
                 <SidebarPanel />
               </div>
-              <ActivityPanel />
             </div>
             <div
               className="w-1 shrink-0 cursor-col-resize bg-border/20 transition-colors hover:bg-primary/25 active:bg-primary/30"

+ 19 - 0
src/components/layout/knowledge-tree.spec.tsx

@@ -38,4 +38,23 @@ describe("KnowledgeTree chapter memory extraction menu", () => {
     expect(source).toContain('listDirectory(`${projectPath}/wiki/chapters`)')
     expect(source).toContain('listDirectory(`${projectPath}/wiki/outlines`)')
   })
+
+  it("counts processing de-AI tasks in the sidebar running total", () => {
+    expect(source).toContain("selectProjectDeAiTasks")
+    expect(source).toContain("selectProjectDeAiTasks(deAiTasks, project?.path)")
+    expect(source).toContain('t.status === "processing"')
+    expect(source).toContain("const processingDeAiTasks = projectDeAiTasks.filter((t) => t.status === \"processing\")")
+    expect(source).toContain("const runningCount = runningTasks.length + processingDeAiTasks.length")
+    expect(source).toContain("{runningCount} 个任务运行中")
+    expect(source).not.toContain("{runningTasks.length} 个任务运行中")
+  })
+
+  it("renders running de-AI tasks above settled import rows and keeps recent finished import tasks", () => {
+    expect(source).toContain("useImportProgressStore.getState().pruneSettledTasks()")
+    expect(source).toContain("{processingDeAiTasks.map((task) => (")
+    expect(source).toContain("{[...runningTasks, ...settledImportTasks].map((task) => {")
+    expect(source).toContain("{settledDeAiTasks.map((task) => (")
+    expect(source).not.toContain("{projectTasks.slice(0, 20).map((task) => {")
+    expect(source).not.toContain("{projectDeAiTasks.map((task) => (")
+  })
 })

+ 30 - 21
src/components/layout/knowledge-tree.tsx

@@ -14,7 +14,7 @@ import { normalizeChapterStatus, type ChapterStatus } from "@/lib/novel/chapter-
 import { moveFileToTrash } from "@/lib/trash"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
 import { useImportProgressStore, type ImportProgressTask } from "@/stores/import-progress-store"
-import { useDeAiTaskStore } from "@/stores/de-ai-task-store"
+import { selectProjectDeAiTasks, useDeAiTaskStore } from "@/stores/de-ai-task-store"
 import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { getOutlineFileName, outlineSnapshotExists } from "@/lib/novel/outline-ingest-utils"
@@ -2051,20 +2051,23 @@ export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?:
   }, [project, tasks])
 
   const projectDeAiTasks = useMemo(() => {
-    if (!project) return []
-    const projectPath = normalizePath(project.path)
-    return deAiTasks
-      .filter((t) =>
-        t.projectPath === projectPath
-        && (t.status === "processing" || t.status === "ready" || t.status === "failed")
-      )
+    return selectProjectDeAiTasks(deAiTasks, project?.path)
+      .filter((t) => t.status === "processing" || t.status === "ready" || t.status === "failed")
       .sort((a, b) => b.createdAt - a.createdAt)
   }, [project, deAiTasks])
 
   const runningTasks = projectTasks.filter((t) => t.status === "running")
-  const hasRunning = runningTasks.length > 0 || projectDeAiTasks.length > 0
+  const settledImportTasks = projectTasks.filter((t) => t.status !== "running")
+  const processingDeAiTasks = projectDeAiTasks.filter((t) => t.status === "processing")
+  const settledDeAiTasks = projectDeAiTasks.filter((t) => t.status !== "processing")
+  const runningCount = runningTasks.length + processingDeAiTasks.length
+  const hasRunning = runningCount > 0
   const hasAnyTask = projectTasks.length > 0 || projectDeAiTasks.length > 0
 
+  useEffect(() => {
+    useImportProgressStore.getState().pruneSettledTasks()
+  }, [])
+
   useEffect(() => {
     if (hasRunning) setExpanded(true)
   }, [hasRunning])
@@ -2084,14 +2087,27 @@ export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?:
         <BookOpen className="h-3.5 w-3.5 shrink-0 text-amber-600" />
         <span className="flex-1 text-left font-medium text-muted-foreground">提取中</span>
         {hasRunning ? (
-          <span className="text-xs text-primary">{runningTasks.length} 个任务运行中</span>
+          <span className="text-xs text-primary">{runningCount} 个任务运行中</span>
         ) : null}
       </button>
       {expanded && (
         <div className="ml-3 max-h-64 space-y-2 overflow-y-auto pr-1 text-xs text-muted-foreground">
           {hasAnyTask ? (
             <>
-            {projectTasks.slice(0, 20).map((task) => {
+            {processingDeAiTasks.map((task) => (
+              <div key={task.id} className="space-y-1 rounded-md bg-muted/30 px-2 py-1.5">
+                <div className="flex items-center gap-1">
+                  <Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
+                  <span className="truncate text-foreground font-medium">
+                    去AI味:{task.chapterTitle}
+                  </span>
+                </div>
+                <div className="text-muted-foreground">
+                  Skill:{task.skillName} · 模型:{task.modelName}
+                </div>
+              </div>
+            ))}
+            {[...runningTasks, ...settledImportTasks].map((task) => {
               const kindLabel = task.kind === "outline" ? "AI 大纲" : "章节"
               const progressPercent = task.total > 0
                 ? Math.round((task.completed / task.total) * 100)
@@ -2159,27 +2175,20 @@ export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?:
                 </div>
               )
             })}
-            {projectDeAiTasks.map((task) => (
+            {settledDeAiTasks.map((task) => (
               <div key={task.id} className="space-y-1 rounded-md bg-muted/30 px-2 py-1.5">
                 <div className="flex items-center gap-1">
-                  {task.status === "processing" ? (
-                    <Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
-                  ) : task.status === "ready" ? (
+                  {task.status === "ready" ? (
                     <Check className="h-3 w-3 shrink-0 text-emerald-500" />
                   ) : (
                     <X className="h-3 w-3 shrink-0 text-destructive" />
                   )}
-                  <span className={`truncate ${task.status === "processing" ? "text-foreground font-medium" : ""}`}>
+                  <span className="truncate">
                     去AI味:{task.chapterTitle}
                     {task.status === "ready" ? " · 待确认" : ""}
                     {task.status === "failed" ? ` · 失败:${task.error ?? ""}` : ""}
                   </span>
                 </div>
-                {task.status === "processing" && (
-                  <div className="text-muted-foreground">
-                    Skill:{task.skillName} · 模型:{task.modelName}
-                  </div>
-                )}
               </div>
             ))}
             </>

+ 1 - 1
src/components/settings/sections/maintenance-section.tsx

@@ -308,7 +308,7 @@ export function MaintenanceSection() {
   // Poll the queue at 1Hz so the UI reflects pending → processing →
   // failed transitions and cross-window queue activity (e.g. a merge
   // that completed while the user was on a different settings tab).
-  // Same pattern activity-panel uses for ingest-queue.
+  // Poll local queue state the same way other in-app queues do.
   const [tasks, setTasks] = useState<readonly DedupTask[]>([])
   const [mergeProgress, setMergeProgress] = useState<{
     taskId: string

+ 0 - 6
src/lib/hardcoded-ui-chinese-i18n.test.ts

@@ -18,7 +18,6 @@ describe("user-facing hardcoded chinese strings", () => {
     const knowledgeTree = read("../components/layout/knowledge-tree.tsx")
     const iconSidebar = read("../components/layout/icon-sidebar.tsx")
     const chatPanel = read("../components/chat/chat-panel.tsx")
-    const activityPanel = read("../components/layout/activity-panel.tsx")
     const llmProviderSection = read("../components/settings/sections/llm-provider-section.tsx")
     const changelogSection = read("../components/settings/sections/changelog-section.tsx")
 
@@ -101,11 +100,6 @@ describe("user-facing hardcoded chinese strings", () => {
     expect(chatPanel).not.toContain("审稿发现")
     expect(chatPanel).not.toContain("缺少LLM配置,跳过自动提取")
 
-    expect(activityPanel).not.toContain("文件同步失败")
-    expect(activityPanel).not.toContain("重新扫描")
-    expect(activityPanel).not.toContain("摄取队列")
-    expect(activityPanel).not.toContain("清除已完成")
-
     expect(llmProviderSection).not.toContain("仅桌面端可用")
     expect(llmProviderSection).not.toContain("CLI status")
     expect(llmProviderSection).not.toContain("Checking…")

+ 83 - 1
src/stores/import-progress-store.spec.ts

@@ -1,5 +1,25 @@
 import { beforeEach, describe, expect, it } from "vitest"
-import { useImportProgressStore } from "./import-progress-store"
+import {
+  MAX_SETTLED_IMPORT_TASKS,
+  retainRecentImportProgressTasks,
+  useImportProgressStore,
+  type ImportProgressTask,
+} from "./import-progress-store"
+
+function settledTask(id: string, updatedAt: number): ImportProgressTask {
+  return {
+    id,
+    projectPath: "E:/Novel",
+    kind: "chapter",
+    status: "done",
+    completed: 1,
+    total: 1,
+    currentTitle: "",
+    cancelling: false,
+    createdAt: updatedAt,
+    updatedAt,
+  }
+}
 
 describe("import progress store", () => {
   beforeEach(() => {
@@ -26,4 +46,66 @@ describe("import progress store", () => {
     expect(task?.total).toBe(6)
     expect(task?.currentTitle).toBe("第3章")
   })
+
+  it("keeps the newest settled tasks and drops older ones", () => {
+    const kept = retainRecentImportProgressTasks([
+      settledTask("old-1", 1),
+      settledTask("old-2", 2),
+      settledTask("old-3", 3),
+      settledTask("old-4", 4),
+      {
+        ...settledTask("running", 5),
+        status: "running",
+        completed: 0,
+      },
+    ])
+
+    expect(kept.filter((task) => task.status === "running").map((task) => task.id)).toEqual(["running"])
+    expect(kept.filter((task) => task.status === "done").map((task) => task.id)).toEqual([
+      "old-4",
+      "old-3",
+      "old-2",
+    ])
+    expect(MAX_SETTLED_IMPORT_TASKS).toBe(3)
+  })
+
+  it("keeps the latest finished task after a new extraction starts", () => {
+    const doneId = useImportProgressStore.getState().startTask({
+      projectPath: "E:/Novel",
+      kind: "chapter",
+      total: 1,
+      currentTitle: "第1章",
+    })
+    useImportProgressStore.getState().finishTask(doneId, "done")
+
+    const runningId = useImportProgressStore.getState().startTask({
+      projectPath: "E:/Novel",
+      kind: "chapter",
+      total: 1,
+      currentTitle: "第2章",
+    })
+
+    const tasks = useImportProgressStore.getState().tasks
+    expect(tasks.map((task) => task.id)).toEqual([runningId, doneId])
+    expect(tasks[0]?.status).toBe("running")
+    expect(tasks[1]?.status).toBe("done")
+  })
+
+  it("prunes settled history down to the latest three", () => {
+    useImportProgressStore.setState({
+      tasks: [
+        settledTask("old-1", 1),
+        settledTask("old-2", 2),
+        settledTask("old-3", 3),
+        settledTask("old-4", 4),
+      ],
+    })
+
+    useImportProgressStore.getState().pruneSettledTasks()
+    expect(useImportProgressStore.getState().tasks.map((task) => task.id)).toEqual([
+      "old-4",
+      "old-3",
+      "old-2",
+    ])
+  })
 })

+ 94 - 73
src/stores/import-progress-store.ts

@@ -4,6 +4,23 @@ import { normalizePath } from "@/lib/path-utils"
 export type ImportProgressKind = "chapter" | "outline" | "outline_generation" | "outline_refinement"
 export type ImportProgressStatus = "running" | "done" | "cancelled" | "error"
 
+export const MAX_SETTLED_IMPORT_TASKS = 3
+
+export function isSettledImportProgressStatus(status: ImportProgressStatus): boolean {
+  return status === "done" || status === "error" || status === "cancelled"
+}
+
+export function retainRecentImportProgressTasks(tasks: ImportProgressTask[]): ImportProgressTask[] {
+  const running: ImportProgressTask[] = []
+  const settled: ImportProgressTask[] = []
+  for (const task of tasks) {
+    if (task.status === "running") running.push(task)
+    else if (isSettledImportProgressStatus(task.status)) settled.push(task)
+  }
+  settled.sort((a, b) => b.updatedAt - a.updatedAt)
+  return [...running, ...settled.slice(0, MAX_SETTLED_IMPORT_TASKS)]
+}
+
 export interface ImportProgressTask {
   id: string
   projectPath: string
@@ -45,81 +62,85 @@ interface ImportProgressState {
   markCancelling: (taskId: string) => void
   cancelTask: (taskId: string) => void
   clearTask: (taskId: string) => void
+  pruneSettledTasks: () => void
   getLatestTask: (projectPath: string, kind?: ImportProgressKind) => ImportProgressTask | null
 }
 
 let importTaskCounter = 0
 
-export const useImportProgressStore = create<ImportProgressState>((set, get) => ({
-  tasks: [],
-  startTask: (input) => {
-    const now = Date.now()
-    const id = `import-progress-${++importTaskCounter}`
-    set((state) => ({
-      tasks: [
-        {
-          id,
-          projectPath: normalizePath(input.projectPath),
-          kind: input.kind,
-          status: "running",
-          completed: 0,
-          total: input.total,
-          currentTitle: input.currentTitle ?? "",
-          message: input.message,
-          activeTitles: input.activeTitles ?? [],
-          concurrency: input.concurrency,
-          cancelling: false,
-          createdAt: now,
-          updatedAt: now,
-          abortController: input.abortController,
-        },
-        ...state.tasks,
-      ],
-    }))
-    return id
-  },
-  updateTask: (taskId, patch) => {
-    set((state) => ({
-      tasks: state.tasks.map((task) =>
-        task.id === taskId
-          ? { ...task, ...patch, updatedAt: Date.now() }
-          : task,
-      ),
-    }))
-  },
-  finishTask: (taskId, status, patch = {}) => {
-    const task = get().tasks.find((item) => item.id === taskId)
-    get().updateTask(taskId, { ...patch, status, cancelling: false })
-    if (task?.kind === "outline" || task?.kind === "outline_generation" || task?.kind === "outline_refinement") {
-      void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
-        reconcileStaleOutlineIngestTasks(task.projectPath)
-      })
-    }
-  },
-  markCancelling: (taskId) => {
-    get().updateTask(taskId, { cancelling: true })
-  },
-  cancelTask: (taskId) => {
-    const task = get().tasks.find((t) => t.id === taskId)
-    if (!task) return
-    task.abortController?.abort()
-    get().updateTask(taskId, { status: "cancelled", cancelling: false, activeTitles: [] })
-    if (task.kind === "outline" || task.kind === "outline_generation" || task.kind === "outline_refinement") {
-      void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
-        reconcileStaleOutlineIngestTasks(task.projectPath)
-      })
-    }
-    setTimeout(() => {
-      get().clearTask(taskId)
-    }, 3000)
-  },
-  clearTask: (taskId) => {
-    set((state) => ({ tasks: state.tasks.filter((task) => task.id !== taskId) }))
-  },
-  getLatestTask: (projectPath, kind) => {
-    const normalizedProjectPath = normalizePath(projectPath)
-    return get().tasks
-      .filter((task) => task.projectPath === normalizedProjectPath && (!kind || task.kind === kind))
-      .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null
-  },
-}))
+export const useImportProgressStore = create<ImportProgressState>((set, get) => {
+  function reconcileOutlineTasks(task: ImportProgressTask | undefined): void {
+    if (task?.kind !== "outline" && task?.kind !== "outline_generation" && task?.kind !== "outline_refinement") return
+    void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
+      reconcileStaleOutlineIngestTasks(task.projectPath)
+    })
+  }
+
+  return {
+    tasks: [],
+    startTask: (input) => {
+      const now = Date.now()
+      const id = `import-progress-${++importTaskCounter}`
+      set((state) => ({
+        tasks: retainRecentImportProgressTasks([
+          {
+            id,
+            projectPath: normalizePath(input.projectPath),
+            kind: input.kind,
+            status: "running",
+            completed: 0,
+            total: input.total,
+            currentTitle: input.currentTitle ?? "",
+            message: input.message,
+            activeTitles: input.activeTitles ?? [],
+            concurrency: input.concurrency,
+            cancelling: false,
+            createdAt: now,
+            updatedAt: now,
+            abortController: input.abortController,
+          },
+          ...state.tasks,
+        ]),
+      }))
+      return id
+    },
+    updateTask: (taskId, patch) => {
+      set((state) => ({
+        tasks: state.tasks.map((task) =>
+          task.id === taskId
+            ? { ...task, ...patch, updatedAt: Date.now() }
+            : task,
+        ),
+      }))
+    },
+    finishTask: (taskId, status, patch = {}) => {
+      const task = get().tasks.find((item) => item.id === taskId)
+      get().updateTask(taskId, { ...patch, status, cancelling: false })
+      reconcileOutlineTasks(task)
+      get().pruneSettledTasks()
+    },
+    markCancelling: (taskId) => {
+      get().updateTask(taskId, { cancelling: true })
+    },
+    cancelTask: (taskId) => {
+      const task = get().tasks.find((t) => t.id === taskId)
+      if (!task) return
+      task.abortController?.abort()
+      get().updateTask(taskId, { status: "cancelled", cancelling: false, activeTitles: [] })
+      reconcileOutlineTasks(task)
+      get().pruneSettledTasks()
+    },
+    clearTask: (taskId) => {
+      set((state) => ({ tasks: state.tasks.filter((task) => task.id !== taskId) }))
+    },
+    pruneSettledTasks: () => {
+      set((state) => ({ tasks: retainRecentImportProgressTasks(state.tasks) }))
+    },
+    getLatestTask: (projectPath, kind) => {
+      const normalizedProjectPath = normalizePath(projectPath)
+      return get().tasks
+        .filter((task) => task.projectPath === normalizedProjectPath && (!kind || task.kind === kind))
+        .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null
+    },
+  }
+})