diff --git a/next.config.ts b/next.config.ts index 490c3cc..e294c70 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,6 +14,9 @@ const nextConfig: NextConfig = { "notebook.127.0.0.1.nip.io", "dashboard.127.0.0.1.nip.io", ], + turbopack: { + root: process.cwd(), + }, }; export default nextConfig; diff --git a/package.json b/package.json index 8449b3b..ee4221c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@antv/chart-visualization-skills": "0.1.3", "@effect/platform": "^0.96.1", "@neondatabase/serverless": "^1.1.0", - "@ontos-ai/knowhere-sdk": "^0.6.0", + "@ontos-ai/knowhere-sdk": "^0.10.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 484f2fd..66cd259 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^1.1.0 version: 1.1.0 '@ontos-ai/knowhere-sdk': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.10.0 + version: 0.10.0 '@radix-ui/react-alert-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1409,9 +1409,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@ontos-ai/knowhere-sdk@0.6.0': - resolution: {integrity: sha512-YqDgg+BKwGp7NepD50aquhEOmUgdDAQxpoyttqh0d+4tWjOgkSo74vEGiJVxt8v+LAcA/5CmxPeghX1Hx0v3wQ==} - engines: {node: '>=20.19.0', npm: '>=10.0.0'} + '@ontos-ai/knowhere-sdk@0.10.0': + resolution: {integrity: sha512-wb5wNnnp4YvdlZ6w2UOQ2gw/9anGsmn2hw2JD+uCya4VUyXK78HKMi1LCI6zNnLeZHyGAm0q6+d2wlqDXZ6haQ==} + engines: {node: '>=20.19.0', npm: '>=10.0.0', pnpm: '>=9.0.0'} '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -6397,7 +6397,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@ontos-ai/knowhere-sdk@0.6.0': + '@ontos-ai/knowhere-sdk@0.10.0': dependencies: axios: 1.16.0 jszip: 3.10.1 diff --git a/src/app/api/sources/[sourceId]/chunks/route.test.ts b/src/app/api/sources/[sourceId]/chunks/route.test.ts index 6074abf..524a657 100644 --- a/src/app/api/sources/[sourceId]/chunks/route.test.ts +++ b/src/app/api/sources/[sourceId]/chunks/route.test.ts @@ -2,14 +2,19 @@ import { NextRequest } from "next/server" import { beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ + blobGet: vi.fn(), + blobPut: vi.fn(), + deleteBlob: vi.fn(), ensureApiKeyForWorkspace: vi.fn(), ensureWorkspace: vi.fn(), fetchDemoChunkPage: vi.fn(), findSourceInWorkspace: vi.fn(), getCurrentUser: vi.fn(), getSourceParseAssetUrls: vi.fn(), + localizeRemoteDocument: vi.fn(), makeKnowhereClient: vi.fn(), requireUser: vi.fn(), + updateSourceRevisionKey: vi.fn(), })) vi.mock("next/headers", () => ({ @@ -36,10 +41,18 @@ vi.mock("@/integrations/knowhere", () => ({ makeKnowhereClient: mocks.makeKnowhereClient, })) +vi.mock("@vercel/blob", () => ({ + del: mocks.deleteBlob, + get: mocks.blobGet, + put: mocks.blobPut, +})) + vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, getParseAssetUrls: mocks.getSourceParseAssetUrls, + localizeRemoteDocument: mocks.localizeRemoteDocument, + updateSourceRevisionKey: mocks.updateSourceRevisionKey, }, })) @@ -54,6 +67,11 @@ import { GET } from "./route" describe("GET /api/sources/[sourceId]/chunks", () => { beforeEach(() => { vi.clearAllMocks() + mocks.blobGet.mockResolvedValue(null) + mocks.blobPut.mockImplementation(async (pathname: string) => ({ + url: `https://blob.example/${pathname}`, + })) + mocks.updateSourceRevisionKey.mockResolvedValue(null) }) it("serves API-owned demo chunks for anonymous canonical demo sources", async () => { @@ -502,7 +520,48 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }) }) - it("does not load chunks from unlocalized remote source ids", async () => { + it("materializes a remote source id on open before loading chunks", async () => { + const knowhereClient = { + documents: { + list: vi.fn(async () => ({ + documents: [ + { + documentId: "doc_remote", + namespace: "default", + status: "active", + currentJobResultId: "job_result_1", + sourceFileName: "remote.pdf", + documentMetadata: { + mimeType: "application/pdf", + }, + }, + ], + })), + listChunks: vi.fn(async () => ({ + documentId: "doc_remote", + jobResultId: "job_result_1", + chunks: [ + { + id: "dchk_remote", + chunkId: "parser_remote", + chunkType: "text", + content: "Remote chunk", + sectionPath: "Summary", + sourceChunkPath: "Default_Root/remote.pdf/Summary", + filePath: null, + metadata: {}, + sortOrder: 0, + }, + ], + pagination: { + page: 1, + pageSize: 1, + total: 1, + totalPages: 1, + }, + })), + }, + } mocks.getCurrentUser.mockResolvedValue({ id: "user_1", email: null, @@ -515,6 +574,27 @@ describe("GET /api/sources/[sourceId]/chunks", () => { createdAt: new Date("2026-05-10T00:00:00.000Z"), }) mocks.fetchDemoChunkPage.mockRejectedValue(new Error("not a demo")) + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") + mocks.makeKnowhereClient.mockReturnValue(knowhereClient) + mocks.localizeRemoteDocument.mockResolvedValue({ + id: "00000000-0000-0000-0000-000000000009", + workspaceId: "workspace_1", + title: "remote.pdf", + mimeType: "application/pdf", + sizeBytes: 0, + status: "ready", + failureReason: null, + knowhereJobId: "job_result_1", + knowhereDocumentId: "doc_remote", + stagedBlobPathname: null, + stagedBlobUrl: null, + originalBlobPathname: null, + originalBlobUrl: null, + demoKey: null, + createdAt: new Date("2026-05-10T00:00:00.000Z"), + updatedAt: new Date("2026-05-10T00:00:00.000Z"), + deletedAt: null, + }) const response = await GET( new NextRequest( @@ -527,12 +607,46 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }, ) - await expect(response.json()).resolves.toEqual({ - message: "Source not found.", + await expect(response.json()).resolves.toMatchObject({ + chunks: [ + { + chunkId: "dchk_remote", + parserChunkId: "parser_remote", + documentId: "doc_remote", + sourceTitle: "remote.pdf", + }, + ], + pagination: { + page: 1, + pageSize: 1, + total: 1, + }, }) - expect(response.status).toBe(404) + expect(response.status).toBe(200) expect(mocks.findSourceInWorkspace).not.toHaveBeenCalled() - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() + expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith( + "workspace_1", + "session=abc", + ) + expect(mocks.localizeRemoteDocument).toHaveBeenCalledWith( + "workspace_1", + { + documentId: "doc_remote", + namespace: "default", + status: "ready", + title: "remote.pdf", + mimeType: "application/pdf", + sizeBytes: undefined, + revisionKey: "job_result_1", + }, + ) + expect(knowhereClient.documents.listChunks).toHaveBeenCalledWith( + "doc_remote", + { + page: 1, + pageSize: 1, + includeAssetUrls: true, + }, + ) }) }) diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts index b82d620..6f82574 100644 --- a/src/app/api/sources/[sourceId]/route.test.ts +++ b/src/app/api/sources/[sourceId]/route.test.ts @@ -14,7 +14,9 @@ const mocks = vi.hoisted(() => { hideDemoSource: vi.fn(), makeKnowhereClient: vi.fn(), requireUser: vi.fn(), + retrySourceToKnowhere: vi.fn(), softDeleteSource: vi.fn(), + startBackgroundReconciliation: vi.fn(), }; }); @@ -46,10 +48,15 @@ vi.mock("@/integrations/knowhere", () => ({ makeKnowhereClient: mocks.makeKnowhereClient, })); +vi.mock("@/domains/sources/background-reconcile", () => ({ + startBackgroundReconciliation: mocks.startBackgroundReconciliation, +})); + vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, hideDemoSource: mocks.hideDemoSource, + retrySourceToKnowhere: mocks.retrySourceToKnowhere, softDelete: mocks.softDeleteSource, }, })); @@ -255,4 +262,124 @@ describe("PATCH /api/sources/[sourceId]", () => { expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); expect(mocks.archive).not.toHaveBeenCalled(); }); + + it("retries a failed source and starts background reconciliation", async () => { + mocks.requireUser.mockResolvedValue({ id: "user_1" }); + mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); + mocks.findSourceInWorkspace.mockResolvedValue({ + id: "source_1", + workspaceId: "workspace_1", + title: "lecture.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + status: "failed", + failureReason: "Knowhere upload failed.", + knowhereJobId: null, + knowhereDocumentId: null, + stagedBlobPathname: null, + stagedBlobUrl: null, + originalBlobPathname: "source-uploads/upload_1/document.pdf", + originalBlobUrl: + "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + demoKey: null, + createdAt: new Date("2026-05-10T00:00:00Z"), + updatedAt: new Date("2026-05-10T00:00:00Z"), + deletedAt: null, + }); + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123"); + const knowhereClient = { + jobs: { + create: vi.fn(), + get: vi.fn(), + upload: vi.fn(), + }, + documents: { + archive: mocks.archive, + }, + }; + mocks.makeKnowhereClient.mockReturnValue(knowhereClient); + mocks.retrySourceToKnowhere.mockResolvedValue({ + id: "source_1", + workspaceId: "workspace_1", + title: "lecture.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + status: "parsing", + failureReason: null, + knowhereJobId: "job_retry", + knowhereDocumentId: null, + stagedBlobPathname: null, + stagedBlobUrl: null, + originalBlobPathname: "source-uploads/upload_1/document.pdf", + originalBlobUrl: + "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + demoKey: null, + createdAt: new Date("2026-05-10T00:00:00Z"), + updatedAt: new Date("2026-05-10T00:00:00Z"), + deletedAt: null, + }); + mocks.startBackgroundReconciliation.mockResolvedValue(undefined); + + const response = await PATCH( + new NextRequest("http://localhost:3001/api/sources/source_1", { + method: "PATCH", + body: JSON.stringify({ retry: true }), + }), + { params: Promise.resolve({ sourceId: "source_1" }) }, + ); + + await expect(response.json()).resolves.toEqual({ + source: { + id: "source_1", + kind: "workspace", + title: "lecture.pdf", + mimeType: "application/pdf", + status: "parsing", + documentId: undefined, + originalFile: { + url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }, + }, + }); + expect(response.status).toBe(200); + expect(mocks.retrySourceToKnowhere).toHaveBeenCalledWith( + { id: "workspace_1" }, + expect.objectContaining({ id: "source_1", status: "failed" }), + knowhereClient, + ); + expect(mocks.startBackgroundReconciliation).toHaveBeenCalledWith( + "workspace_1", + "source_1", + "jwt_123", + ); + }); + + it("rejects retry requests for failed rows without a saved original Blob", async () => { + mocks.requireUser.mockResolvedValue({ id: "user_1" }); + mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); + mocks.findSourceInWorkspace.mockResolvedValue({ + id: "source_1", + status: "failed", + originalBlobPathname: null, + originalBlobUrl: null, + }); + + const response = await PATCH( + new NextRequest("http://localhost:3001/api/sources/source_1", { + method: "PATCH", + body: JSON.stringify({ retry: true }), + }), + { params: Promise.resolve({ sourceId: "source_1" }) }, + ); + + await expect(response.json()).resolves.toEqual({ + message: + "This source cannot be retried because its original file is unavailable.", + }); + expect(response.status).toBe(409); + expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); + expect(mocks.retrySourceToKnowhere).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/sources/[sourceId]/route.ts b/src/app/api/sources/[sourceId]/route.ts index 9d30567..9d05d81 100644 --- a/src/app/api/sources/[sourceId]/route.ts +++ b/src/app/api/sources/[sourceId]/route.ts @@ -19,18 +19,19 @@ export async function PATCH( ): Promise { const { sourceId } = await context.params const routeContext = await nextRouteContext.read() - const archiveRequest = await sourceRouteRequest.readArchiveSource({ + const mutationRequest = await sourceRouteRequest.readSourceMutation({ cookieHeader: routeContext.cookieHeader, request, sourceId, }) - if (!archiveRequest.ok) { - return nextRouteResponse.toNextResponse(archiveRequest.result) + if (!mutationRequest.ok) { + return nextRouteResponse.toNextResponse(mutationRequest.result) } - const result = await sourceRouteService.archiveSource({ - ...archiveRequest.input, - }) + const result = + mutationRequest.mutation.kind === "archive" + ? await sourceRouteService.archiveSource(mutationRequest.mutation.input) + : await sourceRouteService.retrySource(mutationRequest.mutation.input) return nextRouteResponse.toNextResponse(result) } diff --git a/src/app/api/sources/reconcile/route.ts b/src/app/api/sources/reconcile/route.ts index c28bfa4..1f51bac 100644 --- a/src/app/api/sources/reconcile/route.ts +++ b/src/app/api/sources/reconcile/route.ts @@ -1,50 +1,27 @@ import { serve } from "@upstash/workflow/nextjs" -import { reconcileSourcesForWorkspace } from "@/domains/sources/reconcile" -import { makeKnowhereClient } from "@/integrations/knowhere" -import { logger } from "@/lib/logger" - -type ReconcilePayload = { - readonly workspaceId: string - readonly sourceId: string - readonly apiKey: string -} - -const MAX_POLL_ATTEMPTS = 60 -const INITIAL_DELAY_S = 3 -const MAX_DELAY_S = 30 - -export const { POST } = serve(async (context) => { - const { workspaceId, sourceId, apiKey } = context.requestPayload - const workspace = { id: workspaceId } - const client = makeKnowhereClient(apiKey) - let delay = INITIAL_DELAY_S - - for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { - const resolved = await context.run(`poll-${attempt}`, async () => { - const sources = await reconcileSourcesForWorkspace(workspace, client) - const source = sources.find((s) => s.id === sourceId) - if (!source || source.status !== "parsing") { - return { done: true, status: source?.status ?? "gone" } as const - } - return { done: false } as const +import { sourceReconcileRouteWorkflow } from "@/domains/sources/source-reconcile-route-workflow" + +type ReconcilePayload = Parameters< + typeof sourceReconcileRouteWorkflow.normalizeReconcilePayload +>[0] + +export const { POST } = serve( + async (context) => { + const payload = sourceReconcileRouteWorkflow.normalizeReconcilePayload( + context.requestPayload, + ) + await sourceReconcileRouteWorkflow.runPollAndMirrorWorkflow({ + context, + payload, }) - - if (resolved.done) { - logger.info("workflow: source resolved", { - sourceId, - status: resolved.status, - attempts: attempt + 1, - }) - return - } - - await context.sleep(`wait-${attempt}`, delay) - delay = Math.min(Math.round(delay * 1.5), MAX_DELAY_S) - } - - logger.error("workflow: exhausted poll attempts", { - sourceId, - maxAttempts: MAX_POLL_ATTEMPTS, - }) -}) + }, + { + failureFunction: async ({ context, failResponse }) => { + await sourceReconcileRouteWorkflow.markSourceFailedAfterWorkflowFailure( + context.requestPayload, + failResponse, + ) + }, + }, +) diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 41aaf32..4a686a7 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -401,6 +401,7 @@ export function ChunksPanel({ hasOriginalFile ? handleChunkSelected : undefined } onReferenceClick={requestChunkFocus} + selectedSourceFile={selectedSourceFile} /> ))} @@ -1129,6 +1130,7 @@ function VirtualChunkRow({ measureElement, onChunkClick, onReferenceClick, + selectedSourceFile, }: { virtualItem: VirtualItem; chunk: ParsedChunkView | undefined; @@ -1137,6 +1139,7 @@ function VirtualChunkRow({ measureElement: (node: HTMLDivElement | null) => void; onChunkClick?: (chunk: ParsedChunkView) => void; onReferenceClick: (chunkId: string) => void; + selectedSourceFile: SourceOriginalFileView | null; }): ReactNode { if (!chunk) { return null; @@ -1163,6 +1166,7 @@ function VirtualChunkRow({ isOriginalPreviewAvailable={isOriginalPreviewAvailable} onChunkClick={onChunkClick} onReferenceClick={onReferenceClick} + sourceOriginalFile={selectedSourceFile} /> ); diff --git a/src/components/parsed-chunk-card.test.ts b/src/components/parsed-chunk-card.test.ts index b269c08..2e34ab7 100644 --- a/src/components/parsed-chunk-card.test.ts +++ b/src/components/parsed-chunk-card.test.ts @@ -182,4 +182,33 @@ describe("ParsedChunkCard", () => { expect(table.innerHTML).not.toContain("script"); expect(table.innerHTML).not.toContain("onclick"); }); + + it("renders the original image file when an image chunk has no asset URL", () => { + render( + React.createElement(ParsedChunkCard, { + chunk: { + chunkId: "image_1", + type: "image", + content: "Logo summary", + summary: "A black and white logo.", + sourceTitle: "logo.png", + }, + isFocused: false, + onReferenceClick: vi.fn(), + sourceOriginalFile: { + url: "https://blob.example/source-uploads/logo.png", + mimeType: "image/png", + }, + }), + ); + + const image = screen.getByRole("img", { + name: "A black and white logo.", + }); + + expect(image.getAttribute("src")).toBe( + "https://blob.example/source-uploads/logo.png", + ); + expect(screen.queryByText("Image content is not available in this view.")).toBeNull(); + }); }); diff --git a/src/components/parsed-chunk-card.tsx b/src/components/parsed-chunk-card.tsx index 386d596..53178f5 100644 --- a/src/components/parsed-chunk-card.tsx +++ b/src/components/parsed-chunk-card.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { parsedChunkCardModel } from "@/components/parsed-chunk-card-model"; import type { ParsedChunkView } from "@/domains/chunks/types"; +import type { SourceOriginalFileView } from "@/domains/sources/types"; import { cn } from "@/lib/utils"; const keywordPanelClassName = @@ -25,12 +26,14 @@ export function ParsedChunkCard({ isOriginalPreviewAvailable = false, onChunkClick, onReferenceClick, + sourceOriginalFile = null, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; readonly isOriginalPreviewAvailable?: boolean; readonly onChunkClick?: (chunk: ParsedChunkView) => void; readonly onReferenceClick: (chunkId: string) => void; + readonly sourceOriginalFile?: SourceOriginalFileView | null; }): ReactNode { if (chunk.type === "image") { return ( @@ -40,6 +43,7 @@ export function ParsedChunkCard({ isFocused={isFocused} isOriginalPreviewAvailable={isOriginalPreviewAvailable} onChunkClick={onChunkClick} + sourceOriginalFile={sourceOriginalFile} /> ); @@ -362,12 +366,16 @@ function ImageChunkCard({ isFocused, isOriginalPreviewAvailable, onChunkClick, + sourceOriginalFile, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; readonly isOriginalPreviewAvailable: boolean; readonly onChunkClick?: (chunk: ParsedChunkView) => void; + readonly sourceOriginalFile: SourceOriginalFileView | null; }): ReactNode { + const imageAssetUrl = getImageChunkAssetUrl(chunk, sourceOriginalFile); + return ( - {chunk.assetUrl ? ( + {imageAssetUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element -- Parsed artifact dimensions are not known before render. */} {chunk.summary @@ -407,6 +415,16 @@ function ImageChunkCard({ ); } +function getImageChunkAssetUrl( + chunk: ParsedChunkView, + sourceOriginalFile: SourceOriginalFileView | null, +): string | null { + if (chunk.assetUrl) return chunk.assetUrl; + if (!sourceOriginalFile?.mimeType.startsWith("image/")) return null; + + return sourceOriginalFile.url; +} + function renderTextChunkContent( chunk: ParsedChunkView, onReferenceClick: (chunkId: string) => void, diff --git a/src/components/source-row.test.ts b/src/components/source-row.test.ts index 9faa567..26e413f 100644 --- a/src/components/source-row.test.ts +++ b/src/components/source-row.test.ts @@ -75,6 +75,99 @@ describe("SourceRow", () => { .toBeTruthy(); }); + it("shows failed source retry with a brief error message", () => { + const onRetryClick = vi.fn(); + const onSelect = vi.fn(); + + render( + React.createElement(SourceRow, { + isArchiving: false, + isSelected: false, + onRetryClick, + onSelect, + source: { + id: "source_1", + mimeType: "application/pdf", + title: "lecture.pdf", + status: "failed", + failureMessage: + "Too many concurrent requests (2/2 active). Please retry after 30 seconds.", + originalFile: { + url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + mimeType: "application/pdf", + }, + }, + }), + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Retry lecture.pdf processing", + }), + ); + + expect( + screen.getByText( + "Too many concurrent requests (2/2 active). Please retry after 30 seconds.", + ), + ).toBeTruthy(); + expect(onRetryClick).toHaveBeenCalledWith("source_1"); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("shows failed source retry loading locally", () => { + render( + React.createElement(SourceRow, { + isArchiving: false, + isRetrying: true, + isSelected: false, + onRetryClick: vi.fn(), + onSelect: vi.fn(), + source: { + id: "source_1", + mimeType: "application/pdf", + title: "lecture.pdf", + status: "failed", + originalFile: { + url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + mimeType: "application/pdf", + }, + }, + }), + ); + + const retryButton = screen.getByRole("button", { + name: "Retry lecture.pdf processing", + }); + + expect((retryButton as HTMLButtonElement).disabled).toBe(true); + expect(within(retryButton).getByRole("status", { name: "Loading" })) + .toBeTruthy(); + }); + + it("hides retry when a failed source has no saved original file", () => { + render( + React.createElement(SourceRow, { + isArchiving: false, + isSelected: false, + onRetryClick: vi.fn(), + onSelect: vi.fn(), + source: { + id: "source_1", + mimeType: "application/pdf", + title: "legacy.pdf", + status: "failed", + }, + }), + ); + + expect( + screen.queryByRole("button", { + name: "Retry legacy.pdf processing", + }), + ).toBeNull(); + }); + it("links ready sources to the document chunk tree route", () => { const onSelect = vi.fn(); diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index 48be677..abfe8cb 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from "react"; import Link from "next/link"; -import { FileText, ListTree, Plus, Trash2 } from "lucide-react"; +import { FileText, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { Spinner } from "@/components/ui/spinner"; @@ -13,9 +13,11 @@ export type SourceRowProps = { readonly isArchiving: boolean; readonly isAdding?: boolean; readonly isNarrow?: boolean; + readonly isRetrying?: boolean; readonly isSelected: boolean; readonly onAddClick?: (sourceId: string) => void; readonly onArchiveClick?: (sourceId: string) => void; + readonly onRetryClick?: (sourceId: string) => void; readonly onSelect: () => void; readonly onToggleIncluded?: (sourceId: string, included: boolean) => void; readonly source: SourceView; @@ -30,13 +32,17 @@ export function SourceRow({ onSelect, onToggleIncluded, onArchiveClick, + onRetryClick, chunkTreeHref, isArchiving, + isRetrying = false, }: SourceRowProps): ReactElement { const isReady = source.status === "ready"; const isBusy = source.status === "uploading" || source.status === "parsing"; const isFailed = source.status === "failed"; + const canRetry = isFailed && source.originalFile !== undefined; const isLibrarySource = source.officialLibrary !== undefined; + const isRemoteSource = source.kind === "remote"; const iconBg = fileIconTint(source.title); @@ -57,7 +63,7 @@ export function SourceRow({ > onToggleIncluded?.(source.id, checked === true) } @@ -96,15 +102,18 @@ export function SourceRow({ }`} > {isReady - ? `${isLibrarySource ? "Official Library" : "Processed"} · ${ - source.chunkCount ?? 0 - } chunks` + ? `${getReadySourceLabel(source)} · ${source.chunkCount ?? 0} chunks` : source.status === "parsing" ? "Preparing" : source.status === "uploading" ? "Uploading" : "Failed"}

+ {isFailed && source.failureMessage ? ( +

+ {source.failureMessage} +

+ ) : null}
@@ -138,6 +147,26 @@ export function SourceRow({ {isNarrow ? null : "Add"} )} + {canRetry && onRetryClick ? ( + + ) : null} {onArchiveClick && (