Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions src/components/layout/activity-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
ChevronUp, ChevronDown, Loader2, CheckCircle2, AlertCircle,
FileText, Users, Lightbulb, BookOpen, GitMerge, BarChart3, HelpCircle, Layout,
RotateCcw, X, Clock, TrendingUp, Target, Pause, Play,
ArrowUp, ArrowDown,
ArrowUp, ArrowDown, Trash2,
} from "lucide-react"
import { useTranslation } from "react-i18next"
import { useAppDialog } from "@/stores/app-dialog-store"
Expand All @@ -20,6 +20,10 @@ import {
cancelTask,
cancelTasks,
cancelAllTasks,
removeTask,
removeTasks,
hasActiveRun,
retainQueuedSelection,
movePendingTask,
pauseProcessing,
resumeProcessing,
Expand Down Expand Up @@ -154,6 +158,26 @@ export function ActivityPanel() {
})
}, [project, selectedTaskIds])

const syncAfterRemoval = useCallback(() => {
const remaining = [...getQueue()]
setSelectedTaskIds((current) => retainQueuedSelection(current, remaining))
setQueueTasks(remaining)
}, [])

const handleRemoveSelected = useCallback(async () => {
if (!project) return
const removable = queueTasks.filter(
(task) => selectedTaskIds.has(task.id) && isRemovable(task),
)
if (removable.length === 0) return
if (!(await appDialog.confirm({
message: t("activity.removeSelectedConfirm", { count: removable.length }),
variant: "destructive",
}))) return
await removeTasks(removable.map((task) => task.id))
syncAfterRemoval()
}, [appDialog, project, queueTasks, selectedTaskIds, syncAfterRemoval, t])

const handleMoveTask = useCallback((taskId: string, direction: "up" | "down") => {
void movePendingTask(taskId, direction).then(() => setQueueTasks([...getQueue()]))
}, [])
Expand All @@ -172,6 +196,11 @@ export function ActivityPanel() {
cancelTask(taskId)
}, [project])

const handleIngestRemove = useCallback((taskId: string) => {
if (!project) return
void removeTask(taskId).then(syncAfterRemoval)
}, [project, syncAfterRemoval])

