diff --git a/src/app/inspect/[documentId]/chunks/page.tsx b/src/app/inspect/[documentId]/chunks/page.tsx new file mode 100644 index 0000000..0fd92e0 --- /dev/null +++ b/src/app/inspect/[documentId]/chunks/page.tsx @@ -0,0 +1,47 @@ +import { Suspense } from "react" +import { connection } from "next/server" + +import { WorkspaceShell } from "@/components/workspace-shell" +import { loadWorkspaceShellInitialState } from "@/domains/workspace/initial-state" +import { effectOperation } from "@/lib/effect-operation" +import { summarizeUnknownError } from "@/lib/format-log-value" +import { logger } from "@/lib/logger" + +type InspectChunksPageProps = { + readonly params: Promise<{ + readonly documentId: string + }> +} + +export default function InspectChunksPage(props: InspectChunksPageProps) { + return ( + + + + ) +} + +async function InspectChunksPageContent({ + params, +}: InspectChunksPageProps) { + const { documentId } = await params + await connection() + const initialState = await loadWorkspaceInitialState() + return +} + +async function loadWorkspaceInitialState(): ReturnType< + typeof loadWorkspaceShellInitialState +> { + try { + return await loadWorkspaceShellInitialState() + } catch (error) { + logger.error("workspace: initial state failed", { + error: summarizeUnknownError(error), + }) + throw effectOperation.createBoundaryError( + "Workspace initial state failed", + error, + ) + } +} diff --git a/src/components/source-row.test.ts b/src/components/source-row.test.ts index fa4e40f..9faa567 100644 --- a/src/components/source-row.test.ts +++ b/src/components/source-row.test.ts @@ -75,6 +75,59 @@ describe("SourceRow", () => { .toBeTruthy(); }); + it("links ready sources to the document chunk tree route", () => { + const onSelect = vi.fn(); + + render( + React.createElement(SourceRow, { + chunkTreeHref: "/inspect/doc_123/chunks", + isArchiving: false, + isSelected: false, + onSelect, + source: { + id: "source_1", + mimeType: "application/pdf", + title: "lecture.pdf", + status: "ready", + chunkCount: 3, + }, + }), + ); + + const chunkTreeLink = screen.getByRole("link", { + name: "Open lecture.pdf chunk tree link", + }); + + expect((chunkTreeLink as HTMLAnchorElement).getAttribute("href")).toBe( + "/inspect/doc_123/chunks", + ); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("does not link non-ready sources to the document chunk tree route", () => { + render( + React.createElement(SourceRow, { + chunkTreeHref: "/inspect/doc_123/chunks", + isArchiving: false, + isSelected: false, + onSelect: vi.fn(), + source: { + id: "source_1", + mimeType: "application/pdf", + title: "lecture.pdf", + status: "parsing", + chunkCount: 0, + }, + }), + ); + + expect( + screen.queryByRole("link", { + name: "Open lecture.pdf chunk tree link", + }), + ).toBeNull(); + }); + it("keeps the title truncating while the delete action stays in a trailing column", () => { const { container } = render( React.createElement(SourceRow, { diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index 88ef432..48be677 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -1,13 +1,15 @@ "use client"; import type { ReactElement } from "react"; -import { FileText, Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { FileText, ListTree, Plus, Trash2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { Spinner } from "@/components/ui/spinner"; import type { SourceView } from "@/domains/sources/types"; export type SourceRowProps = { + readonly chunkTreeHref?: string; readonly isArchiving: boolean; readonly isAdding?: boolean; readonly isNarrow?: boolean; @@ -28,6 +30,7 @@ export function SourceRow({ onSelect, onToggleIncluded, onArchiveClick, + chunkTreeHref, isArchiving, }: SourceRowProps): ReactElement { const isReady = source.status === "ready"; @@ -104,45 +107,57 @@ export function SourceRow({

- {isLibrarySource && onAddClick && ( - - )} - {onArchiveClick && ( - - )} +
+ {chunkTreeHref && isReady ? ( + + + + ) : null} + {isLibrarySource && onAddClick && ( + + )} + {onArchiveClick && ( + + )} +
); } diff --git a/src/components/sources-panel.test.ts b/src/components/sources-panel.test.ts index 06d82a3..362df06 100644 --- a/src/components/sources-panel.test.ts +++ b/src/components/sources-panel.test.ts @@ -260,6 +260,47 @@ describe("SourcesPanel", () => { .toBeTruthy(); }); + it("paginates large source lists", async () => { + const sources = Array.from({ length: 27 }, (_, index) => + makeReadySource(index + 1), + ); + + render(React.createElement(C, { sources })); + + expect(screen.getByText("source-01.pdf")).toBeTruthy(); + expect(screen.getByText("source-25.pdf")).toBeTruthy(); + expect(screen.queryByText("source-26.pdf")).toBeNull(); + expect(screen.getByText("1-25 of 27")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "Next sources page" }), + ); + + expect(screen.queryByText("source-01.pdf")).toBeNull(); + expect(screen.getByText("source-26.pdf")).toBeTruthy(); + expect(screen.getByText("source-27.pdf")).toBeTruthy(); + expect(screen.getByText("26-27 of 27")).toBeTruthy(); + }); + + it("moves pagination to the selected source page", async () => { + const sources = Array.from({ length: 27 }, (_, index) => + makeReadySource(index + 1), + ); + + render( + React.createElement(C, { + sources, + selectedSourceId: "source_26", + }), + ); + + await waitFor(() => { + expect(screen.getByText("source-26.pdf")).toBeTruthy(); + }); + expect(screen.queryByText("source-01.pdf")).toBeNull(); + expect(screen.getByText("26-27 of 27")).toBeTruthy(); + }); + it("hides source actions that are not wired", () => { render( React.createElement(C, { @@ -518,6 +559,25 @@ function expectPrimaryCompactButton(button: HTMLElement): void { expect(button.className).not.toContain("bg-background"); } +function makeReadySource(index: number): { + readonly chunkCount: number; + readonly documentId: string; + readonly id: string; + readonly mimeType: string; + readonly status: "ready"; + readonly title: string; +} { + const suffix = String(index).padStart(2, "0"); + return { + chunkCount: index, + documentId: `doc_${index}`, + id: `source_${index}`, + mimeType: "application/pdf", + status: "ready", + title: `source-${suffix}.pdf`, + }; +} + function makeUploadedBlob(): { readonly url: string; readonly downloadUrl: string; diff --git a/src/components/sources-panel.tsx b/src/components/sources-panel.tsx index 477244f..ef27ed8 100644 --- a/src/components/sources-panel.tsx +++ b/src/components/sources-panel.tsx @@ -2,9 +2,10 @@ import { type ReactElement, + useMemo, useState, } from "react"; -import { BookOpen, Plus, Database } from "lucide-react"; +import { BookOpen, ChevronLeft, ChevronRight, Database, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -47,6 +48,13 @@ export type SourcesPanelProps = { onLoginClick?: () => void; }; +const sourceListPageSize = 25; + +type SourcePageState = { + readonly page: number; + readonly selectedSourceId: string | null; +}; + export function SourcesPanel({ isNarrow = false, isLibraryOpen = false, @@ -64,6 +72,10 @@ export function SourcesPanel({ onLoginClick, }: Partial = {}): ReactElement { const [confirmSourceId, setConfirmSourceId] = useState(null); + const [sourcePageState, setSourcePageState] = useState({ + page: 1, + selectedSourceId: null, + }); const { archivingSourceIdSet, confirmSource, @@ -76,6 +88,19 @@ export function SourcesPanel({ const workspaceSources = sources.filter( (source) => source.officialLibrary === undefined, ); + const selectedSourcePage = getSelectedSourcePage( + workspaceSources, + selectedSourceId, + ); + const requestedSourcePage = + selectedSourceId !== sourcePageState.selectedSourceId && + selectedSourcePage !== null + ? selectedSourcePage + : sourcePageState.page; + const sourcePagination = useMemo( + () => getSourcePagination(workspaceSources, requestedSourcePage), + [requestedSourcePage, workspaceSources], + ); const hasLibrarySources = officialLibrarySources.length > 0 || sources.some((source) => source.officialLibrary !== undefined); @@ -199,10 +224,11 @@ export function SourcesPanel({ ) : (
- {workspaceSources.map((source) => ( + {sourcePagination.sources.map((source) => ( onSelectSource?.( @@ -223,6 +249,31 @@ export function SourcesPanel({ )}
+ {workspaceSources.length > sourceListPageSize ? ( + + setSourcePageState({ + page: Math.min( + sourcePagination.page + 1, + sourcePagination.totalPages, + ), + selectedSourceId, + }) + } + onPrevious={() => + setSourcePageState({ + page: Math.max(sourcePagination.page - 1, 1), + selectedSourceId, + }) + } + start={sourcePagination.start} + /> + ) : null} ); } @@ -242,3 +293,108 @@ function EmptySourcesState(): ReactElement { ); } + +type SourcePagination = { + readonly end: number; + readonly page: number; + readonly sources: readonly SourceView[]; + readonly start: number; + readonly total: number; + readonly totalPages: number; +}; + +function getSourcePagination( + sources: readonly SourceView[], + requestedPage: number, +): SourcePagination { + const total = sources.length; + const totalPages = getTotalSourcePages(total); + const page = Math.min(Math.max(requestedPage, 1), totalPages); + const startIndex = (page - 1) * sourceListPageSize; + const endIndex = Math.min(startIndex + sourceListPageSize, total); + + return { + end: endIndex, + page, + sources: sources.slice(startIndex, endIndex), + start: total === 0 ? 0 : startIndex + 1, + total, + totalPages, + }; +} + +function getTotalSourcePages(sourceCount: number): number { + return Math.max(1, Math.ceil(sourceCount / sourceListPageSize)); +} + +function getSourcePageForIndex(sourceIndex: number): number { + return Math.floor(sourceIndex / sourceListPageSize) + 1; +} + +function getSelectedSourcePage( + sources: readonly SourceView[], + selectedSourceId: string | null, +): number | null { + if (!selectedSourceId) return null; + + const selectedIndex = sources.findIndex( + (source) => source.id === selectedSourceId, + ); + return selectedIndex >= 0 ? getSourcePageForIndex(selectedIndex) : null; +} + +function getChunkTreeHref(source: SourceView): string | undefined { + return source.documentId + ? `/inspect/${encodeURIComponent(source.documentId)}/chunks` + : undefined; +} + +function SourcePaginationControls({ + end, + isNarrow, + onNext, + onPrevious, + page, + start, + total, + totalPages, +}: { + readonly end: number; + readonly isNarrow: boolean; + readonly onNext: () => void; + readonly onPrevious: () => void; + readonly page: number; + readonly start: number; + readonly total: number; + readonly totalPages: number; +}): ReactElement { + return ( +
+ + {isNarrow ? `${page}/${totalPages}` : `${start}-${end} of ${total}`} + +
+ + +
+
+ ); +} diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index a14075e..98e34f4 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -54,6 +54,7 @@ export type WorkspaceShellProps = { chatThreads?: ChatThreadView[] activeChatThreadId?: string | null chatMessages?: ChatMessageView[] + chunkViewDocumentId?: string | null dashboardUrl?: string initialPrefetchedChunksBySourceId?: Record isGuest?: boolean @@ -83,6 +84,7 @@ function WorkspaceShellContent({ chatThreads: initialChatThreads, activeChatThreadId, chatMessages: initialChatMessages, + chunkViewDocumentId, dashboardUrl, workspace, initialPrefetchedChunksBySourceId, @@ -95,6 +97,7 @@ function WorkspaceShellContent({ const pathname = usePathname() const [contentView, setContentView] = useState("chunks") const sourceWorkflow = useWorkspaceSourceWorkflow({ + initialSelectedDocumentId: chunkViewDocumentId ?? null, initialSources: initialSources ?? [], isGuest, }) diff --git a/src/components/workspace-source-state.test.ts b/src/components/workspace-source-state.test.ts index 2869b3b..f1069d5 100644 --- a/src/components/workspace-source-state.test.ts +++ b/src/components/workspace-source-state.test.ts @@ -58,6 +58,31 @@ describe("workspaceSourceState", () => { ); }); + it("selects a preferred document source when opening a chunk-tree link", () => { + const sources: readonly SourceView[] = [ + { + id: "source_first", + title: "first.pdf", + status: "ready", + mimeType: "application/pdf", + documentId: "doc_first", + excludedFromQuery: false, + }, + { + id: "source_target", + title: "target.pdf", + status: "ready", + mimeType: "application/pdf", + documentId: "doc_target", + excludedFromQuery: false, + }, + ]; + + expect( + workspaceSourceState.getInitialSelectedSourceId(sources, "doc_target"), + ).toBe("source_target"); + }); + it("applies source query exclusions without mutating the source list", () => { const sources: readonly SourceView[] = [ { diff --git a/src/components/workspace-source-state.ts b/src/components/workspace-source-state.ts index 3efe368..676e020 100644 --- a/src/components/workspace-source-state.ts +++ b/src/components/workspace-source-state.ts @@ -20,6 +20,7 @@ type WorkspaceSourceStateModule = { ) => string | null readonly getInitialSelectedSourceId: ( sources: readonly SourceView[], + preferredDocumentId?: string | null, ) => string | null readonly getResolvedSelectedSourceId: ( sources: readonly SourceView[], @@ -45,7 +46,18 @@ type WorkspaceSourceStateModule = { ) => Record } -function getInitialSelectedSourceId(sources: readonly SourceView[]): string | null { +function getInitialSelectedSourceId( + sources: readonly SourceView[], + preferredDocumentId: string | null = null, +): string | null { + if (preferredDocumentId) { + const preferredSource = sources.find( + (source) => + source.documentId === preferredDocumentId && isReadySource(source), + ) + if (preferredSource) return preferredSource.id + } + return getFirstReadySourceId(sources) } diff --git a/src/components/workspace-source-workflow.ts b/src/components/workspace-source-workflow.ts index 47a9822..341eda5 100644 --- a/src/components/workspace-source-workflow.ts +++ b/src/components/workspace-source-workflow.ts @@ -10,6 +10,7 @@ import { workspaceClientCache } from "@/domains/workspace/client-cache" import type { SourceView } from "@/domains/sources/types" type WorkspaceSourceWorkflowInput = { + readonly initialSelectedDocumentId?: string | null readonly initialSources?: readonly SourceView[] readonly isGuest?: boolean } @@ -38,12 +39,14 @@ const archiveSourceSWRKey = workspaceClient.keys.archiveSource const materializeDemoSourceSWRKey = workspaceClient.keys.materializeDemoSources export function useWorkspaceSourceWorkflow({ + initialSelectedDocumentId = null, initialSources = [], isGuest = false, }: WorkspaceSourceWorkflowInput): WorkspaceSourceWorkflow { const initialSourceRows = useMemo(() => [...initialSources], [initialSources]) const initialSelectedSourceId = workspaceSourceState.getInitialSelectedSourceId( initialSourceRows, + initialSelectedDocumentId, ) const [selectedSourceId, setSelectedSourceId] = useState( initialSelectedSourceId,