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 ? (
)
+ ) : isTreeModeVisible ? (
+
) : (
+ {isTreeModeVisible ? (
+
+ ) : null}
{shouldMountOriginalPreview ? (
@@ -245,6 +430,532 @@ export function ChunksPanel({
);
}
+type ChunkSectionTreeNode = ReturnType;
+
+type RenderableChunkTreeNodeKind = "root" | "section" | "chunk";
+
+type RenderableChunkTreeNode = {
+ readonly id: string;
+ readonly kind: RenderableChunkTreeNodeKind;
+ readonly label: string;
+ readonly chunkCount: number;
+ readonly chunk?: ParsedChunkView;
+ readonly children: readonly RenderableChunkTreeNode[];
+};
+
+type ChunkSectionTreeLayout = {
+ readonly height: number;
+ readonly links: readonly HierarchyPointLink[];
+ readonly nodes: readonly HierarchyPointNode[];
+ readonly width: number;
+ readonly xOffset: number;
+ readonly yOffset: number;
+};
+
+type SectionTreeZoomDirection = "in" | "out";
+
+type SectionTreePan = {
+ readonly x: number;
+ readonly y: number;
+};
+
+type SectionTreeDragState = {
+ readonly panStartX: number;
+ readonly panStartY: number;
+ readonly pointerStartX: number;
+ readonly pointerStartY: number;
+};
+
+const sectionTreeNodeWidth = 208;
+const sectionTreeNodeHeight = 58;
+const sectionTreeColumnGap = 254;
+const sectionTreeRowGap = 78;
+const sectionTreeMinimumWidth = 720;
+const sectionTreeMinimumHeight = 260;
+const sectionTreeCardHorizontalPadding = 32;
+const sectionTreeDefaultZoomPercent = 100;
+const sectionTreeMinimumZoomPercent = 30;
+const sectionTreeMaximumZoomPercent = 140;
+const sectionTreeZoomStepPercent = 10;
+const initialSectionTreePan: SectionTreePan = {
+ x: 0,
+ y: 0,
+};
+const sectionTreePadding = {
+ top: 38,
+ right: 42,
+ bottom: 38,
+ left: 24,
+} as const;
+
+function ChunkSectionTree({
+ chunks,
+ focusedChunkId,
+ isLoadingAllChunks,
+ sourceTitle,
+ zoomPercent,
+ onChunkFocus,
+ onWheelZoom,
+}: {
+ readonly chunks: readonly ParsedChunkView[];
+ readonly focusedChunkId: string | null;
+ readonly isLoadingAllChunks: boolean;
+ readonly sourceTitle: string;
+ readonly zoomPercent: number;
+ readonly onChunkFocus: (chunkId: string | null) => void;
+ readonly onWheelZoom: (direction: SectionTreeZoomDirection) => void;
+}): ReactNode {
+ const [sectionTreePan, setSectionTreePan] =
+ useState(initialSectionTreePan);
+ const [sectionTreeDragState, setSectionTreeDragState] =
+ useState(null);
+ const sectionTreeZoomSurfaceRef = useRef(null);
+ const sectionTree = useMemo(
+ () => chunksPanelState.buildSectionTree(chunks, sourceTitle),
+ [chunks, sourceTitle],
+ );
+ const layout = useMemo(
+ () => getChunkSectionTreeLayout(sectionTree),
+ [sectionTree],
+ );
+ const scaledLayoutWidth: number = Math.round(
+ (layout.width * zoomPercent) / 100,
+ );
+ const scaledLayoutHeight: number = Math.round(
+ (layout.height * zoomPercent) / 100,
+ );
+ const zoomScale: string = formatSectionTreeZoomScale(zoomPercent);
+ const handlePanStart = useCallback(
+ (event: ReactMouseEvent): void => {
+ if (event.button !== 0 || isInteractiveSectionTreeTarget(event.target)) {
+ return;
+ }
+
+ event.preventDefault();
+ setSectionTreeDragState({
+ pointerStartX: event.clientX,
+ pointerStartY: event.clientY,
+ panStartX: sectionTreePan.x,
+ panStartY: sectionTreePan.y,
+ });
+ },
+ [sectionTreePan.x, sectionTreePan.y],
+ );
+
+ useEffect(() => {
+ const zoomSurface = sectionTreeZoomSurfaceRef.current;
+ if (!zoomSurface) return;
+
+ const handleWheelZoom = (event: WheelEvent): void => {
+ if (event.deltaY === 0) return;
+
+ event.preventDefault();
+ onWheelZoom(event.deltaY < 0 ? "in" : "out");
+ };
+
+ zoomSurface.addEventListener("wheel", handleWheelZoom, {
+ passive: false,
+ });
+
+ return () => {
+ zoomSurface.removeEventListener("wheel", handleWheelZoom);
+ };
+ }, [onWheelZoom]);
+
+ useEffect(() => {
+ if (!sectionTreeDragState) return;
+
+ const handlePanMove = (event: MouseEvent): void => {
+ setSectionTreePan({
+ x:
+ sectionTreeDragState.panStartX +
+ event.clientX -
+ sectionTreeDragState.pointerStartX,
+ y:
+ sectionTreeDragState.panStartY +
+ event.clientY -
+ sectionTreeDragState.pointerStartY,
+ });
+ };
+ const handlePanEnd = (): void => {
+ setSectionTreeDragState(null);
+ };
+
+ window.addEventListener("mousemove", handlePanMove);
+ window.addEventListener("mouseup", handlePanEnd);
+
+ return () => {
+ window.removeEventListener("mousemove", handlePanMove);
+ window.removeEventListener("mouseup", handlePanEnd);
+ };
+ }, [sectionTreeDragState]);
+
+ return (
+
+ {isLoadingAllChunks ? (
+
+ Loading complete section tree...
+
+ ) : null}
+
+
+
+ {layout.nodes.map((node) => (
+
+ ))}
+
+
+
+ );
+}
+
+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,
+ 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}