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,