const handleCancelAll = useCallback(async () => {
if (!project) return
const activeCount = queueSummary.pending + queueSummary.processing
Expand Down Expand Up @@ -346,6 +375,12 @@ export function ActivityPanel() {
>
{t("activity.cancelSelected")}
</button>
<button
onClick={handleRemoveSelected}
className="rounded px-1.5 py-0.5 text-destructive hover:bg-destructive/10"
>
{t("common.remove")}
</button>
</>
)}
</div>
Expand Down Expand Up @@ -435,7 +470,7 @@ export function ActivityPanel() {

{/* Queue tasks */}
{visibleQueueTasks.map((task) => (
<QueueRow key={task.id} task={task} selected={selectedTaskIds.has(task.id)} onSelect={toggleTaskSelection} onRetry={handleIngestRetry} onCancel={handleIngestCancel} onMove={handleMoveTask} />
<QueueRow key={task.id} task={task} selected={selectedTaskIds.has(task.id)} onSelect={toggleTaskSelection} onRetry={handleIngestRetry} onCancel={handleIngestCancel} onRemove={handleIngestRemove} onMove={handleMoveTask} />
))}
{orderedQueueTasks.length > visibleQueueTasks.length && (
<div className="border-b border-border/50 px-3 py-2 text-center text-[10px] text-muted-foreground">
Expand Down Expand Up @@ -471,12 +506,19 @@ export function ActivityPanel() {
)
}

function QueueRow({ task, selected, onSelect, onRetry, onCancel, onMove }: {
/** Stopped, and its run has already released the worker slot. */
function isRemovable(task: IngestTask): boolean {
return (task.status === "failed" || task.status === "cancelled") &&
!hasActiveRun(task.id)
}

function QueueRow({ task, selected, onSelect, onRetry, onCancel, onRemove, onMove }: {
task: IngestTask
selected: boolean
onSelect: (id: string) => void
onRetry: (id: string) => void
onCancel: (id: string) => void
onRemove: (id: string) => void
onMove: (id: string, direction: "up" | "down") => void
}) {
const { t } = useTranslation()
Expand Down Expand Up @@ -517,6 +559,15 @@ function QueueRow({ task, selected, onSelect, onRetry, onCancel, onMove }: {
<RotateCcw className="h-3 w-3" />
</button>
)}
{isRemovable(task) && (
<button
onClick={() => onRemove(task.id)}
className="p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive"
title={t("common.remove")}
>
<Trash2 className="h-3 w-3" />
</button>
)}
{task.status === "pending" && (
<>
<button onClick={() => onMove(task.id, "up")} className="rounded p-0.5 text-muted-foreground hover:bg-accent" title={t("activity.moveUp")}>
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"common": {
"retry": "Retry", "cancel": "Cancel", "ignore": "Ignore", "close": "Close", "ok": "OK", "confirm": "Confirm", "notice": "Notice",
"dismiss": "Dismiss", "loading": "Loading...", "optional": "optional"
"dismiss": "Dismiss", "remove": "Remove", "loading": "Loading...", "optional": "optional"
},
"layout": { "showSidebar": "Show sidebar", "hideSidebar": "Hide sidebar" },
"errors": { "generic": "Something went wrong" },
Expand Down Expand Up @@ -1065,6 +1065,7 @@
"cancelAll": "Cancel all",
"cancelAllTitle": "Cancel all queued and in-progress tasks",
"cancelAllConfirm": "Cancel all {{count}} queued/processing ingest tasks? Partial files from the in-progress task will be removed. Cancelled tasks stay in the queue so you can restart them.",
"removeSelectedConfirm": "Permanently remove the selected stopped ingest tasks from the queue? Selected: {{count}}. Removal cannot be undone.",
"retryFailed": "Restart stopped",
"retryFailedTitle": "Restart all failed and cancelled ingest tasks",
"selectAll": "Select all",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"common": {
"retry": "Riprova", "cancel": "Annulla", "ignore": "Ignora", "close": "Chiudi", "ok": "OK", "confirm": "Conferma", "notice": "Avviso",
"dismiss": "Nascondi", "loading": "Caricamento...", "optional": "opzionale"
"dismiss": "Nascondi", "remove": "Rimuovi", "loading": "Caricamento...", "optional": "opzionale"
},
"layout": { "showSidebar": "Mostra la barra laterale", "hideSidebar": "Nascondi la barra laterale" },
"errors": { "generic": "Qualcosa è andato storto" },
Expand Down Expand Up @@ -1065,6 +1065,7 @@
"cancelAll": "Annulla tutto",
"cancelAllTitle": "Annulla tutte le attività in coda e in corso",
"cancelAllConfirm": "Annullare tutte le {{count}} attività di elaborazione in coda o in corso? I file parziali dell'attività in corso verranno rimossi. Le attività annullate restano in coda, così puoi riavviarle.",
"removeSelectedConfirm": "Rimuovere definitivamente dalla coda le attività di elaborazione interrotte selezionate? Selezione: {{count}}. L\u0027operazione non può essere annullata.",
"retryFailed": "Riavvia le interrotte",
"retryFailedTitle": "Riavvia tutte le attività di elaborazione non riuscite e annullate",
"selectAll": "Seleziona tutto",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"common": {
"retry": "Попробовать снова", "cancel": "Отмена", "ignore": "Игнорировать", "close": "Закрыть", "ok": "ОК", "confirm": "Подтверждение", "notice": "Уведомление",
"dismiss": "Скрыть", "loading": "Загрузка...", "optional": "необязательно"
"dismiss": "Скрыть", "remove": "Удалить", "loading": "Загрузка...", "optional": "необязательно"
},
"layout": { "showSidebar": "Показать боковую панель", "hideSidebar": "Скрыть боковую панель" },
"errors": { "generic": "Что-то пошло не так" },
Expand Down Expand Up @@ -1065,6 +1065,7 @@
"cancelAll": "Отменить все",
"cancelAllTitle": "Отменить все задачи в очереди и выполняемые",
"cancelAllConfirm": "Отменить все {{count}} задач(и) обработки в очереди/выполняемые? Частичные файлы из выполняемой задачи будут удалены. Отменённые задачи остаются в очереди, чтобы вы могли запустить их заново.",
"removeSelectedConfirm": "Безвозвратно удалить выбранные остановленные задачи обработки из очереди? Выбрано: {{count}}. Отменить это действие нельзя.",
"retryFailed": "Перезапустить остановленные",
"retryFailedTitle": "Перезапустить все неудавшиеся и отменённые задачи обработки",
"selectAll": "Выделить всё",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"common": {
"retry": "重试", "cancel": "取消", "ignore": "忽略", "close": "关闭", "ok": "确定", "confirm": "确认", "notice": "提示",
"dismiss": "关闭", "loading": "加载中……", "optional": "可选"
"dismiss": "关闭", "remove": "移除", "loading": "加载中……", "optional": "可选"
},
"layout": { "showSidebar": "展开侧栏", "hideSidebar": "收起侧栏" },
"errors": { "generic": "出现错误" },
Expand Down Expand Up @@ -1065,6 +1065,7 @@
"cancelAll": "全部取消",
"cancelAllTitle": "取消所有排队和正在处理的任务",
"cancelAllConfirm": "要取消全部 {{count}} 个排队/处理中摄取任务吗?正在处理任务产生的部分文件会被移除;已取消任务会保留在队列中,之后可以重新启动。",
"removeSelectedConfirm": "要将选中的已停止摄取任务从队列中永久移除吗?已选择:{{count}}。移除后无法重新启动。",
"retryFailed": "重启已停止任务",
"retryFailedTitle": "重新启动所有失败和已取消的摄取任务",
"selectAll": "全选",
Expand Down
116 changes: 116 additions & 0 deletions src/lib/ingest-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ import {
retryAllFailedTasks,
cancelTask,
cancelTasks,
removeTask,
removeTasks,
hasActiveRun,
retainQueuedSelection,
discardTasksForSources,
cancelAllTasks,
movePendingTask,
Expand Down Expand Up @@ -578,6 +582,118 @@ describe("ingest-queue — cancel", () => {
expect(getQueue().find((t) => t.sourcePath === "first.md")).toBeDefined()
})

it("removeTask drops a cancelled task but leaves active ones alone", async () => {
mockAutoIngest.mockImplementation(() => new Promise(() => {})) // block first task

await enqueueBatch(TEST_ID, [
{ sourcePath: "first.md", folderContext: "" },
{ sourcePath: "second.md", folderContext: "" },
])
await flushMicrotasks(2)

const second = getQueue().find((t) => t.sourcePath === "second.md")!
await cancelTask(second.id)
expect(await removeTask(second.id)).toBe(true)
expect(getQueue().some((t) => t.sourcePath === "second.md")).toBe(false)

// Processing tasks are not removable — cancel them first.
const first = getQueue().find((t) => t.sourcePath === "first.md")!
expect(await removeTask(first.id)).toBe(false)
expect(getQueue().some((t) => t.sourcePath === "first.md")).toBe(true)
})

it("removeTask drops a failed task and persists the removal", async () => {
mockReadFile.mockResolvedValue(JSON.stringify([
{ id: "ingest-failed-a", sourcePath: "a.md", folderContext: "", status: "failed", addedAt: 1, error: "boom", retryCount: 3 },
{ id: "ingest-failed-b", sourcePath: "b.md", folderContext: "", status: "failed", addedAt: 2, error: "boom", retryCount: 3 },
]))
await restoreQueue(TEST_ID, TEST_PATH)
mockWriteFile.mockClear()

expect(await removeTask("ingest-failed-a")).toBe(true)
expect(getQueue().map((t) => t.id)).toEqual(["ingest-failed-b"])
const calls = mockWriteFile.mock.calls
const [path, snapshot] = calls[calls.length - 1]
expect(path).toBe(`${TEST_PATH}/.llm-wiki/ingest-queue.json`)
// Not just "the removed ID is gone" — a snapshot of `[]` would satisfy that
// while silently losing the survivor on the next restore.
expect(JSON.parse(snapshot).map((t: { id: string }) => t.id))
.toEqual(["ingest-failed-b"])
})

it("removeTask refuses a cancelled task whose run has not exited yet", async () => {
// autoIngest never settles, so the run keeps its slot in activeRuns even
// after the abort. Status is "cancelled" but the worker is still alive.
mockAutoIngest.mockImplementation(() => new Promise(() => {}))

await enqueueBatch(TEST_ID, [{ sourcePath: "in-flight.md", folderContext: "" }])
await flushMicrotasks(2)

const task = getQueue().find((t) => t.sourcePath === "in-flight.md")!
expect(task.status).toBe("processing")
await cancelTask(task.id)
expect(getQueue().find((t) => t.id === task.id)!.status).toBe("cancelled")

// Removing now would free the sourcePath for a re-import, and the new task
// would get a fresh ID that processNext's activeRuns guard cannot match.
expect(await removeTask(task.id)).toBe(false)
expect(await removeTasks([task.id])).toBe(0)
expect(getQueue().some((t) => t.id === task.id)).toBe(true)
})

it("removeTasks drops only the stopped tasks in the selection", async () => {
mockAutoIngest.mockImplementation(() => new Promise(() => {})) // block first task

await enqueueBatch(TEST_ID, [
{ sourcePath: "processing.md", folderContext: "" },
{ sourcePath: "cancelled.md", folderContext: "" },
{ sourcePath: "pending.md", folderContext: "" },
])
await flushMicrotasks(2)

const cancelled = getQueue().find((t) => t.sourcePath === "cancelled.md")!
await cancelTask(cancelled.id)

const removed = await removeTasks(getQueue().map((t) => t.id))
expect(removed).toBe(1)
expect(getQueue().map((t) => t.sourcePath)).toEqual(["processing.md", "pending.md"])

// Unknown IDs and an empty selection are no-ops, not throws.
expect(await removeTasks(["ingest-does-not-exist"])).toBe(0)
expect(await removeTasks([])).toBe(0)
})

it("retainQueuedSelection keeps rows the core refused to remove", async () => {
mockAutoIngest.mockImplementation(() => new Promise(() => {})) // block first task

await enqueueBatch(TEST_ID, [
{ sourcePath: "stuck.md", folderContext: "" },
{ sourcePath: "gone.md", folderContext: "" },
{ sourcePath: "kept.md", folderContext: "" },
])
await flushMicrotasks(2)

const stuck = getQueue().find((t) => t.sourcePath === "stuck.md")!
const gone = getQueue().find((t) => t.sourcePath === "gone.md")!
const kept = getQueue().find((t) => t.sourcePath === "kept.md")!

await cancelTask(stuck.id) // cancelled, but its run never settles
await cancelTask(gone.id)
expect(await removeTask(gone.id)).toBe(true)

const next = retainQueuedSelection(new Set([stuck.id, gone.id, kept.id]), getQueue())
expect(next.has(gone.id)).toBe(false) // actually removed
expect(next.has(stuck.id)).toBe(true) // guard refused it — must stay selected
expect(next.has(kept.id)).toBe(true) // untouched
// ...and the panel hides its remove button rather than offering a no-op.
expect(hasActiveRun(stuck.id)).toBe(true)
expect(hasActiveRun(kept.id)).toBe(false)

// Unchanged membership returns the same Set so React can skip the update.
const unchanged = new Set([stuck.id, kept.id])
expect(retainQueuedSelection(unchanged, getQueue())).toBe(unchanged)
})

it("batch-cancelled tasks can be batch-restarted", async () => {
mockAutoIngest.mockImplementation(() => new Promise(() => {}))
await enqueueBatch(TEST_ID, [
Expand Down
63 changes: 63 additions & 0 deletions src/lib/ingest-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,69 @@ export async function cancelTasks(taskIds: readonly string[]): Promise<number> {
return targets.length
}

/**
* Drop stopped (failed/cancelled) tasks from the queue for good. Restartable
* entries are retained until the user explicitly removes them here; pending
* and processing tasks must be cancelled first and are skipped.
*
* A `cancelled` status only records that cancellation was *requested* — the
* run keeps its worker slot until it observes the AbortSignal, and a commit
* already past the interruptible point still runs to completion. Removing the
* entry while that run is alive would drop the sourcePath that enqueueBatch
* dedupes against, so re-importing the same source would mint a fresh task ID
* that the `!activeRuns.has(task.id)` guard in processNext no longer covers.
* The two runs cannot interleave their writes — autoIngest holds a per-source
* project lock — but the newcomer would take a worker slot and sit in the
* Activity Panel as "processing" while it blocks on that lock. Skip tasks with
* a live run; they become removable once it exits.
*/
export async function removeTasks(taskIds: readonly string[]): Promise<number> {
const selected = new Set(taskIds)
const targets = queue.filter(
(task) =>
task.projectId === currentProjectId &&
selected.has(task.id) &&
(task.status === "failed" || task.status === "cancelled") &&
!activeRuns.has(task.id),
)
if (targets.length === 0) return 0
const doomed = new Set(targets.map((task) => task.id))
queue = queue.filter((task) => !doomed.has(task.id))
await saveQueue(currentProjectPath)
return targets.length
}

/**
* True while a run still owns this task's worker slot. Callers use it to hide
* the remove affordance instead of offering a click that {@link removeTasks}
* would refuse: cancellation is only *requested* synchronously, so a task can
* read as `cancelled` for a beat while its run is still winding down.
*/
export function hasActiveRun(taskId: string): boolean {
return activeRuns.has(taskId)
}

/**
* Narrow a selection to the tasks the queue still holds, preserving the
* original Set when nothing was dropped so React state keeps its identity.
* Used after a removal rather than clearing the selection outright, because
* {@link removeTasks} skips tasks with a live run and those rows must stay
* selected instead of appearing to have been removed.
*/
export function retainQueuedSelection(
selected: Set<string>,
tasks: readonly IngestTask[],
): Set<string> {
const live = new Set(tasks.map((task) => task.id))
const next = new Set([...selected].filter((id) => live.has(id)))
return next.size === selected.size ? selected : next
}

/** Single-task convenience wrapper around {@link removeTasks}. */
export async function removeTask(taskId: string): Promise<boolean> {
return (await removeTasks([taskId])) === 1
}

/**
* Permanently discard active-project tasks for sources that no longer exist.
* Unlike cancelTasks, this does not retain restartable queue entries because
Expand Down
Loading