diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index 363d008..a16ce2a 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -63,9 +63,7 @@ describe("ChunksPanel", () => { expect(screen.getByText(/Showing all parsed chunks from/)).toBeTruthy(); }); - it("switches parsed chunks into a section tree view", async () => { - const user = userEvent.setup(); - + it("defaults parsed chunks into a section tree view", () => { render( React.createElement(C, { chunks: [ @@ -107,8 +105,6 @@ describe("ChunksPanel", () => { }), ); - await user.click(screen.getByRole("button", { name: "Tree" })); - expect( screen.getByRole("tree", { name: "Parsed chunk sections" }), ).toBeTruthy(); @@ -176,7 +172,7 @@ describe("ChunksPanel", () => { expect(cardMinimumWidth).toBeGreaterThanOrEqual(treeWidth); }); - it("does not render section tree zoom controls over the canvas", async () => { + it("renders section tree zoom controls over the canvas", async () => { const user = userEvent.setup(); render( @@ -196,18 +192,65 @@ describe("ChunksPanel", () => { await user.click(screen.getByRole("button", { name: "Tree" })); + const overlay = screen.getByTestId("chunk-section-tree-zoom-overlay"); + const scrollContent = screen.getByTestId("chunks-scroll-content"); + + expect(overlay.className).toContain("absolute"); + expect(overlay.className).toContain("left-3"); + expect(overlay.className).toContain("top-3"); + expect(scrollContent.contains(overlay)).toBe(false); expect( - screen.queryByTestId("chunk-section-tree-zoom-overlay"), - ).toBeNull(); + screen.getByRole("group", { name: "Section tree zoom" }), + ).toBeTruthy(); expect( - screen.queryByRole("group", { name: "Section tree zoom" }), - ).toBeNull(); + screen.getByRole("button", { name: "Zoom in section tree" }), + ).toBeTruthy(); expect( - screen.queryByRole("button", { name: "Zoom in section tree" }), - ).toBeNull(); + screen.getByRole("button", { name: "Zoom out section tree" }), + ).toBeTruthy(); expect( - screen.queryByRole("button", { name: "Zoom out section tree" }), - ).toBeNull(); + screen.getByRole("button", { name: "Reset section tree zoom" }), + ).toBeTruthy(); + }); + + it("resets the section tree zoom from the zoom controls", async () => { + const user = userEvent.setup(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "chunk_1", + type: "text", + content: "Overview text", + sectionPath: "manual.pdf/Overview/Product/Robotics", + sourceTitle: "manual.pdf", + }, + ], + selectedSource: "manual.pdf", + }), + ); + + await user.click(screen.getByRole("button", { name: "Tree" })); + + const tree = screen.getByRole("tree", { name: "Parsed chunk sections" }); + const resetButton = screen.getByRole("button", { + name: "Reset section tree zoom", + }); + + expect(resetButton.hasAttribute("disabled")).toBe(true); + + await user.click(screen.getByRole("button", { name: "Zoom in section tree" })); + + expect(tree.style.transform).toBe("scale(1.1)"); + expect(screen.getByText("110%")).toBeTruthy(); + expect(resetButton.hasAttribute("disabled")).toBe(false); + + await user.click(resetButton); + + expect(tree.style.transform).toBe("scale(1)"); + expect(screen.getByText("100%")).toBeTruthy(); + expect(resetButton.hasAttribute("disabled")).toBe(true); }); it("allows the section tree to zoom out to 30 percent with the mouse wheel", async () => { @@ -450,6 +493,7 @@ describe("ChunksPanel", () => { }, }), ); + selectListView(); const openOriginalButton = screen.getByRole("button", { name: "Open original file", @@ -521,6 +565,7 @@ describe("ChunksPanel", () => { }, }), ); + selectListView(); await user.click( screen.getByRole("button", { name: "Open page 2 in original file" }), @@ -562,6 +607,7 @@ describe("ChunksPanel", () => { }, }), ); + selectListView(); await user.click( screen.getByRole("button", { name: "Open page 2 in original file" }), @@ -605,6 +651,7 @@ describe("ChunksPanel", () => { selectedSourceFile, }), ); + selectListView(); await user.click(screen.getByRole("button", { name: "Original" })); expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); @@ -663,6 +710,7 @@ describe("ChunksPanel", () => { selectedSource: "demo.pdf", }), ); + selectListView(); expect(screen.getByTestId("chunks-scroll-content").className).toContain( "min-w-0", @@ -693,6 +741,7 @@ describe("ChunksPanel", () => { ], }), ); + selectListView(); const sourcePanel = screen.getByTestId("chunk-source-panel-image_1"); @@ -727,6 +776,7 @@ describe("ChunksPanel", () => { selectedSource: "TSLA-Q4-2025-Update.pdf", }), ); + selectListView(); const financialSourcePanel = screen.getByTestId( "chunk-source-panel-text_1", @@ -767,6 +817,7 @@ describe("ChunksPanel", () => { selectedSource: "TSLA-Q4-2025-UPDATE.PDF", }), ); + selectListView(); expect(screen.getByTestId("chunk-source-panel-text_1").textContent).toContain( "Installed Annual Capacity", @@ -858,6 +909,7 @@ describe("ChunksPanel", () => { selectedSource: "manual.pdf", }), ); + selectListView(); const image = screen.getByRole("img", { name: "A wiring diagram." }); expect(image.getAttribute("src")).toBe( @@ -917,6 +969,7 @@ describe("ChunksPanel", () => { focusedChunkRequestId: 1, }), ); + selectListView(); await user.click(screen.getByRole("button", { name: "Table 1" })); @@ -948,6 +1001,7 @@ describe("ChunksPanel", () => { focusedChunkRequestId: 1, }), ); + selectListView(); await waitFor(() => { expect(screen.getByTestId("chunk-card-shell-chunk_50")).toBeTruthy(); @@ -977,6 +1031,7 @@ describe("ChunksPanel", () => { selectedSource: "large.pdf", }), ); + selectListView(); const viewport = screen .getByTestId("chunks-panel") .querySelector("[data-radix-scroll-area-viewport]"); @@ -1039,6 +1094,7 @@ describe("ChunksPanel", () => { selectedSource: "large.pdf", }), ); + selectListView(); rerender( React.createElement(C, { @@ -1106,6 +1162,7 @@ describe("ChunksPanel", () => { selectedSource: "large.pdf", }), ); + selectListView(); const viewport = screen .getByTestId("chunks-panel") .querySelector("[data-radix-scroll-area-viewport]"); @@ -1165,6 +1222,7 @@ describe("ChunksPanel", () => { selectedSource: "annual-report.pdf", }), ); + selectListView(); expect( screen.getByRole("button", { @@ -1226,6 +1284,10 @@ function mockVisibleVirtualViewport(): void { mockVirtualViewportWithChunkHeights({}); } +function selectListView(): void { + fireEvent.click(screen.getByRole("button", { name: "List" })); +} + function createFileDropEvent(file: File): Event { const event = new Event("drop", { bubbles: true, cancelable: true }); const files: Pick & { readonly 0: File } = { diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 3add4cc..cee357e 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -20,11 +20,21 @@ import { import { FilePlus2, Layers, + RotateCcw, UploadCloud, + ZoomIn, + ZoomOut, } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SourceOriginalPreview } from "@/components/source-original-preview"; import { SourceUploadDialog } from "@/components/source-upload-dialog"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useChunksPanelWorkflow } from "@/components/chunks-panel-workflow"; import { ParsedChunkCard } from "@/components/parsed-chunk-card"; import { chunksPanelState } from "@/components/chunks-panel-state"; @@ -52,6 +62,11 @@ export type ChunksPanelProps = { type ChunkDisplayMode = "list" | "tree"; +type ChunkDisplayModeState = { + readonly handledFocusedChunkRequestId: number; + readonly mode: ChunkDisplayMode; +}; + export function ChunksPanel({ chunks = [], selectedSource = null, @@ -76,8 +91,12 @@ export function ChunksPanel({ const [mountedOriginalPreviewKey, setMountedOriginalPreviewKey] = useState< string | null >(null); - const [chunkDisplayMode, setChunkDisplayMode] = - useState("list"); + const [chunkDisplayModeState, setChunkDisplayModeState] = + useState(() => ({ + handledFocusedChunkRequestId: + focusedChunkId === null ? focusedChunkRequestId : -1, + mode: "tree", + })); const [sectionTreeZoomPercent, setSectionTreeZoomPercent] = useState(sectionTreeDefaultZoomPercent); const { @@ -131,20 +150,54 @@ export function ChunksPanel({ selectOriginalView(); }, [rememberOriginalPreview, selectOriginalView]); const handleListModeSelected = useCallback((): void => { - setChunkDisplayMode("list"); - }, []); + setChunkDisplayModeState({ + handledFocusedChunkRequestId: focusedChunkRequestId, + mode: "list", + }); + }, [focusedChunkRequestId]); const handleTreeModeSelected = useCallback((): void => { - setChunkDisplayMode("tree"); - }, []); + setChunkDisplayModeState({ + handledFocusedChunkRequestId: focusedChunkRequestId, + mode: "tree", + }); + }, [focusedChunkRequestId]); const handleTreeChunkFocus = useCallback( (chunkId: string | null): void => { requestChunkFocus(chunkId); if (chunkId !== null) { - setChunkDisplayMode("list"); + setChunkDisplayModeState({ + handledFocusedChunkRequestId: focusedChunkRequestId, + mode: "list", + }); } }, - [requestChunkFocus], + [focusedChunkRequestId, requestChunkFocus], ); + const canZoomSectionTreeOut: boolean = + sectionTreeZoomPercent > sectionTreeMinimumZoomPercent; + const canZoomSectionTreeIn: boolean = + sectionTreeZoomPercent < sectionTreeMaximumZoomPercent; + const canResetSectionTreeZoom: boolean = + sectionTreeZoomPercent !== sectionTreeDefaultZoomPercent; + const handleSectionTreeZoomOut = useCallback((): void => { + setSectionTreeZoomPercent((currentZoomPercent) => + Math.max( + sectionTreeMinimumZoomPercent, + currentZoomPercent - sectionTreeZoomStepPercent, + ), + ); + }, []); + const handleSectionTreeZoomIn = useCallback((): void => { + setSectionTreeZoomPercent((currentZoomPercent) => + Math.min( + sectionTreeMaximumZoomPercent, + currentZoomPercent + sectionTreeZoomStepPercent, + ), + ); + }, []); + const handleSectionTreeZoomReset = useCallback((): void => { + setSectionTreeZoomPercent(sectionTreeDefaultZoomPercent); + }, []); const handleSectionTreeWheelZoom = useCallback( (direction: SectionTreeZoomDirection): void => { setSectionTreeZoomPercent((currentZoomPercent) => { @@ -163,6 +216,12 @@ export function ChunksPanel({ }, [], ); + const chunkDisplayMode: ChunkDisplayMode = + focusedChunkId !== null && + chunkDisplayModeState.handledFocusedChunkRequestId !== + focusedChunkRequestId + ? "list" + : chunkDisplayModeState.mode; const headerTitle = focusedChunkId ? "Referenced Chunks" : "Parsed Chunks"; const shouldMountOriginalPreview = visibleView === "original" || @@ -335,6 +394,24 @@ export function ChunksPanel({ )} + {isTreeModeVisible ? ( +
+
+ +
+
+ ) : null} {shouldMountOriginalPreview ? ( @@ -584,6 +661,86 @@ function ChunkSectionTree({ ); } +function SectionTreeZoomControls({ + canResetZoom, + canZoomIn, + canZoomOut, + zoomPercent, + onZoomIn, + onZoomOut, + onZoomReset, +}: { + readonly canResetZoom: boolean; + readonly canZoomIn: boolean; + readonly canZoomOut: boolean; + readonly zoomPercent: number; + readonly onZoomIn: () => void; + readonly onZoomOut: () => void; + readonly onZoomReset: () => void; +}): ReactNode { + return ( + +
+ + + + + Zoom out + + + {zoomPercent}% + + + + + + Zoom in + + + + + + Reset zoom + +
+
+ ); +} + function SectionTreeItem({ focusedChunkId, node, diff --git a/src/components/workspace-citation-focus.test.ts b/src/components/workspace-citation-focus.test.ts index 2d2803a..ac531d7 100644 --- a/src/components/workspace-citation-focus.test.ts +++ b/src/components/workspace-citation-focus.test.ts @@ -69,7 +69,39 @@ describe("useWorkspaceCitationFocus", () => { expect(result.current.pendingCitationId).toBeNull(); }); - it("clears prefetched chunks and focus when the selected source changes", () => { + it("clears prefetched chunks and focus when selecting a different source", () => { + const selectSource = vi.fn(); + const otherSource: SourceView = { + id: "source_2", + title: "Other.pdf", + mimeType: "application/pdf", + status: "ready", + documentId: "document_2", + }; + const { result } = renderHook(() => + useWorkspaceCitationFocus({ + fetchChunks: vi.fn(async () => []), + initialPrefetchedChunksBySourceId: { source_1: [prefetchedChunk] }, + onSelectSource: selectSource, + selectedSourceId: "source_2", + sources: [readySource, otherSource], + }), + { wrapper: createSWRWrapper }, + ); + + act(() => { + result.current.handleSourceSelected("source_1"); + }); + + expect(selectSource).toHaveBeenCalledWith("source_1"); + expect(result.current.prefetchedChunksBySourceId).toEqual({}); + expect(result.current.focusedChunk).toEqual({ + chunkId: null, + requestId: 1, + }); + }); + + it("keeps prefetched chunks when reselecting the selected source", () => { const selectSource = vi.fn(); const { result } = renderHook(() => useWorkspaceCitationFocus({ @@ -87,7 +119,9 @@ describe("useWorkspaceCitationFocus", () => { }); expect(selectSource).toHaveBeenCalledWith("source_1"); - expect(result.current.prefetchedChunksBySourceId).toEqual({}); + expect(result.current.prefetchedChunksBySourceId).toEqual({ + source_1: [prefetchedChunk], + }); expect(result.current.focusedChunk).toEqual({ chunkId: null, requestId: 1, diff --git a/src/components/workspace-citation-focus.ts b/src/components/workspace-citation-focus.ts index 1e8fc94..1e7bb20 100644 --- a/src/components/workspace-citation-focus.ts +++ b/src/components/workspace-citation-focus.ts @@ -1,6 +1,6 @@ "use client" -import { useCallback, useState } from "react" +import { useCallback, useRef, useState } from "react" import { workspaceCitationState } from "@/components/workspace-citation-state" import { useWorkspaceSelectedChunks } from "@/components/workspace-selected-chunks" @@ -14,6 +14,9 @@ type FocusedChunkState = { } type PrefetchedChunksBySourceId = Readonly> +type PrefetchedChunksUpdater = ( + current: PrefetchedChunksBySourceId, +) => PrefetchedChunksBySourceId type WorkspaceCitationFocusInput = { readonly fetchChunks: (sourceId: string) => Promise @@ -60,8 +63,15 @@ export function useWorkspaceCitationFocus({ const [fullChunkLoadingSourceId, setFullChunkLoadingSourceId] = useState< string | null >(null) + const fullChunkRequestsBySourceIdRef = useRef< + Map> + >(new Map()) + const fullChunkRequestedSourceIdsRef = useRef>(new Set()) const [prefetchedChunksBySourceId, setPrefetchedChunksBySourceId] = useState(initialPrefetchedChunksBySourceId) + const prefetchedChunksBySourceIdRef = useRef( + initialPrefetchedChunksBySourceId, + ) const { hasMoreSelectedChunks, handleLoadMoreChunks, @@ -85,48 +95,79 @@ export function useWorkspaceCitationFocus({ [], ) + const updatePrefetchedChunksBySourceId = useCallback( + (updater: PrefetchedChunksUpdater): void => { + const next = updater(prefetchedChunksBySourceIdRef.current) + prefetchedChunksBySourceIdRef.current = next + setPrefetchedChunksBySourceId(next) + }, + [], + ) + const handleSourceSelected = useCallback( (sourceId: string | null): void => { onSelectSource(sourceId) - if (sourceId) { - setPrefetchedChunksBySourceId((current) => + if (sourceId && sourceId !== selectedSourceId) { + fullChunkRequestedSourceIdsRef.current.delete(sourceId) + updatePrefetchedChunksBySourceId((current) => workspaceCitationState.removePrefetchedChunks(current, sourceId), ) } requestChunkFocus(null) }, - [onSelectSource, requestChunkFocus], + [ + onSelectSource, + requestChunkFocus, + selectedSourceId, + updatePrefetchedChunksBySourceId, + ], + ) + + const loadAllChunksForSource = useCallback( + (sourceId: string): Promise => { + const existingRequest = + fullChunkRequestsBySourceIdRef.current.get(sourceId) + if (existingRequest) return existingRequest + + setFullChunkLoadingSourceId(sourceId) + const request = fetchChunks(sourceId) + .then((chunks) => { + updatePrefetchedChunksBySourceId((current) => + workspaceCitationState.upsertPrefetchedChunks( + current, + sourceId, + chunks, + ), + ) + return chunks + }) + .finally(() => { + fullChunkRequestsBySourceIdRef.current.delete(sourceId) + setFullChunkLoadingSourceId((current) => + current === sourceId ? null : current, + ) + }) + + fullChunkRequestsBySourceIdRef.current.set(sourceId, request) + return request + }, + [fetchChunks, updatePrefetchedChunksBySourceId], ) const handleLoadAllChunks = useCallback((): void => { if ( !selectedSourceId || - prefetchedChunksBySourceId[selectedSourceId] || - fullChunkLoadingSourceId === selectedSourceId + prefetchedChunksBySourceIdRef.current[selectedSourceId] || + fullChunkRequestedSourceIdsRef.current.has(selectedSourceId) || + fullChunkRequestsBySourceIdRef.current.has(selectedSourceId) ) { return } - setFullChunkLoadingSourceId(selectedSourceId) - void fetchChunks(selectedSourceId) - .then((chunks) => { - setPrefetchedChunksBySourceId((current) => - workspaceCitationState.upsertPrefetchedChunks( - current, - selectedSourceId, - chunks, - ), - ) - }) - .finally(() => { - setFullChunkLoadingSourceId((current) => - current === selectedSourceId ? null : current, - ) - }) + fullChunkRequestedSourceIdsRef.current.add(selectedSourceId) + void loadAllChunksForSource(selectedSourceId) }, [ - fetchChunks, - fullChunkLoadingSourceId, - prefetchedChunksBySourceId, + loadAllChunksForSource, selectedSourceId, ]) @@ -157,7 +198,8 @@ export function useWorkspaceCitationFocus({ } if (!workspaceCitationState.hasExactCitationTargetHint(citation)) { - setPrefetchedChunksBySourceId((current) => + fullChunkRequestedSourceIdsRef.current.delete(source.id) + updatePrefetchedChunksBySourceId((current) => workspaceCitationState.removePrefetchedChunks(current, source.id), ) if (selectedSourceId !== source.id) onSelectSource(source.id) @@ -165,7 +207,7 @@ export function useWorkspaceCitationFocus({ return } - const cachedChunks = prefetchedChunksBySourceId[source.id] + const cachedChunks = prefetchedChunksBySourceIdRef.current[source.id] if (cachedChunks) { const cachedFocusId = workspaceCitationState.getLoadedCitationChunkId({ @@ -175,7 +217,7 @@ export function useWorkspaceCitationFocus({ selectedChunks: cachedChunks, hasMoreSelectedChunks: false, }) - setPrefetchedChunksBySourceId((current) => + updatePrefetchedChunksBySourceId((current) => workspaceCitationState.upsertPrefetchedChunks( current, source.id, @@ -188,14 +230,7 @@ export function useWorkspaceCitationFocus({ } requestChunkFocus(null) - const chunks = await fetchChunks(source.id) - setPrefetchedChunksBySourceId((current) => - workspaceCitationState.upsertPrefetchedChunks( - current, - source.id, - chunks, - ), - ) + const chunks = await loadAllChunksForSource(source.id) const prefetchedChunkId = workspaceCitationState.getLoadedCitationChunkId({ citation, @@ -213,14 +248,14 @@ export function useWorkspaceCitationFocus({ } }, [ - fetchChunks, hasMoreSelectedChunks, + loadAllChunksForSource, onSelectSource, - prefetchedChunksBySourceId, requestChunkFocus, selectedChunks, selectedSourceId, sources, + updatePrefetchedChunksBySourceId, ], )