diff --git a/next.config.ts b/next.config.ts index 3a8b106..b665d61 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,7 @@ const nextConfig: NextConfig = { reactCompiler: true, serverExternalPackages: ["pg", "@neondatabase/serverless", "postgres"], allowedDevOrigins: [ + "127.0.0.1", "notebook.local.knowhereto.ai", "notebook.127.0.0.1.nip.io", "dashboard.127.0.0.1.nip.io", diff --git a/package.json b/package.json index 5afc028..18ae7ac 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "ai": "^6.0.175", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-hierarchy": "^3.1.2", "docx-preview": "^0.3.7", "dompurify": "^3.4.2", "drizzle-orm": "^0.45.2", @@ -67,6 +68,7 @@ "@tailwindcss/postcss": "^4", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/d3-hierarchy": "^3.1.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16c3536..14e9bf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3-hierarchy: + specifier: ^3.1.2 + version: 3.1.2 docx-preview: specifier: ^0.3.7 version: 0.3.7 @@ -144,6 +147,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/d3-hierarchy': + specifier: ^3.1.7 + version: 3.1.7 '@types/node': specifier: ^20 version: 20.19.39 @@ -2093,6 +2099,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2731,6 +2740,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -6920,6 +6933,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-hierarchy@3.1.7': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -7551,6 +7566,8 @@ snapshots: csstype@3.2.3: {} + d3-hierarchy@3.1.2: {} + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@4.0.1: {} diff --git a/src/components/chunks-panel-state.test.ts b/src/components/chunks-panel-state.test.ts index 114ee4c..a95c98e 100644 --- a/src/components/chunks-panel-state.test.ts +++ b/src/components/chunks-panel-state.test.ts @@ -88,9 +88,81 @@ describe("chunksPanelState", () => { "Default_Root/Document-->Revenue-->Table 1", ), ).toBe("Revenue / Table 1") + expect( + chunksPanelState.formatChunkSectionPath( + "Default_Root/Document--!>Revenue--!>Table 1", + ), + ).toBe("Revenue / Table 1") expect( chunksPanelState.formatReferenceLabel("[images/image-12.png?token=abc]"), ).toBe("Image 12") }) + it("builds a section tree from slash and arrow separated Knowhere paths", () => { + type TestSectionTreeNode = { + readonly label: string + readonly chunks: readonly ParsedChunkView[] + readonly children: readonly TestSectionTreeNode[] + } + const buildSectionTree = ( + chunksPanelState as typeof chunksPanelState & { + readonly buildSectionTree?: ( + chunks: readonly ParsedChunkView[], + sourceTitle: string, + ) => TestSectionTreeNode + } + ).buildSectionTree + const tableChunk: ParsedChunkView = { + chunkId: "table_chunk", + parserChunkId: "parser_table", + type: "table", + content: "", + sectionPath: "tables/table-1.html", + filePath: "tables/table-1.html", + sourceTitle: "manual.pdf", + } + const chunks: ParsedChunkView[] = [ + { + chunkId: "overview_chunk", + parserChunkId: "parser_overview", + type: "text", + content: "Overview text", + sectionPath: "manual.pdf/Overview", + sourceTitle: "manual.pdf", + }, + { + chunkId: "robotics_chunk", + parserChunkId: "parser_robotics", + type: "text", + content: "Robotics [tables/table-1.html]", + sectionPath: "manual.pdf-->Outlook/Product-->Robotics", + sourceTitle: "manual.pdf", + connections: [ + { + targetParserChunkId: "parser_table", + targetChunkId: "table_chunk", + relation: "embeds", + ref: "[tables/table-1.html]", + }, + ], + }, + tableChunk, + ] + + const tree = buildSectionTree?.(chunks, "manual.pdf") + + expect(tree?.children.map((child) => child.label)).toEqual([ + "Overview", + "Outlook", + ]) + expect(tree?.children[0]?.chunks.map((chunk) => chunk.chunkId)).toEqual([ + "overview_chunk", + ]) + expect(tree?.children[1]?.children[0]?.label).toBe("Product") + expect( + tree?.children[1]?.children[0]?.children[0]?.chunks.map( + (chunk) => chunk.chunkId, + ), + ).toEqual(["robotics_chunk", "table_chunk"]) + }) }) diff --git a/src/components/chunks-panel-state.ts b/src/components/chunks-panel-state.ts index 430951a..83ea12b 100644 --- a/src/components/chunks-panel-state.ts +++ b/src/components/chunks-panel-state.ts @@ -9,7 +9,31 @@ type RenderableReference = { readonly connection: ParsedChunkConnection } +type ChunkSectionTreeNodeKind = "root" | "section" + +type ChunkSectionTreeNode = { + readonly id: string + readonly kind: ChunkSectionTreeNodeKind + readonly label: string + readonly chunks: readonly ParsedChunkView[] + readonly children: readonly ChunkSectionTreeNode[] + readonly chunkCount: number +} + +type MutableChunkSectionTreeNode = { + readonly id: string + readonly kind: ChunkSectionTreeNodeKind + readonly label: string + readonly chunks: ParsedChunkView[] + readonly children: MutableChunkSectionTreeNode[] + readonly childrenByKey: Map +} + type ChunksPanelStateModule = { + readonly buildSectionTree: ( + chunks: readonly ParsedChunkView[], + sourceTitle: string, + ) => ChunkSectionTreeNode readonly formatChunkSectionPath: ( sectionPath: ParsedChunkView["sectionPath"], ) => string | null @@ -24,6 +48,9 @@ type ChunksPanelStateModule = { ) => RenderableReference[] } +const knowhereArrowSectionSeparator = /--!?>/ +const knowhereSectionSegmentSeparator = /--!?>|\/+/ + function getChunksWithFocusedFirst( chunks: readonly ParsedChunkView[], focusedChunkId: string | null, @@ -67,6 +94,217 @@ function getChunksOrderedByPageNumber( .map(({ chunk }) => chunk) } +function buildSectionTree( + chunks: readonly ParsedChunkView[], + sourceTitle: string, +): ChunkSectionTreeNode { + const root = createMutableSectionTreeNode({ + id: "root", + kind: "root", + label: sourceTitle.trim() || "Parsed Chunks", + }) + const chunksByParserChunkId = new Map( + chunks + .filter((chunk) => chunk.parserChunkId) + .map((chunk) => [chunk.parserChunkId!, chunk]), + ) + const chunksByChunkId = new Map(chunks.map((chunk) => [chunk.chunkId, chunk])) + const sectionSegmentsByChunkId = new Map() + + chunks.forEach((chunk) => { + const sectionSegments = getChunkSectionSegments(chunk, sourceTitle) + if (sectionSegments.length > 0) { + sectionSegmentsByChunkId.set(chunk.chunkId, sectionSegments) + } + }) + + const embeddedSectionSegmentsByChunkId = getEmbeddedSectionSegmentsByChunkId({ + chunks, + chunksByChunkId, + chunksByParserChunkId, + sectionSegmentsByChunkId, + }) + + chunks.forEach((chunk) => { + const sectionSegments = + embeddedSectionSegmentsByChunkId.get(chunk.chunkId) ?? + sectionSegmentsByChunkId.get(chunk.chunkId) ?? + getFallbackSectionSegments(chunk) + addChunkToSection(root, sectionSegments, chunk) + }) + + return toReadonlySectionTreeNode(root) +} + +function createMutableSectionTreeNode(input: { + readonly id: string + readonly kind: ChunkSectionTreeNodeKind + readonly label: string +}): MutableChunkSectionTreeNode { + return { + id: input.id, + kind: input.kind, + label: input.label, + chunks: [], + children: [], + childrenByKey: new Map(), + } +} + +function getEmbeddedSectionSegmentsByChunkId(input: { + readonly chunks: readonly ParsedChunkView[] + readonly chunksByChunkId: ReadonlyMap + readonly chunksByParserChunkId: ReadonlyMap + readonly sectionSegmentsByChunkId: ReadonlyMap +}): ReadonlyMap { + const embeddedSectionSegmentsByChunkId = new Map() + + input.chunks.forEach((chunk) => { + const sourceSectionSegments = input.sectionSegmentsByChunkId.get( + chunk.chunkId, + ) + if (!sourceSectionSegments || !chunk.connections) return + + chunk.connections.forEach((connection) => { + const targetChunk = + getConnectionTargetChunk(connection, input.chunksByChunkId) ?? + input.chunksByParserChunkId.get(connection.targetParserChunkId) + if (!targetChunk) return + + if (!embeddedSectionSegmentsByChunkId.has(targetChunk.chunkId)) { + embeddedSectionSegmentsByChunkId.set( + targetChunk.chunkId, + sourceSectionSegments, + ) + } + }) + }) + + return embeddedSectionSegmentsByChunkId +} + +function getConnectionTargetChunk( + connection: ParsedChunkConnection, + chunksByChunkId: ReadonlyMap, +): ParsedChunkView | undefined { + return connection.targetChunkId + ? chunksByChunkId.get(connection.targetChunkId) + : undefined +} + +function getChunkSectionSegments( + chunk: ParsedChunkView, + sourceTitle: string, +): readonly string[] { + const sectionPath = chunk.sectionPath?.trim() + if (!sectionPath) return [] + if (isAssetPath(sectionPath) && chunk.type !== "text") return [] + + const segments = sectionPath + .split(knowhereSectionSegmentSeparator) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + + return removeDocumentRootSegments(segments, sourceTitle) +} + +function removeDocumentRootSegments( + segments: readonly string[], + sourceTitle: string, +): readonly string[] { + const remainingSegments = [...segments] + + if (remainingSegments[0] === "Default_Root") { + remainingSegments.shift() + if (remainingSegments.length > 1) { + remainingSegments.shift() + } + } + + if ( + remainingSegments.length > 1 && + isSamePathSegment(remainingSegments[0]!, sourceTitle) + ) { + remainingSegments.shift() + } + + return remainingSegments.length > 0 ? remainingSegments : ["Unsectioned"] +} + +function isSamePathSegment(left: string, right: string): boolean { + return normalizePathSegment(left) === normalizePathSegment(right) +} + +function normalizePathSegment(value: string): string { + return value.replace(/\s+/g, " ").trim().toLowerCase() +} + +function isAssetPath(sectionPath: string): boolean { + return ( + sectionPath.startsWith("images/") || + sectionPath.startsWith("image/") || + sectionPath.startsWith("tables/") || + sectionPath.startsWith("table/") + ) +} + +function getFallbackSectionSegments( + chunk: ParsedChunkView, +): readonly string[] { + if (chunk.type === "image") return ["Assets", "Images"] + if (chunk.type === "table") return ["Assets", "Tables"] + return ["Unsectioned"] +} + +function addChunkToSection( + root: MutableChunkSectionTreeNode, + sectionSegments: readonly string[], + chunk: ParsedChunkView, +): void { + const targetSection = sectionSegments.reduce( + (parent, sectionSegment) => getOrCreateChildSection(parent, sectionSegment), + root, + ) + targetSection.chunks.push(chunk) +} + +function getOrCreateChildSection( + parent: MutableChunkSectionTreeNode, + label: string, +): MutableChunkSectionTreeNode { + const key = normalizePathSegment(label) + const existing = parent.childrenByKey.get(key) + if (existing) return existing + + const child = createMutableSectionTreeNode({ + id: `${parent.id}/${key}`, + kind: "section", + label, + }) + parent.childrenByKey.set(key, child) + parent.children.push(child) + return child +} + +function toReadonlySectionTreeNode( + node: MutableChunkSectionTreeNode, +): ChunkSectionTreeNode { + const children = node.children.map(toReadonlySectionTreeNode) + const childChunkCount = children.reduce( + (total, child) => total + child.chunkCount, + 0, + ) + + return { + id: node.id, + kind: node.kind, + label: node.label, + chunks: [...node.chunks], + children, + chunkCount: node.chunks.length + childChunkCount, + } +} + function getFirstPageNumber(chunk: ParsedChunkView): number | null { const pageNumbers = chunk.pageNums ?? [] const finitePageNumbers = pageNumbers.filter( @@ -84,7 +322,7 @@ function formatChunkSectionPath( const userVisiblePath = removeKnowhereDefaultRootPrefix(trimmedSectionPath) const readablePath = userVisiblePath - .split("-->") + .split(knowhereArrowSectionSeparator) .map((segment) => segment.trim()) .filter((segment) => segment.length > 0) .join(" / ") @@ -96,7 +334,7 @@ function removeKnowhereDefaultRootPrefix(sectionPath: string): string { const knowhereDefaultRootPrefix = "Default_Root/" as const if (!sectionPath.startsWith(knowhereDefaultRootPrefix)) return sectionPath - const sectionSegments = sectionPath.split("-->") + const sectionSegments = sectionPath.split(knowhereArrowSectionSeparator) if (sectionSegments.length <= 1) { return sectionPath.slice(knowhereDefaultRootPrefix.length) } @@ -190,6 +428,7 @@ function capitalize(value: string): string { } export const chunksPanelState: ChunksPanelStateModule = { + buildSectionTree, formatChunkSectionPath, formatReferenceLabel, getChunksWithFocusedFirst, diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index edfa49d..3e110ac 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -63,6 +63,407 @@ describe("ChunksPanel", () => { expect(screen.getByText(/Showing all parsed chunks from/)).toBeTruthy(); }); + it("defaults parsed chunks into a section tree view", () => { + render( + React.createElement(C, { + chunks: [ + { + chunkId: "overview_chunk", + parserChunkId: "parser_overview", + type: "text", + content: "Overview text", + sectionPath: "manual.pdf/Overview", + sourceTitle: "manual.pdf", + }, + { + chunkId: "robotics_chunk", + parserChunkId: "parser_robotics", + type: "text", + content: "Robotics [tables/table-1.html]", + sectionPath: "manual.pdf-->Outlook/Product-->Robotics", + sourceTitle: "manual.pdf", + connections: [ + { + targetParserChunkId: "parser_table", + targetChunkId: "table_chunk", + relation: "embeds", + ref: "[tables/table-1.html]", + }, + ], + }, + { + chunkId: "table_chunk", + parserChunkId: "parser_table", + type: "table", + content: "
", + sectionPath: "tables/table-1.html", + filePath: "tables/table-1.html", + sourceTitle: "manual.pdf", + }, + ], + selectedSource: "manual.pdf", + }), + ); + + expect( + screen.getByRole("tree", { name: "Parsed chunk sections" }), + ).toBeTruthy(); + expect(screen.getByText("Overview")).toBeTruthy(); + expect(screen.getByText("Outlook")).toBeTruthy(); + expect( + screen.getByRole("treeitem", { + name: /Robotics section with 2 chunks/i, + }), + ).toBeTruthy(); + }); + + it("requests the full chunk list before showing the section tree", async () => { + const user = userEvent.setup(); + const handleLoadAllChunks = vi.fn(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "chunk_1", + type: "text", + content: "Overview text", + sectionPath: "manual.pdf/Overview", + sourceTitle: "manual.pdf", + }, + ], + selectedSource: "manual.pdf", + hasMoreChunks: true, + onLoadAllChunks: handleLoadAllChunks, + }), + ); + + await user.click(screen.getByRole("button", { name: "Tree" })); + + expect(handleLoadAllChunks).toHaveBeenCalledTimes(1); + }); + + it("keeps the section tree background as wide as the computed tree", 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 card = tree.parentElement as HTMLElement; + const treeWidth = Number.parseInt(tree.style.width, 10); + const cardMinimumWidth = Number.parseInt(card.style.minWidth, 10); + + expect(tree.style.width).not.toBe(""); + expect(cardMinimumWidth).toBeGreaterThanOrEqual(treeWidth); + }); + + it("fills the visible section tree card with the draggable canvas", 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 surface = screen.getByTestId("chunk-section-tree-zoom-surface"); + const tree = screen.getByRole("tree", { name: "Parsed chunk sections" }); + const treeWidth = Number.parseInt(tree.style.width, 10); + const surfaceMinimumWidth = Number.parseInt(surface.style.minWidth, 10); + + expect(surface.style.width).toBe("100%"); + expect(surfaceMinimumWidth).toBeGreaterThanOrEqual(treeWidth); + }); + + it("renders section tree zoom controls over the canvas", 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 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.getByRole("group", { name: "Section tree zoom" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Zoom in section tree" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Zoom out section tree" }), + ).toBeTruthy(); + expect( + 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 () => { + 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 surface = screen.getByTestId("chunk-section-tree-zoom-surface"); + const initialSurfaceMinimumWidth = Number.parseInt( + surface.style.minWidth, + 10, + ); + + for (let i = 0; i < 7; i += 1) { + fireEvent.wheel(surface, { deltaY: 120 }); + } + + const zoomedOutSurfaceMinimumWidth = Number.parseInt( + surface.style.minWidth, + 10, + ); + + expect(tree.style.transform).toBe("scale(0.3)"); + expect(zoomedOutSurfaceMinimumWidth).toBeLessThan( + initialSurfaceMinimumWidth, + ); + }); + + it("zooms the section tree with the mouse wheel", 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 surface = screen.getByTestId("chunk-section-tree-zoom-surface"); + const zoomInEvent = new WheelEvent("wheel", { + cancelable: true, + deltaY: -120, + }); + + act(() => { + surface.dispatchEvent(zoomInEvent); + }); + + expect(zoomInEvent.defaultPrevented).toBe(true); + expect(tree.style.transform).toBe("scale(1.1)"); + + fireEvent.wheel(surface, { deltaY: 120 }); + + expect(tree.style.transform).toBe("scale(1)"); + }); + + it("pans the section tree canvas by dragging the background", 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 surface = screen.getByTestId("chunk-section-tree-zoom-surface"); + const tree = screen.getByRole("tree", { name: "Parsed chunk sections" }); + + fireEvent.mouseDown(surface, { button: 0, clientX: 100, clientY: 90 }); + fireEvent.mouseMove(window, { clientX: 142, clientY: 126 }); + fireEvent.mouseUp(window); + + expect(tree.style.left).toBe("42px"); + expect(tree.style.top).toBe("36px"); + expect(surface.className).toContain("cursor-grab"); + }); + + it("uses pointer cursor and Violet colors for clickable section tree chunk nodes", async () => { + const user = userEvent.setup(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "robotics_chunk", + type: "text", + content: "Robotics details", + sectionPath: "manual.pdf/Outlook/Robotics", + sourceTitle: "manual.pdf", + }, + ], + selectedSource: "manual.pdf", + }), + ); + + await user.click(screen.getByRole("button", { name: "Tree" })); + + const chunkNode = screen.getByRole("button", { + name: /Robotics details\s*Text/, + }); + + expect(chunkNode.className).toContain("cursor-pointer"); + expect(chunkNode.className).toContain("border-violet-200"); + expect(chunkNode.className).toContain("bg-violet-50"); + expect(chunkNode.className).toContain("hover:bg-violet-100"); + }); + + it("returns to the list and focuses a chunk when its tree node is clicked", async () => { + mockVisibleVirtualViewport(); + const user = userEvent.setup(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "overview_chunk", + type: "text", + content: "Overview text", + sectionPath: "manual.pdf/Overview", + sourceTitle: "manual.pdf", + }, + { + chunkId: "robotics_chunk", + type: "text", + content: "Robotics details", + sectionPath: "manual.pdf/Outlook/Robotics", + sourceTitle: "manual.pdf", + }, + ], + selectedSource: "manual.pdf", + }), + ); + + await user.click(screen.getByRole("button", { name: "Tree" })); + await user.click( + screen.getByRole("button", { name: /Robotics details\s*Text/ }), + ); + + await waitFor(() => { + expect( + screen.queryByRole("tree", { name: "Parsed chunk sections" }), + ).toBeNull(); + }); + expect(screen.getByTestId("chunk-card-shell-robotics_chunk")).toBeTruthy(); + }); + it("shows a large upload target when no document is selected", async () => { const user = userEvent.setup(); @@ -129,6 +530,7 @@ describe("ChunksPanel", () => { }, }), ); + selectListView(); const openOriginalButton = screen.getByRole("button", { name: "Open original file", @@ -200,6 +602,7 @@ describe("ChunksPanel", () => { }, }), ); + selectListView(); await user.click( screen.getByRole("button", { name: "Open page 2 in original file" }), @@ -241,6 +644,7 @@ describe("ChunksPanel", () => { }, }), ); + selectListView(); await user.click( screen.getByRole("button", { name: "Open page 2 in original file" }), @@ -284,6 +688,7 @@ describe("ChunksPanel", () => { selectedSourceFile, }), ); + selectListView(); await user.click(screen.getByRole("button", { name: "Original" })); expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); @@ -342,6 +747,7 @@ describe("ChunksPanel", () => { selectedSource: "demo.pdf", }), ); + selectListView(); expect(screen.getByTestId("chunks-scroll-content").className).toContain( "min-w-0", @@ -372,6 +778,7 @@ describe("ChunksPanel", () => { ], }), ); + selectListView(); const sourcePanel = screen.getByTestId("chunk-source-panel-image_1"); @@ -406,6 +813,7 @@ describe("ChunksPanel", () => { selectedSource: "TSLA-Q4-2025-Update.pdf", }), ); + selectListView(); const financialSourcePanel = screen.getByTestId( "chunk-source-panel-text_1", @@ -446,6 +854,7 @@ describe("ChunksPanel", () => { selectedSource: "TSLA-Q4-2025-UPDATE.PDF", }), ); + selectListView(); expect(screen.getByTestId("chunk-source-panel-text_1").textContent).toContain( "Installed Annual Capacity", @@ -537,6 +946,7 @@ describe("ChunksPanel", () => { selectedSource: "manual.pdf", }), ); + selectListView(); const image = screen.getByRole("img", { name: "A wiring diagram." }); expect(image.getAttribute("src")).toBe( @@ -596,6 +1006,7 @@ describe("ChunksPanel", () => { focusedChunkRequestId: 1, }), ); + selectListView(); await user.click(screen.getByRole("button", { name: "Table 1" })); @@ -627,6 +1038,7 @@ describe("ChunksPanel", () => { focusedChunkRequestId: 1, }), ); + selectListView(); await waitFor(() => { expect(screen.getByTestId("chunk-card-shell-chunk_50")).toBeTruthy(); @@ -656,6 +1068,7 @@ describe("ChunksPanel", () => { selectedSource: "large.pdf", }), ); + selectListView(); const viewport = screen .getByTestId("chunks-panel") .querySelector("[data-radix-scroll-area-viewport]"); @@ -718,6 +1131,7 @@ describe("ChunksPanel", () => { selectedSource: "large.pdf", }), ); + selectListView(); rerender( React.createElement(C, { @@ -785,6 +1199,7 @@ describe("ChunksPanel", () => { selectedSource: "large.pdf", }), ); + selectListView(); const viewport = screen .getByTestId("chunks-panel") .querySelector("[data-radix-scroll-area-viewport]"); @@ -844,6 +1259,7 @@ describe("ChunksPanel", () => { selectedSource: "annual-report.pdf", }), ); + selectListView(); expect( screen.getByRole("button", { @@ -905,6 +1321,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 6ea3684..b27e341 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -2,22 +2,42 @@ import { type CSSProperties, + type MouseEvent as ReactMouseEvent, type ReactNode, useCallback, useEffect, + useMemo, + useRef, useState, } from "react"; import { type VirtualItem } from "@tanstack/react-virtual"; +import { + hierarchy, + tree as createD3Tree, + type HierarchyPointLink, + type HierarchyPointNode, +} from "d3-hierarchy"; 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"; import { useSourceOriginalPreviewWarmup } from "@/components/source-original-preview-warmup"; import { sourceOriginalPreviewModel } from "@/components/source-original-preview-model"; import type { ParsedChunkView } from "@/domains/chunks/types"; @@ -32,12 +52,21 @@ export type ChunksPanelProps = { focusedChunkRequestId?: number; isLoading?: boolean; isLoadingMore?: boolean; + isLoadingAllChunks?: boolean; hasMoreChunks?: boolean; onLoadMore?: () => void; + onLoadAllChunks?: () => void; onLoginClick?: () => void; onSourceUploaded?: (source: SourceView) => void; }; +type ChunkDisplayMode = "list" | "tree"; + +type ChunkDisplayModeState = { + readonly handledFocusedChunkRequestId: number; + readonly mode: ChunkDisplayMode; +}; + export function ChunksPanel({ chunks = [], selectedSource = null, @@ -46,8 +75,10 @@ export function ChunksPanel({ focusedChunkRequestId = 0, isLoading = false, isLoadingMore = false, + isLoadingAllChunks = false, hasMoreChunks = false, onLoadMore, + onLoadAllChunks, onLoginClick, onSourceUploaded, }: Partial = {}) { @@ -60,6 +91,14 @@ export function ChunksPanel({ const [mountedOriginalPreviewKey, setMountedOriginalPreviewKey] = useState< string | null >(null); + const [chunkDisplayModeState, setChunkDisplayModeState] = + useState(() => ({ + handledFocusedChunkRequestId: + focusedChunkId === null ? focusedChunkRequestId : -1, + mode: "tree", + })); + const [sectionTreeZoomPercent, setSectionTreeZoomPercent] = + useState(sectionTreeDefaultZoomPercent); const { activeFocusedChunkId, handleChunkSelected: selectChunk, @@ -110,11 +149,86 @@ export function ChunksPanel({ rememberOriginalPreview(); selectOriginalView(); }, [rememberOriginalPreview, selectOriginalView]); + const handleListModeSelected = useCallback((): void => { + setChunkDisplayModeState({ + handledFocusedChunkRequestId: focusedChunkRequestId, + mode: "list", + }); + }, [focusedChunkRequestId]); + const handleTreeModeSelected = useCallback((): void => { + setChunkDisplayModeState({ + handledFocusedChunkRequestId: focusedChunkRequestId, + mode: "tree", + }); + }, [focusedChunkRequestId]); + const handleTreeChunkFocus = useCallback( + (chunkId: string | null): void => { + requestChunkFocus(chunkId); + if (chunkId !== null) { + setChunkDisplayModeState({ + handledFocusedChunkRequestId: focusedChunkRequestId, + mode: "list", + }); + } + }, + [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) => { + if (direction === "in") { + return Math.min( + sectionTreeMaximumZoomPercent, + currentZoomPercent + sectionTreeZoomStepPercent, + ); + } + + return Math.max( + sectionTreeMinimumZoomPercent, + currentZoomPercent - sectionTreeZoomStepPercent, + ); + }); + }, + [], + ); + const chunkDisplayMode: ChunkDisplayMode = + focusedChunkId !== null && + chunkDisplayModeState.handledFocusedChunkRequestId !== + focusedChunkRequestId + ? "list" + : chunkDisplayModeState.mode; const headerTitle = focusedChunkId ? "Referenced Chunks" : "Parsed Chunks"; const shouldMountOriginalPreview = visibleView === "original" || (originalPreviewCacheKey !== null && mountedOriginalPreviewKey === originalPreviewCacheKey); + const isTreeModeVisible = + visibleView === "parsed" && chunkDisplayMode === "tree"; const headerSubtitle = visibleView === "original" ? ( selectedSource ? ( <> @@ -139,6 +253,26 @@ export function ChunksPanel({ "Select a source to see its parsed chunks." ); + useEffect(() => { + if ( + chunkDisplayMode !== "tree" || + visibleView !== "parsed" || + !hasMoreChunks || + isLoadingAllChunks + ) { + return; + } + + onLoadAllChunks?.(); + }, [ + chunkDisplayMode, + hasMoreChunks, + isLoadingAllChunks, + onLoadAllChunks, + selectedSource, + visibleView, + ]); + return (
+ {visibleView === "parsed" && chunks.length > 0 ? ( +
+ + +
+ ) : null} {hasOriginalFile ? (
+ + Zoom out + + + {zoomPercent}% + + + + + + Zoom in + + + + + + Reset zoom + +
+ + ); +} + +function SectionTreeItem({ + focusedChunkId, + node, + xOffset, + yOffset, + onChunkFocus, +}: { + readonly focusedChunkId: string | null; + readonly node: HierarchyPointNode; + readonly xOffset: number; + readonly yOffset: number; + readonly onChunkFocus: (chunkId: string | null) => void; +}): ReactNode { + const itemStyle: CSSProperties = { + left: node.y + yOffset, + position: "absolute", + top: node.x + xOffset - sectionTreeNodeHeight / 2, + width: sectionTreeNodeWidth, + }; + const isFocusedChunk = + node.data.kind === "chunk" && node.data.chunk?.chunkId === focusedChunkId; + + return ( +
+ {node.data.kind === "chunk" && node.data.chunk ? ( + + ) : ( +
+ + {node.data.label} + + + {formatChunkCount(node.data.chunkCount)} + +
+ )} +
+ ); +} + +function isInteractiveSectionTreeTarget(target: EventTarget): boolean { + return ( + target instanceof Element && + target.closest("a,button,input,select,textarea,[role='button']") !== null + ); +} + +function getChunkSectionTreeLayout( + sectionTree: ChunkSectionTreeNode, +): ChunkSectionTreeLayout { + const renderableTree = toRenderableChunkTreeNode(sectionTree); + const root = hierarchy( + renderableTree, + (node) => (node.children.length > 0 ? [...node.children] : undefined), + ); + const positionedRoot = createD3Tree() + .nodeSize([sectionTreeRowGap, sectionTreeColumnGap])(root); + const nodes = positionedRoot.descendants(); + const links = positionedRoot.links(); + const xPositions = nodes.map((node) => node.x); + const yPositions = nodes.map((node) => node.y); + const minX = Math.min(...xPositions); + const maxX = Math.max(...xPositions); + const maxY = Math.max(...yPositions); + const width = Math.max( + sectionTreeMinimumWidth, + maxY + + sectionTreePadding.left + + sectionTreeNodeWidth + + sectionTreePadding.right, + ); + const height = Math.max( + sectionTreeMinimumHeight, + maxX - minX + sectionTreePadding.top + sectionTreePadding.bottom, + ); + + return { + height, + links, + nodes, + width, + xOffset: sectionTreePadding.top - minX, + yOffset: sectionTreePadding.left, + }; +} + +function formatSectionTreeZoomScale(zoomPercent: number): string { + return (zoomPercent / 100).toFixed(2).replace(/\.?0+$/, ""); +} + +function toRenderableChunkTreeNode( + node: ChunkSectionTreeNode, +): RenderableChunkTreeNode { + const sectionChildren = node.children.map(toRenderableChunkTreeNode); + const chunkChildren = node.chunks.map((chunk): RenderableChunkTreeNode => ({ + id: `${node.id}/chunk/${chunk.chunkId}`, + kind: "chunk", + label: getChunkTreeLabel(chunk), + chunk, + chunkCount: 1, + children: [], + })); + + return { + id: node.id, + kind: node.kind, + label: node.label, + chunkCount: node.chunkCount, + children: [...sectionChildren, ...chunkChildren], + }; +} + +function getSectionTreeLinkPath( + link: HierarchyPointLink, + layout: ChunkSectionTreeLayout, +): string { + const sourceX = link.source.x + layout.xOffset; + const sourceY = link.source.y + layout.yOffset + sectionTreeNodeWidth; + const targetX = link.target.x + layout.xOffset; + const targetY = link.target.y + layout.yOffset; + const middleY = (sourceY + targetY) / 2; + + return [ + `M${sourceY},${sourceX}`, + `C${middleY},${sourceX}`, + `${middleY},${targetX}`, + `${targetY},${targetX}`, + ].join(" "); +} + +function getTreeItemAriaLabel(node: RenderableChunkTreeNode): string { + if (node.kind === "chunk") { + return `${node.label} ${getChunkTreeDetail(node.chunk!)}`; + } + return `${node.label} section with ${formatChunkCount(node.chunkCount)}`; +} + +function getChunkTreeLabel(chunk: ParsedChunkView): string { + if (chunk.filePath) { + return chunksPanelState.formatReferenceLabel(`[${chunk.filePath}]`); + } + + const summary = chunk.summary?.split(/\r?\n/, 1)[0]?.trim(); + if (summary) return truncateTreeLabel(summary); + + const content = chunk.content.replace(/\s+/g, " ").trim(); + if (content) return truncateTreeLabel(content); + + return `${getChunkTypeLabel(chunk.type)} chunk`; +} + +function getChunkTreeDetail(chunk: ParsedChunkView): string { + const pageLabel = getChunkPageLabel(chunk); + return pageLabel + ? `${getChunkTypeLabel(chunk.type)} ยท ${pageLabel}` + : getChunkTypeLabel(chunk.type); +} + +function getChunkPageLabel(chunk: ParsedChunkView): string | null { + const pageNumbers = chunk.pageNums ?? []; + const validPageNumbers = pageNumbers.filter( + (pageNumber) => Number.isFinite(pageNumber) && pageNumber > 0, + ); + if (validPageNumbers.length === 0) return null; + + const firstPageNumber = Math.min(...validPageNumbers); + return `Page ${firstPageNumber}`; +} + +function getChunkTypeLabel(type: ParsedChunkView["type"]): string { + if (type === "image") return "Image"; + if (type === "table") return "Table"; + return "Text"; +} + +function formatChunkCount(chunkCount: number): string { + return `${chunkCount} ${chunkCount === 1 ? "chunk" : "chunks"}`; +} + +function truncateTreeLabel(value: string): string { + const maxLength = 42; + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3).trim()}...`; +} + function EmptySourceUploadState({ onLoginClick, onSourceUploaded, diff --git a/src/components/source-original-docx-workflow.test.ts b/src/components/source-original-docx-workflow.test.ts index 74bb78a..97f0e29 100644 --- a/src/components/source-original-docx-workflow.test.ts +++ b/src/components/source-original-docx-workflow.test.ts @@ -77,17 +77,13 @@ describe("useSourceOriginalDocxWorkflow", () => { expect(document.querySelector("script")).toBeNull() }) - it("aborts in-flight DOCX requests on cleanup", async () => { - const signals: AbortSignal[] = [] + it("leaves shared DOCX requests uncancelled on cleanup", async () => { + const signals: Array = [] vi.stubGlobal( "fetch", vi.fn((_input, init) => { - if (init?.signal instanceof AbortSignal) signals.push(init.signal) - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - reject(new DOMException("Aborted", "AbortError")) - }) - }) + signals.push(init?.signal ?? undefined) + return new Promise(() => undefined) }), ) @@ -100,7 +96,7 @@ describe("useSourceOriginalDocxWorkflow", () => { unmount() - expect(signals[0]?.aborted).toBe(true) + expect(signals[0]).toBeUndefined() }) }) diff --git a/src/components/source-original-preview-request.test.ts b/src/components/source-original-preview-request.test.ts index f16c708..7bf8c4d 100644 --- a/src/components/source-original-preview-request.test.ts +++ b/src/components/source-original-preview-request.test.ts @@ -40,29 +40,25 @@ describe("sourceOriginalPreviewRequest", () => { expect([...new Uint8Array(data)]).toEqual([1, 2]); }); - it("passes cancellation signals to the underlying request", async () => { + it("does not bind caller cancellation signals to the shared request", async () => { const fetchSignals: Array = []; vi.stubGlobal( "fetch", vi.fn((_input, init) => { fetchSignals.push(init?.signal ?? null); - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - reject(new DOMException("Aborted", "AbortError")); - }); - }); + return Promise.resolve(new Response("hello", { status: 200 })); }), ); const controller = new AbortController(); - const request = sourceOriginalPreviewRequest - .getText("https://example.com/notes.txt", controller.signal) - .catch(() => undefined); - await Promise.resolve(); + const text = await sourceOriginalPreviewRequest.getText( + "https://example.com/notes.txt", + controller.signal, + ); controller.abort(); - await request; - expect(fetchSignals[0]?.aborted).toBe(true); + expect(text).toBe("hello"); + expect(fetchSignals[0]).toBeNull(); }); it("reuses a warmed binary download for the preview request", async () => { @@ -83,6 +79,36 @@ describe("sourceOriginalPreviewRequest", () => { expect(fetchOriginal).toHaveBeenCalledTimes(1); }); + it("keeps a shared preview download alive when a warmup caller aborts", async () => { + let resolveFetch: (response: Response) => void = () => undefined; + const fetchOriginal = vi.fn((_input, init) => { + return new Promise((resolve, reject) => { + resolveFetch = resolve; + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + }); + }); + vi.stubGlobal("fetch", fetchOriginal); + const warmupController = new AbortController(); + const previewController = new AbortController(); + + sourceOriginalPreviewRequest.prefetchArrayBuffer( + "https://example.com/report.pdf", + warmupController.signal, + ); + const preview = sourceOriginalPreviewRequest.getArrayBuffer( + "https://example.com/report.pdf", + previewController.signal, + ); + + warmupController.abort(); + resolveFetch(new Response(new Uint8Array([1, 2]), { status: 200 })); + + await expect(preview).resolves.toEqual(new Uint8Array([1, 2]).buffer); + expect(fetchOriginal).toHaveBeenCalledTimes(1); + }); + it("returns fresh binary buffers from cached downloads", async () => { const fetchOriginal = vi.fn(() => Promise.resolve(new Response(new Uint8Array([1, 2]), { status: 200 })), diff --git a/src/components/source-original-preview-request.ts b/src/components/source-original-preview-request.ts index 994f4c9..1f6cc44 100644 --- a/src/components/source-original-preview-request.ts +++ b/src/components/source-original-preview-request.ts @@ -12,8 +12,9 @@ const textCache = new Map>(); const arrayBufferCache = new Map>(); async function getText(url: string, signal: AbortSignal): Promise { + assertSignalIsActive(signal); return getCachedValue(textCache, url, () => - Effect.runPromise(getTextEffect(url, signal)), + Effect.runPromise(getTextEffect(url)), ); } @@ -21,8 +22,9 @@ async function getArrayBuffer( url: string, signal: AbortSignal, ): Promise { + assertSignalIsActive(signal); const bytes = await getCachedValue(arrayBufferCache, url, async () => - toUint8Array(await Effect.runPromise(getArrayBufferEffect(url, signal))), + toUint8Array(await Effect.runPromise(getArrayBufferEffect(url))), ); return copyArrayBuffer(bytes); } @@ -57,8 +59,8 @@ function getCachedValue( } const getTextEffect = Effect.fn("getSourceOriginalText")( - function* (url: string, signal: AbortSignal) { - const response = yield* fetchSourceOriginal(url, signal, "Text"); + function* (url: string) { + const response = yield* fetchSourceOriginal(url, "Text"); if (!isSuccessfulStatus(response.status)) { return yield* new SourceOriginalPreviewRequestError({ message: "Text download failed.", @@ -76,8 +78,8 @@ const getTextEffect = Effect.fn("getSourceOriginalText")( ); const getArrayBufferEffect = Effect.fn("getSourceOriginalArrayBuffer")( - function* (url: string, signal: AbortSignal) { - const response = yield* fetchSourceOriginal(url, signal, "Binary"); + function* (url: string) { + const response = yield* fetchSourceOriginal(url, "Binary"); if (!isSuccessfulStatus(response.status)) { return yield* new SourceOriginalPreviewRequestError({ message: "Binary download failed.", @@ -95,9 +97,12 @@ const getArrayBufferEffect = Effect.fn("getSourceOriginalArrayBuffer")( ); const fetchSourceOriginal = Effect.fn("fetchSourceOriginal")( - function* (url: string, signal: AbortSignal, label: "Binary" | "Text") { + function* (url: string, label: "Binary" | "Text") { return yield* Effect.tryPromise({ - try: () => fetch(url, { signal }), + // Downloads are shared between warmup and visible preview callers. + // A caller-owned AbortSignal must not cancel the cached request for all + // other consumers. + try: () => fetch(url), catch: () => new SourceOriginalPreviewRequestError({ message: `${label} download failed.`, @@ -117,6 +122,12 @@ function isSuccessfulStatus(status: number): boolean { return status >= 200 && status < 300; } +function assertSignalIsActive(signal: AbortSignal): void { + if (signal.aborted) { + throw new DOMException("Aborted", "AbortError"); + } +} + function toUint8Array(data: ArrayBuffer): Uint8Array { return new Uint8Array(data); } diff --git a/src/components/source-original-preview.test.ts b/src/components/source-original-preview.test.ts index 0ceb1e9..afbd5f9 100644 --- a/src/components/source-original-preview.test.ts +++ b/src/components/source-original-preview.test.ts @@ -487,7 +487,7 @@ describe("SourceOriginalPreview", () => { }); }); - it("aborts text preview downloads when the preview unmounts", async () => { + it("leaves shared text preview downloads uncancelled when the preview unmounts", async () => { const fetchSignals: Array = []; vi.stubGlobal( "fetch", @@ -510,11 +510,10 @@ describe("SourceOriginalPreview", () => { await waitFor(() => { expect(fetchSignals).toHaveLength(1); }); - expect(fetchSignals[0]?.aborted).toBe(false); unmount(); - expect(fetchSignals[0]?.aborted).toBe(true); + expect(fetchSignals[0]).toBeUndefined(); }); it("falls back to download-only for large text previews", () => { @@ -633,7 +632,7 @@ describe("SourceOriginalPreview", () => { ).toBeTruthy(); }); - it("aborts DOCX preview downloads when the preview unmounts", async () => { + it("leaves shared DOCX preview downloads uncancelled when the preview unmounts", async () => { const fetchSignals: Array = []; vi.stubGlobal( "fetch", @@ -657,11 +656,10 @@ describe("SourceOriginalPreview", () => { await waitFor(() => { expect(fetchSignals).toHaveLength(1); }); - expect(fetchSignals[0]?.aborted).toBe(false); unmount(); - expect(fetchSignals[0]?.aborted).toBe(true); + expect(fetchSignals[0]).toBeUndefined(); }); it("renders DOCX previews without the library fixed page width", async () => { diff --git a/src/components/source-original-text-preview.test.ts b/src/components/source-original-text-preview.test.ts index 9d16fe1..195d774 100644 --- a/src/components/source-original-text-preview.test.ts +++ b/src/components/source-original-text-preview.test.ts @@ -40,7 +40,7 @@ describe("SourceOriginalTextPreview", () => { expect(screen.queryByText(/
/)).toBeNull(); }); - it("aborts text downloads when the preview unmounts", async () => { + it("leaves shared text downloads uncancelled when the preview unmounts", async () => { const fetchSignals: Array = []; vi.stubGlobal( "fetch", @@ -65,6 +65,6 @@ describe("SourceOriginalTextPreview", () => { }); unmount(); - expect(fetchSignals[0]?.aborted).toBe(true); + expect(fetchSignals[0]).toBeUndefined(); }); }); diff --git a/src/components/source-original-text-workflow.test.ts b/src/components/source-original-text-workflow.test.ts index 6c75e65..e0bfdf9 100644 --- a/src/components/source-original-text-workflow.test.ts +++ b/src/components/source-original-text-workflow.test.ts @@ -34,17 +34,13 @@ describe("useSourceOriginalTextWorkflow", () => { expect(screen.getByTestId("text-value").textContent).toBe("Notebook text") }) - it("aborts in-flight text requests on cleanup", async () => { - const signals: AbortSignal[] = [] + it("leaves shared text requests uncancelled on cleanup", async () => { + const signals: Array = [] vi.stubGlobal( "fetch", vi.fn((_input, init) => { - if (init?.signal instanceof AbortSignal) signals.push(init.signal) - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - reject(new DOMException("Aborted", "AbortError")) - }) - }) + signals.push(init?.signal ?? undefined) + return new Promise(() => undefined) }), ) @@ -59,7 +55,7 @@ describe("useSourceOriginalTextWorkflow", () => { }) unmount() - expect(signals[0]?.aborted).toBe(true) + expect(signals[0]).toBeUndefined() }) }) diff --git a/src/components/source-row.test.ts b/src/components/source-row.test.ts index 2bfc428..fa4e40f 100644 --- a/src/components/source-row.test.ts +++ b/src/components/source-row.test.ts @@ -74,4 +74,33 @@ describe("SourceRow", () => { expect(within(deleteButton).getByRole("status", { name: "Loading" })) .toBeTruthy(); }); + + it("keeps the title truncating while the delete action stays in a trailing column", () => { + const { container } = render( + React.createElement(SourceRow, { + isArchiving: false, + isSelected: true, + onArchiveClick: vi.fn(), + onSelect: vi.fn(), + source: { + id: "source_1", + mimeType: "application/pdf", + title: "very-long-quarterly-report-filename.pdf", + status: "ready", + chunkCount: 3, + }, + }), + ); + + const row = container.querySelector("[data-testid='source-row']"); + const deleteButton = screen.getByRole("button", { + name: "Delete very-long-quarterly-report-filename.pdf", + }); + + expect(row?.className).toContain("grid-cols-[auto_minmax(0,1fr)_auto]"); + expect( + screen.getByText("very-long-quarterly-report-filename.pdf").className, + ).toContain("truncate"); + expect(deleteButton.className).toContain("shrink-0"); + }); }); diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index c49654a..3fc2746 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -9,6 +9,7 @@ import type { SourceView } from "@/domains/sources/types"; export type SourceRowProps = { readonly isArchiving: boolean; + readonly isNarrow?: boolean; readonly isSelected: boolean; readonly onArchiveClick?: (sourceId: string) => void; readonly onSelect: () => void; @@ -19,6 +20,7 @@ export type SourceRowProps = { export function SourceRow({ source, isSelected, + isNarrow = false, onSelect, onToggleIncluded, onArchiveClick, @@ -32,10 +34,13 @@ export function SourceRow({ return (
@@ -68,7 +77,7 @@ export function SourceRow({ {source.title}

{isArchiving ? ( diff --git a/src/components/sources-panel.test.ts b/src/components/sources-panel.test.ts index 16ada98..bf6daae 100644 --- a/src/components/sources-panel.test.ts +++ b/src/components/sources-panel.test.ts @@ -80,6 +80,24 @@ describe("SourcesPanel", () => { ); }); + it("keeps the narrow upload trigger visible as a primary icon button", () => { + render( + React.createElement(C, { + isNarrow: true, + sources: [], + }), + ); + + const uploadButton = screen.getByRole("button", { + name: "Upload Document", + }); + + expect(uploadButton.textContent).toBe(""); + expect(uploadButton.className).toContain("bg-[#8E51FF]"); + expect(uploadButton.className).toContain("px-0"); + expect(uploadButton.querySelector("svg")).toBeTruthy(); + }); + it("keeps upload confirmation controls visible inside the dialog viewport", async () => { const user = userEvent.setup(); diff --git a/src/components/sources-panel.tsx b/src/components/sources-panel.tsx index e947c49..459186b 100644 --- a/src/components/sources-panel.tsx +++ b/src/components/sources-panel.tsx @@ -24,6 +24,7 @@ import { SourceUploadDialog } from "@/components/source-upload-dialog"; import type { SourceView } from "@/domains/sources/types"; export type SourcesPanelProps = { + readonly isNarrow?: boolean; sources: SourceView[]; onSourceUploaded?: (source: SourceView) => void; selectedSourceId?: string | null; @@ -36,6 +37,7 @@ export type SourcesPanelProps = { }; export function SourcesPanel({ + isNarrow = false, sources = [], onSourceUploaded, selectedSourceId = null, @@ -104,23 +106,49 @@ export function SourcesPanel({ -

+
{onLoginClick ? ( + ) : isNarrow ? ( + ( + + )} + /> ) : ( )}
-
-

+
+

Sources

@@ -145,6 +173,7 @@ export function SourcesPanel({ onArchiveSource ? setConfirmSourceId : undefined } isArchiving={archivingSourceIdSet.has(source.id)} + isNarrow={isNarrow} /> ))}
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 711e912..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 @@ -30,8 +33,10 @@ type WorkspaceCitationFocus = { citationId: string, ) => Promise readonly handleLoadMoreChunks: () => void + readonly handleLoadAllChunks: () => void readonly handleSourceSelected: (sourceId: string | null) => void readonly hasMoreSelectedChunks: boolean + readonly isSelectedAllChunksLoading: boolean readonly pendingCitationId: string | null readonly prefetchedChunksBySourceId: PrefetchedChunksBySourceId readonly requestChunkFocus: (chunkId: string | null) => void @@ -55,8 +60,18 @@ export function useWorkspaceCitationFocus({ const [pendingCitationId, setPendingCitationId] = useState( null, ) + 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, @@ -80,19 +95,82 @@ 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 || + prefetchedChunksBySourceIdRef.current[selectedSourceId] || + fullChunkRequestedSourceIdsRef.current.has(selectedSourceId) || + fullChunkRequestsBySourceIdRef.current.has(selectedSourceId) + ) { + return + } + + fullChunkRequestedSourceIdsRef.current.add(selectedSourceId) + void loadAllChunksForSource(selectedSourceId) + }, [ + loadAllChunksForSource, + selectedSourceId, + ]) + const handleCitationClick = useCallback( async ( citation: ChatCitationView, @@ -120,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) @@ -128,7 +207,7 @@ export function useWorkspaceCitationFocus({ return } - const cachedChunks = prefetchedChunksBySourceId[source.id] + const cachedChunks = prefetchedChunksBySourceIdRef.current[source.id] if (cachedChunks) { const cachedFocusId = workspaceCitationState.getLoadedCitationChunkId({ @@ -138,7 +217,7 @@ export function useWorkspaceCitationFocus({ selectedChunks: cachedChunks, hasMoreSelectedChunks: false, }) - setPrefetchedChunksBySourceId((current) => + updatePrefetchedChunksBySourceId((current) => workspaceCitationState.upsertPrefetchedChunks( current, source.id, @@ -151,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, @@ -176,23 +248,25 @@ export function useWorkspaceCitationFocus({ } }, [ - fetchChunks, hasMoreSelectedChunks, + loadAllChunksForSource, onSelectSource, - prefetchedChunksBySourceId, requestChunkFocus, selectedChunks, selectedSourceId, sources, + updatePrefetchedChunksBySourceId, ], ) return { focusedChunk, handleCitationClick, + handleLoadAllChunks, handleLoadMoreChunks, handleSourceSelected, hasMoreSelectedChunks, + isSelectedAllChunksLoading: fullChunkLoadingSourceId === selectedSourceId, isSelectedChunksLoading, isSelectedChunksLoadingMore, pendingCitationId, diff --git a/src/components/workspace-desktop-panels.test.ts b/src/components/workspace-desktop-panels.test.ts index 80e58fb..4348fd2 100644 --- a/src/components/workspace-desktop-panels.test.ts +++ b/src/components/workspace-desktop-panels.test.ts @@ -3,6 +3,7 @@ import { act, renderHook } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { useWorkspaceDesktopPanels } from "./workspace-desktop-panels"; +import { workspaceShellState } from "./workspace-shell-state"; describe("useWorkspaceDesktopPanels", () => { it("fits default desktop panel widths to the rendered layout width", () => { @@ -18,7 +19,10 @@ describe("useWorkspaceDesktopPanels", () => { result.current.desktopPanelWidths.chat; expect(totalWidth).toBe(1264); - expect(result.current.desktopPanelWidths.chat).toBeGreaterThanOrEqual(360); + expect(result.current.desktopPanelWidths.chat).toBeGreaterThanOrEqual( + workspaceShellState.collapsedDesktopPanelWidth, + ); + expect(result.current.desktopPanelWidths.chat).toBeLessThan(360); }); it("resizes desktop panels from their rendered widths during a drag", () => { diff --git a/src/components/workspace-desktop-panels.ts b/src/components/workspace-desktop-panels.ts index ccc6251..3cae404 100644 --- a/src/components/workspace-desktop-panels.ts +++ b/src/components/workspace-desktop-panels.ts @@ -6,6 +6,7 @@ import { workspaceShellState } from "@/components/workspace-shell-state"; type DesktopPanelKey = keyof typeof workspaceShellState.minimumDesktopPanelWidths; type DesktopPanelWidths = Record; +type DesktopSidePanelKey = Exclude; type DesktopPanelResizeDrag = { readonly leftPanel: DesktopPanelKey; @@ -24,6 +25,7 @@ type WorkspaceDesktopPanels = { panel: DesktopPanelKey, element: HTMLDivElement | null, ) => void; + readonly handleDesktopPanelExpand: (panel: DesktopSidePanelKey) => void; readonly handleDesktopPanelResize: ( leftPanel: DesktopPanelKey, rightPanel: DesktopPanelKey, @@ -54,8 +56,11 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels { const fitDesktopPanelWidthsToElement = useCallback( (element: HTMLDivElement): void => { const renderedWidth = element.getBoundingClientRect().width; - setDesktopPanelWidths( - workspaceShellState.fitDesktopPanelWidthsToContainer(renderedWidth), + setDesktopPanelWidths((current) => + workspaceShellState.fitDesktopPanelWidthsToContainer( + renderedWidth, + current, + ), ); }, [], @@ -147,6 +152,12 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels { desktopPanelResizeDrag.current = null; } + function handleDesktopPanelExpand(panel: DesktopSidePanelKey): void { + setDesktopPanelWidths((current) => + workspaceShellState.expandDesktopPanelWidth(current, panel), + ); + } + function handleDesktopPanelElementChange( panel: DesktopPanelKey, element: HTMLDivElement | null, @@ -156,9 +167,11 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels { return { desktopPanelWidths, - minimumDesktopPanelWidth: workspaceShellState.getMinimumDesktopPanelWidth(), + minimumDesktopPanelWidth: + workspaceShellState.getMinimumDesktopPanelWidth(desktopPanelWidths), handleDesktopLayoutElementChange, handleDesktopPanelElementChange, + handleDesktopPanelExpand, handleDesktopPanelResize, handleDesktopPanelResizeEnd, handleDesktopPanelResizeStart, diff --git a/src/components/workspace-shell-layout.test.ts b/src/components/workspace-shell-layout.test.ts index 76999ed..d414f1a 100644 --- a/src/components/workspace-shell-layout.test.ts +++ b/src/components/workspace-shell-layout.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import React from "react" -import { render, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { cleanup, render, screen, within } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" import { WorkspaceShellLayout } from "./workspace-shell-layout" import { workspaceShellState } from "./workspace-shell-state" @@ -9,6 +9,10 @@ import { workspaceShellState } from "./workspace-shell-state" const C = WorkspaceShellLayout as React.FC> describe("WorkspaceShellLayout", () => { + afterEach(() => { + cleanup() + }) + it("renders desktop workspace panels with stable layout widths", () => { render( React.createElement(C, { @@ -28,6 +32,7 @@ describe("WorkspaceShellLayout", () => { hasMoreSelectedChunks: false, isCreatingThread: false, isGuest: false, + isSelectedAllChunksLoading: false, isSelectedChunksLoading: false, isSelectedChunksLoadingMore: false, loadingThreadId: null, @@ -50,9 +55,11 @@ describe("WorkspaceShellLayout", () => { onCreateChatThread: vi.fn(), onDesktopLayoutElementChange: vi.fn(), onDesktopPanelElementChange: vi.fn(), + onDesktopPanelExpand: vi.fn(), onDesktopPanelResize: vi.fn(), onDesktopPanelResizeEnd: vi.fn(), onDesktopPanelResizeStart: vi.fn(), + onLoadAllChunks: vi.fn(), onLoadMoreChunks: vi.fn(), onLoginClick: vi.fn(), onMobilePanelChange: vi.fn(), @@ -71,4 +78,200 @@ describe("WorkspaceShellLayout", () => { ) expect(screen.getByTestId("desktop-chat-panel").style.width).toBe("420px") }) + + it("renders compact sidebars when the side panels are collapsed", () => { + const handleSourceSelected = vi.fn() + const handleChatThreadSelected = vi.fn() + const handlePanelExpand = vi.fn() + + render( + React.createElement(C, { + archivingSourceIds: [], + archivingThreadIds: [], + chat: { + error: null, + isLoading: false, + isSending: false, + messages: [], + pendingStatusText: null, + threadId: null, + }, + chatThreads: [ + { + id: "thread_market", + title: "Market outlook", + createdAt: "2026-05-26T00:00:00.000Z", + updatedAt: "2026-05-26T00:00:00.000Z", + }, + ], + desktopPanelWidths: { + sources: workspaceShellState.collapsedDesktopPanelWidth, + chunks: 960, + chat: workspaceShellState.collapsedDesktopPanelWidth, + }, + focusedChunk: { chunkId: null, requestId: 0 }, + hasMessages: false, + hasMoreSelectedChunks: false, + isCreatingThread: false, + isGuest: false, + isSelectedAllChunksLoading: false, + isSelectedChunksLoading: false, + isSelectedChunksLoadingMore: false, + loadingThreadId: null, + minimumDesktopPanelWidth: + workspaceShellState.getMinimumDesktopPanelWidth({ + sources: workspaceShellState.collapsedDesktopPanelWidth, + chunks: 960, + chat: workspaceShellState.collapsedDesktopPanelWidth, + }), + mobilePanel: "chat", + pendingCitationId: null, + readySourceCount: 0, + selectedChunks: [], + selectedSourceFile: null, + selectedSourceId: null, + selectedSourceTitle: null, + sourceTitlesByDocumentId: {}, + sources: [ + { + id: "source_report", + title: "Quarterly Report.pdf", + mimeType: "application/pdf", + status: "ready", + documentId: "doc_report", + chunkCount: 12, + }, + ], + user: undefined, + onArchiveChatThread: vi.fn(), + onArchiveSource: vi.fn(), + onChatSend: vi.fn(), + onCitationClick: vi.fn(), + onCreateChatThread: vi.fn(), + onDesktopLayoutElementChange: vi.fn(), + onDesktopPanelElementChange: vi.fn(), + onDesktopPanelExpand: handlePanelExpand, + onDesktopPanelResize: vi.fn(), + onDesktopPanelResizeEnd: vi.fn(), + onDesktopPanelResizeStart: vi.fn(), + onLoadAllChunks: vi.fn(), + onLoadMoreChunks: vi.fn(), + onLoginClick: vi.fn(), + onMobilePanelChange: vi.fn(), + onSelectChatThread: handleChatThreadSelected, + onSourceSelected: handleSourceSelected, + onSourceUploaded: vi.fn(), + onToggleIncluded: vi.fn(), + }), + ) + + expect(screen.getByTestId("desktop-sources-panel").style.width).toBe( + `${workspaceShellState.collapsedDesktopPanelWidth}px`, + ) + expect(screen.getByTestId("desktop-chat-panel").style.width).toBe( + `${workspaceShellState.collapsedDesktopPanelWidth}px`, + ) + expect( + screen.getByRole("button", { name: "Show sources panel" }), + ).toBeTruthy() + expect(screen.getByRole("button", { name: "Show chat panel" })).toBeTruthy() + + screen.getByRole("button", { name: "Open source Quarterly Report.pdf" }).click() + expect(handleSourceSelected).toHaveBeenCalledWith("source_report") + + screen.getByRole("button", { name: "Open chat Market outlook" }).click() + expect(handleChatThreadSelected).toHaveBeenCalledWith("thread_market") + expect(handlePanelExpand).toHaveBeenCalledWith("chat") + }) + + it("keeps normal source rows usable at narrow pre-sidebar widths", () => { + render( + React.createElement(C, { + archivingSourceIds: [], + archivingThreadIds: [], + chat: { + error: null, + isLoading: false, + isSending: false, + messages: [], + pendingStatusText: null, + threadId: null, + }, + chatThreads: [], + desktopPanelWidths: { + sources: 180, + chunks: 900, + chat: 180, + }, + focusedChunk: { chunkId: null, requestId: 0 }, + hasMessages: false, + hasMoreSelectedChunks: false, + isCreatingThread: false, + isGuest: false, + isSelectedAllChunksLoading: false, + isSelectedChunksLoading: false, + isSelectedChunksLoadingMore: false, + loadingThreadId: null, + minimumDesktopPanelWidth: + workspaceShellState.getMinimumDesktopPanelWidth({ + sources: 180, + chunks: 900, + chat: 180, + }), + mobilePanel: "chat", + pendingCitationId: null, + readySourceCount: 1, + selectedChunks: [], + selectedSourceFile: null, + selectedSourceId: "source_report", + selectedSourceTitle: null, + sourceTitlesByDocumentId: {}, + sources: [ + { + id: "source_report", + title: "Very Long Quarterly Report Filename.pdf", + mimeType: "application/pdf", + status: "ready", + documentId: "doc_report", + chunkCount: 12, + }, + ], + user: undefined, + onArchiveChatThread: vi.fn(), + onArchiveSource: vi.fn(), + onChatSend: vi.fn(), + onCitationClick: vi.fn(), + onCreateChatThread: vi.fn(), + onDesktopLayoutElementChange: vi.fn(), + onDesktopPanelElementChange: vi.fn(), + onDesktopPanelExpand: vi.fn(), + onDesktopPanelResize: vi.fn(), + onDesktopPanelResizeEnd: vi.fn(), + onDesktopPanelResizeStart: vi.fn(), + onLoadAllChunks: vi.fn(), + onLoadMoreChunks: vi.fn(), + onLoginClick: vi.fn(), + onMobilePanelChange: vi.fn(), + onSelectChatThread: vi.fn(), + onSourceSelected: vi.fn(), + onSourceUploaded: vi.fn(), + onToggleIncluded: vi.fn(), + }), + ) + + expect(screen.queryByRole("button", { name: "Show sources panel" })) + .toBeNull() + const desktopSourcesPanel = screen.getByTestId("desktop-sources-panel") + + expect( + within(desktopSourcesPanel).getByRole("button", { + name: "Open Very Long Quarterly Report Filename.pdf parsed chunks", + }), + ).toBeTruthy() + expect( + within(desktopSourcesPanel).getByRole("button", { + name: "Delete Very Long Quarterly Report Filename.pdf", + }), + ).toBeTruthy() + }) }) diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index 74a5ec3..3f1eecb 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -1,4 +1,11 @@ import { useCallback, type ReactElement } from "react" +import { + Database, + FileText, + MessageSquare, + PanelLeftOpen, + PanelRightOpen, +} from "lucide-react" import { ChatPanel } from "@/components/chat-panel" import { ChunksPanel } from "@/components/chunks-panel" @@ -21,6 +28,7 @@ import type { export type PanelId = "sources" | "content" | "chat" type DesktopPanelKey = keyof typeof workspaceShellState.minimumDesktopPanelWidths +type DesktopSidePanelKey = Exclude type DesktopPanelWidths = Record type FocusedChunkState = { @@ -55,6 +63,7 @@ export type WorkspaceShellLayoutProps = { readonly hasMoreSelectedChunks: boolean readonly isCreatingThread: boolean readonly isGuest: boolean + readonly isSelectedAllChunksLoading: boolean readonly isSelectedChunksLoading: boolean readonly isSelectedChunksLoadingMore: boolean readonly loadingThreadId: string | null @@ -82,6 +91,7 @@ export type WorkspaceShellLayoutProps = { panel: DesktopPanelKey, element: HTMLDivElement | null, ) => void + readonly onDesktopPanelExpand: (panel: DesktopSidePanelKey) => void readonly onDesktopPanelResize: ( leftPanel: DesktopPanelKey, rightPanel: DesktopPanelKey, @@ -92,6 +102,7 @@ export type WorkspaceShellLayoutProps = { leftPanel: DesktopPanelKey, rightPanel: DesktopPanelKey, ) => void + readonly onLoadAllChunks: () => void readonly onLoadMoreChunks: () => void readonly onLoginClick: () => void readonly onMobilePanelChange: (panel: PanelId) => void @@ -105,6 +116,13 @@ export function WorkspaceShellLayout( props: WorkspaceShellLayoutProps, ): ReactElement { const { onDesktopLayoutElementChange } = props + const isSourcesPanelCollapsed = + props.desktopPanelWidths.sources <= + workspaceShellState.desktopSidePanelCompactThreshold + const isChatPanelCollapsed = + props.desktopPanelWidths.chat <= + workspaceShellState.desktopSidePanelCompactThreshold + const isSourcesPanelNarrow = props.desktopPanelWidths.sources < 220 const handleDesktopLayoutRef = useCallback( (element: HTMLDivElement | null): void => { onDesktopLayoutElementChange(element) @@ -142,24 +160,36 @@ export function WorkspaceShellLayout( }} className="h-full shrink-0" style={{ - minWidth: `${workspaceShellState.minimumDesktopPanelWidths.sources}px`, + minWidth: `${workspaceShellState.collapsedDesktopPanelWidth}px`, width: `${props.desktopPanelWidths.sources}px`, }} > - + {isSourcesPanelCollapsed ? ( + props.onDesktopPanelExpand("sources")} + onSourceSelected={props.onSourceSelected} + /> + ) : ( + + )}

- + {isChatPanelCollapsed ? ( + props.onDesktopPanelExpand("chat")} + onThreadSelected={(threadId) => { + props.onSelectChatThread(threadId) + props.onDesktopPanelExpand("chat") + }} + /> + ) : ( + + )}
@@ -285,8 +329,10 @@ export function WorkspaceShellLayout( focusedChunkId={props.focusedChunk.chunkId} focusedChunkRequestId={props.focusedChunk.requestId} isLoading={props.isSelectedChunksLoading} + isLoadingAllChunks={props.isSelectedAllChunksLoading} isLoadingMore={props.isSelectedChunksLoadingMore} hasMoreChunks={props.hasMoreSelectedChunks} + onLoadAllChunks={props.onLoadAllChunks} onLoadMore={props.onLoadMoreChunks} onLoginClick={props.isGuest ? props.onLoginClick : undefined} onSourceUploaded={props.isGuest ? undefined : props.onSourceUploaded} @@ -382,3 +428,172 @@ function DesktopResizeHandle({ ) } + +function DesktopPanelRestoreButton({ + label, + onClick, + side, +}: { + readonly label: string + readonly onClick: () => void + readonly side: "left" | "right" +}): ReactElement { + const Icon = side === "left" ? PanelLeftOpen : PanelRightOpen + + return ( + + ) +} + +function CompactSourcesSidebar({ + onExpand, + onSourceSelected, + selectedSourceId, + sources, +}: { + readonly onExpand: () => void + readonly onSourceSelected: (sourceId: string | null) => void + readonly selectedSourceId: string | null + readonly sources: readonly SourceView[] +}): ReactElement { + return ( + + ) +} + +function CompactChatSidebar({ + activeThreadId, + onExpand, + onThreadSelected, + threads, +}: { + readonly activeThreadId: string | null + readonly onExpand: () => void + readonly onThreadSelected: (threadId: string) => void + readonly threads: readonly ChatThreadView[] +}): ReactElement { + return ( + + ) +} + +function CompactSidebarButton({ + ariaLabel, + children, + isActive, + label, + onClick, + title, +}: { + readonly ariaLabel: string + readonly children: ReactElement + readonly isActive: boolean + readonly label: string + readonly onClick: () => void + readonly title: string +}): ReactElement { + return ( + + ) +} + +function getCompactItemLabel(title: string): string { + const normalizedTitle = title.trim() + if (!normalizedTitle) return "?" + + const words = normalizedTitle + .replace(/\.[a-z0-9]+$/i, "") + .split(/[\s._-]+/) + .filter(Boolean) + + if (words.length >= 2) { + return `${words[0]![0] ?? ""}${words[1]![0] ?? ""}`.toUpperCase() + } + + return normalizedTitle.slice(0, 2).toUpperCase() +} diff --git a/src/components/workspace-shell-state.test.ts b/src/components/workspace-shell-state.test.ts index cb5c0a4..071533a 100644 --- a/src/components/workspace-shell-state.test.ts +++ b/src/components/workspace-shell-state.test.ts @@ -46,7 +46,7 @@ describe("workspaceShellState", () => { }); }); - it("clamps desktop panel resizing to each panel minimum", () => { + it("allows the sources panel to narrow continuously before sidebar mode", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, @@ -56,16 +56,99 @@ describe("workspaceShellState", () => { { leftPanel: "sources", rightPanel: "chunks", - deltaX: -1_000, + deltaX: -170, leftWidth: 350, rightWidth: 600, }, ); expect(resized).toEqual({ - sources: workspaceShellState.minimumDesktopPanelWidths.sources, - chunks: 690, + sources: 180, + chunks: 770, chat: 420, }); }); + + it("allows the chat panel to narrow continuously before sidebar mode", () => { + const resized = workspaceShellState.resizeDesktopPanelWidths( + { + sources: 350, + chunks: 720, + chat: 420, + }, + { + leftPanel: "chunks", + rightPanel: "chat", + deltaX: 240, + leftWidth: 650, + rightWidth: 420, + }, + ); + + expect(resized).toEqual({ + sources: 350, + chunks: 890, + chat: 180, + }); + }); + + it("clamps the sources panel at the compact sidebar width", () => { + const resized = workspaceShellState.resizeDesktopPanelWidths( + { + sources: 350, + chunks: 720, + chat: 420, + }, + { + leftPanel: "sources", + rightPanel: "chunks", + deltaX: -300, + leftWidth: 350, + rightWidth: 600, + }, + ); + + expect(resized).toEqual({ + sources: workspaceShellState.collapsedDesktopPanelWidth, + chunks: 950 - workspaceShellState.collapsedDesktopPanelWidth, + chat: 420, + }); + }); + + it("clamps the chat panel at the compact sidebar width", () => { + const resized = workspaceShellState.resizeDesktopPanelWidths( + { + sources: 350, + chunks: 720, + chat: 420, + }, + { + leftPanel: "chunks", + rightPanel: "chat", + deltaX: 400, + leftWidth: 650, + rightWidth: 420, + }, + ); + + expect(resized).toEqual({ + sources: 350, + chunks: 1_070 - workspaceShellState.collapsedDesktopPanelWidth, + chat: workspaceShellState.collapsedDesktopPanelWidth, + }); + }); + + it("includes compact sidebars when calculating the minimum desktop width", () => { + const minimumWidth = workspaceShellState.getMinimumDesktopPanelWidth({ + sources: workspaceShellState.collapsedDesktopPanelWidth, + chunks: 900, + chat: workspaceShellState.collapsedDesktopPanelWidth, + }); + + expect(minimumWidth).toBe( + workspaceShellState.collapsedDesktopPanelWidth * 2 + + workspaceShellState.minimumDesktopPanelWidths.chunks + + workspaceShellState.desktopPanelGutterWidth * 2, + ); + }); }); diff --git a/src/components/workspace-shell-state.ts b/src/components/workspace-shell-state.ts index ed6bf93..dadcb6d 100644 --- a/src/components/workspace-shell-state.ts +++ b/src/components/workspace-shell-state.ts @@ -1,9 +1,11 @@ const desktopPanelGutterWidth = 8 +const collapsedDesktopPanelWidth = 72 +const desktopSidePanelCompactThreshold = 120 const minimumDesktopPanelWidths = { - sources: 260, + sources: collapsedDesktopPanelWidth, chunks: 480, - chat: 360, + chat: collapsedDesktopPanelWidth, } as const const defaultDesktopPanelWidths = { @@ -15,6 +17,7 @@ const defaultDesktopPanelWidths = { type DesktopPanelKey = keyof typeof minimumDesktopPanelWidths type DesktopPanelWidths = Record +type DesktopSidePanelKey = Exclude const desktopPanelKeys = ["sources", "chunks", "chat"] as const @@ -28,11 +31,20 @@ type DesktopPanelResizeInput = { type WorkspaceShellStateModule = { readonly desktopPanelGutterWidth: number + readonly collapsedDesktopPanelWidth: number + readonly desktopSidePanelCompactThreshold: number readonly minimumDesktopPanelWidths: typeof minimumDesktopPanelWidths readonly defaultDesktopPanelWidths: typeof defaultDesktopPanelWidths - readonly getMinimumDesktopPanelWidth: () => number + readonly getMinimumDesktopPanelWidth: ( + currentWidths?: Readonly, + ) => number readonly fitDesktopPanelWidthsToContainer: ( containerWidth: number, + currentWidths?: Readonly, + ) => DesktopPanelWidths + readonly expandDesktopPanelWidth: ( + currentWidths: Readonly, + panel: DesktopSidePanelKey, ) => DesktopPanelWidths readonly resizeDesktopPanelWidths: ( currentWidths: Readonly, @@ -40,69 +52,87 @@ type WorkspaceShellStateModule = { ) => DesktopPanelWidths } -function getMinimumDesktopPanelWidth(): number { +function getMinimumDesktopPanelWidth( + currentWidths: Readonly = defaultDesktopPanelWidths, +): number { return ( - minimumDesktopPanelWidths.sources + - minimumDesktopPanelWidths.chunks + - minimumDesktopPanelWidths.chat + - desktopPanelGutterWidth * 2 + getMinimumDesktopPanelContentWidth(currentWidths) + + getVisibleDesktopPanelGutterCount(currentWidths) * desktopPanelGutterWidth ) } -function getDefaultDesktopPanelContentWidth(): number { - return ( - defaultDesktopPanelWidths.sources + - defaultDesktopPanelWidths.chunks + - defaultDesktopPanelWidths.chat +function getDefaultDesktopPanelContentWidth( + currentWidths: Readonly, +): number { + return getVisibleDesktopPanelKeys(currentWidths).reduce( + (totalWidth, panel) => + totalWidth + getDefaultDesktopPanelWidth(panel, currentWidths), + 0, ) } -function getMinimumDesktopPanelContentWidth(): number { - return ( - minimumDesktopPanelWidths.sources + - minimumDesktopPanelWidths.chunks + - minimumDesktopPanelWidths.chat +function getMinimumDesktopPanelContentWidth( + currentWidths: Readonly, +): number { + return getVisibleDesktopPanelKeys(currentWidths).reduce( + (totalWidth, panel) => + totalWidth + getMinimumDesktopPanelWidthForPanel(panel, currentWidths), + 0, ) } function fitDesktopPanelWidthsToContainer( containerWidth: number, + currentWidths: Readonly = defaultDesktopPanelWidths, ): DesktopPanelWidths { if (!Number.isFinite(containerWidth) || containerWidth <= 0) { - return { ...defaultDesktopPanelWidths } + return getDefaultDesktopPanelWidths(currentWidths) } - const availableContentWidth = containerWidth - desktopPanelGutterWidth * 2 - const defaultContentWidth = getDefaultDesktopPanelContentWidth() + const visiblePanels = getVisibleDesktopPanelKeys(currentWidths) + const availableContentWidth = + containerWidth - + getVisibleDesktopPanelGutterCount(currentWidths) * desktopPanelGutterWidth + const defaultContentWidth = getDefaultDesktopPanelContentWidth(currentWidths) if (availableContentWidth >= defaultContentWidth) { - return { ...defaultDesktopPanelWidths } + return getDefaultDesktopPanelWidths(currentWidths) } - const minimumContentWidth = getMinimumDesktopPanelContentWidth() + const minimumContentWidth = getMinimumDesktopPanelContentWidth(currentWidths) if (availableContentWidth <= minimumContentWidth) { - return { ...minimumDesktopPanelWidths } + return getMinimumDesktopPanelWidths(currentWidths) } const defaultExtraWidth = defaultContentWidth - minimumContentWidth const availableExtraWidth = availableContentWidth - minimumContentWidth - const fittedWidths = {} as DesktopPanelWidths + const fittedWidths = getMinimumDesktopPanelWidths(currentWidths) + const flexiblePanels = visiblePanels.filter( + (panel) => + getDefaultDesktopPanelWidth(panel, currentWidths) > + getMinimumDesktopPanelWidthForPanel(panel, currentWidths), + ) let assignedWidth = 0 - for (const [index, panel] of desktopPanelKeys.entries()) { - const isLastPanel = index === desktopPanelKeys.length - 1 + for (const [index, panel] of flexiblePanels.entries()) { + const isLastPanel = index === flexiblePanels.length - 1 if (isLastPanel) { - fittedWidths[panel] = availableContentWidth - assignedWidth + fittedWidths[panel] = + getMinimumDesktopPanelWidthForPanel(panel, currentWidths) + + availableExtraWidth - + assignedWidth break } const panelExtraWidth = - defaultDesktopPanelWidths[panel] - minimumDesktopPanelWidths[panel] + getDefaultDesktopPanelWidth(panel, currentWidths) - + getMinimumDesktopPanelWidthForPanel(panel, currentWidths) const fittedWidth = Math.round( - minimumDesktopPanelWidths[panel] + + getMinimumDesktopPanelWidthForPanel(panel, currentWidths) + (panelExtraWidth / defaultExtraWidth) * availableExtraWidth, ) fittedWidths[panel] = fittedWidth - assignedWidth += fittedWidth + assignedWidth += + fittedWidth - getMinimumDesktopPanelWidthForPanel(panel, currentWidths) } return fittedWidths @@ -128,15 +158,148 @@ function resizeDesktopPanelWidths( } } +function expandDesktopPanelWidth( + currentWidths: Readonly, + panel: DesktopSidePanelKey, +): DesktopPanelWidths { + if (panel === "sources") { + const totalWidth = currentWidths.sources + currentWidths.chunks + const expandedWidth = getExpandedSidePanelWidth(panel, totalWidth) + + return { + ...currentWidths, + sources: expandedWidth, + chunks: Math.max( + minimumDesktopPanelWidths.chunks, + totalWidth - expandedWidth, + ), + } + } + + const totalWidth = currentWidths.chunks + currentWidths.chat + const expandedWidth = getExpandedSidePanelWidth(panel, totalWidth) + + return { + ...currentWidths, + chunks: Math.max( + minimumDesktopPanelWidths.chunks, + totalWidth - expandedWidth, + ), + chat: expandedWidth, + } +} + +function getExpandedSidePanelWidth( + panel: DesktopSidePanelKey, + totalWidth: number, +): number { + const preferredWidth = defaultDesktopPanelWidths[panel] + const minimumWidth = minimumDesktopPanelWidths[panel] + const maximumSideWidth = totalWidth - minimumDesktopPanelWidths.chunks + + if (maximumSideWidth >= preferredWidth) return preferredWidth + if (maximumSideWidth >= minimumWidth) return maximumSideWidth + return minimumWidth +} + +function getVisibleDesktopPanelKeys( + currentWidths: Readonly, +): DesktopPanelKey[] { + return desktopPanelKeys.filter((panel) => { + if (panel === "chunks") return true + return currentWidths[panel] > 0 + }) +} + +function getVisibleDesktopPanelGutterCount( + currentWidths: Readonly, +): number { + return getVisibleDesktopPanelKeys(currentWidths).length - 1 +} + +function getDefaultDesktopPanelWidths( + currentWidths: Readonly, +): DesktopPanelWidths { + return getDesktopPanelWidthsForVisibility( + currentWidths, + defaultDesktopPanelWidths, + ) +} + +function getMinimumDesktopPanelWidths( + currentWidths: Readonly, +): DesktopPanelWidths { + return getDesktopPanelWidthsForVisibility( + currentWidths, + minimumDesktopPanelWidths, + ) +} + +function getDesktopPanelWidthsForVisibility( + currentWidths: Readonly, + visibleWidths: Readonly, +): DesktopPanelWidths { + return { + sources: + isDesktopPanelCollapsed(currentWidths, "sources") + ? collapsedDesktopPanelWidth + : visibleWidths.sources, + chunks: visibleWidths.chunks, + chat: + isDesktopPanelCollapsed(currentWidths, "chat") + ? collapsedDesktopPanelWidth + : visibleWidths.chat, + } +} + +function getDefaultDesktopPanelWidth( + panel: DesktopPanelKey, + currentWidths: Readonly, +): number { + if (panel === "sources" || panel === "chat") { + if (isDesktopPanelCollapsed(currentWidths, panel)) { + return collapsedDesktopPanelWidth + } + } + + return defaultDesktopPanelWidths[panel] +} + +function getMinimumDesktopPanelWidthForPanel( + panel: DesktopPanelKey, + currentWidths: Readonly, +): number { + if (panel === "sources" || panel === "chat") { + if (isDesktopPanelCollapsed(currentWidths, panel)) { + return collapsedDesktopPanelWidth + } + } + + return minimumDesktopPanelWidths[panel] +} + +function isDesktopPanelCollapsed( + currentWidths: Readonly, + panel: DesktopSidePanelKey, +): boolean { + return ( + currentWidths[panel] > 0 && + currentWidths[panel] <= desktopSidePanelCompactThreshold + ) +} + function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } export const workspaceShellState: WorkspaceShellStateModule = { desktopPanelGutterWidth, + collapsedDesktopPanelWidth, + desktopSidePanelCompactThreshold, minimumDesktopPanelWidths, defaultDesktopPanelWidths, getMinimumDesktopPanelWidth, fitDesktopPanelWidthsToContainer, + expandDesktopPanelWidth, resizeDesktopPanelWidths, } diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index 4a2609c..8bad459 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -66,7 +66,7 @@ describe("WorkspaceShell", () => { ); }); - it("lets desktop users resize neighboring panels without folding below minimum widths", () => { + it("lets desktop users resize neighboring panels and collapse sources below the threshold", () => { render(React.createElement(C, { sources: [] })); const firstHandle = screen.getByRole("separator", { @@ -82,13 +82,19 @@ describe("WorkspaceShell", () => { expect(sourcesPanel.style.width).toBe("470px"); expect(chunksPanel.style.width).toBe("600px"); - fireEvent.pointerDown(firstHandle, { clientX: 120 }); + const resizedHandle = screen.getByRole("separator", { + name: "Resize sources and parsed chunks", + }); + fireEvent.pointerDown(resizedHandle, { clientX: 120 }); fireEvent.pointerMove(window, { clientX: -1000 }); fireEvent.pointerUp(window); - expect(sourcesPanel.style.width).toBe( - `${DESKTOP_PANEL_MIN_WIDTHS.sources}px`, + expect(screen.getByTestId("desktop-sources-panel").style.width).toBe( + "72px", ); + expect( + screen.getByRole("button", { name: "Show sources panel" }), + ).toBeTruthy(); }); it("lets desktop users expand the chat panel by shrinking parsed chunks further", () => { diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index e931cf6..5bab04a 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -102,6 +102,7 @@ function WorkspaceShellContent({ minimumDesktopPanelWidth, handleDesktopLayoutElementChange, handleDesktopPanelElementChange, + handleDesktopPanelExpand, handleDesktopPanelResize, handleDesktopPanelResizeEnd, handleDesktopPanelResizeStart, @@ -132,6 +133,7 @@ function WorkspaceShellContent({ hasMoreSelectedChunks={citationFocus.hasMoreSelectedChunks} isCreatingThread={chatWorkflow.isCreatingThread} isGuest={isGuest} + isSelectedAllChunksLoading={citationFocus.isSelectedAllChunksLoading} isSelectedChunksLoading={citationFocus.isSelectedChunksLoading} isSelectedChunksLoadingMore={citationFocus.isSelectedChunksLoadingMore} loadingThreadId={chatWorkflow.loadingThreadId} @@ -153,9 +155,11 @@ function WorkspaceShellContent({ onCreateChatThread={chatWorkflow.handleCreateChatThread} onDesktopLayoutElementChange={handleDesktopLayoutElementChange} onDesktopPanelElementChange={handleDesktopPanelElementChange} + onDesktopPanelExpand={handleDesktopPanelExpand} onDesktopPanelResize={handleDesktopPanelResize} onDesktopPanelResizeEnd={handleDesktopPanelResizeEnd} onDesktopPanelResizeStart={handleDesktopPanelResizeStart} + onLoadAllChunks={citationFocus.handleLoadAllChunks} onLoadMoreChunks={citationFocus.handleLoadMoreChunks} onLoginClick={redirectToLogin} onMobilePanelChange={setMobilePanel}