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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/app/inspect/[documentId]/chunks/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Suspense>
<InspectChunksPageContent params={props.params} />
</Suspense>
)
}

async function InspectChunksPageContent({
params,
}: InspectChunksPageProps) {
const { documentId } = await params
await connection()
const initialState = await loadWorkspaceInitialState()
return <WorkspaceShell {...initialState} chunkViewDocumentId={documentId} />
}

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,
)
}
}
53 changes: 53 additions & 0 deletions src/components/source-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
95 changes: 55 additions & 40 deletions src/components/source-row.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -28,6 +30,7 @@ export function SourceRow({
onSelect,
onToggleIncluded,
onArchiveClick,
chunkTreeHref,
isArchiving,
}: SourceRowProps): ReactElement {
const isReady = source.status === "ready";
Expand Down Expand Up @@ -104,45 +107,57 @@ export function SourceRow({
</p>
</div>
</button>
{isLibrarySource && onAddClick && (
<button
type="button"
disabled={isAdding || !source.demoSourceId}
onClick={(event) => {
event.stopPropagation();
if (isAdding || !source.demoSourceId) return;
onAddClick(source.demoSourceId);
}}
className="inline-flex shrink-0 items-center gap-1 justify-self-end rounded-md border border-border/70 px-2 py-1 text-[11px] font-semibold text-foreground hover:bg-muted disabled:cursor-wait disabled:opacity-70"
aria-label={`Add ${source.title} to sources`}
>
{isAdding ? (
<Spinner className="size-3.5" />
) : (
<Plus className="size-3.5" />
)}
{isNarrow ? null : "Add"}
</button>
)}
{onArchiveClick && (
<button
type="button"
disabled={isArchiving}
onClick={(event) => {
event.stopPropagation();
if (isArchiving) return;
onArchiveClick(source.id);
}}
className="shrink-0 justify-self-end rounded-lg p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:cursor-wait disabled:opacity-70"
aria-label={`Delete ${source.title}`}
>
{isArchiving ? (
<Spinner className="size-3.5" />
) : (
<Trash2 className="size-3.5" />
)}
</button>
)}
<div className="flex shrink-0 items-center justify-self-end">
{chunkTreeHref && isReady ? (
<Link
href={chunkTreeHref}
className="shrink-0 rounded-lg p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={`Open ${source.title} chunk tree link`}
title="Open chunk tree"
>
<ListTree className="size-3.5" />
</Link>
) : null}
{isLibrarySource && onAddClick && (
<button
type="button"
disabled={isAdding || !source.demoSourceId}
onClick={(event) => {
event.stopPropagation();
if (isAdding || !source.demoSourceId) return;
onAddClick(source.demoSourceId);
}}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border/70 px-2 py-1 text-[11px] font-semibold text-foreground hover:bg-muted disabled:cursor-wait disabled:opacity-70"
aria-label={`Add ${source.title} to sources`}
>
{isAdding ? (
<Spinner className="size-3.5" />
) : (
<Plus className="size-3.5" />
)}
{isNarrow ? null : "Add"}
</button>
)}
{onArchiveClick && (
<button
type="button"
disabled={isArchiving}
onClick={(event) => {
event.stopPropagation();
if (isArchiving) return;
onArchiveClick(source.id);
}}
className="shrink-0 rounded-lg p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:cursor-wait disabled:opacity-70"
aria-label={`Delete ${source.title}`}
>
{isArchiving ? (
<Spinner className="size-3.5" />
) : (
<Trash2 className="size-3.5" />
)}
</button>
)}
</div>
</div>
);
}
Expand Down
60 changes: 60 additions & 0 deletions src/components/sources-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading