From c16b358a79106870c53d57f9133da2a21fd470f0 Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 21:53:13 -0400 Subject: [PATCH 01/25] update style for oss of the failed task in the task panel --- .../components/task-collapsible-section.tsx | 6 +- frontend/components/task-error-content.tsx | 132 +++++++++--------- .../components/task-notification-menu.tsx | 95 +++++++------ frontend/components/tasks_details.tsx | 1 + frontend/contexts/task-context.tsx | 74 ++++++---- frontend/lib/task-utils.ts | 14 ++ frontend/package-lock.json | 35 +++++ frontend/package.json | 1 + frontend/tailwind.config.ts | 4 + .../tests/core/tasks-unified-panel.spec.ts | 29 ++-- 10 files changed, 244 insertions(+), 147 deletions(-) diff --git a/frontend/components/task-collapsible-section.tsx b/frontend/components/task-collapsible-section.tsx index e7c28068c..fc0f081f1 100644 --- a/frontend/components/task-collapsible-section.tsx +++ b/frontend/components/task-collapsible-section.tsx @@ -33,14 +33,14 @@ export function TaskCollapsibleSection({ >

{title} - + {items.length}

{isOpen ? ( - + ) : ( - + )} diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index 34266bea2..c2d51d480 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -1,6 +1,7 @@ "use client"; -import { ArrowDown, ArrowUp, ChevronDown, XCircle } from "lucide-react"; +import { ErrorFilled, IncidentReporter } from "@carbon/icons-react"; +import { ChevronDown } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Accordion, @@ -11,6 +12,7 @@ import { import { type Task } from "@/contexts/task-context"; import { getFailedFileEntries } from "@/lib/task-utils"; import { formatTaskTimestamp, parseTimestamp } from "@/lib/time-utils"; +import { cn } from "@/lib/utils"; interface TaskErrorContentProps { task: Task; @@ -29,19 +31,24 @@ export function TaskErrorContent({ defaultExpanded = false, expandTrigger = 0, }: TaskErrorContentProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [accordionValue, setAccordionValue] = useState( + defaultExpanded ? "failed-files" : "", + ); useEffect(() => { if (defaultExpanded) { - setIsExpanded(true); + setAccordionValue("failed-files"); } }, [defaultExpanded, expandTrigger]); + + const isExpanded = accordionValue === "failed-files"; + const failedEntries = useMemo(() => getFailedFileEntries(task), [task]); const failedCount = task.failed_files ?? failedEntries.length; const successCount = task.successful_files ?? 0; const timestamp = parseTimestamp(task.created_at) ?? parseTimestamp(task.updated_at); - const statusLabel = "INCOMPLETE"; + const statusLabel = "Failed"; const statusPillClassName = "text-destructive border-failure-pill bg-failure-soft"; @@ -51,17 +58,18 @@ export function TaskErrorContent({ return (
{showHeader && ( <> -
-
- +
+
+

Task {task.task_id.slice(0, 8)}...

@@ -75,7 +83,7 @@ export function TaskErrorContent({ )}
-
+

{formatTaskTimestamp(timestamp, mode, nowMs)}

@@ -86,63 +94,59 @@ export function TaskErrorContent({ setIsExpanded(Boolean(value))} + className="rounded-task border-0" + value={accordionValue} + onValueChange={(value) => + setAccordionValue(value === "failed-files" ? "failed-files" : "") + } > - - -
- - {successCount} success, - - - {failedCount} failed - - + + +
+
+ + {successCount} success · {failedCount} failed + + +
+
- -
-

- Failure Log{" "} - - ({failedCount} of {failedCount} pending) - -

-
- {failedEntries.map(([filePath, fileInfo], index) => { - const fileName = - fileInfo.filename || filePath.split("/").pop() || filePath; - const message = - typeof fileInfo.error === "string" && fileInfo.error.trim() - ? fileInfo.error.trim() - : task.error || "Unknown error"; + +
+ {failedEntries.map(([filePath, fileInfo], index) => { + const fileName = + fileInfo.filename || filePath.split("/").pop() || filePath; + const message = + typeof fileInfo.error === "string" && fileInfo.error.trim() + ? fileInfo.error.trim() + : task.error || "Unknown error"; - return ( -
-

- {">"} {fileName} -

-

- {message} -

-
- ); - })} -
- {failedCount > 1 && ( -
-
- - + return ( +
+

+ {fileName} +

+

+ {message} +

- scroll · {failedCount} errors -
- )} + ); + })}
diff --git a/frontend/components/task-notification-menu.tsx b/frontend/components/task-notification-menu.tsx index d88005bdb..3d63edd84 100644 --- a/frontend/components/task-notification-menu.tsx +++ b/frontend/components/task-notification-menu.tsx @@ -1,5 +1,6 @@ "use client"; +import { ErrorFilled } from "@carbon/icons-react"; import { AlertCircle, Bell, @@ -7,7 +8,6 @@ import { Clock, Loader2, X, - XCircle, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { TaskCollapsibleSection } from "@/components/task-collapsible-section"; @@ -23,7 +23,11 @@ import { CardTitle, } from "@/components/ui/card"; import { Task, useTask } from "@/contexts/task-context"; -import { hasFailedFileEntries, isTerminalFailedTask } from "@/lib/task-utils"; +import { + hasFailedFileEntries, + isCompletedTotalFailure, + isTerminalFailedTask, +} from "@/lib/task-utils"; import { parseTimestampMs } from "@/lib/time-utils"; export function TaskNotificationMenu() { @@ -81,11 +85,6 @@ export function TaskNotificationMenu() { }), [tasks], ); - const mostRecentFailureTaskId = - terminalTasks.find( - (task) => isTerminalFailedTask(task) || hasFailedFileEntries(task), - )?.task_id ?? null; - // Ensure selected task is visible in the past tasks section. useEffect(() => { if (!selectedTaskId) return; @@ -103,16 +102,23 @@ export function TaskNotificationMenu() { // Don't render if menu is closed if (!isMenuOpen) return null; - const getTaskIcon = (status: Task["status"], hasFailedFiles = false) => { + const getTaskIcon = ( + status: Task["status"], + hasFailedFiles = false, + isTotalFailure = false, + ) => { switch (status) { case "completed": if (hasFailedFiles) { + if (isTotalFailure) { + return ; + } return ; } return ; case "failed": case "error": - return ; + return ; case "pending": return ; case "running": @@ -123,14 +129,28 @@ export function TaskNotificationMenu() { } }; - const getStatusBadge = (status: Task["status"], hasFailedFiles = false) => { + const getStatusBadge = ( + status: Task["status"], + hasFailedFiles = false, + isTotalFailure = false, + ) => { switch (status) { case "completed": + if (hasFailedFiles && isTotalFailure) { + return ( + + FAILED + + ); + } if (hasFailedFiles) { return ( COMPLETED @@ -139,7 +159,7 @@ export function TaskNotificationMenu() { return ( COMPLETED @@ -149,16 +169,16 @@ export function TaskNotificationMenu() { return ( - INCOMPLETE + FAILED ); case "pending": return ( Pending @@ -168,7 +188,7 @@ export function TaskNotificationMenu() { return ( Processing @@ -177,7 +197,7 @@ export function TaskNotificationMenu() { return ( Unknown @@ -278,7 +298,7 @@ export function TaskNotificationMenu() {
{/* Active Tasks */} {activeTasks.length > 0 && ( -
+

Active Tasks

@@ -292,9 +312,9 @@ export function TaskNotificationMenu() { return ( - +
{getTaskIcon(task.status)} @@ -311,7 +331,7 @@ export function TaskNotificationMenu() { {(progress || showCancel) && ( - + {progress && (
@@ -369,12 +389,6 @@ export function TaskNotificationMenu() { task={task} mode="recent" showHeader={false} - defaultExpanded={true} - expandTrigger={ - selectedTaskId === task.task_id - ? selectedTaskTrigger - : 0 - } />
)} @@ -395,24 +409,17 @@ export function TaskNotificationMenu() { onToggle={() => setIsPastOpen((prev) => !prev)} emptyText="No past tasks." containerClassName="" - contentClassName="transition-all duration-200" + contentClassName="flex flex-col gap-2 p-4 transition-all duration-200" renderItem={(task) => { const progress = formatTaskProgress(task); const hasFailedFiles = hasFailedFileEntries(task); - const shouldExpandDetails = - selectedTaskId === task.task_id || - (!selectedTaskId && task.task_id === mostRecentFailureTaskId); - - if (isTerminalFailedTask(task)) { + const isTotalFailure = isCompletedTotalFailure(task); + if (isTerminalFailedTask(task) || isTotalFailure) { return ( ); } @@ -420,10 +427,10 @@ export function TaskNotificationMenu() { return (
- {getTaskIcon(task.status, hasFailedFiles)} + {getTaskIcon(task.status, hasFailedFiles, isTotalFailure)}
Task {task.task_id.substring(0, 8)}... @@ -451,7 +458,11 @@ export function TaskNotificationMenu() { )}
- {getStatusBadge(task.status, hasFailedFiles)} + {getStatusBadge( + task.status, + hasFailedFiles, + isTotalFailure, + )}
{hasFailedFiles && ( @@ -460,10 +471,6 @@ export function TaskNotificationMenu() { task={task} mode="past" showHeader={false} - defaultExpanded={shouldExpandDetails} - expandTrigger={ - shouldExpandDetails ? selectedTaskTrigger : 0 - } />
)} diff --git a/frontend/components/tasks_details.tsx b/frontend/components/tasks_details.tsx index 8a9d2f3ae..20a0543ff 100644 --- a/frontend/components/tasks_details.tsx +++ b/frontend/components/tasks_details.tsx @@ -98,6 +98,7 @@ export const FailedTasksInfo = ({ failedTasks }: FailedTasksInfoProps) => { })) } emptyText={section.emptyText} + contentClassName="flex flex-col gap-2 p-4" renderItem={(task) => ( 0 && successfulFiles === 0; + + if (isTotalFailure) { + trackProcessFailure({ + processType: "Ingestion", + process: "Document Upload", + category: "Knowledge", + task_id: currentTask.task_id, + total_files: currentTask.total_files, + failed_files: failedFiles, + duration_seconds: currentTask.duration_seconds, + }); + } else { + trackProcessSuccess({ + processType: "Ingestion", + process: "Document Upload", + category: "Knowledge", + task_id: currentTask.task_id, + total_files: currentTask.total_files, + successful_files: successfulFiles, + failed_files: failedFiles, + duration_seconds: currentTask.duration_seconds, + }); + } let description = ""; if (failedFiles > 0) { @@ -356,17 +372,27 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { } uploaded successfully`; } if (!isOnboardingActive) { - toast.success("Task completed", { - description, - action: { - label: "View", - onClick: () => { - selectTask(currentTask.task_id); - setIsMenuOpen(true); - setIsRecentTasksExpanded(true); - }, + const toastAction = { + label: "View", + onClick: () => { + selectTask(currentTask.task_id); + setIsMenuOpen(true); + setIsRecentTasksExpanded(true); }, - }); + }; + if (isTotalFailure) { + toast.error("Task failed", { + description: `${failedFiles} file${ + failedFiles !== 1 ? "s" : "" + } failed`, + action: toastAction, + }); + } else { + toast.success("Task completed", { + description, + action: toastAction, + }); + } } const completedHasFailures = hasFailedFileEntries(currentTask); diff --git a/frontend/lib/task-utils.ts b/frontend/lib/task-utils.ts index 94b441fa9..88c5cab33 100644 --- a/frontend/lib/task-utils.ts +++ b/frontend/lib/task-utils.ts @@ -24,6 +24,20 @@ export function isCompletedWithFailures(task: Task): boolean { return task.status === "completed" && hasFailedFileEntries(task); } +export function getSuccessfulFileCount(task: Task): number { + if (typeof task.successful_files === "number") { + return task.successful_files; + } + return Object.values(task.files || {}).filter( + (fileInfo) => fileInfo?.status === "completed", + ).length; +} + +/** Completed task with failures and no successful files — treat as failed, not partial success. */ +export function isCompletedTotalFailure(task: Task): boolean { + return isCompletedWithFailures(task) && getSuccessfulFileCount(task) === 0; +} + export function isFailureLikeTask(task: Task): boolean { return isTerminalFailedTask(task) || isCompletedWithFailures(task); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3e5165d5a..adb7ecda2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@carbon/icons-react": "^11.81.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.11", @@ -257,6 +258,31 @@ "node": ">=14.21.3" } }, + "node_modules/@carbon/icon-helpers": { + "version": "10.76.0", + "resolved": "https://registry.npmjs.org/@carbon/icon-helpers/-/icon-helpers-10.76.0.tgz", + "integrity": "sha512-DLxVV/NtEFauMbmKFW53cGKMs84RRdIp9AzHbasIxlCBjO0P4eZbPdO3gTzYxn02jDMo++2m3MsEAwNydzrzmQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/icons-react": { + "version": "11.81.0", + "resolved": "https://registry.npmjs.org/@carbon/icons-react/-/icons-react-11.81.0.tgz", + "integrity": "sha512-rkaQz45ioyQQ1FeSB69ojL1EfsEecYE0+S88marMqz6GIqJxnDhHap3a2C89kXwuBUbCNsZ5aIhKqfiMquotvg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@carbon/icon-helpers": "^10.76.0", + "@ibm/telemetry-js": "^1.5.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -524,6 +550,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ibm/telemetry-js": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@ibm/telemetry-js/-/telemetry-js-1.11.0.tgz", + "integrity": "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA==", + "license": "Apache-2.0", + "bin": { + "ibmtelemetry": "dist/collect.js" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b712c0ac6..3ed1dbef5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "prepare": "cd .. && npx husky frontend/.husky || true" }, "dependencies": { + "@carbon/icons-react": "^11.81.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.11", diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index a74c8c8ab..ec66f1ecd 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -194,10 +194,14 @@ const config = { muted: "var(--failure-muted)", }, }, + spacing: { + mmd: "13px", + }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", + task: "13px", }, fontFamily: { sans: ["var(--font-sans)", ...fontFamily.sans], diff --git a/frontend/tests/core/tasks-unified-panel.spec.ts b/frontend/tests/core/tasks-unified-panel.spec.ts index 73f711a54..f55909c6c 100644 --- a/frontend/tests/core/tasks-unified-panel.spec.ts +++ b/frontend/tests/core/tasks-unified-panel.spec.ts @@ -77,13 +77,18 @@ const wireTasksState = async (page: Page, initialTasks: MockTask[]) => { }; }; -const expandFirstFailureAccordion = async (page: Page) => { - const failureLog = page.getByText("Failure Log").first(); - if (await failureLog.isVisible()) { +const expandFirstFailureAccordion = async ( + page: Page, + expectedError?: string, +) => { + if ( + expectedError && + (await page.getByText(expectedError).first().isVisible()) + ) { return; } await page - .getByRole("button", { name: /\d+\s*success,\s*\d+\s*failed/i }) + .getByRole("button", { name: /\d+\s*success\s*·\s*\d+\s*failed/i }) .first() .click(); }; @@ -178,8 +183,10 @@ test("completed task with failures keeps failure log in Tasks panel", async ({ ); await openTasksPanel(page); await openPastTasksSection(page); - await expandFirstFailureAccordion(page); - await expect(page.getByText("Failure Log")).toBeVisible(); + await expandFirstFailureAccordion( + page, + "Synthetic ingestion failure for test", + ); await expect( page.getByText("Synthetic ingestion failure for test"), ).toBeVisible(); @@ -228,8 +235,7 @@ test("completed task with failures requires View click to open tasks panel", asy ); await openTasksPanel(page); await openPastTasksSection(page); - await expandFirstFailureAccordion(page); - await expect(page.getByText("Failure Log")).toBeVisible(); + await expandFirstFailureAccordion(page, "Auto-open on partial success"); await expect(page.getByText("Auto-open on partial success")).toBeVisible(); }); @@ -274,8 +280,7 @@ test("new failed task auto-opens tasks panel", async ({ page }) => { ); await openTasksPanel(page); await openPastTasksSection(page); - await expandFirstFailureAccordion(page); - await expect(page.getByText("Failure Log")).toBeVisible(); + await expandFirstFailureAccordion(page, "Auto-open on failed task"); await expect(page.getByText("Auto-open on failed task")).toBeVisible(); }); @@ -325,6 +330,6 @@ test("unified panel shows all completed tasks in a single past tasks section", a await openTasksPanel(page); await expect(page.getByText("Task task-new...")).toBeVisible(); await expect(page.getByText("Task task-old...")).toBeVisible(); - // The most recent failure task auto-expands, hiding its INCOMPLETE pill; the older one stays collapsed. - await expect(page.getByText("INCOMPLETE")).toHaveCount(1); + // Failure summaries stay collapsed by default; both tasks show the FAILED pill. + await expect(page.getByText("FAILED")).toHaveCount(2); }); From ee99d06620bf2a72a0e581e0b92d64b8fb8c1684 Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 22:14:21 -0400 Subject: [PATCH 02/25] keep logic on click, remove unecessary useeffect --- frontend/components/task-error-content.tsx | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index c2d51d480..337589869 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -2,7 +2,7 @@ import { ErrorFilled, IncidentReporter } from "@carbon/icons-react"; import { ChevronDown } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Accordion, AccordionContent, @@ -19,8 +19,6 @@ interface TaskErrorContentProps { mode?: "recent" | "past"; nowMs?: number; showHeader?: boolean; - defaultExpanded?: boolean; - expandTrigger?: number; } export function TaskErrorContent({ @@ -28,18 +26,8 @@ export function TaskErrorContent({ mode = "recent", nowMs = Date.now(), showHeader = true, - defaultExpanded = false, - expandTrigger = 0, }: TaskErrorContentProps) { - const [accordionValue, setAccordionValue] = useState( - defaultExpanded ? "failed-files" : "", - ); - useEffect(() => { - if (defaultExpanded) { - setAccordionValue("failed-files"); - } - }, [defaultExpanded, expandTrigger]); - + const [accordionValue, setAccordionValue] = useState(""); const isExpanded = accordionValue === "failed-files"; const failedEntries = useMemo(() => getFailedFileEntries(task), [task]); From d3f05c1beb3ef814c6715222019d937bc5070b1a Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 22:21:11 -0400 Subject: [PATCH 03/25] fix padding --- frontend/components/task-notification-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/task-notification-menu.tsx b/frontend/components/task-notification-menu.tsx index 3d63edd84..cb1e3896a 100644 --- a/frontend/components/task-notification-menu.tsx +++ b/frontend/components/task-notification-menu.tsx @@ -409,7 +409,7 @@ export function TaskNotificationMenu() { onToggle={() => setIsPastOpen((prev) => !prev)} emptyText="No past tasks." containerClassName="" - contentClassName="flex flex-col gap-2 p-4 transition-all duration-200" + contentClassName="flex flex-col gap-2 p-4 pt-2 transition-all duration-200" renderItem={(task) => { const progress = formatTaskProgress(task); const hasFailedFiles = hasFailedFileEntries(task); From 1d4f425d028bf07c49a1b73bcc26e181a68f61ec Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 23:15:51 -0400 Subject: [PATCH 04/25] wip implementing Saas style --- frontend/app/globals.css | 2 + frontend/components/task-error-content.tsx | 221 +++++++++++------- .../components/task-notification-menu.tsx | 7 +- frontend/components/tasks_details.tsx | 10 +- frontend/tailwind.config.ts | 1 + .../tests/core/tasks-unified-panel.spec.ts | 26 ++- 6 files changed, 172 insertions(+), 95 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 9fb849a30..ab08eeffd 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -88,6 +88,7 @@ --failure-message: hsl(var(--foreground)); --failure-scroll: #ff6464; --failure-muted: #a66262; + --failure-component-cause: #525252; /* IBM Gray 70 — OSS component label beside flag */ } .dark { @@ -156,6 +157,7 @@ --failure-message: #ffc9c9cc; --failure-scroll: #ff6464; --failure-muted: #a66262; + --failure-component-cause: #525252; } /* IBM Light */ diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index 337589869..7c9912d0b 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { ErrorFilled, IncidentReporter } from "@carbon/icons-react"; +import { ErrorFilled, FlagFilled, IncidentReporter } from "@carbon/icons-react"; import { ChevronDown } from "lucide-react"; import { useMemo, useState } from "react"; import { @@ -9,7 +9,9 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { useIsCloudBrand } from "@/contexts/brand-context"; import { type Task } from "@/contexts/task-context"; +import { displayFileTaskError } from "@/lib/task-error-display"; import { getFailedFileEntries } from "@/lib/task-utils"; import { formatTaskTimestamp, parseTimestamp } from "@/lib/time-utils"; import { cn } from "@/lib/utils"; @@ -27,6 +29,7 @@ export function TaskErrorContent({ nowMs = Date.now(), showHeader = true, }: TaskErrorContentProps) { + const isCloudBrand = useIsCloudBrand(); const [accordionValue, setAccordionValue] = useState(""); const isExpanded = accordionValue === "failed-files"; @@ -44,101 +47,157 @@ export function TaskErrorContent({ return null; } + const ossIconColumn = showHeader && !isCloudBrand; + + const accordionTrigger = ( +
+
+ + {successCount} success · {failedCount} failed + + +
+ +
+ ); + return (
- {showHeader && ( - <> -
-
+
+ {showHeader && ( +
+ {ossIconColumn && ( -

- Task {task.task_id.slice(0, 8)}... + )} +

+
+

+ Task {task.task_id.slice(0, 8)}... +

+ {!isExpanded && ( +

+ {statusLabel} +

+ )} +
+

+ {formatTaskTimestamp(timestamp, mode, nowMs)}

- {!isExpanded && ( -

- {statusLabel} -

- )}
+ )} -
-

- {formatTaskTimestamp(timestamp, mode, nowMs)} -

-
- - )} + + setAccordionValue(value === "failed-files" ? "failed-files" : "") + } + > + + + {ossIconColumn ? ( +
+
+
{accordionTrigger}
+
+ ) : ( + accordionTrigger + )} + + +
+ {failedEntries.map(([filePath, fileInfo], index) => { + const fileName = + fileInfo.filename || filePath.split("/").pop() || filePath; + const rawError = + typeof fileInfo.error === "string" && fileInfo.error.trim() + ? fileInfo.error.trim() + : task.error; + const { line, componentCause } = + displayFileTaskError(rawError); - - setAccordionValue(value === "failed-files" ? "failed-files" : "") - } - > - - -
-
- - {successCount} success · {failedCount} failed - - + return ( +
+

+ {fileName} +

+

+ {line} +

+ {componentCause ? ( +
+ + + {componentCause} + +
+ ) : null} +
+ ); + })}
- -
-
- -
- {failedEntries.map(([filePath, fileInfo], index) => { - const fileName = - fileInfo.filename || filePath.split("/").pop() || filePath; - const message = - typeof fileInfo.error === "string" && fileInfo.error.trim() - ? fileInfo.error.trim() - : task.error || "Unknown error"; - - return ( -
-

- {fileName} -

-

- {message} -

-
- ); - })} -
-
-
-
+ + + +
); } diff --git a/frontend/components/task-notification-menu.tsx b/frontend/components/task-notification-menu.tsx index cb1e3896a..bfd193786 100644 --- a/frontend/components/task-notification-menu.tsx +++ b/frontend/components/task-notification-menu.tsx @@ -22,6 +22,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useIsCloudBrand } from "@/contexts/brand-context"; import { Task, useTask } from "@/contexts/task-context"; import { hasFailedFileEntries, @@ -29,8 +30,10 @@ import { isTerminalFailedTask, } from "@/lib/task-utils"; import { parseTimestampMs } from "@/lib/time-utils"; +import { cn } from "@/lib/utils"; export function TaskNotificationMenu() { + const isCloudBrand = useIsCloudBrand(); const { tasks, isFetching, @@ -286,7 +289,9 @@ export function TaskNotificationMenu() { }; return ( -
+
{ + const isCloudBrand = useIsCloudBrand(); const [openSections, setOpenSections] = useState< Record<"recent" | "past", boolean> >({ @@ -76,7 +79,12 @@ export const FailedTasksInfo = ({ failedTasks }: FailedTasksInfoProps) => { ); return ( -
+
{failedTasks.length === 0 ? ( diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index ec66f1ecd..3954f604d 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -192,6 +192,7 @@ const config = { message: "var(--failure-message)", scroll: "var(--failure-scroll)", muted: "var(--failure-muted)", + "component-cause": "var(--failure-component-cause)", }, }, spacing: { diff --git a/frontend/tests/core/tasks-unified-panel.spec.ts b/frontend/tests/core/tasks-unified-panel.spec.ts index f55909c6c..7bb57146a 100644 --- a/frontend/tests/core/tasks-unified-panel.spec.ts +++ b/frontend/tests/core/tasks-unified-panel.spec.ts @@ -77,6 +77,9 @@ const wireTasksState = async (page: Page, initialTasks: MockTask[]) => { }; }; +/** Matches accordion summary, e.g. "1 success · 2 failed". */ +const FAILURE_SUMMARY_BUTTON = /\d+\s*success\s*[·,.]\s*\d+\s*failed/i; + const expandFirstFailureAccordion = async ( page: Page, expectedError?: string, @@ -88,7 +91,7 @@ const expandFirstFailureAccordion = async ( return; } await page - .getByRole("button", { name: /\d+\s*success\s*·\s*\d+\s*failed/i }) + .getByRole("button", { name: FAILURE_SUMMARY_BUTTON }) .first() .click(); }; @@ -115,18 +118,17 @@ const openTasksPanel = async (page: Page) => { }; const openPastTasksSection = async (page: Page) => { - const failureAccordionTrigger = page.getByRole("button", { - name: /\d+\s*success,\s*\d+\s*failed/i, - }); - if (await failureAccordionTrigger.count()) { - return; - } - const pastTasksToggle = page.getByRole("button", { name: /Past Tasks/i }); - if (await pastTasksToggle.count()) { + await expect(pastTasksToggle.first()).toBeVisible({ timeout: 15000 }); + + const failureSummary = page.getByRole("button", { + name: FAILURE_SUMMARY_BUTTON, + }); + if ((await failureSummary.count()) === 0) { await pastTasksToggle.first().click(); } - await expect(failureAccordionTrigger.first()).toBeVisible({ timeout: 15000 }); + + await expect(failureSummary.first()).toBeVisible({ timeout: 15000 }); }; test("completed task with failures keeps failure log in Tasks panel", async ({ @@ -330,6 +332,6 @@ test("unified panel shows all completed tasks in a single past tasks section", a await openTasksPanel(page); await expect(page.getByText("Task task-new...")).toBeVisible(); await expect(page.getByText("Task task-old...")).toBeVisible(); - // Failure summaries stay collapsed by default; both tasks show the FAILED pill. - await expect(page.getByText("FAILED")).toHaveCount(2); + // Failure summaries stay collapsed by default; both tasks show the Failed pill. + await expect(page.getByText("Failed", { exact: true })).toHaveCount(2); }); From e1fa26dd2ed3c04a3dc72903ba8f916b90b2df1a Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 23:23:12 -0400 Subject: [PATCH 05/25] utils to reshape error until backend provide info we need --- frontend/contexts/task-context.tsx | 3 ++- frontend/lib/task-utils.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/contexts/task-context.tsx b/frontend/contexts/task-context.tsx index d49dbbf05..838e3760b 100644 --- a/frontend/contexts/task-context.tsx +++ b/frontend/contexts/task-context.tsx @@ -21,6 +21,7 @@ import { useAuth } from "@/contexts/auth-context"; import { useOnboardingState } from "@/hooks/use-onboarding-state"; import { trackProcessFailure, trackProcessSuccess } from "@/lib/analytics"; import { + getFailedFileCount, getSuccessfulFileCount, hasFailedFileEntries, isTerminalFailedTask, @@ -333,7 +334,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { currentTask.status === "completed" ) { const successfulFiles = getSuccessfulFileCount(currentTask); - const failedFiles = currentTask.failed_files || 0; + const failedFiles = getFailedFileCount(currentTask); const isTotalFailure = failedFiles > 0 && successfulFiles === 0; if (isTotalFailure) { diff --git a/frontend/lib/task-utils.ts b/frontend/lib/task-utils.ts index 88c5cab33..8f22468d5 100644 --- a/frontend/lib/task-utils.ts +++ b/frontend/lib/task-utils.ts @@ -33,6 +33,13 @@ export function getSuccessfulFileCount(task: Task): number { ).length; } +export function getFailedFileCount(task: Task): number { + if (typeof task.failed_files === "number") { + return task.failed_files; + } + return getFailedFileEntries(task).length; +} + /** Completed task with failures and no successful files — treat as failed, not partial success. */ export function isCompletedTotalFailure(task: Task): boolean { return isCompletedWithFailures(task) && getSuccessfulFileCount(task) === 0; From 1e5e7e3f739a82873de57e0acb75e9778a82cb4c Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 23:28:33 -0400 Subject: [PATCH 06/25] utils to reshape error until backend provide info we need --- frontend/app/globals.css | 1 + frontend/components/task-error-content.tsx | 4 ++-- frontend/tailwind.config.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index ab08eeffd..d542549b1 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -63,6 +63,7 @@ --badge-foreground: 0 0% 100%; /* #FFFFFF */ --radius: 0.5rem; + --radius-mmd: 13px; --sidebar-background: 0 0% 98%; diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index 7c9912d0b..05db27c1e 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -77,7 +77,7 @@ export function TaskErrorContent({ className={cn( "w-full", showHeader - ? "rounded-task border border-muted py-mmd px-4 hover:bg-muted/60 transition-colors" + ? "rounded-mmd border border-muted py-mmd px-4 hover:bg-muted/60 transition-colors" : "pt-2", )} > @@ -112,7 +112,7 @@ export function TaskErrorContent({ setAccordionValue(value === "failed-files" ? "failed-files" : "") diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 3954f604d..0b302b8ca 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -202,7 +202,7 @@ const config = { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", - task: "13px", + mmd: "var(--radius-mmd)", }, fontFamily: { sans: ["var(--font-sans)", ...fontFamily.sans], From 550b75929d2768af95a2322351f1aa61aae805fe Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 23:34:35 -0400 Subject: [PATCH 07/25] utils to reshape error until backend provide info we need and fixinf fallbacks of isTotalFailure --- frontend/components/task-error-content.tsx | 10 ++- frontend/lib/task-error-display.ts | 99 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 frontend/lib/task-error-display.ts diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index 05db27c1e..2d827933b 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -12,7 +12,11 @@ import { import { useIsCloudBrand } from "@/contexts/brand-context"; import { type Task } from "@/contexts/task-context"; import { displayFileTaskError } from "@/lib/task-error-display"; -import { getFailedFileEntries } from "@/lib/task-utils"; +import { + getFailedFileCount, + getFailedFileEntries, + getSuccessfulFileCount, +} from "@/lib/task-utils"; import { formatTaskTimestamp, parseTimestamp } from "@/lib/time-utils"; import { cn } from "@/lib/utils"; @@ -35,8 +39,8 @@ export function TaskErrorContent({ const failedEntries = useMemo(() => getFailedFileEntries(task), [task]); - const failedCount = task.failed_files ?? failedEntries.length; - const successCount = task.successful_files ?? 0; + const failedCount = getFailedFileCount(task); + const successCount = getSuccessfulFileCount(task); const timestamp = parseTimestamp(task.created_at) ?? parseTimestamp(task.updated_at); const statusLabel = "Failed"; diff --git a/frontend/lib/task-error-display.ts b/frontend/lib/task-error-display.ts new file mode 100644 index 000000000..9594e6d97 --- /dev/null +++ b/frontend/lib/task-error-display.ts @@ -0,0 +1,99 @@ +/** + * Formats raw per-file task error strings for compact UI. + * + * TODO(backend): Prefer structured fields on TaskFileEntry when available, e.g. + * error_summary (one line), failing_step, and component_cause — then use this + * module only as a fallback for older tasks. + */ + +export const FILE_ERROR_MAX_LINE_LENGTH = 80; + +export type TaskErrorComponentCause = "OpenSearch" | "Docling" | "Langflow"; + +const COMPONENT_CAUSES: ReadonlyArray<{ + keyword: string; + label: TaskErrorComponentCause; +}> = [ + { keyword: "opensearch", label: "OpenSearch" }, + { keyword: "docling", label: "Docling" }, + { keyword: "langflow", label: "Langflow" }, +]; + +export interface FileTaskErrorDisplay { + line: string; + componentCause?: TaskErrorComponentCause; +} + +function normalizeErrorText(raw: string): string { + return raw.replace(/\s+/g, " ").trim(); +} + +function stripNoisePrefixes(text: string): string { + return text + .replace(/^Error running graph:\s*/i, "") + .replace(/^Error building Component [^:]+:\s*/i, "") + .trim(); +} + +function truncateLine( + text: string, + maxLength = FILE_ERROR_MAX_LINE_LENGTH, +): string { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 1).trimEnd()}…`; +} + +/** Prefer a short clause from long, nested error strings. */ +function extractReadableLine(text: string): string { + const beforeCausedBy = text.split(/\s+caused by:/i)[0]?.trim() ?? text; + + if (beforeCausedBy.length <= FILE_ERROR_MAX_LINE_LENGTH) { + return beforeCausedBy; + } + + const colonParts = beforeCausedBy.split(":"); + const lastClause = colonParts[colonParts.length - 1]?.trim(); + if ( + lastClause && + lastClause.length >= 10 && + lastClause.length <= FILE_ERROR_MAX_LINE_LENGTH + ) { + return lastClause; + } + + return beforeCausedBy; +} + +export function detectComponentCause( + raw: string, +): TaskErrorComponentCause | undefined { + const lower = raw.toLowerCase(); + for (const { keyword, label } of COMPONENT_CAUSES) { + if (lower.includes(keyword)) { + return label; + } + } + return undefined; +} + +export function displayFileTaskError( + raw: string | undefined | null, +): FileTaskErrorDisplay { + if (!raw?.trim()) { + return { line: "Unknown error" }; + } + + const normalized = normalizeErrorText(raw); + const componentCause = detectComponentCause(normalized); + + let line = stripNoisePrefixes(normalized); + line = truncateLine(extractReadableLine(line)); + + if (!line) { + line = "Unknown error"; + } + + return componentCause ? { line, componentCause } : { line }; +} From 4cadb5fa16b554da47ab13485f74748f614d40b6 Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 23:35:47 -0400 Subject: [PATCH 08/25] utils to reshape error until backend provide into --- frontend/lib/task-error-display.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/frontend/lib/task-error-display.ts b/frontend/lib/task-error-display.ts index 9594e6d97..7f3ce7890 100644 --- a/frontend/lib/task-error-display.ts +++ b/frontend/lib/task-error-display.ts @@ -1,10 +1,6 @@ -/** - * Formats raw per-file task error strings for compact UI. - * - * TODO(backend): Prefer structured fields on TaskFileEntry when available, e.g. - * error_summary (one line), failing_step, and component_cause — then use this - * module only as a fallback for older tasks. - */ +//formatting error displayed +//waiting for backend step pf failure, cause etc +//in meantime make sure we display only one line and search for opensearch, docling or langflow export const FILE_ERROR_MAX_LINE_LENGTH = 80; From 45d782ca8225ee8de0c5576614a57d12ee157e5f Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 20 May 2026 23:51:35 -0400 Subject: [PATCH 09/25] have Saas style for failed and complete labelstatus and width and border --- frontend/app/globals.css | 11 +++++++ frontend/components/task-error-content.tsx | 33 ++++++++++++------- .../components/task-notification-menu.tsx | 12 +++++-- frontend/components/tasks_details.tsx | 7 +++- frontend/tailwind.config.ts | 10 ++++++ 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index d542549b1..c1683f8ff 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -241,6 +241,12 @@ --badge: 0 0% 22.4%; /* #393939 */ --badge-foreground: 0 0% 96%; /* #F4F4F4 */ + + /* IBM task panel status pills */ + --task-status-failed-bg: #a2191f; /* IBM Red 70 */ + --task-status-failed-fg: #ffd7d9; /* IBM Red 20 */ + --task-status-complete-bg: #0e6027; /* IBM Green 70 */ + --task-status-complete-fg: #a7f0ba; /* IBM Green 20 */ } /* IBM: match OSS switch colors — black track when checked, white thumb always */ @@ -332,6 +338,11 @@ --badge: 0 0% 32%; /* #525252 */ --badge-foreground: 0 0% 96%; /* #F4F4F4 */ + + --task-status-failed-bg: #a2191f; + --task-status-failed-fg: #ffd7d9; + --task-status-complete-bg: #0e6027; + --task-status-complete-fg: #a7f0ba; } /* IBM Settings: section titles (productive heading-04); beats CardTitle utilities */ diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index 2d827933b..d6e65189e 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -16,6 +16,8 @@ import { getFailedFileCount, getFailedFileEntries, getSuccessfulFileCount, + isCompletedTotalFailure, + isTerminalFailedTask, } from "@/lib/task-utils"; import { formatTaskTimestamp, parseTimestamp } from "@/lib/time-utils"; import { cn } from "@/lib/utils"; @@ -43,9 +45,17 @@ export function TaskErrorContent({ const successCount = getSuccessfulFileCount(task); const timestamp = parseTimestamp(task.created_at) ?? parseTimestamp(task.updated_at); - const statusLabel = "Failed"; - const statusPillClassName = - "text-destructive border-failure-pill bg-failure-soft"; + const isFailedStatus = + isTerminalFailedTask(task) || isCompletedTotalFailure(task); + const statusLabel = isFailedStatus ? "Failed" : "Complete"; + const statusPillClassName = cn( + "shrink-0 rounded-full px-2 py-1 text-xs", + isCloudBrand + ? isFailedStatus + ? "border-0 bg-task-status-failed text-task-status-failed-foreground" + : "border-0 bg-task-status-complete text-task-status-complete-foreground" + : "border border-failure-pill bg-failure-soft text-destructive", + ); if (failedCount <= 0 && failedEntries.length === 0) { return null; @@ -80,9 +90,14 @@ export function TaskErrorContent({
@@ -99,11 +114,7 @@ export function TaskErrorContent({ Task {task.task_id.slice(0, 8)}...

{!isExpanded && ( -

- {statusLabel} -

+

{statusLabel}

)}

diff --git a/frontend/components/task-notification-menu.tsx b/frontend/components/task-notification-menu.tsx index bfd193786..792b0d102 100644 --- a/frontend/components/task-notification-menu.tsx +++ b/frontend/components/task-notification-menu.tsx @@ -414,7 +414,12 @@ export function TaskNotificationMenu() { onToggle={() => setIsPastOpen((prev) => !prev)} emptyText="No past tasks." containerClassName="" - contentClassName="flex flex-col gap-2 p-4 pt-2 transition-all duration-200" + contentClassName={cn( + "flex flex-col transition-all duration-200", + isCloudBrand + ? "p-0 [&>*:last-child]:border-b [&>*:last-child]:border-muted" + : "gap-2 p-4 pt-2", + )} renderItem={(task) => { const progress = formatTaskProgress(task); const hasFailedFiles = hasFailedFileEntries(task); @@ -432,7 +437,10 @@ export function TaskNotificationMenu() { return (

{getTaskIcon(task.status, hasFailedFiles, isTotalFailure)} diff --git a/frontend/components/tasks_details.tsx b/frontend/components/tasks_details.tsx index d420ce10a..f6f923453 100644 --- a/frontend/components/tasks_details.tsx +++ b/frontend/components/tasks_details.tsx @@ -106,7 +106,12 @@ export const FailedTasksInfo = ({ failedTasks }: FailedTasksInfoProps) => { })) } emptyText={section.emptyText} - contentClassName="flex flex-col gap-2 p-4" + contentClassName={cn( + "flex flex-col", + isCloudBrand + ? "p-0 [&>*:last-child]:border-b [&>*:last-child]:border-muted" + : "gap-2 p-4", + )} renderItem={(task) => ( Date: Thu, 21 May 2026 07:59:45 -0400 Subject: [PATCH 10/25] few style adjustment to follow codebase pattern --- frontend/app/globals.css | 21 ++++++++++----------- frontend/components/task-error-content.tsx | 2 +- frontend/lib/task-error-display.ts | 4 +--- frontend/tailwind.config.ts | 10 +++++----- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index c1683f8ff..3cabaf359 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -89,7 +89,7 @@ --failure-message: hsl(var(--foreground)); --failure-scroll: #ff6464; --failure-muted: #a66262; - --failure-component-cause: #525252; /* IBM Gray 70 — OSS component label beside flag */ + --failure-component-cause: 0 0% 32%; /* #525252 */ } .dark { @@ -158,7 +158,7 @@ --failure-message: #ffc9c9cc; --failure-scroll: #ff6464; --failure-muted: #a66262; - --failure-component-cause: #525252; + --failure-component-cause: 0 0% 32%; /* #525252 */ } /* IBM Light */ @@ -242,11 +242,10 @@ --badge: 0 0% 22.4%; /* #393939 */ --badge-foreground: 0 0% 96%; /* #F4F4F4 */ - /* IBM task panel status pills */ - --task-status-failed-bg: #a2191f; /* IBM Red 70 */ - --task-status-failed-fg: #ffd7d9; /* IBM Red 20 */ - --task-status-complete-bg: #0e6027; /* IBM Green 70 */ - --task-status-complete-fg: #a7f0ba; /* IBM Green 20 */ + --task-status-failed-bg: 357 73% 37%; /* #a2191f */ + --task-status-failed-fg: 357 100% 93%; /* #ffd7d9 */ + --task-status-complete-bg: 137 73% 22%; /* #0e6027 */ + --task-status-complete-fg: 135 59% 80%; /* #a7f0ba */ } /* IBM: match OSS switch colors — black track when checked, white thumb always */ @@ -339,10 +338,10 @@ --badge: 0 0% 32%; /* #525252 */ --badge-foreground: 0 0% 96%; /* #F4F4F4 */ - --task-status-failed-bg: #a2191f; - --task-status-failed-fg: #ffd7d9; - --task-status-complete-bg: #0e6027; - --task-status-complete-fg: #a7f0ba; + --task-status-failed-bg: 357 73% 37%; /* #a2191f */ + --task-status-failed-fg: 357 100% 93%; /* #ffd7d9 */ + --task-status-complete-bg: 137 73% 22%; /* #0e6027 */ + --task-status-complete-fg: 135 59% 80%; /* #a7f0ba */ } /* IBM Settings: section titles (productive heading-04); beats CardTitle utilities */ diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index d6e65189e..a3ab416bb 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -162,7 +162,7 @@ export function TaskErrorContent({ className={cn( "task-failed-file-card min-w-0", isCloudBrand - ? "flex h-[90px] flex-col items-start gap-2 self-stretch rounded-none rounded-r border-l-[1.5px] border-l-destructive bg-border p-2" + ? "flex flex-col items-start gap-2 self-stretch rounded-none rounded-r border-l-[1.5px] border-l-destructive bg-border p-2" : "flex flex-col gap-1 rounded border-destructive/20 bg-failure-soft py-mmd px-4", )} > diff --git a/frontend/lib/task-error-display.ts b/frontend/lib/task-error-display.ts index 7f3ce7890..782b40502 100644 --- a/frontend/lib/task-error-display.ts +++ b/frontend/lib/task-error-display.ts @@ -1,6 +1,4 @@ -//formatting error displayed -//waiting for backend step pf failure, cause etc -//in meantime make sure we display only one line and search for opensearch, docling or langflow +// Will update when backend is ready (error_summary, failing_step, component_cause). export const FILE_ERROR_MAX_LINE_LENGTH = 80; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 575f24edd..2bcd546a5 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -192,16 +192,16 @@ const config = { message: "var(--failure-message)", scroll: "var(--failure-scroll)", muted: "var(--failure-muted)", - "component-cause": "var(--failure-component-cause)", + "component-cause": "hsl(var(--failure-component-cause))", }, "task-status": { failed: { - DEFAULT: "var(--task-status-failed-bg)", - foreground: "var(--task-status-failed-fg)", + DEFAULT: "hsl(var(--task-status-failed-bg))", + foreground: "hsl(var(--task-status-failed-fg))", }, complete: { - DEFAULT: "var(--task-status-complete-bg)", - foreground: "var(--task-status-complete-fg)", + DEFAULT: "hsl(var(--task-status-complete-bg))", + foreground: "hsl(var(--task-status-complete-fg))", }, }, }, From 5984d900a5716ab6fc302a4293688af603c27e8e Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Thu, 21 May 2026 09:36:22 -0400 Subject: [PATCH 11/25] adjust succeed and partially succeed case --- frontend/app/globals.css | 4 + frontend/components/task-error-content.tsx | 33 +++-- .../components/task-notification-menu.tsx | 137 ++++++++++++------ frontend/tailwind.config.ts | 4 + 4 files changed, 121 insertions(+), 57 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 3cabaf359..fad853f2f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -244,6 +244,8 @@ --task-status-failed-bg: 357 73% 37%; /* #a2191f */ --task-status-failed-fg: 357 100% 93%; /* #ffd7d9 */ + --task-status-partial-bg: 26 90% 37%; /* #8a3800 */ + --task-status-partial-fg: 46 100% 90%; /* #fff1c8 */ --task-status-complete-bg: 137 73% 22%; /* #0e6027 */ --task-status-complete-fg: 135 59% 80%; /* #a7f0ba */ } @@ -340,6 +342,8 @@ --task-status-failed-bg: 357 73% 37%; /* #a2191f */ --task-status-failed-fg: 357 100% 93%; /* #ffd7d9 */ + --task-status-partial-bg: 22 78% 32%; /* warm orange */ + --task-status-partial-fg: 46 97% 65%; /* #f1c21b */ --task-status-complete-bg: 137 73% 22%; /* #0e6027 */ --task-status-complete-fg: 135 59% 80%; /* #a7f0ba */ } diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index a3ab416bb..bc61c93f4 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -1,7 +1,7 @@ "use client"; import { ErrorFilled, FlagFilled, IncidentReporter } from "@carbon/icons-react"; -import { ChevronDown } from "lucide-react"; +import { AlertCircle, ChevronDown } from "lucide-react"; import { useMemo, useState } from "react"; import { Accordion, @@ -27,6 +27,7 @@ interface TaskErrorContentProps { mode?: "recent" | "past"; nowMs?: number; showHeader?: boolean; + defaultExpanded?: boolean; } export function TaskErrorContent({ @@ -34,9 +35,12 @@ export function TaskErrorContent({ mode = "recent", nowMs = Date.now(), showHeader = true, + defaultExpanded = false, }: TaskErrorContentProps) { const isCloudBrand = useIsCloudBrand(); - const [accordionValue, setAccordionValue] = useState(""); + const [accordionValue, setAccordionValue] = useState( + defaultExpanded ? "failed-files" : "", + ); const isExpanded = accordionValue === "failed-files"; const failedEntries = useMemo(() => getFailedFileEntries(task), [task]); @@ -50,11 +54,13 @@ export function TaskErrorContent({ const statusLabel = isFailedStatus ? "Failed" : "Complete"; const statusPillClassName = cn( "shrink-0 rounded-full px-2 py-1 text-xs", - isCloudBrand - ? isFailedStatus + isFailedStatus + ? isCloudBrand ? "border-0 bg-task-status-failed text-task-status-failed-foreground" - : "border-0 bg-task-status-complete text-task-status-complete-foreground" - : "border border-failure-pill bg-failure-soft text-destructive", + : "border border-failure-pill bg-failure-soft text-destructive" + : isCloudBrand + ? "border-0 bg-task-status-partial text-task-status-partial-foreground" + : "border border-brand-amber-30 bg-brand-amber-10 text-brand-amber", ); if (failedCount <= 0 && failedEntries.length === 0) { @@ -105,9 +111,18 @@ export function TaskErrorContent({
- {ossIconColumn && ( - - )} + {ossIconColumn && + (isFailedStatus ? ( + + ) : ( + + ))}

diff --git a/frontend/components/task-notification-menu.tsx b/frontend/components/task-notification-menu.tsx index 792b0d102..1ea904799 100644 --- a/frontend/components/task-notification-menu.tsx +++ b/frontend/components/task-notification-menu.tsx @@ -132,6 +132,13 @@ export function TaskNotificationMenu() { } }; + const pastTaskRowClass = cn( + "w-full py-mmd px-4 transition-colors hover:bg-muted/60", + isCloudBrand ? "border-t border-muted" : "rounded-mmd border border-muted", + ); + + const statusBadgeBase = "shrink-0 rounded-full px-2 py-1 text-xs font-normal"; + const getStatusBadge = ( status: Task["status"], hasFailedFiles = false, @@ -143,7 +150,12 @@ export function TaskNotificationMenu() { return ( FAILED @@ -153,18 +165,28 @@ export function TaskNotificationMenu() { return ( - COMPLETED + Complete ); } return ( - COMPLETED + Complete ); case "failed": @@ -172,7 +194,12 @@ export function TaskNotificationMenu() { return ( FAILED @@ -181,7 +208,10 @@ export function TaskNotificationMenu() { return ( Pending @@ -191,7 +221,10 @@ export function TaskNotificationMenu() { return ( Processing @@ -200,7 +233,10 @@ export function TaskNotificationMenu() { return ( Unknown @@ -309,10 +345,15 @@ export function TaskNotificationMenu() { {activeTasks.map((task) => { const progress = formatTaskProgress(task); + const hasFailedFiles = hasFailedFileEntries(task); const showCancel = task.status === "pending" || task.status === "running" || task.status === "processing"; + const showTaskIcon = + !isCloudBrand || + task.status !== "completed" || + hasFailedFiles; return (

- {getTaskIcon(task.status)} + {showTaskIcon && + getTaskIcon( + task.status, + hasFailedFiles, + isCompletedTotalFailure(task), + )} Task {task.task_id.substring(0, 8)}...
@@ -388,12 +434,18 @@ export function TaskNotificationMenu() {
)} - {hasFailedFileEntries(task) && ( + {hasFailedFiles && (
)} @@ -424,26 +476,32 @@ export function TaskNotificationMenu() { const progress = formatTaskProgress(task); const hasFailedFiles = hasFailedFileEntries(task); const isTotalFailure = isCompletedTotalFailure(task); - if (isTerminalFailedTask(task) || isTotalFailure) { + const shouldExpandDetails = selectedTaskId === task.task_id; + + // Same full card as total failure; partial only differs inside (Complete pill / amber icon). + if ( + isTerminalFailedTask(task) || + isTotalFailure || + hasFailedFiles + ) { return ( ); } return ( -
+
- {getTaskIcon(task.status, hasFailedFiles, isTotalFailure)} + {!isCloudBrand && getTaskIcon(task.status)}
Task {task.task_id.substring(0, 8)}... @@ -456,37 +514,20 @@ export function TaskNotificationMenu() { )}
- {task.status === "completed" && - progress?.detailed && - !hasFailedFiles && ( -
- {progress.detailed.successful} success,{" "} - {progress.detailed.failed} failed - {(progress.detailed.running || 0) > 0 && ( - - , {progress.detailed.running} running - - )} -
- )} + {task.status === "completed" && progress?.detailed && ( +
+ {progress.detailed.successful} success,{" "} + {progress.detailed.failed} failed + {(progress.detailed.running || 0) > 0 && ( + , {progress.detailed.running} running + )} +
+ )}
- {getStatusBadge( - task.status, - hasFailedFiles, - isTotalFailure, - )} + {getStatusBadge(task.status)}
- {hasFailedFiles && ( -
- -
- )}
); }} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 2bcd546a5..cbed098bc 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -199,6 +199,10 @@ const config = { DEFAULT: "hsl(var(--task-status-failed-bg))", foreground: "hsl(var(--task-status-failed-fg))", }, + partial: { + DEFAULT: "hsl(var(--task-status-partial-bg))", + foreground: "hsl(var(--task-status-partial-fg))", + }, complete: { DEFAULT: "hsl(var(--task-status-complete-bg))", foreground: "hsl(var(--task-status-complete-fg))", From a30991d3b3acc915e008fbc2a0569d1691bccbae Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Thu, 21 May 2026 09:58:36 -0400 Subject: [PATCH 12/25] adding comment for TODO implementation or more clarity --- frontend/components/task-error-content.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index bc61c93f4..c59de1add 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -52,6 +52,7 @@ export function TaskErrorContent({ const isFailedStatus = isTerminalFailedTask(task) || isCompletedTotalFailure(task); const statusLabel = isFailedStatus ? "Failed" : "Complete"; + // Pill colors: failed (red) vs partial success (amber/orange), each with IBM tokens or OSS borders. const statusPillClassName = cn( "shrink-0 rounded-full px-2 py-1 text-xs", isFailedStatus @@ -84,6 +85,7 @@ export function TaskErrorContent({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); + // TODO: open report-incident dialog }} onPointerDown={(event) => event.stopPropagation()} > From 357ca70f7388b5976d1ac418f18b2d27f00f8813 Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Tue, 26 May 2026 15:11:00 -0400 Subject: [PATCH 13/25] Introduce a modular TaskDialog for reviewing task files from the error panel, with brand-specific header, filters, category chips, and tabs. Wire list/detail helpers and ingestion failure display; fix nested button hydration in the task error accordion. --- frontend/app/api/queries/useGetTaskQuery.ts | 27 ++ frontend/app/api/queries/useGetTasksQuery.ts | 10 + frontend/app/globals.css | 6 + .../components/task-dialog/category-chips.tsx | 65 ++++ frontend/components/task-dialog/constants.ts | 40 +++ .../task-dialog/file-error-details.tsx | 125 ++++++++ frontend/components/task-dialog/file-list.tsx | 289 +++++++++++++++++ frontend/components/task-dialog/filters.tsx | 156 ++++++++++ frontend/components/task-dialog/header.tsx | 97 ++++++ frontend/components/task-dialog/index.ts | 1 + .../components/task-dialog/task-dialog.tsx | 121 ++++++++ .../components/task-dialog/use-task-dialog.ts | 90 ++++++ frontend/components/task-error-content.tsx | 70 +++-- frontend/components/tasks_details.tsx | 16 +- frontend/lib/task-error-display.ts | 291 +++++++++++++++++- frontend/lib/task-utils.ts | 209 +++++++++++++ frontend/package-lock.json | 35 --- frontend/package.json | 1 - frontend/tailwind.config.ts | 3 + 19 files changed, 1559 insertions(+), 93 deletions(-) create mode 100644 frontend/app/api/queries/useGetTaskQuery.ts create mode 100644 frontend/components/task-dialog/category-chips.tsx create mode 100644 frontend/components/task-dialog/constants.ts create mode 100644 frontend/components/task-dialog/file-error-details.tsx create mode 100644 frontend/components/task-dialog/file-list.tsx create mode 100644 frontend/components/task-dialog/filters.tsx create mode 100644 frontend/components/task-dialog/header.tsx create mode 100644 frontend/components/task-dialog/index.ts create mode 100644 frontend/components/task-dialog/task-dialog.tsx create mode 100644 frontend/components/task-dialog/use-task-dialog.ts diff --git a/frontend/app/api/queries/useGetTaskQuery.ts b/frontend/app/api/queries/useGetTaskQuery.ts new file mode 100644 index 000000000..be735c858 --- /dev/null +++ b/frontend/app/api/queries/useGetTaskQuery.ts @@ -0,0 +1,27 @@ +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { Task } from "@/app/api/queries/useGetTasksQuery"; + +export function useGetTaskQuery( + taskId: string | null, + options?: Omit, "queryKey" | "queryFn">, +) { + return useQuery({ + queryKey: ["tasks", taskId], + queryFn: async (): Promise => { + if (!taskId) { + return null; + } + //will be replace /api/tasks/{taskId}/enhanced, when backend merged + const response = await fetch(`/api/tasks/${taskId}`); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error("Failed to fetch task"); + } + return response.json() as Promise; + }, + enabled: !!taskId, + ...options, + }); +} diff --git a/frontend/app/api/queries/useGetTasksQuery.ts b/frontend/app/api/queries/useGetTasksQuery.ts index bcdfa9d14..7a5d57004 100644 --- a/frontend/app/api/queries/useGetTasksQuery.ts +++ b/frontend/app/api/queries/useGetTasksQuery.ts @@ -21,6 +21,16 @@ export interface TaskFileEntry { filename?: string; embedding_model?: string; embedding_dimensions?: number; + /** Ingestion pipeline phase from the task service (`docling` | `langflow` | `complete`). */ + phase?: "docling" | "langflow" | "complete" | string; + /** + * Phase or step where ingestion failed (from API). + * Ingestion phase: `docling` | `langflow` | `complete`. + * Pipeline step: `parsing` | `chunking` | `embedding` | `indexing`. + */ + failure_phase?: string; + docling_status?: string; + docling_task_id?: string; [key: string]: unknown; } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index fad853f2f..77a5a5198 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -31,6 +31,8 @@ --placeholder-foreground: 240 5% 65%; /* App-level UI tokens (safe defaults, override per brand/theme) */ + --canvas: 0 0% 100%; + --border-subtle-background-contextual: var(--border); --layered-select-bg: hsl(var(--muted) / 0.5); --chat-surface-gradient: rgba(69, 137, 255, 0.16); --chat-input-border: hsl(var(--input)); @@ -115,6 +117,8 @@ --placeholder-foreground: 240 4% 46%; /* App-level UI tokens (safe defaults, override per brand/theme) */ + --canvas: 0 0% 3.9%; /* #0A0A0A */ + --border-subtle-background-contextual: var(--border); --layered-select-bg: hsl(var(--muted) / 0.5); --chat-surface-gradient: rgba(69, 137, 255, 0.16); --chat-input-border: hsl(var(--input)); @@ -230,6 +234,7 @@ --layered-select-bg: rgba(131, 131, 131, 0.24); --chat-input-border: #f4f4f4; --icon-disabled: #525252; + --border-subtle-background-contextual: 0 0% 82%; /* Contextual layer (~Gray 100); use hsl(var(--layer-contextual)) */ /* Layer / contextual surfaces (IBM Gray 10 light, Gray 100 dark) */ --layer-contextual: 0 0% 96%; @@ -330,6 +335,7 @@ --icon-primary: 0 0% 96%; /* #f4f4f4*/ --placeholder: 0 0% 44%; /* #6F6F6F */ + --border-subtle-background-contextual: 0 0% 22.4%; /* #393939 */ --layer-contextual: 0 0% 15%; --layer-contextual-foreground: 0 0% 96%; --text-text-01: 0 0% 96%; diff --git a/frontend/components/task-dialog/category-chips.tsx b/frontend/components/task-dialog/category-chips.tsx new file mode 100644 index 000000000..3ed341cb8 --- /dev/null +++ b/frontend/components/task-dialog/category-chips.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { + ALL_TASK_STATUS_CATEGORIES, + type TaskFileStatusCategory, +} from "@/lib/task-utils"; +import { cn } from "@/lib/utils"; +import { CATEGORY_CHIPS } from "./constants"; + +interface TaskDialogCategoryChipsProps { + isCloudBrand: boolean; + counts: Record | null; + statusCategory: string; + onStatusCategoryChange: (value: string) => void; +} + +export function TaskDialogCategoryChips({ + isCloudBrand, + counts, + statusCategory, + onStatusCategoryChange, +}: TaskDialogCategoryChipsProps) { + if (!counts) return null; + + return ( +
+ {CATEGORY_CHIPS.map((chip) => { + const Icon = chip.icon; + const isActive = statusCategory === chip.id; + const count = counts[chip.id]; + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/components/task-dialog/constants.ts b/frontend/components/task-dialog/constants.ts new file mode 100644 index 000000000..1c0351dbd --- /dev/null +++ b/frontend/components/task-dialog/constants.ts @@ -0,0 +1,40 @@ +import { + AlertCircle, + CheckCircle, + Clock, + Focus, + type LucideIcon, +} from "lucide-react"; +import type { TaskFileStatusCategory } from "@/lib/task-utils"; + +export const CATEGORY_CHIPS: Array<{ + id: TaskFileStatusCategory; + label: string; + icon: LucideIcon; + iconClassName: string; +}> = [ + { + id: "completed", + label: "Completed", + icon: CheckCircle, + iconClassName: "text-emerald-500", + }, + { + id: "system_error", + label: "System error", + icon: AlertCircle, + iconClassName: "text-destructive", + }, + { + id: "indexing", + label: "Indexing", + icon: Clock, + iconClassName: "text-muted-foreground", + }, + { + id: "partial", + label: "Partial", + icon: Focus, + iconClassName: "text-muted-foreground", + }, +]; diff --git a/frontend/components/task-dialog/file-error-details.tsx b/frontend/components/task-dialog/file-error-details.tsx new file mode 100644 index 000000000..7a13b6dd0 --- /dev/null +++ b/frontend/components/task-dialog/file-error-details.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { Ban, Check, Flag } from "lucide-react"; +import type { TaskFileEntry } from "@/app/api/queries/useGetTasksQuery"; +import { + analyzeTaskFileIngestionFailure, + type TaskFileIngestionFailureAnalysis, +} from "@/lib/task-error-display"; +import { cn } from "@/lib/utils"; + +interface TaskDialogFileErrorDetailsProps { + isCloudBrand: boolean; + indentClassName?: string; + fileInfo: TaskFileEntry; + taskError?: string; + analysis?: TaskFileIngestionFailureAnalysis; +} + +export function TaskDialogFileErrorDetails({ + isCloudBrand, + indentClassName, + fileInfo, + taskError, + analysis: analysisProp, +}: TaskDialogFileErrorDetailsProps) { + const analysis = + analysisProp ?? analyzeTaskFileIngestionFailure(fileInfo, taskError); + + return ( +
+
+ {analysis.pipelineSteps.map((step, index) => { + const isFailed = step.status === "failed"; + const isLast = index === analysis.pipelineSteps.length - 1; + + return ( +
+
+ {step.status === "completed" ? ( + + ) : ( + + )} + {!isLast && ( + + )} +
+ +
+

+ {step.label} +

+ {isFailed && ( +
+

+ {analysis.resolvedError} +

+

+ {analysis.failureSummary} +

+ {analysis.componentTags.length > 0 && ( +
+ + {analysis.componentTags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/components/task-dialog/file-list.tsx b/frontend/components/task-dialog/file-list.tsx new file mode 100644 index 000000000..2a4afc80b --- /dev/null +++ b/frontend/components/task-dialog/file-list.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { ArrowUpAZ, ChevronDown, FileText } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import type { TaskFileEntry } from "@/app/api/queries/useGetTasksQuery"; +import type { Task } from "@/contexts/task-context"; +import { analyzeTaskFileIngestionFailure } from "@/lib/task-error-display"; +import { + getTaskFileDialogStatusLabel, + getTaskFileName, + isTaskFileFailed, + type TaskFileNameSort, +} from "@/lib/task-utils"; +import { cn } from "@/lib/utils"; +import { TaskDialogFileErrorDetails } from "./file-error-details"; + +const OSS_ERROR_INDENT = "pl-9"; + +type TaskDialogFileListTab = "task-ingestions" | "retry-ingestions"; + +interface TaskDialogFileListProps { + isCloudBrand: boolean; + task: Task; + entries: Array<[string, TaskFileEntry]>; + totalSourceCount: number; + totalSourceCountAll?: number; + nameSort: TaskFileNameSort; + onToggleNameSort: () => void; + expandedPath: string | null; + onExpandedPathChange: (path: string | null) => void; + /** Retry-ingestion row count; tab is shown only when greater than zero. */ + retryIngestionCount?: number; +} + +export function TaskDialogFileList({ + isCloudBrand, + task, + entries, + totalSourceCount, + totalSourceCountAll, + nameSort, + onToggleNameSort, + expandedPath, + onExpandedPathChange, + retryIngestionCount = 0, +}: TaskDialogFileListProps) { + const [activeTab, setActiveTab] = + useState("task-ingestions"); + + const showRetryIngestionsTab = retryIngestionCount > 0; + + useEffect(() => { + if (!showRetryIngestionsTab && activeTab === "retry-ingestions") { + setActiveTab("task-ingestions"); + } + }, [showRetryIngestionsTab, activeTab]); + + const analysisByPath = useMemo(() => { + const map = new Map< + string, + ReturnType + >(); + for (const [filePath, fileInfo] of entries) { + if (isTaskFileFailed(fileInfo)) { + map.set( + filePath, + analyzeTaskFileIngestionFailure(fileInfo, task.error), + ); + } + } + return map; + }, [entries, task.error]); + + if (entries.length === 0) { + return ( +

+ No files match your filters. +

+ ); + } + + const containerClass = cn( + "flex min-h-0 flex-1 flex-col overflow-hidden", + isCloudBrand ? "rounded-md border" : "border-b border-muted", + ); + + const taskIngestionsTabCount = + totalSourceCountAll != null && totalSourceCountAll > totalSourceCount + ? `${totalSourceCount} of ${totalSourceCountAll}` + : String(totalSourceCount); + + const isTabActive = (tab: TaskDialogFileListTab) => activeTab === tab; + + const tabTriggerClass = (tab: TaskDialogFileListTab) => { + const isActive = isTabActive(tab); + return cn( + "inline-flex w-fit max-w-fit min-h-10 shrink-0 items-center px-4 text-sm font-medium transition-colors", + isCloudBrand + ? cn( + "rounded-none border-0 border-b-2", + isActive + ? "border-[var(--border-border-interactive)] bg-muted text-foreground" + : "border-transparent bg-transparent text-muted-foreground hover:border-[var(--border-border-interactive)]", + ) + : cn( + "border-0", + isActive + ? "rounded-none rounded-t-lg bg-muted text-foreground" + : "rounded-none bg-transparent text-muted-foreground hover:text-foreground", + ), + ); + }; + + const listScrollClass = "min-h-0 flex-1 overflow-y-auto overscroll-contain"; + + const fileRows = entries.map(([filePath, fileInfo]) => { + const fileName = getTaskFileName(filePath, fileInfo); + const failed = isTaskFileFailed(fileInfo); + const analysis = analysisByPath.get(filePath); + const rowStatusLabel = failed + ? (analysis?.rowStatusLabel ?? "Failed") + : getTaskFileDialogStatusLabel(fileInfo, task.error); + const isExpanded = expandedPath === filePath; + + return ( +
+
+ {failed ? ( + + ) : ( + + )} + + + {rowStatusLabel} + +
+ + {failed && isExpanded && analysis && ( + + )} +
+ ); + }); + + return ( +
+
+ + {showRetryIngestionsTab && ( + + )} +
+ + {isTabActive("task-ingestions") ? ( +
+
+ +
+
+ {fileRows} +
+
+ ) : ( +
+ )} +
+ ); +} diff --git a/frontend/components/task-dialog/filters.tsx b/frontend/components/task-dialog/filters.tsx new file mode 100644 index 000000000..9ed34c512 --- /dev/null +++ b/frontend/components/task-dialog/filters.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { ChevronDown, Search } from "lucide-react"; +import type { ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { ALL_TASK_FILE_TYPES, formatTaskFileTypeLabel } from "@/lib/task-utils"; +import { cn } from "@/lib/utils"; + +interface TaskDialogFiltersProps { + isCloudBrand: boolean; + search: string; + onSearchChange: (value: string) => void; + fileType: string; + onFileTypeChange: (value: string) => void; + fileTypes: string[]; + fileTypeLabel: string; + searchDisabled: boolean; + fileTypeDisabled: boolean; +} + +function fileTypeItemClassName(selected: boolean) { + return cn( + "px-2", + selected && + "bg-muted text-foreground focus:bg-muted data-[highlighted]:bg-muted", + ); +} + +function FileTypeMenu({ + fileType, + onFileTypeChange, + fileTypes, + allTypesLabel, + trigger, +}: { + fileType: string; + onFileTypeChange: (value: string) => void; + fileTypes: string[]; + allTypesLabel: string; + trigger: ReactNode; +}) { + const options = [ + { value: ALL_TASK_FILE_TYPES, label: allTypesLabel }, + ...fileTypes.map((type) => ({ + value: type, + label: formatTaskFileTypeLabel(type), + })), + ]; + + return ( + + {trigger} + + {options.map(({ value, label }) => ( + onFileTypeChange(value)} + className={fileTypeItemClassName(fileType === value)} + > + {label} + + ))} + + + ); +} + +export function TaskDialogFilters({ + isCloudBrand, + search, + onSearchChange, + fileType, + onFileTypeChange, + fileTypes, + fileTypeLabel, + searchDisabled, + fileTypeDisabled, +}: TaskDialogFiltersProps) { + const allTypesLabel = isCloudBrand ? "All categories" : "All file types"; + + if (isCloudBrand) { + return ( +
+
+ onSearchChange(e.target.value)} + disabled={searchDisabled} + icon={} + inputClassName="h-10 min-w-0 !rounded-none !border-0 bg-layer-contextual text-layer-contextual-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ + {fileTypeLabel} + + + } + /> +
+ ); + } + + return ( +
+
+ onSearchChange(e.target.value)} + disabled={searchDisabled} + icon={} + inputClassName="h-10 rounded-md !bg-canvas" + /> +
+ + {fileTypeLabel} + + + } + /> +
+ ); +} diff --git a/frontend/components/task-dialog/header.tsx b/frontend/components/task-dialog/header.tsx new file mode 100644 index 000000000..2a42284b9 --- /dev/null +++ b/frontend/components/task-dialog/header.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + ALL_TASK_FILE_TYPES, + formatTaskFileTypeLabel, + type TaskFileStatusCategory, +} from "@/lib/task-utils"; +import { cn } from "@/lib/utils"; +import { TaskDialogCategoryChips } from "./category-chips"; +import { TaskDialogFilters } from "./filters"; + +interface TaskDialogHeaderProps { + isCloudBrand: boolean; + taskId: string; + search: string; + onSearchChange: (value: string) => void; + fileType: string; + onFileTypeChange: (value: string) => void; + fileTypes: string[]; + statusCategory: string; + onStatusCategoryChange: (value: string) => void; + categoryCounts: Record | null; + filtersDisabled: boolean; + fileTypeDisabled: boolean; +} + +export function TaskDialogHeader({ + isCloudBrand, + taskId, + search, + onSearchChange, + fileType, + onFileTypeChange, + fileTypes, + statusCategory, + onStatusCategoryChange, + categoryCounts, + filtersDisabled, + fileTypeDisabled, +}: TaskDialogHeaderProps) { + const allTypesLabel = isCloudBrand ? "All categories" : "All file types"; + const fileTypeLabel = + fileType === ALL_TASK_FILE_TYPES + ? allTypesLabel + : formatTaskFileTypeLabel(fileType); + + return ( +
+ + + Task {taskId} + + + +
+ + +
+
+ ); +} diff --git a/frontend/components/task-dialog/index.ts b/frontend/components/task-dialog/index.ts new file mode 100644 index 000000000..5d29db243 --- /dev/null +++ b/frontend/components/task-dialog/index.ts @@ -0,0 +1 @@ +export { default } from "./task-dialog"; diff --git a/frontend/components/task-dialog/task-dialog.tsx b/frontend/components/task-dialog/task-dialog.tsx new file mode 100644 index 000000000..e4da75aea --- /dev/null +++ b/frontend/components/task-dialog/task-dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog"; +import { useIsCloudBrand } from "@/contexts/brand-context"; +import { cn } from "@/lib/utils"; +import { TaskDialogFileList } from "./file-list"; +import { TaskDialogHeader } from "./header"; +import { useTaskDialog } from "./use-task-dialog"; + +interface TaskDialogProps { + open: boolean; + task_id: string; + onOpenChange: (open: boolean) => void; + onClose: () => void; +} + +function TaskDialogContent({ + open, + task_id, + onClose, +}: Pick) { + const isCloudBrand = useIsCloudBrand(); + const { + task, + fileEntries, + fileTypes, + categoryCounts, + sortedEntries, + search, + setSearch, + fileType, + setFileType, + statusCategory, + setStatusCategory, + expandedPath, + setExpandedPath, + nameSort, + toggleNameSort, + } = useTaskDialog(open, task_id); + + const filtersDisabled = !task; + const fileTypeDisabled = !task || fileTypes.length === 0; + + return ( +
+ + +
+ {!task ? ( +

Task not found.

+ ) : fileEntries.length === 0 ? ( +

+ No files in this task. +

+ ) : ( + + )} +
+ + + + +
+ ); +} + +export default function TaskDialog({ + open, + onOpenChange, + task_id, + onClose, +}: TaskDialogProps) { + return ( + + + + + + ); +} diff --git a/frontend/components/task-dialog/use-task-dialog.ts b/frontend/components/task-dialog/use-task-dialog.ts new file mode 100644 index 000000000..97d169d92 --- /dev/null +++ b/frontend/components/task-dialog/use-task-dialog.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useGetTaskQuery } from "@/app/api/queries/useGetTaskQuery"; +import type { Task } from "@/app/api/queries/useGetTasksQuery"; +import { useTask } from "@/contexts/task-context"; +import { + ALL_TASK_FILE_TYPES, + ALL_TASK_STATUS_CATEGORIES, + countTaskFilesByCategory, + filterTaskFileEntries, + getTaskFileEntries, + getTaskFileTypes, + sortTaskFileEntries, + type TaskFileNameSort, + TaskFileStatusCategory, +} from "@/lib/task-utils"; + +export function useTaskDialog(open: boolean, taskId: string) { + const { tasks } = useTask(); + const { data: taskDetail } = useGetTaskQuery(taskId, { enabled: open }); + + const [search, setSearch] = useState(""); + const [fileType, setFileType] = useState(ALL_TASK_FILE_TYPES); + const [statusCategory, setStatusCategory] = useState( + ALL_TASK_STATUS_CATEGORIES, + ); + const [expandedPath, setExpandedPath] = useState(null); + const [nameSort, setNameSort] = useState("asc"); + + const task = useMemo( + () => taskDetail ?? tasks.find((entry) => entry.task_id === taskId), + [taskDetail, tasks, taskId], + ); + + const fileEntries = useMemo( + () => (task ? getTaskFileEntries(task) : []), + [task], + ); + + const fileTypes = useMemo(() => (task ? getTaskFileTypes(task) : []), [task]); + + const categoryCounts = useMemo( + () => (task ? countTaskFilesByCategory(task) : null), + [task], + ); + + const activeFileType = + fileType === ALL_TASK_FILE_TYPES || fileTypes.includes(fileType) + ? fileType + : ALL_TASK_FILE_TYPES; + + const filteredEntries = useMemo( + () => + filterTaskFileEntries(fileEntries, { + search, + fileType: activeFileType, + statusCategory: statusCategory as TaskFileStatusCategory, + task, + }), + [fileEntries, search, activeFileType, statusCategory, task], + ); + + const sortedEntries = useMemo( + () => sortTaskFileEntries(filteredEntries, nameSort), + [filteredEntries, nameSort], + ); + + const toggleNameSort = () => { + setNameSort((current) => (current === "asc" ? "desc" : "asc")); + }; + + return { + task, + fileEntries, + fileTypes, + categoryCounts, + sortedEntries, + search, + setSearch, + fileType: activeFileType, + setFileType, + statusCategory, + setStatusCategory, + expandedPath, + setExpandedPath, + nameSort, + toggleNameSort, + }; +} diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index 4142149e3..c50869f35 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -1,5 +1,6 @@ "use client"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { AlertCircle, ChevronDown, Flag, XCircle } from "lucide-react"; import { useMemo, useState } from "react"; import { IncidentReporterIcon } from "@/components/icons/incident-reporter-icon"; @@ -8,7 +9,6 @@ import { Accordion, AccordionContent, AccordionItem, - AccordionTrigger, } from "@/components/ui/accordion"; import { useIsCloudBrand } from "@/contexts/brand-context"; import { type Task } from "@/contexts/task-context"; @@ -72,30 +72,45 @@ export function TaskErrorContent({ const ossIconColumn = showHeader && !isCloudBrand; - const accordionTrigger = ( -
-
- - {successCount} success · {failedCount} failed - - -
- + const accordionSummary = ( +
+ + {successCount} success · {failedCount} failed + +
); + const openTaskDialogButton = ( + + ); + + const accordionHeader = ( + + {ossIconColumn ?
: null} + + {accordionSummary} + + {openTaskDialogButton} + + ); + return (
- - {ossIconColumn ? ( -
-
-
{accordionTrigger}
-
- ) : ( - accordionTrigger - )} - + {accordionHeader}
{failedEntries.map(([filePath, fileInfo], index) => { diff --git a/frontend/components/tasks_details.tsx b/frontend/components/tasks_details.tsx index f6f923453..8a9d2f3ae 100644 --- a/frontend/components/tasks_details.tsx +++ b/frontend/components/tasks_details.tsx @@ -2,18 +2,15 @@ import { useEffect, useMemo, useState } from "react"; import { TaskCollapsibleSection } from "@/components/task-collapsible-section"; import { TaskErrorContent } from "@/components/task-error-content"; import { TaskPanelHeader } from "@/components/task-panel-header"; -import { useIsCloudBrand } from "@/contexts/brand-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { type Task } from "@/contexts/task-context"; import { parseTimestampMs } from "@/lib/time-utils"; -import { cn } from "@/lib/utils"; interface FailedTasksInfoProps { failedTasks: Task[]; } export const FailedTasksInfo = ({ failedTasks }: FailedTasksInfoProps) => { - const isCloudBrand = useIsCloudBrand(); const [openSections, setOpenSections] = useState< Record<"recent" | "past", boolean> >({ @@ -79,12 +76,7 @@ export const FailedTasksInfo = ({ failedTasks }: FailedTasksInfoProps) => { ); return ( -
+
{failedTasks.length === 0 ? ( @@ -106,12 +98,6 @@ export const FailedTasksInfo = ({ failedTasks }: FailedTasksInfoProps) => { })) } emptyText={section.emptyText} - contentClassName={cn( - "flex flex-col", - isCloudBrand - ? "p-0 [&>*:last-child]:border-b [&>*:last-child]:border-muted" - : "gap-2 p-4", - )} renderItem={(task) => ( = { + parsing: "Parsing", + chunking: "Chunking", + embedding: "Embedding", + indexing: "Indexing", +}; + const COMPONENT_CAUSES: ReadonlyArray<{ - keyword: string; + keyword: RegExp; label: TaskErrorComponentCause; }> = [ - { keyword: "opensearch", label: "OpenSearch" }, - { keyword: "docling", label: "Docling" }, - { keyword: "langflow", label: "Langflow" }, + { keyword: /opensearch/i, label: "OpenSearch" }, + { keyword: /docling/i, label: "Docling" }, + { keyword: /langflow/i, label: "Langflow" }, +]; + +const STEP_ERROR_SIGNALS: Record< + IngestionPipelineStepId, + ReadonlyArray +> = { + parsing: [/docling/i, /\bpars(e|ing)\b/i, /convert/i, /ocr/i], + chunking: [/chunk/i, /\bsplit/i, /segment/i], + embedding: [/embed/i, /\bvector/i, /dimension/i], + indexing: [/opensearch/i, /\bindex/i, /mapping/i, /schema/i], +}; + +const ISSUE_TYPE_SIGNALS: ReadonlyArray<{ pattern: RegExp; label: string }> = [ + { + pattern: /schema|mapping|does not match.*index/i, + label: "pipeline configuration issue", + }, + { pattern: /timeout|timed?\s*out/i, label: "timeout" }, + { + pattern: /unauthorized|forbidden|403|401|permission/i, + label: "access issue", + }, + { pattern: /connection|unreachable|network/i, label: "connectivity issue" }, + { pattern: /embed|model|dimension/i, label: "embedding configuration issue" }, + { pattern: /quota|limit|rate/i, label: "rate limit" }, ]; export interface FileTaskErrorDisplay { @@ -39,7 +104,6 @@ function truncateLine( return `${text.slice(0, maxLength - 1).trimEnd()}…`; } -/** Prefer a short clause from long, nested error strings. */ function extractReadableLine(text: string): string { const beforeCausedBy = text.split(/\s+caused by:/i)[0]?.trim() ?? text; @@ -63,28 +127,235 @@ function extractReadableLine(text: string): string { export function detectComponentCause( raw: string, ): TaskErrorComponentCause | undefined { - const lower = raw.toLowerCase(); for (const { keyword, label } of COMPONENT_CAUSES) { - if (lower.includes(keyword)) { + if (keyword.test(raw)) { return label; } } return undefined; } +function scorePipelineStepsFromError( + error: string, +): Record { + const scores: Record = { + parsing: 0, + chunking: 0, + embedding: 0, + indexing: 0, + }; + for (const step of PIPELINE_STEP_ORDER) { + for (const pattern of STEP_ERROR_SIGNALS[step]) { + if (pattern.test(error)) { + scores[step] += 1; + } + } + } + return scores; +} + +function pickFailedStepFromErrorScores( + scores: Record, +): IngestionPipelineStepId | null { + const maxScore = Math.max(...PIPELINE_STEP_ORDER.map((step) => scores[step])); + if (maxScore === 0) { + return null; + } + const matching = PIPELINE_STEP_ORDER.filter( + (step) => scores[step] === maxScore, + ); + return matching[matching.length - 1] ?? null; +} + +function inferFailedStepFromPhase( + fileInfo: TaskFileEntry, +): IngestionPipelineStepId | null { + const phase = + typeof fileInfo.phase === "string" ? fileInfo.phase.toLowerCase() : ""; + const doclingStatus = + typeof fileInfo.docling_status === "string" + ? fileInfo.docling_status.toLowerCase() + : ""; + + if (phase === "docling" || doclingStatus === "failed") { + return "parsing"; + } + if (phase === "langflow") { + return "embedding"; + } + if (phase === "complete") { + return "indexing"; + } + return null; +} + +export function inferFailedPipelineStep( + fileInfo: TaskFileEntry, + rawError: string, +): IngestionPipelineStepId { + const normalized = normalizeErrorText(rawError); + const fromError = pickFailedStepFromErrorScores( + scorePipelineStepsFromError(normalized), + ); + if (fromError) { + return fromError; + } + return inferFailedStepFromPhase(fileInfo) ?? "embedding"; +} + +function getPhaseMinimumFailedIndex(phase: string): number { + if (phase === "complete") { + return PIPELINE_STEP_ORDER.indexOf("indexing"); + } + if (phase === "langflow") { + return PIPELINE_STEP_ORDER.indexOf("embedding"); + } + return 0; +} + +export function buildIngestionPipelineSteps( + failedStep: IngestionPipelineStepId, + fileInfo: TaskFileEntry, +): IngestionPipelineStep[] { + const phase = + typeof fileInfo.phase === "string" ? fileInfo.phase.toLowerCase() : ""; + + const failedIndex = PIPELINE_STEP_ORDER.indexOf(failedStep); + const lastIndex = Math.max(failedIndex, getPhaseMinimumFailedIndex(phase)); + + return PIPELINE_STEP_ORDER.slice(0, lastIndex + 1).map((id, index) => ({ + id, + label: PIPELINE_STEP_LABELS[id], + status: index < lastIndex ? "completed" : "failed", + })); +} + +function inferIssueType(error: string): string | null { + for (const { pattern, label } of ISSUE_TYPE_SIGNALS) { + if (pattern.test(error)) { + return label; + } + } + return null; +} + +export function buildFailureSummary( + failedStep: IngestionPipelineStepId, + error: string, +): string { + const stepLabel = PIPELINE_STEP_LABELS[failedStep].toLowerCase(); + const issueType = inferIssueType(error); + return issueType + ? `Failed at ${stepLabel} · ${issueType}` + : `Failed at ${stepLabel}`; +} + +export function buildRowStatusLabel( + failedStep: IngestionPipelineStepId, +): string { + return `${PIPELINE_STEP_LABELS[failedStep]} issue`; +} + +export function buildComponentTags( + error: string, + componentCause?: TaskErrorComponentCause, +): string[] { + const tags: string[] = []; + if (componentCause) { + tags.push(componentCause); + } + if (/mapping/i.test(error) && !tags.includes("Mapping")) { + tags.push("Mapping"); + } + if (/schema/i.test(error) && !tags.includes("Schema")) { + tags.push("Schema"); + } + return tags; +} + +export function resolveTaskFileError( + fileInfo: TaskFileEntry, + taskError?: string, +): string { + if (typeof fileInfo.error === "string" && fileInfo.error.trim()) { + return fileInfo.error.trim(); + } + if (typeof taskError === "string" && taskError.trim()) { + return taskError.trim(); + } + return "Unknown error"; +} + +export function analyzeTaskFileIngestionFailure( + fileInfo: TaskFileEntry, + taskError?: string, +): TaskFileIngestionFailureAnalysis { + const resolvedError = resolveTaskFileError(fileInfo, taskError); + const normalized = normalizeErrorText(resolvedError); + const componentCause = detectComponentCause(normalized); + const failedStep = inferFailedPipelineStep(fileInfo, normalized); + const pipelineSteps = buildIngestionPipelineSteps(failedStep, fileInfo); + const summaryLine = truncateLine( + extractReadableLine(stripNoisePrefixes(normalized)), + ); + + return { + resolvedError, + failedStep, + pipelineSteps, + rowStatusLabel: buildRowStatusLabel(failedStep), + failureSummary: buildFailureSummary(failedStep, normalized), + componentCause, + componentTags: buildComponentTags(normalized, componentCause), + summaryLine, + }; +} + +/** @deprecated Use analyzeTaskFileIngestionFailure */ +export function getIngestionPipelineSteps( + raw: string | undefined | null, + componentCause?: TaskErrorComponentCause, + fileInfo?: TaskFileEntry, +): IngestionPipelineStep[] { + const resolved = (raw ?? "").trim() || "Unknown error"; + const failedStep = fileInfo + ? inferFailedPipelineStep(fileInfo, resolved) + : (pickFailedStepFromErrorScores(scorePipelineStepsFromError(resolved)) ?? + (componentCause === "Docling" + ? "parsing" + : componentCause === "Langflow" + ? "embedding" + : componentCause === "OpenSearch" + ? "indexing" + : "embedding")); + return buildIngestionPipelineSteps( + failedStep, + fileInfo ?? + ({ phase: undefined, docling_status: undefined } as TaskFileEntry), + ); +} + export function displayFileTaskError( raw: string | undefined | null, + fileInfo?: TaskFileEntry, + taskError?: string, ): FileTaskErrorDisplay { + if (fileInfo) { + const analysis = analyzeTaskFileIngestionFailure(fileInfo, taskError); + return { + line: analysis.summaryLine, + componentCause: analysis.componentCause, + }; + } + if (!raw?.trim()) { return { line: "Unknown error" }; } const normalized = normalizeErrorText(raw); const componentCause = detectComponentCause(normalized); - let line = stripNoisePrefixes(normalized); line = truncateLine(extractReadableLine(line)); - if (!line) { line = "Unknown error"; } diff --git a/frontend/lib/task-utils.ts b/frontend/lib/task-utils.ts index 8f22468d5..f586cdf8c 100644 --- a/frontend/lib/task-utils.ts +++ b/frontend/lib/task-utils.ts @@ -1,4 +1,213 @@ import type { Task, TaskFileEntry } from "@/app/api/queries/useGetTasksQuery"; +import { + buildRowStatusLabel, + inferFailedPipelineStep, + resolveTaskFileError, +} from "@/lib/task-error-display"; + +export const ALL_TASK_FILE_TYPES = "__all__"; +export const ALL_TASK_STATUS_CATEGORIES = "__all__"; + +export type TaskFileStatusCategory = + | "completed" + | "system_error" + | "indexing" + | "partial"; + +export type TaskFileNameSort = "asc" | "desc"; + +export type TaskFileFilterOptions = { + search?: string; + fileType?: string | typeof ALL_TASK_FILE_TYPES; + statusCategory?: TaskFileStatusCategory | typeof ALL_TASK_STATUS_CATEGORIES; + task?: Task; +}; + +interface TaskFileCategoryContext { + taskHasFailures: boolean; + successfulFileCount: number; +} + +export function isTaskFileCompleted(fileInfo: TaskFileEntry): boolean { + return fileInfo.status === "completed"; +} + +export function isTaskFileFailed(fileInfo: TaskFileEntry): boolean { + return fileInfo.status === "failed" || fileInfo.status === "error"; +} + +export function getTaskFileDialogStatusLabel( + fileInfo: TaskFileEntry, + taskError?: string, +): string { + if (isTaskFileFailed(fileInfo)) { + return buildRowStatusLabel( + inferFailedPipelineStep( + fileInfo, + resolveTaskFileError(fileInfo, taskError), + ), + ); + } + if (isTaskFileCompleted(fileInfo)) { + return "Complete"; + } + return "Processing"; +} + +export function getTaskFileName( + filePath: string, + fileInfo: TaskFileEntry, +): string { + return fileInfo.filename || filePath.split("/").pop() || filePath; +} + +/** Lowercase extension without dot, or empty string when none. */ +export function getFileExtensionFromName(filename: string): string { + const trimmed = filename.trim(); + const dotIndex = trimmed.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === trimmed.length - 1) { + return ""; + } + return trimmed.slice(dotIndex + 1).toLowerCase(); +} + +export function getTaskFileTypeKey( + filePath: string, + fileInfo: TaskFileEntry, +): string { + const extension = getFileExtensionFromName( + getTaskFileName(filePath, fileInfo), + ); + return extension || "unknown"; +} + +export function getTaskFileEntries(task: Task): Array<[string, TaskFileEntry]> { + return Object.entries(task.files || {}); +} + +export function getTaskFileTypes(task: Task): string[] { + const types = new Set( + getTaskFileEntries(task).map(([path, entry]) => + getTaskFileTypeKey(path, entry), + ), + ); + return Array.from(types).sort((a, b) => a.localeCompare(b)); +} + +export function formatTaskFileTypeLabel(fileType: string): string { + if (fileType === "unknown") { + return "Unknown"; + } + return fileType.toUpperCase(); +} + +function getTaskFileCategoryContext(task: Task): TaskFileCategoryContext { + return { + taskHasFailures: hasFailedFileEntries(task), + successfulFileCount: getSuccessfulFileCount(task), + }; +} + +/** + * Maps a file to a dialog filter chip bucket. + * Completed files in a mixed task (failures + successes) use `partial`, not `completed`. + */ +export function getTaskFileStatusCategory( + fileInfo: TaskFileEntry, + task: Task, + context: TaskFileCategoryContext = getTaskFileCategoryContext(task), +): TaskFileStatusCategory { + if (isTaskFileFailed(fileInfo)) { + return "system_error"; + } + + const status = fileInfo.status ?? "pending"; + if (status === "pending" || status === "running" || status === "processing") { + return "indexing"; + } + + if (status === "completed") { + if (context.taskHasFailures && context.successfulFileCount > 0) { + return "partial"; + } + return "completed"; + } + + return "indexing"; +} + +export function countTaskFilesByCategory( + task: Task, +): Record { + const counts: Record = { + completed: 0, + system_error: 0, + indexing: 0, + partial: 0, + }; + const context = getTaskFileCategoryContext(task); + + for (const [, fileInfo] of getTaskFileEntries(task)) { + const category = getTaskFileStatusCategory(fileInfo, task, context); + counts[category] += 1; + } + + return counts; +} + +export function sortTaskFileEntries( + entries: Array<[string, TaskFileEntry]>, + direction: TaskFileNameSort = "asc", +): Array<[string, TaskFileEntry]> { + const sorted = [...entries].sort(([pathA, infoA], [pathB, infoB]) => + getTaskFileName(pathA, infoA).localeCompare( + getTaskFileName(pathB, infoB), + undefined, + { sensitivity: "base" }, + ), + ); + return direction === "asc" ? sorted : sorted.reverse(); +} + +export function filterTaskFileEntries( + entries: Array<[string, TaskFileEntry]>, + options: TaskFileFilterOptions, +): Array<[string, TaskFileEntry]> { + const query = options.search?.trim().toLowerCase() ?? ""; + const fileType = options.fileType ?? ALL_TASK_FILE_TYPES; + const statusCategory = options.statusCategory ?? ALL_TASK_STATUS_CATEGORIES; + const categoryContext = options.task + ? getTaskFileCategoryContext(options.task) + : undefined; + + return entries.filter(([filePath, fileInfo]) => { + if (fileType !== ALL_TASK_FILE_TYPES) { + const typeKey = getTaskFileTypeKey(filePath, fileInfo); + if (typeKey !== fileType) { + return false; + } + } + + if ( + options.task && + statusCategory !== ALL_TASK_STATUS_CATEGORIES && + categoryContext && + getTaskFileStatusCategory(fileInfo, options.task, categoryContext) !== + statusCategory + ) { + return false; + } + + if (query) { + const name = getTaskFileName(filePath, fileInfo); + if (!name.toLowerCase().includes(query)) { + return false; + } + } + + return true; + }); +} export function getFailedFileEntries( task: Task, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index adb7ecda2..3e5165d5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@carbon/icons-react": "^11.81.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.11", @@ -258,31 +257,6 @@ "node": ">=14.21.3" } }, - "node_modules/@carbon/icon-helpers": { - "version": "10.76.0", - "resolved": "https://registry.npmjs.org/@carbon/icon-helpers/-/icon-helpers-10.76.0.tgz", - "integrity": "sha512-DLxVV/NtEFauMbmKFW53cGKMs84RRdIp9AzHbasIxlCBjO0P4eZbPdO3gTzYxn02jDMo++2m3MsEAwNydzrzmQ==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@ibm/telemetry-js": "^1.5.0" - } - }, - "node_modules/@carbon/icons-react": { - "version": "11.81.0", - "resolved": "https://registry.npmjs.org/@carbon/icons-react/-/icons-react-11.81.0.tgz", - "integrity": "sha512-rkaQz45ioyQQ1FeSB69ojL1EfsEecYE0+S88marMqz6GIqJxnDhHap3a2C89kXwuBUbCNsZ5aIhKqfiMquotvg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@carbon/icon-helpers": "^10.76.0", - "@ibm/telemetry-js": "^1.5.0", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": ">=16" - } - }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -550,15 +524,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@ibm/telemetry-js": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@ibm/telemetry-js/-/telemetry-js-1.11.0.tgz", - "integrity": "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA==", - "license": "Apache-2.0", - "bin": { - "ibmtelemetry": "dist/collect.js" - } - }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3ed1dbef5..b712c0ac6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,6 @@ "prepare": "cd .. && npx husky frontend/.husky || true" }, "dependencies": { - "@carbon/icons-react": "^11.81.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.11", diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index cbed098bc..b1e025a67 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -157,9 +157,12 @@ const config = { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, + canvas: "hsl(var(--canvas))", "layer-contextual": "hsl(var(--layer-contextual))", "layer-contextual-foreground": "hsl(var(--layer-contextual-foreground))", + "border-subtle-contextual": + "hsl(var(--border-subtle-background-contextual))", "text-text-01": "hsl(var(--text-text-01))", "link-primary": "hsl(var(--link-primary))", "button-tertiary": "hsl(var(--button-tertiary))", From d51cfe2f29734f1733ec0d6e634b2bdc81a3bec7 Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 27 May 2026 17:34:02 -0400 Subject: [PATCH 14/25] Backend - Add POST /tasks/{task_id}/retry to re-queue failed RETRYABLE files on the existing task processor (optional file_paths subset). - Implement TaskService.retry_failed_files with skip reasons (not_failed, not_retryable, source_file_missing, task_in_progress) and retry_count tracking. - Classify non-retryable file errors vs transient Docling failures. - Add tests/unit/test_task_service_retry_failed_files.py. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend — retry - Add useRetryTaskMutation calling the retry endpoint. - Extend enhanced task types (actionable_by, retry_count, failure component/ phase) in useGetTasksQuery and single-task fetch in useGetTaskQuery. - Task dialog: retry all/selected, retry ingestions tab, row selection, polling while retry runs, and toasts for retried/skipped files. - task-utils: retryable file helpers, filters, and dialog status labels. - task-context: markTaskFilesProcessing when retry starts; improved overlay sync/finalization with enhanced task payloads and knowledge refetch on completio - task-error-display: use structured API failure metadata for file errors. - task-error-content: open task dialog from failure panel; show API component and resolved error text. - useCancelTaskMutation: invalidate tasks queries with exact: false. - Playwright: mock GET /api/tasks** only so retry POST is not intercepted. Frontend — knowledge bulk delete - Fix delete modal listing wrong files after partial ingest/retry: AG Grid kept failed rows selected after checkboxes disappeared (processing → failed). - Restrict selection and delete to active rows present in listFiles/search (indexed knowledge only, not overlay-only rows without chunks). - Prune non-deletable selection when grid data changes; add isRowSelectable. - Delete with Promise.allSettled via deleteDocumentByFilename so one file without chunks does not abort the batch; resolve indexed filename for delete. Tests - Extend tests/unit/test_task_service_get_task_status2.py for enhanced status. --- .../api/mutations/useCancelTaskMutation.ts | 2 +- .../app/api/mutations/useDeleteDocument.ts | 13 +- frontend/app/api/queries/useGetTaskQuery.ts | 14 +- frontend/app/api/queries/useGetTasksQuery.ts | 47 ++- frontend/app/knowledge/page.tsx | 151 +++++-- frontend/components/task-dialog/file-list.tsx | 374 ++++++++++++------ .../components/task-dialog/task-dialog.tsx | 66 +++- .../components/task-dialog/use-task-dialog.ts | 289 +++++++++++++- frontend/components/task-error-content.tsx | 19 +- frontend/contexts/task-context.tsx | 160 ++++++-- frontend/lib/task-error-display.ts | 346 ++++------------ frontend/lib/task-utils.ts | 166 +++++++- .../tests/core/tasks-unified-panel.spec.ts | 6 +- src/api/tasks.py | 36 +- src/app/routes/internal.py | 6 + src/services/task_service.py | 245 +++++++++++- .../test_task_service_get_task_status2.py | 51 +++ 17 files changed, 1456 insertions(+), 535 deletions(-) diff --git a/frontend/app/api/mutations/useCancelTaskMutation.ts b/frontend/app/api/mutations/useCancelTaskMutation.ts index 88ea7e0c5..e96c0c19d 100644 --- a/frontend/app/api/mutations/useCancelTaskMutation.ts +++ b/frontend/app/api/mutations/useCancelTaskMutation.ts @@ -40,7 +40,7 @@ export const useCancelTaskMutation = ( mutationFn: cancelTask, onSuccess: () => { // Invalidate tasks query to refresh the list - queryClient.invalidateQueries({ queryKey: ["tasks"] }); + queryClient.invalidateQueries({ queryKey: ["tasks"], exact: false }); }, ...options, }); diff --git a/frontend/app/api/mutations/useDeleteDocument.ts b/frontend/app/api/mutations/useDeleteDocument.ts index f4fd9ce41..20039ad07 100644 --- a/frontend/app/api/mutations/useDeleteDocument.ts +++ b/frontend/app/api/mutations/useDeleteDocument.ts @@ -13,15 +13,15 @@ interface DeleteDocumentResponse { message: string; } -const deleteDocument = async ( - data: DeleteDocumentRequest, -): Promise => { +export async function deleteDocumentByFilename( + filename: string, +): Promise { const response = await fetch("/api/documents/delete-by-filename", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(data), + body: JSON.stringify({ filename } satisfies DeleteDocumentRequest), }); if (!response.ok) { @@ -30,13 +30,14 @@ const deleteDocument = async ( } return response.json(); -}; +} export const useDeleteDocument = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: deleteDocument, + mutationFn: ({ filename }: DeleteDocumentRequest) => + deleteDocumentByFilename(filename), onSettled: () => { // Invalidate and refetch search queries to update the UI setTimeout(() => { diff --git a/frontend/app/api/queries/useGetTaskQuery.ts b/frontend/app/api/queries/useGetTaskQuery.ts index be735c858..e5cd2948a 100644 --- a/frontend/app/api/queries/useGetTaskQuery.ts +++ b/frontend/app/api/queries/useGetTaskQuery.ts @@ -1,18 +1,24 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { Task } from "@/app/api/queries/useGetTasksQuery"; +export const TASK_DETAIL_QUERY_KEY = ["tasks", "detail"] as const; + +export function taskDetailQueryKey(taskId: string) { + return [...TASK_DETAIL_QUERY_KEY, taskId] as const; +} export function useGetTaskQuery( taskId: string | null, options?: Omit, "queryKey" | "queryFn">, ) { return useQuery({ - queryKey: ["tasks", taskId], + queryKey: taskId + ? taskDetailQueryKey(taskId) + : [...TASK_DETAIL_QUERY_KEY, "idle"], queryFn: async (): Promise => { if (!taskId) { return null; } - //will be replace /api/tasks/{taskId}/enhanced, when backend merged - const response = await fetch(`/api/tasks/${taskId}`); + const response = await fetch(`/api/tasks/${taskId}/enhanced`); if (response.status === 404) { return null; } @@ -21,7 +27,7 @@ export function useGetTaskQuery( } return response.json() as Promise; }, - enabled: !!taskId, ...options, + enabled: options?.enabled ?? !!taskId, }); } diff --git a/frontend/app/api/queries/useGetTasksQuery.ts b/frontend/app/api/queries/useGetTasksQuery.ts index 7a5d57004..d871007e6 100644 --- a/frontend/app/api/queries/useGetTasksQuery.ts +++ b/frontend/app/api/queries/useGetTasksQuery.ts @@ -4,6 +4,25 @@ import { useQueryClient, } from "@tanstack/react-query"; +/** Component that failed, from GET /tasks/enhanced file metadata. */ +export type TaskFailureComponent = + | "docling" + | "openrag" + | "langflow" + | "opensearch"; + +/** Pipeline or validation step where failure occurred. */ +export type TaskFailurePhase = + | "parsing" + | "chunking" + | "embedding" + | "indexing" + | "file_validation" + | "unknown"; + +/** Who can resolve the failure (enhanced API). */ +export type TaskActionableBy = "USER_ACTIONABLE" | "RETRYABLE"; + export interface TaskFileEntry { status?: | "pending" @@ -21,17 +40,14 @@ export interface TaskFileEntry { filename?: string; embedding_model?: string; embedding_dimensions?: number; - /** Ingestion pipeline phase from the task service (`docling` | `langflow` | `complete`). */ phase?: "docling" | "langflow" | "complete" | string; - /** - * Phase or step where ingestion failed (from API). - * Ingestion phase: `docling` | `langflow` | `complete`. - * Pipeline step: `parsing` | `chunking` | `embedding` | `indexing`. - */ - failure_phase?: string; docling_status?: string; docling_task_id?: string; - [key: string]: unknown; + /** Present on failed files when the enhanced API can classify the failure. */ + component?: TaskFailureComponent; + failure_phase?: TaskFailurePhase; + user_facing_message?: string; + actionable_by?: TaskActionableBy; } export interface Task { @@ -61,13 +77,15 @@ export interface TasksResponse { tasks: Task[]; } +export const TASKS_QUERY_KEY = ["tasks", "enhanced"] as const; + export const useGetTasksQuery = ( options?: Omit, "queryKey" | "queryFn">, ) => { const queryClient = useQueryClient(); async function getTasks(): Promise { - const response = await fetch("/api/tasks"); + const response = await fetch("/api/tasks/enhanced"); if (!response.ok) { throw new Error("Failed to fetch tasks"); @@ -79,13 +97,12 @@ export const useGetTasksQuery = ( const queryResult = useQuery( { - queryKey: ["tasks"], + queryKey: [...TASKS_QUERY_KEY], queryFn: getTasks, refetchInterval: (query) => { - // Only poll if there are tasks with pending or running status const data = query.state.data; if (!data || data.length === 0) { - return false; // Stop polling if no tasks + return false; } const hasActiveTasks = data.some( @@ -95,11 +112,11 @@ export const useGetTasksQuery = ( task.status === "processing", ); - return hasActiveTasks ? 3000 : false; // Poll every 3 seconds if active tasks exist + return hasActiveTasks ? 3000 : false; }, refetchIntervalInBackground: true, - staleTime: 0, // Always consider data stale to ensure fresh updates - gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + staleTime: 0, + gcTime: 5 * 60 * 1000, ...options, }, queryClient, diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index ea5f9af27..086148c55 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -12,7 +12,7 @@ import { import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react"; import { AlertTriangle, Cloud, FileIcon, Globe, RefreshCw } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; import { ProtectedRoute } from "@/components/protected-route"; import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner"; @@ -56,7 +56,10 @@ import IBMCOSIcon from "../../components/icons/ibm-cos-icon"; import OneDriveIcon from "../../components/icons/one-drive-logo"; import SharePointIcon from "../../components/icons/share-point-logo"; import { SyncConfirmDialog } from "../../components/sync-confirm-dialog"; -import { useDeleteDocument } from "../api/mutations/useDeleteDocument"; +import { + deleteDocumentByFilename, + useDeleteDocument, +} from "../api/mutations/useDeleteDocument"; import { useRefreshOpenragDocs } from "../api/mutations/useRefreshOpenragDocs"; import { type SyncAllPreviewResponse, @@ -64,6 +67,19 @@ import { useSyncAllConnectorsPreview, } from "../api/mutations/useSyncConnector"; +/** Failed overlays can stay selected after they lose their checkbox (processing → failed). */ +function syncGridSelectionToDeletableRows( + api: NonNullable["api"]>, + isDeletable: (file?: File) => boolean, +): File[] { + api.forEachNode((node) => { + if (node.isSelected() && !isDeletable(node.data)) { + node.setSelected(false); + } + }); + return api.getSelectedRows().filter(isDeletable); +} + /** List-files uses term filters; "*" means "any" in the UI — do not send it literally. */ function listFilesFilterParam(values?: string[]): string | undefined { const raw = values?.[0]?.trim(); @@ -327,6 +343,37 @@ function SearchPage() { return getKnowledgeFileIdentity(file); }, []); + const indexedFileIdentities = useMemo( + () => + new Set( + effectiveData + .map((file) => getKnowledgeFileIdentity(file)) + .filter(Boolean), + ), + [effectiveData], + ); + + const isDeletableKnowledgeRow = useCallback( + (file?: File) => { + if ((file?.status || "active") !== "active") { + return false; + } + return indexedFileIdentities.has(getKnowledgeFileIdentity(file)); + }, + [indexedFileIdentities], + ); + + const resolveDeleteFilename = useCallback( + (row: File) => { + const identity = getKnowledgeFileIdentity(row); + const indexed = effectiveData.find( + (file) => getKnowledgeFileIdentity(file) === identity, + ); + return indexed?.filename ?? row.filename; + }, + [effectiveData], + ); + const getOwnerLabel = useCallback((file?: File): string => { return file?.owner_name?.trim() || file?.owner_email?.trim() || "—"; }, []); @@ -417,6 +464,29 @@ function SearchPage() { const gridRows = fileResults; const gridRef = useRef(null); + useEffect(() => { + const api = gridRef.current?.api; + if (!api) { + return; + } + const activeSelected = syncGridSelectionToDeletableRows( + api, + isDeletableKnowledgeRow, + ); + setSelectedRows((current) => { + if ( + current.length === activeSelected.length && + current.every( + (row, index) => + getFileIdentity(row) === getFileIdentity(activeSelected[index]), + ) + ) { + return current; + } + return activeSelected; + }); + }, [gridRows, getFileIdentity, isDeletableKnowledgeRow]); + const columnDefs: ColDef[] = [ { field: "filename", @@ -436,7 +506,7 @@ function SearchPage() { return sourceA < sourceB ? -1 : 1; }, checkboxSelection: (params: CheckboxSelectionCallbackParams) => - (params?.data?.status || "active") === "active", + isDeletableKnowledgeRow(params?.data), headerCheckboxSelection: true, ...(isCloudBrand ? { flex: 2.2, minWidth: 260 } @@ -685,51 +755,78 @@ function SearchPage() { }; const onSelectionChanged = useCallback(() => { - if (gridRef.current) { - const selectedNodes = gridRef.current.api.getSelectedRows(); - setSelectedRows(selectedNodes); + if (!gridRef.current) { + return; } - }, []); + setSelectedRows( + syncGridSelectionToDeletableRows( + gridRef.current.api, + isDeletableKnowledgeRow, + ), + ); + }, [isDeletableKnowledgeRow]); const handleBulkDelete = async () => { - if (selectedRows.length === 0) return; + const rowsToDelete = selectedRows.filter(isDeletableKnowledgeRow); + if (rowsToDelete.length === 0) return; try { - // Delete each file individually since the API expects one filename at a time - const deletePromises = selectedRows.map((row) => - deleteDocumentMutation.mutateAsync({ filename: row.filename }), + const deleteResults = await Promise.allSettled( + rowsToDelete.map((row) => + deleteDocumentByFilename(resolveDeleteFilename(row)), + ), ); - const deleteResults = await Promise.all(deletePromises); await refreshTasks(); await queryClient.invalidateQueries({ queryKey: ["search"] }); + await queryClient.invalidateQueries({ queryKey: ["listFiles"] }); await queryClient.refetchQueries({ queryKey: ["search"] }); + await queryClient.refetchQueries({ queryKey: ["listFiles"] }); - const totalDeletedChunks = deleteResults.reduce( - (sum, result) => sum + (result.deleted_chunks || 0), - 0, + const deleted = deleteResults.filter( + ( + result, + ): result is PromiseFulfilledResult< + Awaited> + > => + result.status === "fulfilled" && + (result.value.deleted_chunks || 0) > 0, ); - const filesWithNoDeletion = deleteResults.filter( - (result) => (result.deleted_chunks || 0) === 0, + const noChunks = deleteResults.filter( + (result) => + result.status === "fulfilled" && + (result.value.deleted_chunks || 0) === 0, + ); + const failed = deleteResults.filter( + (result): result is PromiseRejectedResult => + result.status === "rejected", ); - if (totalDeletedChunks > 0) { + if (deleted.length > 0) { toast.success( - `Successfully deleted ${selectedRows.length} document${ - selectedRows.length > 1 ? "s" : "" - }`, + `Deleted ${deleted.length} document${deleted.length > 1 ? "s" : ""}`, ); } else { toast.warning( - "No document chunks were deleted. Files may be owned by another context or already removed.", + "No document chunks were deleted. Files may be missing or not deletable in your current context.", ); } - if (filesWithNoDeletion.length > 0 && totalDeletedChunks > 0) { + if (noChunks.length > 0 && deleted.length > 0) { toast.warning( - `${filesWithNoDeletion.length} selected file${ - filesWithNoDeletion.length > 1 ? "s were" : " was" - } not deleted (0 chunks matched).`, + `${noChunks.length} selected file${noChunks.length > 1 ? "s had" : " had"} no matching chunks.`, + ); + } + + if (failed.length > 0) { + toast.error( + `${failed.length} document${failed.length > 1 ? "s" : ""} could not be deleted`, + { + description: + failed[0].reason instanceof Error + ? failed[0].reason.message + : undefined, + }, ); } setSelectedRows([]); @@ -919,6 +1016,7 @@ function SearchPage() { getRowId={(params: GetRowIdParams) => getFileIdentity(params.data) } + isRowSelectable={(params) => isDeletableKnowledgeRow(params.data)} domLayout="normal" onSelectionChanged={onSelectionChanged} pagination={pagination} @@ -952,6 +1050,7 @@ function SearchPage() { getRowId={(params: GetRowIdParams) => getFileIdentity(params.data) } + isRowSelectable={(params) => isDeletableKnowledgeRow(params.data)} domLayout="normal" onSelectionChanged={onSelectionChanged} pagination={pagination} diff --git a/frontend/components/task-dialog/file-list.tsx b/frontend/components/task-dialog/file-list.tsx index 2a4afc80b..21275fde2 100644 --- a/frontend/components/task-dialog/file-list.tsx +++ b/frontend/components/task-dialog/file-list.tsx @@ -9,12 +9,14 @@ import { getTaskFileDialogStatusLabel, getTaskFileName, isTaskFileFailed, + isTaskFileRetryable, type TaskFileNameSort, } from "@/lib/task-utils"; import { cn } from "@/lib/utils"; import { TaskDialogFileErrorDetails } from "./file-error-details"; const OSS_ERROR_INDENT = "pl-9"; +const CHECKBOX_CLASS = "h-4 w-4 shrink-0 rounded border-border accent-primary"; type TaskDialogFileListTab = "task-ingestions" | "retry-ingestions"; @@ -22,20 +24,31 @@ interface TaskDialogFileListProps { isCloudBrand: boolean; task: Task; entries: Array<[string, TaskFileEntry]>; + retryIngestionEntries: Array<[string, TaskFileEntry]>; totalSourceCount: number; totalSourceCountAll?: number; nameSort: TaskFileNameSort; onToggleNameSort: () => void; expandedPath: string | null; onExpandedPathChange: (path: string | null) => void; - /** Retry-ingestion row count; tab is shown only when greater than zero. */ retryIngestionCount?: number; + selectablePaths: string[]; + selectedPaths: Set; + allSelectableSelected: boolean; + onToggleSelectedPath: (filePath: string) => void; + onToggleSelectAllVisible: () => void; + allRetryIngestionsSelected: boolean; + onToggleSelectAllRetryIngestions: () => void; + selectedCount: number; + retryIngestionSelectedCount: number; + retryingTarget?: "all" | "selected" | string | null; } export function TaskDialogFileList({ isCloudBrand, task, entries, + retryIngestionEntries, totalSourceCount, totalSourceCountAll, nameSort, @@ -43,6 +56,16 @@ export function TaskDialogFileList({ expandedPath, onExpandedPathChange, retryIngestionCount = 0, + selectablePaths, + selectedPaths, + allSelectableSelected, + onToggleSelectedPath, + onToggleSelectAllVisible, + allRetryIngestionsSelected, + onToggleSelectAllRetryIngestions, + selectedCount, + retryIngestionSelectedCount, + retryingTarget = null, }: TaskDialogFileListProps) { const [activeTab, setActiveTab] = useState("task-ingestions"); @@ -60,7 +83,11 @@ export function TaskDialogFileList({ string, ReturnType >(); - for (const [filePath, fileInfo] of entries) { + const allEntries = [...entries, ...retryIngestionEntries]; + const seen = new Set(); + for (const [filePath, fileInfo] of allEntries) { + if (seen.has(filePath)) continue; + seen.add(filePath); if (isTaskFileFailed(fileInfo)) { map.set( filePath, @@ -69,20 +96,7 @@ export function TaskDialogFileList({ } } return map; - }, [entries, task.error]); - - if (entries.length === 0) { - return ( -

- No files match your filters. -

- ); - } + }, [entries, retryIngestionEntries, task.error]); const containerClass = cn( "flex min-h-0 flex-1 flex-col overflow-hidden", @@ -118,93 +132,188 @@ export function TaskDialogFileList({ const listScrollClass = "min-h-0 flex-1 overflow-y-auto overscroll-contain"; - const fileRows = entries.map(([filePath, fileInfo]) => { - const fileName = getTaskFileName(filePath, fileInfo); - const failed = isTaskFileFailed(fileInfo); - const analysis = analysisByPath.get(filePath); - const rowStatusLabel = failed - ? (analysis?.rowStatusLabel ?? "Failed") - : getTaskFileDialogStatusLabel(fileInfo, task.error); - const isExpanded = expandedPath === filePath; + const rowGridClass = + "grid min-h-10 grid-cols-[auto_auto_1fr_auto] items-center gap-3"; - return ( -
+ const renderFileRows = (listEntries: Array<[string, TaskFileEntry]>) => + listEntries.map(([filePath, fileInfo]) => { + const fileName = getTaskFileName(filePath, fileInfo); + const failed = isTaskFileFailed(fileInfo); + const analysis = analysisByPath.get(filePath); + const rowStatusLabel = failed + ? (analysis?.rowStatusLabel ?? "Failed") + : getTaskFileDialogStatusLabel(fileInfo, task.error); + const isExpanded = expandedPath === filePath; + const retryable = isTaskFileRetryable(fileInfo); + const isSelected = selectedPaths.has(filePath); + const isRowRetrying = + retryingTarget === "all" || + retryingTarget === filePath || + (retryingTarget === "selected" && isSelected); + const retryAttempts = fileInfo.retry_count ?? 0; + const statusLabel = + retryable && retryAttempts > 0 + ? `${rowStatusLabel} · Retry ${retryAttempts}` + : rowStatusLabel; + + return (
- {failed ? ( +
+ {retryable ? ( + onToggleSelectedPath(filePath)} + /> + ) : ( + + )} + {failed ? ( + + ) : ( + + )} - ) : ( - - )} - - - {rowStatusLabel} - +
+ + {failed && isExpanded && analysis && ( + + )}
+ ); + }); - {failed && isExpanded && analysis && ( - - )} -
- ); - }); + const renderListHeader = ({ + showSelectAll, + allSelected, + onToggleSelectAll, + selectedLabel, + }: { + showSelectAll: boolean; + allSelected: boolean; + onToggleSelectAll: () => void; + selectedLabel?: string; + }) => ( +
+ {showSelectAll ? ( + + ) : null} + + {selectedLabel ? ( + {selectedLabel} + ) : null} +
+ ); + + const renderEmptyPanel = (message: string) => ( +

+ {message} +

+ ); return (
@@ -241,48 +350,55 @@ export function TaskDialogFileList({ {isTabActive("task-ingestions") ? (
-
- -
-
- {fileRows} -
+ {entries.length === 0 ? ( + renderEmptyPanel("No files match your filters.") + ) : ( + <> + {renderListHeader({ + showSelectAll: selectablePaths.length > 0, + allSelected: allSelectableSelected, + onToggleSelectAll: onToggleSelectAllVisible, + selectedLabel: + selectedCount > 0 ? `${selectedCount} selected` : undefined, + })} +
+ {renderFileRows(entries)} +
+ + )}
) : ( -
+
+ {retryIngestionEntries.length === 0 ? ( + renderEmptyPanel("No retryable files in this task.") + ) : ( + <> + {renderListHeader({ + showSelectAll: true, + allSelected: allRetryIngestionsSelected, + onToggleSelectAll: onToggleSelectAllRetryIngestions, + selectedLabel: + retryIngestionSelectedCount > 0 + ? `${retryIngestionSelectedCount} selected` + : undefined, + })} +
+ {renderFileRows(retryIngestionEntries)} +
+ + )} +
)}
); diff --git a/frontend/components/task-dialog/task-dialog.tsx b/frontend/components/task-dialog/task-dialog.tsx index e4da75aea..3fe715d9c 100644 --- a/frontend/components/task-dialog/task-dialog.tsx +++ b/frontend/components/task-dialog/task-dialog.tsx @@ -23,10 +23,16 @@ function TaskDialogContent({ const isCloudBrand = useIsCloudBrand(); const { task, + isLoading, + isError, fileEntries, fileTypes, categoryCounts, sortedEntries, + retryIngestionEntries, + retryIngestionSelectedCount, + allRetryIngestionsSelected, + toggleSelectAllRetryIngestions, search, setSearch, fileType, @@ -37,10 +43,22 @@ function TaskDialogContent({ setExpandedPath, nameSort, toggleNameSort, + retryableCount, + selectedCount, + selectedPaths, + selectablePaths, + allSelectableSelected, + toggleSelectedPath, + toggleSelectAllVisible, + isRetrying, + retryingTarget, + handleRetryAll, + handleRetrySelected, } = useTaskDialog(open, task_id); const filtersDisabled = !task; const fileTypeDisabled = !task || fileTypes.length === 0; + const showRetryActions = retryableCount > 0; return (
@@ -65,9 +83,11 @@ function TaskDialogContent({ !isCloudBrand && "px-4", )} > - {!task ? ( + {isLoading ? ( +

Loading task…

+ ) : isError || !task ? (

Task not found.

- ) : fileEntries.length === 0 ? ( + ) : fileEntries.length === 0 && retryableCount === 0 ? (

No files in this task.

@@ -76,25 +96,63 @@ function TaskDialogContent({ isCloudBrand={isCloudBrand} task={task} entries={sortedEntries} + retryIngestionEntries={retryIngestionEntries} totalSourceCount={sortedEntries.length} totalSourceCountAll={fileEntries.length} nameSort={nameSort} onToggleNameSort={toggleNameSort} expandedPath={expandedPath} onExpandedPathChange={setExpandedPath} + retryIngestionCount={retryableCount} + selectablePaths={selectablePaths} + selectedPaths={selectedPaths} + allSelectableSelected={allSelectableSelected} + onToggleSelectedPath={toggleSelectedPath} + onToggleSelectAllVisible={toggleSelectAllVisible} + allRetryIngestionsSelected={allRetryIngestionsSelected} + onToggleSelectAllRetryIngestions={toggleSelectAllRetryIngestions} + selectedCount={selectedCount} + retryIngestionSelectedCount={retryIngestionSelectedCount} + retryingTarget={retryingTarget} /> )}
- + {showRetryActions && selectedCount > 0 ? ( + + ) : null} + {showRetryActions && selectedCount === 0 ? ( + + ) : null}
); diff --git a/frontend/components/task-dialog/use-task-dialog.ts b/frontend/components/task-dialog/use-task-dialog.ts index 97d169d92..5ef7dab52 100644 --- a/frontend/components/task-dialog/use-task-dialog.ts +++ b/frontend/components/task-dialog/use-task-dialog.ts @@ -1,24 +1,106 @@ "use client"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + type RetryTaskResponse, + useRetryTaskMutation, +} from "@/app/api/mutations/useRetryTaskMutation"; import { useGetTaskQuery } from "@/app/api/queries/useGetTaskQuery"; -import type { Task } from "@/app/api/queries/useGetTasksQuery"; import { useTask } from "@/contexts/task-context"; import { ALL_TASK_FILE_TYPES, ALL_TASK_STATUS_CATEGORIES, + countRetryIngestionFiles, countTaskFilesByCategory, filterTaskFileEntries, + getRetryableFileEntries, + getRetryableFilePaths, getTaskFileEntries, getTaskFileTypes, + isTaskInProgressStatus, sortTaskFileEntries, type TaskFileNameSort, TaskFileStatusCategory, } from "@/lib/task-utils"; +function showRetryResultToast(result: RetryTaskResponse) { + if (result.skipped.length > 0) { + const missingSources = result.skipped.filter( + (entry) => entry.reason === "source_file_missing", + ).length; + if (missingSources > 0) { + toast.warning("Some files could not be retried", { + description: `${result.retried} file(s) queued. ${missingSources} need to be uploaded again.`, + }); + return; + } + } + + if (result.retried > 0) { + toast.success("Retry started", { + description: `${result.retried} file(s) queued for ingestion`, + }); + return; + } + + toast.warning("No files were retried", { + description: result.message ?? "Selected files could not be retried", + }); +} + export function useTaskDialog(open: boolean, taskId: string) { - const { tasks } = useTask(); - const { data: taskDetail } = useGetTaskQuery(taskId, { enabled: open }); + const { markTaskFilesProcessing } = useTask(); + + const [selectedPaths, setSelectedPaths] = useState>( + () => new Set(), + ); + + const [retryingTarget, setRetryingTarget] = useState< + "all" | "selected" | string | null + >(null); + + const retryMutation = useRetryTaskMutation(); + + const { + data: task, + isLoading, + isError, + refetch: refetchTask, + } = useGetTaskQuery(taskId, { + enabled: open && !!taskId, + refetchOnMount: "always", + refetchInterval: (query) => { + if (!open) { + return false; + } + if (retryingTarget != null) { + return 2000; + } + const data = query.state.data; + if (!data) { + return false; + } + if (isTaskInProgressStatus(data.status)) { + return 2000; + } + return getTaskFileEntries(data).some(([, fileInfo]) => { + const status = fileInfo.status ?? "pending"; + return ( + status === "pending" || + status === "running" || + status === "processing" + ); + }) + ? 2000 + : false; + }, + }); + + const retryableCount = useMemo( + () => (task ? countRetryIngestionFiles(task) : 0), + [task], + ); const [search, setSearch] = useState(""); const [fileType, setFileType] = useState(ALL_TASK_FILE_TYPES); @@ -28,10 +110,11 @@ export function useTaskDialog(open: boolean, taskId: string) { const [expandedPath, setExpandedPath] = useState(null); const [nameSort, setNameSort] = useState("asc"); - const task = useMemo( - () => taskDetail ?? tasks.find((entry) => entry.task_id === taskId), - [taskDetail, tasks, taskId], - ); + useEffect(() => { + if (!open) { + setSelectedPaths(new Set()); + } + }, [open]); const fileEntries = useMemo( () => (task ? getTaskFileEntries(task) : []), @@ -56,7 +139,7 @@ export function useTaskDialog(open: boolean, taskId: string) { search, fileType: activeFileType, statusCategory: statusCategory as TaskFileStatusCategory, - task, + task: task ?? undefined, }), [fileEntries, search, activeFileType, statusCategory, task], ); @@ -66,16 +149,202 @@ export function useTaskDialog(open: boolean, taskId: string) { [filteredEntries, nameSort], ); + const retryIngestionEntries = useMemo( + () => + task ? sortTaskFileEntries(getRetryableFileEntries(task), nameSort) : [], + [task, nameSort], + ); + + const retryIngestionPaths = useMemo( + () => retryIngestionEntries.map(([filePath]) => filePath), + [retryIngestionEntries], + ); + + const selectablePaths = useMemo( + () => getRetryableFilePaths(sortedEntries), + [sortedEntries], + ); + + const selectedCount = useMemo(() => { + let count = 0; + for (const path of selectablePaths) { + if (selectedPaths.has(path)) { + count += 1; + } + } + return count; + }, [selectablePaths, selectedPaths]); + + const allSelectableSelected = + selectablePaths.length > 0 && selectedCount === selectablePaths.length; + + const retryIngestionSelectedCount = useMemo(() => { + let count = 0; + for (const path of retryIngestionPaths) { + if (selectedPaths.has(path)) { + count += 1; + } + } + return count; + }, [retryIngestionPaths, selectedPaths]); + + const allRetryIngestionsSelected = + retryIngestionPaths.length > 0 && + retryIngestionSelectedCount === retryIngestionPaths.length; + + const selectedRetryablePaths = useMemo( + () => selectablePaths.filter((path) => selectedPaths.has(path)), + [selectablePaths, selectedPaths], + ); + + const runRetry = useCallback( + async (filePaths?: string[]) => { + if (!taskId) { + return; + } + if (!filePaths && retryableCount === 0) { + return; + } + if (filePaths && filePaths.length === 0) { + return; + } + + const pathsToRetry = + filePaths ?? + (task ? getRetryableFilePaths(getRetryableFileEntries(task)) : []); + + setRetryingTarget( + filePaths + ? filePaths.length === 1 + ? filePaths[0] + : "selected" + : "all", + ); + + if (pathsToRetry.length > 0) { + markTaskFilesProcessing(taskId, pathsToRetry); + } + + try { + const result = await retryMutation.mutateAsync({ + taskId, + ...(filePaths ? { filePaths } : {}), + }); + await refetchTask(); + showRetryResultToast(result); + if (result.retried > 0) { + setSelectedPaths(new Set()); + } + } catch (error) { + toast.error("Retry failed", { + description: + error instanceof Error ? error.message : "Could not retry files", + }); + } finally { + setRetryingTarget(null); + } + }, + [ + task, + taskId, + retryableCount, + retryMutation, + refetchTask, + markTaskFilesProcessing, + ], + ); + + const handleRetryAll = useCallback(() => runRetry(), [runRetry]); + + const handleRetrySelected = useCallback( + () => runRetry(selectedRetryablePaths), + [runRetry, selectedRetryablePaths], + ); + + const toggleSelectedPath = useCallback((filePath: string) => { + setSelectedPaths((current) => { + const next = new Set(current); + if (next.has(filePath)) { + next.delete(filePath); + } else { + next.add(filePath); + } + return next; + }); + }, []); + + const toggleSelectAllVisible = useCallback(() => { + setSelectedPaths((current) => { + const visible = new Set(selectablePaths); + const allSelected = + selectablePaths.length > 0 && + selectablePaths.every((path) => current.has(path)); + + if (allSelected) { + const next = new Set(current); + for (const path of visible) { + next.delete(path); + } + return next; + } + + const next = new Set(current); + for (const path of visible) { + next.add(path); + } + return next; + }); + }, [selectablePaths]); + + const toggleSelectAllRetryIngestions = useCallback(() => { + setSelectedPaths((current) => { + const allSelected = + retryIngestionPaths.length > 0 && + retryIngestionPaths.every((path) => current.has(path)); + + if (allSelected) { + const next = new Set(current); + for (const path of retryIngestionPaths) { + next.delete(path); + } + return next; + } + + const next = new Set(current); + for (const path of retryIngestionPaths) { + next.add(path); + } + return next; + }); + }, [retryIngestionPaths]); + const toggleNameSort = () => { setNameSort((current) => (current === "asc" ? "desc" : "asc")); }; return { - task, + task: task ?? undefined, + isLoading, + isError, + retryableCount, + selectedCount, + selectedPaths, + selectablePaths, + allSelectableSelected, + toggleSelectedPath, + toggleSelectAllVisible, + isRetrying: retryMutation.isPending, + retryingTarget, + handleRetryAll, + handleRetrySelected, fileEntries, fileTypes, categoryCounts, sortedEntries, + retryIngestionEntries, + retryIngestionSelectedCount, + allRetryIngestionsSelected, + toggleSelectAllRetryIngestions, search, setSearch, fileType: activeFileType, diff --git a/frontend/components/task-error-content.tsx b/frontend/components/task-error-content.tsx index c50869f35..c03d9fe44 100644 --- a/frontend/components/task-error-content.tsx +++ b/frontend/components/task-error-content.tsx @@ -12,11 +12,15 @@ import { } from "@/components/ui/accordion"; import { useIsCloudBrand } from "@/contexts/brand-context"; import { type Task } from "@/contexts/task-context"; -import { displayFileTaskError } from "@/lib/task-error-display"; +import { + formatApiComponent, + resolveTaskFileError, +} from "@/lib/task-error-display"; import { getFailedFileCount, getFailedFileEntries, getSuccessfulFileCount, + getTaskFileName, isCompletedTotalFailure, isTerminalFailedTask, } from "@/lib/task-utils"; @@ -172,14 +176,9 @@ export function TaskErrorContent({
{failedEntries.map(([filePath, fileInfo], index) => { - const fileName = - fileInfo.filename || filePath.split("/").pop() || filePath; - const rawError = - typeof fileInfo.error === "string" && fileInfo.error.trim() - ? fileInfo.error.trim() - : task.error; - const { line, componentCause } = - displayFileTaskError(rawError); + const fileName = getTaskFileName(filePath, fileInfo); + const line = resolveTaskFileError(fileInfo, task.error); + const componentCause = formatApiComponent(fileInfo.component); return (
{line}

diff --git a/frontend/contexts/task-context.tsx b/frontend/contexts/task-context.tsx index 838e3760b..121df5fd2 100644 --- a/frontend/contexts/task-context.tsx +++ b/frontend/contexts/task-context.tsx @@ -12,18 +12,27 @@ import { } from "react"; import { toast } from "sonner"; import { useCancelTaskMutation } from "@/app/api/mutations/useCancelTaskMutation"; +import type { SearchResult } from "@/app/api/queries/useGetSearchQuery"; import { + TASKS_QUERY_KEY, type Task, type TaskFileEntry, useGetTasksQuery, } from "@/app/api/queries/useGetTasksQuery"; +import type { ListFilesResponse } from "@/app/api/queries/useListFiles"; import { useAuth } from "@/contexts/auth-context"; import { useOnboardingState } from "@/hooks/use-onboarding-state"; import { trackProcessFailure, trackProcessSuccess } from "@/lib/analytics"; +import { getKnowledgeFileIdentity } from "@/lib/knowledge-table-state"; import { + didTaskReachCompleted, + finalizeProcessingOverlaysForEnhancedTask, + findTaskFileOverlayIndex, + getEnhancedListDisappearedFilePaths, getFailedFileCount, getSuccessfulFileCount, hasFailedFileEntries, + isTaskInProgressStatus, isTerminalFailedTask, } from "@/lib/task-utils"; @@ -49,6 +58,8 @@ interface TaskContextType { files: TaskFile[]; addTask: (taskId: string) => void; addFiles: (files: Partial[], taskId: string) => void; + /** Mark knowledge-table overlays as processing when a retry starts. */ + markTaskFilesProcessing: (taskId: string, sourceUrls: string[]) => void; refreshTasks: () => Promise; cancelTask: (taskId: string) => Promise; isPolling: boolean; @@ -102,10 +113,13 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { const cancelTaskMutation = useCancelTaskMutation({ onSuccess: (_data, variables) => { // Immediately remove from React Query cache - queryClient.setQueryData(["tasks"], (oldTasks: Task[] | undefined) => { - if (!oldTasks) return []; - return oldTasks.filter((task) => task.task_id !== variables.taskId); - }); + queryClient.setQueryData( + [...TASKS_QUERY_KEY], + (oldTasks: Task[] | undefined) => { + if (!oldTasks) return []; + return oldTasks.filter((task) => task.task_id !== variables.taskId); + }, + ); // Update file to display as cancelled setFiles((prevFiles) => @@ -141,6 +155,33 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { }); }, [queryClient]); + const markTaskFilesProcessing = useCallback( + (taskId: string, sourceUrls: string[]) => { + const paths = new Set(sourceUrls); + if (paths.size === 0) { + return; + } + const now = new Date().toISOString(); + setFiles((prevFiles) => { + let changed = false; + const updated = prevFiles.map((file) => { + if (file.task_id !== taskId || !paths.has(file.source_url)) { + return file; + } + changed = true; + return { + ...file, + status: "processing" as const, + error: undefined, + updated_at: now, + }; + }); + return changed ? updated : prevFiles; + }); + }, + [], + ); + const addFiles = useCallback( (newFiles: Partial[], taskId: string) => { const now = new Date().toISOString(); @@ -178,11 +219,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { (prev) => prev.task_id === currentTask.task_id, ); - // Check if task is in progress - const isTaskInProgress = - currentTask.status === "pending" || - currentTask.status === "running" || - currentTask.status === "processing"; + const isTaskInProgress = isTaskInProgressStatus(currentTask.status); // On initial load, previousTasksRef is empty, so we need to process all in-progress tasks const isInitialLoad = previousTasksRef.current.length === 0; @@ -249,8 +286,11 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { })(); setFiles((prevFiles) => { - const existingFileIndex = prevFiles.findIndex( - (f) => f.source_url === filePath, + const existingFileIndex = findTaskFileOverlayIndex( + prevFiles, + currentTask.task_id, + filePath, + fileName, ); // Detect connector type based on file path or other indicators @@ -302,28 +342,28 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { }); } - if (isTaskInProgress && previousTask?.files) { - const currentFileKeys = new Set(Object.keys(currentTask.files ?? {})); - const disappearedFilePaths = Object.keys(previousTask.files).filter( - (fp) => !currentFileKeys.has(fp), + if (previousTask?.files) { + const disappearedFilePaths = getEnhancedListDisappearedFilePaths( + currentTask, + previousTask, ); - - if (disappearedFilePaths.length > 0) { - setFiles((prevFiles) => { - let changed = false; - const updated = prevFiles.map((f) => { - if ( - f.task_id === currentTask.task_id && - f.status === "processing" && - disappearedFilePaths.includes(f.source_url) - ) { - changed = true; - return { ...f, status: "active" as TaskFile["status"] }; - } - return f; - }); - return changed ? updated : prevFiles; - }); + const taskJustCompleted = didTaskReachCompleted( + previousTask, + currentTask, + ); + const shouldFinalizeDisappeared = + disappearedFilePaths.length > 0 && + (isTaskInProgress || taskJustCompleted); + const shouldFinalizeAllProcessing = taskJustCompleted; + + if (shouldFinalizeDisappeared || shouldFinalizeAllProcessing) { + setFiles((prevFiles) => + finalizeProcessingOverlaysForEnhancedTask( + prevFiles, + currentTask, + shouldFinalizeAllProcessing ? undefined : disappearedFilePaths, + ), + ); refetchSearch(); } } @@ -417,12 +457,57 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { e, ); } finally { + const indexedIdentities = new Set(); + const indexedFilenames = new Set(); + for (const [ + , + data, + ] of queryClient.getQueriesData({ + queryKey: ["listFiles"], + })) { + for (const indexed of data?.files ?? []) { + indexedIdentities.add(getKnowledgeFileIdentity(indexed)); + if (indexed.filename?.trim()) { + indexedFilenames.add(indexed.filename.trim()); + } + } + } + for (const [, data] of queryClient.getQueriesData({ + queryKey: ["search"], + })) { + for (const indexed of data?.files ?? []) { + indexedIdentities.add(getKnowledgeFileIdentity(indexed)); + if (indexed.filename?.trim()) { + indexedFilenames.add(indexed.filename.trim()); + } + } + } + setFiles((prevFiles) => - prevFiles.filter( - (file) => - file.task_id !== currentTask.task_id || - (completedHasFailures && file.status === "failed"), - ), + prevFiles.filter((file) => { + if (file.task_id !== currentTask.task_id) { + return true; + } + if (file.status === "failed") { + return completedHasFailures; + } + if (file.status === "processing") { + return false; + } + if (file.status === "active") { + const filename = file.filename?.trim(); + if (filename && indexedFilenames.has(filename)) { + return false; + } + return !indexedIdentities.has( + getKnowledgeFileIdentity({ + filename: file.filename, + source_url: file.source_url, + }), + ); + } + return false; + }), ); } } @@ -531,6 +616,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { files, addTask, addFiles, + markTaskFilesProcessing, refreshTasks, cancelTask, isPolling, diff --git a/frontend/lib/task-error-display.ts b/frontend/lib/task-error-display.ts index 038ee850d..6ba87d05e 100644 --- a/frontend/lib/task-error-display.ts +++ b/frontend/lib/task-error-display.ts @@ -1,98 +1,64 @@ -// Prefer API `failure_phase` for failed-step inference; `component_cause` may follow later. - -import type { TaskFileEntry } from "@/app/api/queries/useGetTasksQuery"; +import type { + TaskFailureComponent, + TaskFailurePhase, + TaskFileEntry, +} from "@/app/api/queries/useGetTasksQuery"; export const FILE_ERROR_MAX_LINE_LENGTH = 80; -export type TaskErrorComponentCause = "OpenSearch" | "Docling" | "Langflow"; +export type TaskErrorComponentCause = + | "OpenSearch" + | "Docling" + | "Langflow" + | "OpenRAG"; -export type IngestionPipelineStepId = +export type TaskPipelineStepId = | "parsing" | "chunking" | "embedding" - | "indexing"; + | "indexing" + | "file_validation" + | "unknown"; export interface IngestionPipelineStep { - id: IngestionPipelineStepId; + id: TaskPipelineStepId; label: string; status: "completed" | "failed"; } export interface TaskFileIngestionFailureAnalysis { resolvedError: string; - failedStep: IngestionPipelineStepId; + failedStep: TaskPipelineStepId; pipelineSteps: IngestionPipelineStep[]; rowStatusLabel: string; failureSummary: string; componentCause?: TaskErrorComponentCause; componentTags: string[]; - /** Short line for compact panels (truncated). */ summaryLine: string; } -const PIPELINE_STEP_ORDER: IngestionPipelineStepId[] = [ +const PIPELINE_STEP_ORDER: TaskPipelineStepId[] = [ "parsing", "chunking", "embedding", "indexing", ]; -const PIPELINE_STEP_LABELS: Record = { +const PIPELINE_STEP_LABELS: Record = { parsing: "Parsing", chunking: "Chunking", embedding: "Embedding", indexing: "Indexing", + file_validation: "File validation", + unknown: "Ingestion", }; -const COMPONENT_CAUSES: ReadonlyArray<{ - keyword: RegExp; - label: TaskErrorComponentCause; -}> = [ - { keyword: /opensearch/i, label: "OpenSearch" }, - { keyword: /docling/i, label: "Docling" }, - { keyword: /langflow/i, label: "Langflow" }, -]; - -const STEP_ERROR_SIGNALS: Record< - IngestionPipelineStepId, - ReadonlyArray -> = { - parsing: [/docling/i, /\bpars(e|ing)\b/i, /convert/i, /ocr/i], - chunking: [/chunk/i, /\bsplit/i, /segment/i], - embedding: [/embed/i, /\bvector/i, /dimension/i], - indexing: [/opensearch/i, /\bindex/i, /mapping/i, /schema/i], -}; - -const ISSUE_TYPE_SIGNALS: ReadonlyArray<{ pattern: RegExp; label: string }> = [ +const COMPONENT_LABELS: Record = { - pattern: /schema|mapping|does not match.*index/i, - label: "pipeline configuration issue", - }, - { pattern: /timeout|timed?\s*out/i, label: "timeout" }, - { - pattern: /unauthorized|forbidden|403|401|permission/i, - label: "access issue", - }, - { pattern: /connection|unreachable|network/i, label: "connectivity issue" }, - { pattern: /embed|model|dimension/i, label: "embedding configuration issue" }, - { pattern: /quota|limit|rate/i, label: "rate limit" }, -]; - -export interface FileTaskErrorDisplay { - line: string; - componentCause?: TaskErrorComponentCause; -} - -function normalizeErrorText(raw: string): string { - return raw.replace(/\s+/g, " ").trim(); -} - -function stripNoisePrefixes(text: string): string { - return text - .replace(/^Error running graph:\s*/i, "") - .replace(/^Error building Component [^:]+:\s*/i, "") - .trim(); -} + docling: "Docling", + openrag: "OpenRAG", + langflow: "Langflow", + }; function truncateLine( text: string, @@ -104,179 +70,78 @@ function truncateLine( return `${text.slice(0, maxLength - 1).trimEnd()}…`; } -function extractReadableLine(text: string): string { - const beforeCausedBy = text.split(/\s+caused by:/i)[0]?.trim() ?? text; - - if (beforeCausedBy.length <= FILE_ERROR_MAX_LINE_LENGTH) { - return beforeCausedBy; - } - - const colonParts = beforeCausedBy.split(":"); - const lastClause = colonParts[colonParts.length - 1]?.trim(); - if ( - lastClause && - lastClause.length >= 10 && - lastClause.length <= FILE_ERROR_MAX_LINE_LENGTH - ) { - return lastClause; - } - - return beforeCausedBy; -} - -export function detectComponentCause( - raw: string, +export function formatApiComponent( + component?: TaskFailureComponent, ): TaskErrorComponentCause | undefined { - for (const { keyword, label } of COMPONENT_CAUSES) { - if (keyword.test(raw)) { - return label; - } + if (!component) { + return undefined; } - return undefined; + return COMPONENT_LABELS[component]; } -function scorePipelineStepsFromError( - error: string, -): Record { - const scores: Record = { - parsing: 0, - chunking: 0, - embedding: 0, - indexing: 0, - }; - for (const step of PIPELINE_STEP_ORDER) { - for (const pattern of STEP_ERROR_SIGNALS[step]) { - if (pattern.test(error)) { - scores[step] += 1; - } - } +export function normalizeFailurePhase( + phase?: string, +): TaskPipelineStepId | undefined { + if (!phase) { + return undefined; } - return scores; -} - -function pickFailedStepFromErrorScores( - scores: Record, -): IngestionPipelineStepId | null { - const maxScore = Math.max(...PIPELINE_STEP_ORDER.map((step) => scores[step])); - if (maxScore === 0) { - return null; + if (phase in PIPELINE_STEP_LABELS) { + return phase as TaskPipelineStepId; } - const matching = PIPELINE_STEP_ORDER.filter( - (step) => scores[step] === maxScore, - ); - return matching[matching.length - 1] ?? null; + return undefined; } -function inferFailedStepFromPhase( - fileInfo: TaskFileEntry, -): IngestionPipelineStepId | null { - const phase = - typeof fileInfo.phase === "string" ? fileInfo.phase.toLowerCase() : ""; - const doclingStatus = - typeof fileInfo.docling_status === "string" - ? fileInfo.docling_status.toLowerCase() - : ""; - - if (phase === "docling" || doclingStatus === "failed") { - return "parsing"; +export function buildRowStatusLabel(failedStep: TaskPipelineStepId): string { + if (failedStep === "file_validation") { + return "File validation issue"; } - if (phase === "langflow") { - return "embedding"; + if (failedStep === "unknown") { + return "Failed"; } - if (phase === "complete") { - return "indexing"; - } - return null; + return `${PIPELINE_STEP_LABELS[failedStep]} issue`; } -export function inferFailedPipelineStep( - fileInfo: TaskFileEntry, - rawError: string, -): IngestionPipelineStepId { - const normalized = normalizeErrorText(rawError); - const fromError = pickFailedStepFromErrorScores( - scorePipelineStepsFromError(normalized), - ); - if (fromError) { - return fromError; - } - return inferFailedStepFromPhase(fileInfo) ?? "embedding"; +export function buildFailureSummary(failedStep: TaskPipelineStepId): string { + return `Failed at ${PIPELINE_STEP_LABELS[failedStep].toLowerCase()}`; } -function getPhaseMinimumFailedIndex(phase: string): number { - if (phase === "complete") { - return PIPELINE_STEP_ORDER.indexOf("indexing"); - } - if (phase === "langflow") { - return PIPELINE_STEP_ORDER.indexOf("embedding"); - } - return 0; -} - -export function buildIngestionPipelineSteps( - failedStep: IngestionPipelineStepId, - fileInfo: TaskFileEntry, +export function buildPipelineStepsFromFailurePhase( + failurePhase: TaskPipelineStepId, ): IngestionPipelineStep[] { - const phase = - typeof fileInfo.phase === "string" ? fileInfo.phase.toLowerCase() : ""; + if (failurePhase === "file_validation" || failurePhase === "unknown") { + return [ + { + id: failurePhase, + label: PIPELINE_STEP_LABELS[failurePhase], + status: "failed", + }, + ]; + } - const failedIndex = PIPELINE_STEP_ORDER.indexOf(failedStep); - const lastIndex = Math.max(failedIndex, getPhaseMinimumFailedIndex(phase)); + const failedIndex = PIPELINE_STEP_ORDER.indexOf(failurePhase); + if (failedIndex < 0) { + return [ + { id: "unknown", label: PIPELINE_STEP_LABELS.unknown, status: "failed" }, + ]; + } - return PIPELINE_STEP_ORDER.slice(0, lastIndex + 1).map((id, index) => ({ + return PIPELINE_STEP_ORDER.slice(0, failedIndex + 1).map((id, index) => ({ id, label: PIPELINE_STEP_LABELS[id], - status: index < lastIndex ? "completed" : "failed", + status: index < failedIndex ? "completed" : "failed", })); } -function inferIssueType(error: string): string | null { - for (const { pattern, label } of ISSUE_TYPE_SIGNALS) { - if (pattern.test(error)) { - return label; - } - } - return null; -} - -export function buildFailureSummary( - failedStep: IngestionPipelineStepId, - error: string, -): string { - const stepLabel = PIPELINE_STEP_LABELS[failedStep].toLowerCase(); - const issueType = inferIssueType(error); - return issueType - ? `Failed at ${stepLabel} · ${issueType}` - : `Failed at ${stepLabel}`; -} - -export function buildRowStatusLabel( - failedStep: IngestionPipelineStepId, -): string { - return `${PIPELINE_STEP_LABELS[failedStep]} issue`; -} - -export function buildComponentTags( - error: string, - componentCause?: TaskErrorComponentCause, -): string[] { - const tags: string[] = []; - if (componentCause) { - tags.push(componentCause); - } - if (/mapping/i.test(error) && !tags.includes("Mapping")) { - tags.push("Mapping"); - } - if (/schema/i.test(error) && !tags.includes("Schema")) { - tags.push("Schema"); - } - return tags; -} - export function resolveTaskFileError( fileInfo: TaskFileEntry, taskError?: string, ): string { + if (typeof fileInfo.user_facing_message === "string") { + const message = fileInfo.user_facing_message.trim(); + if (message) { + return message; + } + } if (typeof fileInfo.error === "string" && fileInfo.error.trim()) { return fileInfo.error.trim(); } @@ -291,74 +156,19 @@ export function analyzeTaskFileIngestionFailure( taskError?: string, ): TaskFileIngestionFailureAnalysis { const resolvedError = resolveTaskFileError(fileInfo, taskError); - const normalized = normalizeErrorText(resolvedError); - const componentCause = detectComponentCause(normalized); - const failedStep = inferFailedPipelineStep(fileInfo, normalized); - const pipelineSteps = buildIngestionPipelineSteps(failedStep, fileInfo); - const summaryLine = truncateLine( - extractReadableLine(stripNoisePrefixes(normalized)), - ); + const failedStep = normalizeFailurePhase(fileInfo.failure_phase) ?? "unknown"; + const pipelineSteps = buildPipelineStepsFromFailurePhase(failedStep); + const componentCause = formatApiComponent(fileInfo.component); + const componentTags = componentCause ? [componentCause] : []; return { resolvedError, failedStep, pipelineSteps, rowStatusLabel: buildRowStatusLabel(failedStep), - failureSummary: buildFailureSummary(failedStep, normalized), + failureSummary: buildFailureSummary(failedStep), componentCause, - componentTags: buildComponentTags(normalized, componentCause), - summaryLine, + componentTags, + summaryLine: truncateLine(resolvedError), }; } - -/** @deprecated Use analyzeTaskFileIngestionFailure */ -export function getIngestionPipelineSteps( - raw: string | undefined | null, - componentCause?: TaskErrorComponentCause, - fileInfo?: TaskFileEntry, -): IngestionPipelineStep[] { - const resolved = (raw ?? "").trim() || "Unknown error"; - const failedStep = fileInfo - ? inferFailedPipelineStep(fileInfo, resolved) - : (pickFailedStepFromErrorScores(scorePipelineStepsFromError(resolved)) ?? - (componentCause === "Docling" - ? "parsing" - : componentCause === "Langflow" - ? "embedding" - : componentCause === "OpenSearch" - ? "indexing" - : "embedding")); - return buildIngestionPipelineSteps( - failedStep, - fileInfo ?? - ({ phase: undefined, docling_status: undefined } as TaskFileEntry), - ); -} - -export function displayFileTaskError( - raw: string | undefined | null, - fileInfo?: TaskFileEntry, - taskError?: string, -): FileTaskErrorDisplay { - if (fileInfo) { - const analysis = analyzeTaskFileIngestionFailure(fileInfo, taskError); - return { - line: analysis.summaryLine, - componentCause: analysis.componentCause, - }; - } - - if (!raw?.trim()) { - return { line: "Unknown error" }; - } - - const normalized = normalizeErrorText(raw); - const componentCause = detectComponentCause(normalized); - let line = stripNoisePrefixes(normalized); - line = truncateLine(extractReadableLine(line)); - if (!line) { - line = "Unknown error"; - } - - return componentCause ? { line, componentCause } : { line }; -} diff --git a/frontend/lib/task-utils.ts b/frontend/lib/task-utils.ts index f586cdf8c..9de93438d 100644 --- a/frontend/lib/task-utils.ts +++ b/frontend/lib/task-utils.ts @@ -1,8 +1,7 @@ import type { Task, TaskFileEntry } from "@/app/api/queries/useGetTasksQuery"; import { buildRowStatusLabel, - inferFailedPipelineStep, - resolveTaskFileError, + normalizeFailurePhase, } from "@/lib/task-error-display"; export const ALL_TASK_FILE_TYPES = "__all__"; @@ -41,12 +40,14 @@ export function getTaskFileDialogStatusLabel( taskError?: string, ): string { if (isTaskFileFailed(fileInfo)) { - return buildRowStatusLabel( - inferFailedPipelineStep( - fileInfo, - resolveTaskFileError(fileInfo, taskError), - ), - ); + const failurePhase = normalizeFailurePhase(fileInfo.failure_phase); + if (failurePhase) { + return buildRowStatusLabel(failurePhase); + } + if (fileInfo.user_facing_message?.trim()) { + return "Failed"; + } + return buildRowStatusLabel("unknown"); } if (isTaskFileCompleted(fileInfo)) { return "Complete"; @@ -101,6 +102,32 @@ export function formatTaskFileTypeLabel(fileType: string): string { return fileType.toUpperCase(); } +export function isTaskFileRetryable(fileInfo: TaskFileEntry): boolean { + return fileInfo.actionable_by === "RETRYABLE"; +} + +export function getRetryableFileEntries( + task: Task, +): Array<[string, TaskFileEntry]> { + return getTaskFileEntries(task).filter(([, fileInfo]) => + isTaskFileRetryable(fileInfo), + ); +} + +export function getRetryableFilePaths( + entries: Array<[string, TaskFileEntry]>, +): string[] { + return entries + .filter(([, fileInfo]) => isTaskFileRetryable(fileInfo)) + .map(([filePath]) => filePath); +} + +export function countRetryIngestionFiles(task: Task): number { + return getTaskFileEntries(task).filter(([, fileInfo]) => + isTaskFileRetryable(fileInfo), + ).length; +} + function getTaskFileCategoryContext(task: Task): TaskFileCategoryContext { return { taskHasFailures: hasFailedFileEntries(task), @@ -212,9 +239,8 @@ export function filterTaskFileEntries( export function getFailedFileEntries( task: Task, ): Array<[string, TaskFileEntry]> { - return Object.entries(task.files || {}).filter( - ([, fileInfo]) => - fileInfo?.status === "failed" || fileInfo?.status === "error", + return Object.entries(task.files || {}).filter(([, fileInfo]) => + isTaskFileFailed(fileInfo), ); } @@ -257,3 +283,121 @@ export function isCompletedTotalFailure(task: Task): boolean { export function isFailureLikeTask(task: Task): boolean { return isTerminalFailedTask(task) || isCompletedWithFailures(task); } + +export function isTaskInProgressStatus(status: Task["status"]): boolean { + return ( + status === "pending" || status === "running" || status === "processing" + ); +} + +/** True when a task has just transitioned to completed. */ +export function didTaskReachCompleted( + previousTask: Task | undefined, + currentTask: Task, +): boolean { + return ( + !!previousTask && + previousTask.status !== "completed" && + currentTask.status === "completed" + ); +} + +/** + * File paths present on the previous enhanced-list payload but omitted now. + * The enhanced list drops completed files from `files` while a task is still running. + */ +/** Stable overlay key before the backend temp path is known. */ +export function pendingTaskFileSourceUrl( + taskId: string, + filename: string, +): string { + return `pending:${taskId}:${filename}`; +} + +export function isPendingTaskFileSourceUrl(sourceUrl: string): boolean { + return sourceUrl.startsWith("pending:"); +} + +export function findTaskFileOverlayIndex( + files: Array<{ task_id: string; source_url: string; filename: string }>, + taskId: string, + filePath: string, + fileName: string, +): number { + const pendingUrl = pendingTaskFileSourceUrl(taskId, fileName); + return files.findIndex( + (f) => + f.task_id === taskId && + (f.source_url === filePath || + f.source_url === pendingUrl || + (f.filename === fileName && isPendingTaskFileSourceUrl(f.source_url))), + ); +} + +export function getEnhancedListDisappearedFilePaths( + currentTask: Task, + previousTask: Task, +): string[] { + const currentKeys = new Set(Object.keys(currentTask.files ?? {})); + return Object.keys(previousTask.files ?? {}).filter( + (filePath) => !currentKeys.has(filePath), + ); +} + +interface ProcessingFileOverlay { + task_id: string; + source_url: string; + status: "active" | "failed" | "processing"; + error?: string; +} + +/** + * Promote local processing overlays when the enhanced list omits completed files. + * Pass `disappearedPaths` while the task is in progress; omit it when the task completes + * to finalize every remaining processing file for that task. + */ +export function finalizeProcessingOverlaysForEnhancedTask< + T extends ProcessingFileOverlay, +>(prevFiles: T[], currentTask: Task, disappearedPaths?: string[]): T[] { + const pathsFilter = + disappearedPaths === undefined ? null : new Set(disappearedPaths); + let changed = false; + + const updated = prevFiles.map((file) => { + if (file.task_id !== currentTask.task_id) { + return file; + } + if (pathsFilter !== null && !pathsFilter.has(file.source_url)) { + return file; + } + // Overlays can still be "failed" until the list poll sees a retry as running. + if (file.status !== "processing" && file.status !== "failed") { + return file; + } + + const entry = currentTask.files?.[file.source_url]; + if (entry && isTaskFileFailed(entry)) { + if (file.status === "failed") { + const error = + typeof entry.error === "string" && entry.error.trim().length > 0 + ? entry.error.trim() + : file.error; + if (error === file.error) { + return file; + } + } + changed = true; + const error = + typeof entry.error === "string" && entry.error.trim().length > 0 + ? entry.error.trim() + : file.error; + return { ...file, status: "failed" as const, error }; + } + + // Left the enhanced list (completed files are omitted) or task finished. + changed = true; + return { ...file, status: "active" as const, error: undefined }; + }); + + return changed ? (updated as T[]) : prevFiles; +} diff --git a/frontend/tests/core/tasks-unified-panel.spec.ts b/frontend/tests/core/tasks-unified-panel.spec.ts index 7bb57146a..3d6797a29 100644 --- a/frontend/tests/core/tasks-unified-panel.spec.ts +++ b/frontend/tests/core/tasks-unified-panel.spec.ts @@ -64,7 +64,11 @@ const buildTask = ( const wireTasksState = async (page: Page, initialTasks: MockTask[]) => { let currentTasks = initialTasks; - await page.route("**/api/tasks", async (route: Route) => { + await page.route("**/api/tasks**", async (route: Route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } await route.fulfill({ status: 200, contentType: "application/json", diff --git a/src/api/tasks.py b/src/api/tasks.py index c5c779ce7..02435567d 100644 --- a/src/api/tasks.py +++ b/src/api/tasks.py @@ -1,5 +1,6 @@ -from fastapi import Depends +from fastapi import Body, Depends from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from dependencies import get_current_user, get_task_service from session_manager import User @@ -50,6 +51,39 @@ async def all_tasks_enhanced( return JSONResponse({"tasks": tasks}) +class RetryTaskBody(BaseModel): + file_paths: list[str] | None = Field( + default=None, + description=( + "Optional subset of task file paths to retry. " + "Omit to retry all failed RETRYABLE files." + ), + ) + + +async def retry_task( + task_id: str, + body: RetryTaskBody = Body(default_factory=RetryTaskBody), + task_service=Depends(get_task_service), + user: User = Depends(get_current_user), +): + """Re-run ingestion for failed RETRYABLE files in an existing task.""" + result = await task_service.retry_failed_files( + user.user_id, task_id, file_paths=body.file_paths + ) + if result is None: + return JSONResponse({"error": "Task not found"}, status_code=404) + + if result.get("error") == "task_in_progress": + return JSONResponse(result, status_code=409) + + if result.get("status") == "no_op": + return JSONResponse(result, status_code=400) + + await TelemetryClient.send_event(Category.TASK_OPERATIONS, MessageId.ORB_TASK_CREATED) + return JSONResponse(result, status_code=202) + + async def cancel_task( task_id: str, task_service=Depends(get_task_service), diff --git a/src/app/routes/internal.py b/src/app/routes/internal.py index 86b13e80e..21bc25e7e 100644 --- a/src/app/routes/internal.py +++ b/src/app/routes/internal.py @@ -91,6 +91,12 @@ def register_internal_routes(app: FastAPI): tags=["internal"], ) app.add_api_route("/tasks", tasks.all_tasks, methods=["GET"], tags=["internal"]) + app.add_api_route( + "/tasks/{task_id}/retry", + tasks.retry_task, + methods=["POST"], + tags=["internal"], + ) app.add_api_route( "/tasks/{task_id}/cancel", tasks.cancel_task, diff --git a/src/services/task_service.py b/src/services/task_service.py index 20604ff84..4fa4d6c5b 100644 --- a/src/services/task_service.py +++ b/src/services/task_service.py @@ -17,6 +17,48 @@ logger = get_logger(__name__) +# Substrings that indicate a permanent file/content problem (not worth retrying). +_NON_RETRYABLE_FILE_ERROR_MARKERS = ( + "corrupt", + "corrupted", + "corruption", + "invalid file", + "invalid document", + "unsupported format", + "unsupported file", + "malformed", + "damaged", + "not a zip file", + "bad zipfile", + "failed to parse", + "could not be parsed", + "cannot parse", + "no text content could be extracted", + "empty or unreadable", + "unreadable", + "validationerror", +) + +# Docling polling errors that are transient (service/timeout), not bad file content. +_DOCLING_TRANSIENT_ERROR_MARKERS = ( + "(timeout)", + "timed out", + "polling exceeded", + "polling timed out", +) + + +def _is_non_retryable_file_error(error: str) -> bool: + lowered = error.lower() + return any(marker in lowered for marker in _NON_RETRYABLE_FILE_ERROR_MARKERS) + + +def _is_docling_transient_error(error: str) -> bool: + lowered = error.lower() + if "docling conversion did not complete" not in lowered: + return False + return any(marker in lowered for marker in _DOCLING_TRANSIENT_ERROR_MARKERS) + class IngestionTimeoutError(Exception): """Raised when file processing exceeds the configured timeout""" @@ -590,6 +632,155 @@ def _resolve_upload_task(self, user_id: str, task_id: str) -> UploadTask | None: return self.task_store[candidate_user_id][task_id] return None + def _resolve_upload_task_store( + self, user_id: str, task_id: str + ) -> tuple[str, UploadTask] | None: + """Return (store_user_id, upload_task) for a task visible to this user.""" + if not task_id: + return None + for candidate_user_id in [user_id, AnonymousUser().user_id]: + if ( + candidate_user_id in self.task_store + and task_id in self.task_store[candidate_user_id] + ): + return candidate_user_id, self.task_store[candidate_user_id][task_id] + return None + + async def retry_failed_files( + self, + user_id: str, + task_id: str, + *, + file_paths: list[str] | None = None, + retryable_only: bool = True, + ) -> dict | None: + """Re-queue failed files for ingestion when their source paths still exist. + + Only files classified as RETRYABLE are retried when *retryable_only* is + True (the default). When *file_paths* is set, only those task paths are + considered; paths missing from the task or not in a failed state are + reported in *skipped*. This reuses the task's original processor — it + does not accept new uploads from the client. + """ + resolved = self._resolve_upload_task_store(user_id, task_id) + if resolved is None: + return None + + store_user_id, upload_task = resolved + + if upload_task.status == TaskStatus.RUNNING: + return { + "error": "task_in_progress", + "message": "Task is still running", + "task_id": task_id, + } + + processor = upload_task.processor + if processor is None: + return { + "error": "no_processor", + "message": "Cannot retry: task processor is no longer available", + "task_id": task_id, + } + + paths_to_retry: list[str] = [] + skipped: list[dict] = [] + requested_paths = set(file_paths) if file_paths is not None else None + + if requested_paths is not None: + for path in requested_paths: + file_task = upload_task.file_tasks.get(path) + if file_task is None: + skipped.append({"file_path": path, "reason": "file_not_in_task"}) + elif file_task.status != TaskStatus.FAILED: + skipped.append( + { + "file_path": path, + "filename": file_task.filename, + "reason": "not_failed", + } + ) + + # Build retry candidates before mutating shared task/file state. + retry_candidates: list[tuple[str, FileTask]] = [] + for file_path, file_task in list(upload_task.file_tasks.items()): + if requested_paths is not None and file_path not in requested_paths: + continue + if file_task.status != TaskStatus.FAILED: + continue + + if retryable_only: + metadata = self._infer_failure_metadata(file_task) + if not metadata or metadata.get("actionable_by") != "RETRYABLE": + skipped.append( + { + "file_path": file_path, + "filename": file_task.filename, + "reason": "not_retryable", + } + ) + continue + + if not os.path.isfile(file_path): + skipped.append( + { + "file_path": file_path, + "filename": file_task.filename, + "reason": "source_file_missing", + } + ) + continue + + retry_candidates.append((file_path, file_task)) + + # All shared task/file state transitions are protected by the task lock + # so retry requests and background processors cannot interleave updates. + async with self._get_task_lock(task_id): + now = time.time() + for file_path, file_task in retry_candidates: + if upload_task.failed_files > 0: + upload_task.failed_files -= 1 + if upload_task.processed_files > 0: + upload_task.processed_files -= 1 + + file_task.status = TaskStatus.PENDING + file_task.error = None + file_task.result = None + file_task.retry_count += 1 + file_task.docling_status = DoclingPhaseStatus.PENDING + file_task.phase = IngestionPhase.DOCLING + file_task.docling_task_id = None + file_task.updated_at = now + paths_to_retry.append(file_path) + + if not paths_to_retry: + return { + "task_id": task_id, + "retried": 0, + "skipped": skipped, + "status": "no_op", + "message": "No retryable files with available source data", + } + + async with self._get_task_lock(task_id): + upload_task.status = TaskStatus.RUNNING + upload_task.updated_at = time.time() + + background_task = asyncio.create_task( + self.background_custom_processor( + store_user_id, task_id, paths_to_retry, processor + ) + ) + self.background_tasks.add(background_task) + background_task.add_done_callback(self.background_tasks.discard) + + return { + "task_id": task_id, + "retried": len(paths_to_retry), + "skipped": skipped, + "status": "accepted", + } + def _serialize_file_task(self, file_task: FileTask) -> dict: """Serialize a FileTask to the standard dict shape.""" return { @@ -613,35 +804,42 @@ def _infer_failure_metadata(self, file_task: FileTask) -> dict | None: actionable_by when the failure can be classified, or None when the cause is unknown and no fields should be emitted. - Priority order: docling_status enum first (stable), error string patterns - second (fallback for edge cases like polling timeout). + Priority order: transient / retryable docling outcomes (expired, polling + timeout) before generic docling FAILED, which indicates conversion failure. """ docling_status = file_task.docling_status phase = file_task.phase error = file_task.error or "" - if docling_status == DoclingPhaseStatus.FAILED: + if docling_status == DoclingPhaseStatus.EXPIRED: return { "component": "docling", "failure_phase": "parsing", "user_facing_message": ( - "The file could not be processed into readable document content." + "The document processing result could not be found. " + "The task may have expired. Please retry ingestion." ), - "actionable_by": "USER_ACTIONABLE", + "actionable_by": "RETRYABLE", } - if docling_status == DoclingPhaseStatus.EXPIRED: + if _is_non_retryable_file_error(error): + if phase == IngestionPhase.LANGFLOW: + component = "langflow" + failure_phase = "unknown" + else: + component = "docling" + failure_phase = "parsing" return { - "component": "docling", - "failure_phase": "parsing", + "component": component, + "failure_phase": failure_phase, "user_facing_message": ( - "The document processing result could not be found. " - "The task may have expired. Please retry ingestion." + "The file appears corrupted or invalid and cannot be processed. " + "Upload a valid file." ), - "actionable_by": "RETRYABLE", + "actionable_by": "USER_ACTIONABLE", } - if phase == IngestionPhase.DOCLING and "Docling conversion did not complete" in error: + if phase == IngestionPhase.DOCLING and _is_docling_transient_error(error): return { "component": "docling", "failure_phase": "parsing", @@ -657,6 +855,16 @@ def _infer_failure_metadata(self, file_task: FileTask) -> dict | None: "actionable_by": "RETRYABLE", } + if docling_status == DoclingPhaseStatus.FAILED: + return { + "component": "docling", + "failure_phase": "parsing", + "user_facing_message": ( + "The file could not be processed into readable document content." + ), + "actionable_by": "USER_ACTIONABLE", + } + if "already exists" in error: return { "component": "openrag", @@ -666,6 +874,19 @@ def _infer_failure_metadata(self, file_task: FileTask) -> dict | None: } if phase == IngestionPhase.LANGFLOW: + error_lower = error.lower() + if any( + marker in error_lower + for marker in ("timeout", "timed out", "unavailable", "connection refused") + ): + return { + "component": "langflow", + "failure_phase": "unknown", + "user_facing_message": ( + "Ingestion timed out or the service was unavailable. Please retry." + ), + "actionable_by": "RETRYABLE", + } return { "component": "langflow", "failure_phase": "unknown", diff --git a/tests/unit/test_task_service_get_task_status2.py b/tests/unit/test_task_service_get_task_status2.py index 01b54321b..b141455cd 100644 --- a/tests/unit/test_task_service_get_task_status2.py +++ b/tests/unit/test_task_service_get_task_status2.py @@ -87,6 +87,57 @@ def test_docling_timeout_via_error_string(self, task_service): assert meta["failure_phase"] == "parsing" assert meta["actionable_by"] == "RETRYABLE" + def test_docling_polling_timeout_sets_failed_status(self, task_service): + """Backend polling marks TIMEOUT as docling_status=FAILED; still retryable.""" + ft = _make_file_task( + phase=IngestionPhase.DOCLING, + docling_status=DoclingPhaseStatus.FAILED, + error=( + "Docling conversion did not complete (timeout): " + "Docling polling timed out after 10s" + ), + ) + meta = task_service._infer_failure_metadata(ft) + assert meta is not None + assert meta["actionable_by"] == "RETRYABLE" + + def test_docling_conversion_failed_not_retryable(self, task_service): + """Permanent conversion failure (e.g. corrupt file) is not retryable.""" + ft = _make_file_task( + phase=IngestionPhase.DOCLING, + docling_status=DoclingPhaseStatus.FAILED, + error=( + "Docling conversion did not complete (failed): " + "Docling reported failure" + ), + ) + meta = task_service._infer_failure_metadata(ft) + assert meta is not None + assert meta["actionable_by"] == "USER_ACTIONABLE" + + def test_corrupt_docx_bad_zip_not_retryable(self, task_service): + ft = _make_file_task( + phase=IngestionPhase.DOCLING, + docling_status=DoclingPhaseStatus.FAILED, + error=( + "Docling conversion did not complete (failed): " + "BadZipFile: File is not a zip file" + ), + ) + meta = task_service._infer_failure_metadata(ft) + assert meta is not None + assert meta["actionable_by"] == "USER_ACTIONABLE" + + def test_langflow_empty_content_not_retryable(self, task_service): + ft = _make_file_task( + phase=IngestionPhase.LANGFLOW, + docling_status=DoclingPhaseStatus.SUCCESS, + error="No text content could be extracted from document", + ) + meta = task_service._infer_failure_metadata(ft) + assert meta is not None + assert meta["actionable_by"] == "USER_ACTIONABLE" + def test_docling_phase_still_processing(self, task_service): ft = _make_file_task( phase=IngestionPhase.DOCLING, From f418f5fe3d9fb7163289ee4073fc6c54b639b7e4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 21:34:43 +0000 Subject: [PATCH 15/25] style: ruff autofix (auto) --- src/api/tasks.py | 3 +-- src/services/task_service.py | 4 +--- tests/unit/test_task_service_get_task_status2.py | 11 +++-------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/api/tasks.py b/src/api/tasks.py index 02435567d..ed27b24a0 100644 --- a/src/api/tasks.py +++ b/src/api/tasks.py @@ -55,8 +55,7 @@ class RetryTaskBody(BaseModel): file_paths: list[str] | None = Field( default=None, description=( - "Optional subset of task file paths to retry. " - "Omit to retry all failed RETRYABLE files." + "Optional subset of task file paths to retry. Omit to retry all failed RETRYABLE files." ), ) diff --git a/src/services/task_service.py b/src/services/task_service.py index 4fa4d6c5b..9322cf13e 100644 --- a/src/services/task_service.py +++ b/src/services/task_service.py @@ -767,9 +767,7 @@ async def retry_failed_files( upload_task.updated_at = time.time() background_task = asyncio.create_task( - self.background_custom_processor( - store_user_id, task_id, paths_to_retry, processor - ) + self.background_custom_processor(store_user_id, task_id, paths_to_retry, processor) ) self.background_tasks.add(background_task) background_task.add_done_callback(self.background_tasks.discard) diff --git a/tests/unit/test_task_service_get_task_status2.py b/tests/unit/test_task_service_get_task_status2.py index b141455cd..8d7a4c082 100644 --- a/tests/unit/test_task_service_get_task_status2.py +++ b/tests/unit/test_task_service_get_task_status2.py @@ -93,8 +93,7 @@ def test_docling_polling_timeout_sets_failed_status(self, task_service): phase=IngestionPhase.DOCLING, docling_status=DoclingPhaseStatus.FAILED, error=( - "Docling conversion did not complete (timeout): " - "Docling polling timed out after 10s" + "Docling conversion did not complete (timeout): Docling polling timed out after 10s" ), ) meta = task_service._infer_failure_metadata(ft) @@ -106,10 +105,7 @@ def test_docling_conversion_failed_not_retryable(self, task_service): ft = _make_file_task( phase=IngestionPhase.DOCLING, docling_status=DoclingPhaseStatus.FAILED, - error=( - "Docling conversion did not complete (failed): " - "Docling reported failure" - ), + error=("Docling conversion did not complete (failed): Docling reported failure"), ) meta = task_service._infer_failure_metadata(ft) assert meta is not None @@ -120,8 +116,7 @@ def test_corrupt_docx_bad_zip_not_retryable(self, task_service): phase=IngestionPhase.DOCLING, docling_status=DoclingPhaseStatus.FAILED, error=( - "Docling conversion did not complete (failed): " - "BadZipFile: File is not a zip file" + "Docling conversion did not complete (failed): BadZipFile: File is not a zip file" ), ) meta = task_service._infer_failure_metadata(ft) From 4fff47b817f2d6d1337e50825fbf55d462d470d9 Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 27 May 2026 20:54:27 -0400 Subject: [PATCH 16/25] lint fix and re-rendering in gridRows changes --- .../app/api/mutations/useRetryTaskMutation.ts | 79 ++++++++++ frontend/app/knowledge/page.tsx | 68 ++++++--- frontend/lib/task-error-display.ts | 1 + .../test_task_service_retry_failed_files.py | 137 ++++++++++++++++++ 4 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 frontend/app/api/mutations/useRetryTaskMutation.ts create mode 100644 tests/unit/test_task_service_retry_failed_files.py diff --git a/frontend/app/api/mutations/useRetryTaskMutation.ts b/frontend/app/api/mutations/useRetryTaskMutation.ts new file mode 100644 index 000000000..7843b3720 --- /dev/null +++ b/frontend/app/api/mutations/useRetryTaskMutation.ts @@ -0,0 +1,79 @@ +import { + type UseMutationOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { taskDetailQueryKey } from "@/app/api/queries/useGetTaskQuery"; + +export interface RetryTaskRequest { + taskId: string; + /** When set, only these task file paths are retried. Omit to retry all failed RETRYABLE files. */ + filePaths?: string[]; +} + +export interface RetryTaskSkippedFile { + file_path: string; + filename?: string; + reason: + | "not_retryable" + | "source_file_missing" + | "file_not_in_task" + | "not_failed" + | string; +} + +export interface RetryTaskResponse { + task_id: string; + retried: number; + skipped: RetryTaskSkippedFile[]; + status: string; + message?: string; + error?: string; +} + +export const useRetryTaskMutation = ( + options?: Omit< + UseMutationOptions, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + + async function retryTask( + variables: RetryTaskRequest, + ): Promise { + const body = + variables.filePaths != null + ? JSON.stringify({ file_paths: variables.filePaths }) + : undefined; + + const response = await fetch(`/api/tasks/${variables.taskId}/retry`, { + method: "POST", + headers: body ? { "Content-Type": "application/json" } : undefined, + body, + }); + + const payload = (await response + .json() + .catch(() => ({}))) as RetryTaskResponse; + + if (!response.ok) { + throw new Error( + payload.message || payload.error || "Failed to retry task files", + ); + } + + return payload; + } + + return useMutation({ + mutationFn: retryTask, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tasks"], exact: false }); + queryClient.invalidateQueries({ + queryKey: taskDetailQueryKey(variables.taskId), + }); + }, + ...options, + }); +}; diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index 086148c55..eed4f51d6 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -67,6 +67,14 @@ import { useSyncAllConnectorsPreview, } from "../api/mutations/useSyncConnector"; +function sameFileSelection(a: File[], b: File[]): boolean { + if (a.length !== b.length) { + return false; + } + const identities = new Set(b.map((row) => getKnowledgeFileIdentity(row))); + return a.every((row) => identities.has(getKnowledgeFileIdentity(row))); +} + /** Failed overlays can stay selected after they lose their checkbox (processing → failed). */ function syncGridSelectionToDeletableRows( api: NonNullable["api"]>, @@ -80,6 +88,21 @@ function syncGridSelectionToDeletableRows( return api.getSelectedRows().filter(isDeletable); } +/** Deselect non-deletable rows in the grid only; returns whether anything changed. */ +function pruneNonDeletableGridSelection( + api: NonNullable["api"]>, + isDeletable: (file?: File) => boolean, +): boolean { + let pruned = false; + api.forEachNode((node) => { + if (node.isSelected() && !isDeletable(node.data)) { + node.setSelected(false); + pruned = true; + } + }); + return pruned; +} + /** List-files uses term filters; "*" means "any" in the UI — do not send it literally. */ function listFilesFilterParam(values?: string[]): string | undefined { const raw = values?.[0]?.trim(); @@ -464,28 +487,30 @@ function SearchPage() { const gridRows = fileResults; const gridRef = useRef(null); + // Re-run only when row identity/status changes, not on every list poll reference. + const gridRowsSelectionKey = useMemo( + () => + gridRows + .map( + (row) => `${getKnowledgeFileIdentity(row)}:${row.status ?? "active"}`, + ) + .join("\0"), + [gridRows], + ); + useEffect(() => { const api = gridRef.current?.api; if (!api) { return; } - const activeSelected = syncGridSelectionToDeletableRows( - api, - isDeletableKnowledgeRow, + if (!pruneNonDeletableGridSelection(api, isDeletableKnowledgeRow)) { + return; + } + const nextSelected = api.getSelectedRows().filter(isDeletableKnowledgeRow); + setSelectedRows((current) => + sameFileSelection(current, nextSelected) ? current : nextSelected, ); - setSelectedRows((current) => { - if ( - current.length === activeSelected.length && - current.every( - (row, index) => - getFileIdentity(row) === getFileIdentity(activeSelected[index]), - ) - ) { - return current; - } - return activeSelected; - }); - }, [gridRows, getFileIdentity, isDeletableKnowledgeRow]); + }, [gridRowsSelectionKey, isDeletableKnowledgeRow]); const columnDefs: ColDef[] = [ { @@ -758,11 +783,12 @@ function SearchPage() { if (!gridRef.current) { return; } - setSelectedRows( - syncGridSelectionToDeletableRows( - gridRef.current.api, - isDeletableKnowledgeRow, - ), + const nextSelected = syncGridSelectionToDeletableRows( + gridRef.current.api, + isDeletableKnowledgeRow, + ); + setSelectedRows((current) => + sameFileSelection(current, nextSelected) ? current : nextSelected, ); }, [isDeletableKnowledgeRow]); diff --git a/frontend/lib/task-error-display.ts b/frontend/lib/task-error-display.ts index 6ba87d05e..bbe2e8277 100644 --- a/frontend/lib/task-error-display.ts +++ b/frontend/lib/task-error-display.ts @@ -58,6 +58,7 @@ const COMPONENT_LABELS: Record = docling: "Docling", openrag: "OpenRAG", langflow: "Langflow", + opensearch: "OpenSearch", }; function truncateLine( diff --git a/tests/unit/test_task_service_retry_failed_files.py b/tests/unit/test_task_service_retry_failed_files.py new file mode 100644 index 000000000..cf29433b5 --- /dev/null +++ b/tests/unit/test_task_service_retry_failed_files.py @@ -0,0 +1,137 @@ +"""Unit tests for TaskService.retry_failed_files.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from models.tasks import DoclingPhaseStatus, FileTask, IngestionPhase, TaskStatus, UploadTask +from services.task_service import TaskService + + +@pytest.fixture +def task_service(): + return TaskService(document_service=Mock(), ingestion_timeout=2) + + +def _retryable_failed_file( + file_path: str = "/data/doc.pdf", + *, + error: str = "Docling conversion did not complete (timeout)", +) -> FileTask: + ft = FileTask(file_path=file_path, filename="doc.pdf") + ft.status = TaskStatus.FAILED + ft.phase = IngestionPhase.DOCLING + ft.docling_status = DoclingPhaseStatus.FAILED + ft.error = error + return ft + + +def _store_task( + task_service: TaskService, + user_id: str, + upload_task: UploadTask, +) -> None: + task_service.task_store.setdefault(user_id, {})[upload_task.task_id] = upload_task + + +@pytest.mark.asyncio +async def test_retry_all_failed_retryable_files(task_service): + processor = Mock() + ft_a = _retryable_failed_file("/data/a.pdf") + ft_b = _retryable_failed_file("/data/b.pdf") + task = UploadTask( + task_id="task-1", + total_files=2, + file_tasks={"/data/a.pdf": ft_a, "/data/b.pdf": ft_b}, + status=TaskStatus.FAILED, + failed_files=2, + processor=processor, + ) + _store_task(task_service, "user1", task) + + with ( + patch("os.path.isfile", return_value=True), + patch.object( + task_service, + "background_custom_processor", + new_callable=AsyncMock, + ) as mock_bg, + patch("asyncio.create_task", return_value=Mock()), + ): + result = await task_service.retry_failed_files("user1", "task-1") + + assert result is not None + assert result["retried"] == 2 + assert result["status"] == "accepted" + assert ft_a.status == TaskStatus.PENDING + assert ft_b.status == TaskStatus.PENDING + mock_bg.assert_called_once() + assert set(mock_bg.call_args[0][2]) == {"/data/a.pdf", "/data/b.pdf"} + + +@pytest.mark.asyncio +async def test_retry_subset_by_file_paths(task_service): + processor = Mock() + ft_a = _retryable_failed_file("/data/a.pdf") + ft_b = _retryable_failed_file("/data/b.pdf") + task = UploadTask( + task_id="task-1", + total_files=2, + file_tasks={"/data/a.pdf": ft_a, "/data/b.pdf": ft_b}, + status=TaskStatus.FAILED, + failed_files=2, + processor=processor, + ) + _store_task(task_service, "user1", task) + + with ( + patch("os.path.isfile", return_value=True), + patch.object( + task_service, + "background_custom_processor", + new_callable=AsyncMock, + ) as mock_bg, + patch("asyncio.create_task", return_value=Mock()), + ): + result = await task_service.retry_failed_files( + "user1", + "task-1", + file_paths=["/data/a.pdf"], + ) + + assert result is not None + assert result["retried"] == 1 + assert ft_a.status == TaskStatus.PENDING + assert ft_b.status == TaskStatus.FAILED + mock_bg.assert_called_once() + assert mock_bg.call_args[0][2] == ["/data/a.pdf"] + + +@pytest.mark.asyncio +async def test_retry_unknown_path_is_skipped(task_service): + processor = Mock() + ft_a = _retryable_failed_file("/data/a.pdf") + task = UploadTask( + task_id="task-1", + total_files=1, + file_tasks={"/data/a.pdf": ft_a}, + status=TaskStatus.FAILED, + failed_files=1, + processor=processor, + ) + _store_task(task_service, "user1", task) + + with patch("os.path.isfile", return_value=True): + result = await task_service.retry_failed_files( + "user1", + "task-1", + file_paths=["/data/missing.pdf"], + ) + + assert result is not None + assert result["retried"] == 0 + assert result["status"] == "no_op" + assert result["skipped"] == [ + {"file_path": "/data/missing.pdf", "reason": "file_not_in_task"} + ] + assert ft_a.status == TaskStatus.FAILED From 42816a1f4b94c3656ca5efd1146110c9f6d02174 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 00:55:09 +0000 Subject: [PATCH 17/25] style: ruff autofix (auto) --- tests/unit/test_task_service_retry_failed_files.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/test_task_service_retry_failed_files.py b/tests/unit/test_task_service_retry_failed_files.py index cf29433b5..dc50e818b 100644 --- a/tests/unit/test_task_service_retry_failed_files.py +++ b/tests/unit/test_task_service_retry_failed_files.py @@ -131,7 +131,5 @@ async def test_retry_unknown_path_is_skipped(task_service): assert result is not None assert result["retried"] == 0 assert result["status"] == "no_op" - assert result["skipped"] == [ - {"file_path": "/data/missing.pdf", "reason": "file_not_in_task"} - ] + assert result["skipped"] == [{"file_path": "/data/missing.pdf", "reason": "file_not_in_task"}] assert ft_a.status == TaskStatus.FAILED From 8a9b7011374fb34b73cae47592f699cb5cef6de9 Mon Sep 17 00:00:00 2001 From: Olfa Maslah Date: Wed, 27 May 2026 23:18:59 -0400 Subject: [PATCH 18/25] normalize task dialog styling with tokens and brand-aware surfaces: add task-dialog layout/color tokens in frontend/app/globals.css expose tokenized utilities in frontend/tailwind.config.ts apply OSS dialog background #171717 and OSS selected surfaces #232323 via tokens keep cloud contextual surfaces aligned and set cloud dialog typography to IBM Plex Sans improve task dialog UX details: update tabs/source row/chips/filter surfaces and spacing consistency make dropdown chevron reflect open/closed state align error details layout and keep failure flag + component tags inline harden mutation/query behavior: merge caller onSuccess with internal invalidation in useCancelTaskMutation and useRetryTaskMutation fix knowledge-page selection/delete flow: always sync grid selection state (remove early return) route bulk delete through useDeleteDocument().mutateAsync to honor mutation pending state keep query invalidation/refetch and deletion result handling explicit minor cleanup: remove unused category chip entry (partial) and simplify related dialog code paths --- .../api/mutations/useCancelTaskMutation.ts | 10 +- .../app/api/mutations/useRetryTaskMutation.ts | 9 +- frontend/app/globals.css | 11 + frontend/app/knowledge/page.tsx | 37 +-- .../components/task-dialog/category-chips.tsx | 12 +- frontend/components/task-dialog/constants.ts | 14 +- .../task-dialog/file-error-details.tsx | 106 ++++----- frontend/components/task-dialog/file-list.tsx | 27 +-- frontend/components/task-dialog/filters.tsx | 210 +++++++++++------- frontend/components/task-dialog/header.tsx | 10 +- .../components/task-dialog/task-dialog.tsx | 48 +++- .../components/task-dialog/use-task-dialog.ts | 4 +- frontend/lib/task-utils.ts | 40 +--- frontend/tailwind.config.ts | 16 ++ src/api/tasks.py | 2 +- src/services/task_service.py | 114 +++++----- src/utils/telemetry/message_id.py | 2 + 17 files changed, 367 insertions(+), 305 deletions(-) diff --git a/frontend/app/api/mutations/useCancelTaskMutation.ts b/frontend/app/api/mutations/useCancelTaskMutation.ts index e96c0c19d..dd27b68f0 100644 --- a/frontend/app/api/mutations/useCancelTaskMutation.ts +++ b/frontend/app/api/mutations/useCancelTaskMutation.ts @@ -36,12 +36,16 @@ export const useCancelTaskMutation = ( return response.json(); } + const { onSuccess, onError, onSettled, ...restOptions } = options ?? {}; + return useMutation({ mutationFn: cancelTask, - onSuccess: () => { - // Invalidate tasks query to refresh the list + ...restOptions, + onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: ["tasks"], exact: false }); + onSuccess?.(data, variables, context); }, - ...options, + onError, + onSettled, }); }; diff --git a/frontend/app/api/mutations/useRetryTaskMutation.ts b/frontend/app/api/mutations/useRetryTaskMutation.ts index 7843b3720..6e9305c48 100644 --- a/frontend/app/api/mutations/useRetryTaskMutation.ts +++ b/frontend/app/api/mutations/useRetryTaskMutation.ts @@ -66,14 +66,19 @@ export const useRetryTaskMutation = ( return payload; } + const { onSuccess, onError, onSettled, ...restOptions } = options ?? {}; + return useMutation({ mutationFn: retryTask, - onSuccess: (_data, variables) => { + ...restOptions, + onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: ["tasks"], exact: false }); queryClient.invalidateQueries({ queryKey: taskDetailQueryKey(variables.taskId), }); + onSuccess?.(data, variables, context); }, - ...options, + onError, + onSettled, }); }; diff --git a/frontend/app/globals.css b/frontend/app/globals.css index bdb8cd5d5..afdeda928 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -33,6 +33,15 @@ /* App-level UI tokens (safe defaults, override per brand/theme) */ --canvas: 0 0% 100%; --border-subtle-background-contextual: var(--border); + /* Task dialog layout (file row indent = px + checkbox + gaps + chevron) */ + --task-dialog-width: 40rem; + --task-dialog-max-height: 90vh; + --task-dialog-file-type-width: 10rem; + --task-dialog-error-indent: 4.5rem; + --task-dialog-error-indent-cloud: 4.75rem; + --task-dialog-oss-bg: var(--background); + --task-dialog-oss-selected: var(--muted); + --z-task-dialog-menu: 100; --layered-select-bg: hsl(var(--muted) / 0.5); --chat-surface-gradient: rgba(69, 137, 255, 0.16); --chat-input-border: hsl(var(--input)); @@ -122,6 +131,8 @@ /* App-level UI tokens (safe defaults, override per brand/theme) */ --canvas: 0 0% 3.9%; /* #0A0A0A */ --border-subtle-background-contextual: var(--border); + --task-dialog-oss-bg: 0 0% 9.02%; /* #171717 */ + --task-dialog-oss-selected: 0 0% 13.73%; /* #232323 */ --layered-select-bg: hsl(var(--muted) / 0.5); --chat-surface-gradient: rgba(69, 137, 255, 0.16); --chat-input-border: hsl(var(--input)); diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index eed4f51d6..aef291d5e 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -56,10 +56,7 @@ import IBMCOSIcon from "../../components/icons/ibm-cos-icon"; import OneDriveIcon from "../../components/icons/one-drive-logo"; import SharePointIcon from "../../components/icons/share-point-logo"; import { SyncConfirmDialog } from "../../components/sync-confirm-dialog"; -import { - deleteDocumentByFilename, - useDeleteDocument, -} from "../api/mutations/useDeleteDocument"; +import { useDeleteDocument } from "../api/mutations/useDeleteDocument"; import { useRefreshOpenragDocs } from "../api/mutations/useRefreshOpenragDocs"; import { type SyncAllPreviewResponse, @@ -366,25 +363,9 @@ function SearchPage() { return getKnowledgeFileIdentity(file); }, []); - const indexedFileIdentities = useMemo( - () => - new Set( - effectiveData - .map((file) => getKnowledgeFileIdentity(file)) - .filter(Boolean), - ), - [effectiveData], - ); - - const isDeletableKnowledgeRow = useCallback( - (file?: File) => { - if ((file?.status || "active") !== "active") { - return false; - } - return indexedFileIdentities.has(getKnowledgeFileIdentity(file)); - }, - [indexedFileIdentities], - ); + const isDeletableKnowledgeRow = useCallback((file?: File) => { + return (file?.status || "active") === "active"; + }, []); const resolveDeleteFilename = useCallback( (row: File) => { @@ -503,9 +484,7 @@ function SearchPage() { if (!api) { return; } - if (!pruneNonDeletableGridSelection(api, isDeletableKnowledgeRow)) { - return; - } + pruneNonDeletableGridSelection(api, isDeletableKnowledgeRow); const nextSelected = api.getSelectedRows().filter(isDeletableKnowledgeRow); setSelectedRows((current) => sameFileSelection(current, nextSelected) ? current : nextSelected, @@ -799,7 +778,9 @@ function SearchPage() { try { const deleteResults = await Promise.allSettled( rowsToDelete.map((row) => - deleteDocumentByFilename(resolveDeleteFilename(row)), + deleteDocumentMutation.mutateAsync({ + filename: resolveDeleteFilename(row), + }), ), ); @@ -813,7 +794,7 @@ function SearchPage() { ( result, ): result is PromiseFulfilledResult< - Awaited> + Awaited> > => result.status === "fulfilled" && (result.value.deleted_chunks || 0) > 0, diff --git a/frontend/components/task-dialog/category-chips.tsx b/frontend/components/task-dialog/category-chips.tsx index 3ed341cb8..d009c5f97 100644 --- a/frontend/components/task-dialog/category-chips.tsx +++ b/frontend/components/task-dialog/category-chips.tsx @@ -26,7 +26,7 @@ export function TaskDialogCategoryChips({
{CATEGORY_CHIPS.map((chip) => { @@ -47,9 +47,13 @@ export function TaskDialogCategoryChips({ "inline-flex min-h-10 items-center gap-2 border text-sm transition-colors", isCloudBrand ? "px-4" : "px-3", isCloudBrand ? "rounded-full" : "rounded-lg", - isActive - ? "border-badge bg-badge text-foreground" - : "border-border bg-muted hover:bg-badge hover:text-badge-foreground", + isCloudBrand + ? isActive + ? "border-[var(--border-border-interactive)] bg-[#333333] text-foreground" + : "border-border-subtle-contextual bg-[#333333] text-layer-contextual-foreground hover:bg-[#333333] hover:text-foreground" + : isActive + ? "border-border bg-task-dialog-oss-selected text-foreground" + : "border-border bg-muted hover:bg-badge hover:text-badge-foreground", )} > diff --git a/frontend/components/task-dialog/constants.ts b/frontend/components/task-dialog/constants.ts index 1c0351dbd..881f08f5d 100644 --- a/frontend/components/task-dialog/constants.ts +++ b/frontend/components/task-dialog/constants.ts @@ -1,10 +1,4 @@ -import { - AlertCircle, - CheckCircle, - Clock, - Focus, - type LucideIcon, -} from "lucide-react"; +import { AlertCircle, CheckCircle, Clock, type LucideIcon } from "lucide-react"; import type { TaskFileStatusCategory } from "@/lib/task-utils"; export const CATEGORY_CHIPS: Array<{ @@ -31,10 +25,4 @@ export const CATEGORY_CHIPS: Array<{ icon: Clock, iconClassName: "text-muted-foreground", }, - { - id: "partial", - label: "Partial", - icon: Focus, - iconClassName: "text-muted-foreground", - }, ]; diff --git a/frontend/components/task-dialog/file-error-details.tsx b/frontend/components/task-dialog/file-error-details.tsx index 7a13b6dd0..1416d0be2 100644 --- a/frontend/components/task-dialog/file-error-details.tsx +++ b/frontend/components/task-dialog/file-error-details.tsx @@ -10,7 +10,6 @@ import { cn } from "@/lib/utils"; interface TaskDialogFileErrorDetailsProps { isCloudBrand: boolean; - indentClassName?: string; fileInfo: TaskFileEntry; taskError?: string; analysis?: TaskFileIngestionFailureAnalysis; @@ -18,7 +17,6 @@ interface TaskDialogFileErrorDetailsProps { export function TaskDialogFileErrorDetails({ isCloudBrand, - indentClassName, fileInfo, taskError, analysis: analysisProp, @@ -29,23 +27,36 @@ export function TaskDialogFileErrorDetails({ return (
{analysis.pipelineSteps.map((step, index) => { const isFailed = step.status === "failed"; const isLast = index === analysis.pipelineSteps.length - 1; + const showComponentTags = + isFailed && analysis.componentTags.length > 0; + const contentRowCount = + 1 + (isFailed ? 2 + (showComponentTags ? 1 : 0) : 0); return ( -
-
+
+
{step.status === "completed" ? ( -
-

+ + {isFailed && ( + <> +

+ {analysis.resolvedError} +

+

+ {analysis.failureSummary} +

+ {showComponentTags && ( +
+ + {analysis.componentTags.map((tag) => ( + + {tag} + + ))} +
)} - > - {step.label} -

- {isFailed && ( -
-

- {analysis.resolvedError} -

-

- {analysis.failureSummary} -

- {analysis.componentTags.length > 0 && ( -
- - {analysis.componentTags.map((tag) => ( - - {tag} - - ))} -
- )} -
- )} -
+ + )}
); })} diff --git a/frontend/components/task-dialog/file-list.tsx b/frontend/components/task-dialog/file-list.tsx index 21275fde2..d709cebe5 100644 --- a/frontend/components/task-dialog/file-list.tsx +++ b/frontend/components/task-dialog/file-list.tsx @@ -15,9 +15,6 @@ import { import { cn } from "@/lib/utils"; import { TaskDialogFileErrorDetails } from "./file-error-details"; -const OSS_ERROR_INDENT = "pl-9"; -const CHECKBOX_CLASS = "h-4 w-4 shrink-0 rounded border-border accent-primary"; - type TaskDialogFileListTab = "task-ingestions" | "retry-ingestions"; interface TaskDialogFileListProps { @@ -100,7 +97,9 @@ export function TaskDialogFileList({ const containerClass = cn( "flex min-h-0 flex-1 flex-col overflow-hidden", - isCloudBrand ? "rounded-md border" : "border-b border-muted", + isCloudBrand + ? "rounded-md border-x border-b bg-layer-contextual" + : "border-b border-muted bg-task-dialog-oss", ); const taskIngestionsTabCount = @@ -124,7 +123,7 @@ export function TaskDialogFileList({ : cn( "border-0", isActive - ? "rounded-none rounded-t-lg bg-muted text-foreground" + ? "rounded-none rounded-t-lg bg-task-dialog-oss-selected text-foreground" : "rounded-none bg-transparent text-muted-foreground hover:text-foreground", ), ); @@ -162,22 +161,21 @@ export function TaskDialogFileList({ className={cn( "border-b last:border-b-0", isCloudBrand ? "border-border" : "border-muted", - isSelected && "bg-muted/30", + isSelected && (isCloudBrand ? "bg-muted" : "bg-muted/30"), )} >
{retryable ? ( (
{showSelectAll ? ( void; fileTypes: string[]; allTypesLabel: string; - trigger: ReactNode; + fileTypeLabel: string; + disabled: boolean; }) { + const [open, setOpen] = useState(false); const options = [ { value: ALL_TASK_FILE_TYPES, label: allTypesLabel }, ...fileTypes.map((type) => ({ @@ -55,14 +52,66 @@ function FileTypeMenu({ ]; return ( - - {trigger} - + + + {isCloudBrand ? ( + + ) : ( + + )} + + event.preventDefault()} + > {options.map(({ value, label }) => ( onFileTypeChange(value)} - className={fileTypeItemClassName(fileType === value)} + className={cn( + "px-2", + isCloudBrand && + "bg-layer-contextual text-layer-contextual-foreground hover:bg-muted focus:bg-muted data-[highlighted]:bg-muted", + fileType === value && + "bg-muted text-foreground focus:bg-muted data-[highlighted]:bg-muted", + )} > {label} @@ -72,6 +121,47 @@ function FileTypeMenu({ ); } +function TaskDialogSearchField({ + isCloudBrand, + search, + onSearchChange, + disabled, +}: { + isCloudBrand: boolean; + search: string; + onSearchChange: (value: string) => void; + disabled: boolean; +}) { + const input = ( + onSearchChange(e.target.value)} + disabled={disabled} + icon={} + inputClassName={ + isCloudBrand + ? "h-10 min-w-0 !rounded-none !border-0 bg-layer-contextual text-layer-contextual-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + : "h-10 rounded-md !bg-canvas" + } + /> + ); + + return ( +
+ {input} +
+ ); +} + export function TaskDialogFilters({ isCloudBrand, search, @@ -83,74 +173,32 @@ export function TaskDialogFilters({ searchDisabled, fileTypeDisabled, }: TaskDialogFiltersProps) { - const allTypesLabel = isCloudBrand ? "All categories" : "All file types"; - - if (isCloudBrand) { - return ( -
-
- onSearchChange(e.target.value)} - disabled={searchDisabled} - icon={} - inputClassName="h-10 min-w-0 !rounded-none !border-0 bg-layer-contextual text-layer-contextual-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" - /> -
- - {fileTypeLabel} - - - } - /> -
- ); - } + const fileTypeMenuProps = { + isCloudBrand, + fileType, + onFileTypeChange, + fileTypes, + allTypesLabel: isCloudBrand ? "All categories" : "All file types", + fileTypeLabel, + disabled: fileTypeDisabled, + }; return ( -
-
- onSearchChange(e.target.value)} - disabled={searchDisabled} - icon={} - inputClassName="h-10 rounded-md !bg-canvas" - /> -
- - {fileTypeLabel} - - - } +
+ +
); } diff --git a/frontend/components/task-dialog/header.tsx b/frontend/components/task-dialog/header.tsx index 2a42284b9..92bed7da0 100644 --- a/frontend/components/task-dialog/header.tsx +++ b/frontend/components/task-dialog/header.tsx @@ -49,13 +49,17 @@ export function TaskDialogHeader({
0; return ( -
+
{isLoading ? ( @@ -120,14 +128,21 @@ function TaskDialogContent({