diff --git a/package.json b/package.json index 3515371..bb9d756 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", @@ -44,6 +45,7 @@ "lucide-react": "^1.14.0", "mammoth": "^1.12.0", "next": "16.2.4", + "next-themes": "^0.4.6", "pdfjs-dist": "5.4.296", "postgres": "^3.4.9", "react": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df6967f..303e312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-label': specifier: ^2.1.8 version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -83,6 +86,9 @@ importers: next: specifier: 16.2.4 version: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pdfjs-dist: specifier: 5.4.296 version: 5.4.296 @@ -1523,6 +1529,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -1567,6 +1586,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -4179,6 +4211,12 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@16.2.4: resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} engines: {node: '>=20.9.0'} @@ -6388,6 +6426,21 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -6421,6 +6474,32 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -9246,6 +9325,11 @@ snapshots: negotiator@1.0.0: {} + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.4 diff --git a/src/app/globals.css b/src/app/globals.css index 84b8d5f..0fab70e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,9 +6,15 @@ @theme inline { --color-background: var(--background); + --color-background-secondary: var(--background-secondary); + --color-background-tertiary: var(--background-tertiary); --color-foreground: var(--foreground); --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); + --font-mono: var(--font-mono); + --font-mono-display: var(--font-mono-display); + --font-mono-readable: var(--font-mono-readable); + --font-accent: var(--font-accent); + --font-brand: var(--font-brand); --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); @@ -29,12 +35,16 @@ --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); + --color-accent-light: var(--accent-light); + --color-accent-dark: var(--accent-dark); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); + --color-primary-light: var(--primary-light); + --color-primary-dark: var(--primary-dark); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); @@ -51,6 +61,25 @@ :root { color-scheme: light; --radius: 0.5rem; + --font-sans: + var(--font-geist-sans), "Geist", "Inter", "Segoe UI", "Helvetica Neue", + Arial, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: + var(--font-geist-mono), "Azeret Mono", "JetBrains Mono", "SFMono-Regular", + "SF Mono", "Cascadia Code", Menlo, Consolas, ui-monospace, monospace; + --font-mono-display: + "Azeret Mono", var(--font-geist-mono), "JetBrains Mono", "SFMono-Regular", + "SF Mono", "Cascadia Code", Menlo, Consolas, ui-monospace, monospace; + --font-mono-readable: + "Azeret Mono", "Atkinson Hyperlegible Mono", var(--font-geist-mono), + "JetBrains Mono", "SFMono-Regular", "SF Mono", "Cascadia Code", Menlo, + Consolas, ui-monospace, monospace; + --font-accent: + "Anuphan", "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, system-ui, + sans-serif; + --font-brand: + var(--font-geist-sans), "Geist", "Ysabeau", "Iowan Old Style", + "Palatino Linotype", "Book Antiqua", Georgia, ui-serif, serif; } .dark { @@ -130,6 +159,8 @@ .dark { --background: hsl(222.2 47.4% 11.2%); + --background-secondary: hsl(217.2 32.6% 17.5%); + --background-tertiary: hsl(215.3 25% 26.7%); --foreground: hsl(210 40% 98%); --card: hsl(222.2 47.4% 11.2%); --card-foreground: hsl(210 40% 98%); @@ -137,6 +168,8 @@ --popover-foreground: hsl(210 40% 98%); --primary: hsl(217.2 91.2% 59.8%); + --primary-light: hsl(213.1 93.9% 67.8%); + --primary-dark: hsl(221.2 83.2% 53.3%); --primary-foreground: hsl(222.2 47.4% 11.2%); --secondary: hsl(217.2 32.6% 17.5%); @@ -144,6 +177,8 @@ --muted: hsl(217.2 32.6% 17.5%); --muted-foreground: hsl(215 20.2% 65.1%); --accent: hsl(217.2 32.6% 17.5%); + --accent-light: hsl(215.3 25% 26.7%); + --accent-dark: hsl(222.2 47.4% 11.2%); --accent-foreground: hsl(210 40% 98%); --destructive: hsl(0 72.2% 50.6%); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd8d3fc..afbda8b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; import { appMetadata } from "@/lib/app-metadata"; import "./globals.css"; @@ -24,8 +25,18 @@ export default function RootLayout({ - {children} + + + {children} + + ); } diff --git a/src/components/chunks-panel-state.test.ts b/src/components/chunks-panel-state.test.ts index 60b3c4d..81c17a8 100644 --- a/src/components/chunks-panel-state.test.ts +++ b/src/components/chunks-panel-state.test.ts @@ -92,4 +92,37 @@ describe("chunksPanelState", () => { chunksPanelState.formatReferenceLabel("[images/image-12.png?token=abc]"), ).toBe("Image 12") }) + + it("finds chunk search matches across content, summary, and keywords", () => { + const chunks: ParsedChunkView[] = [ + { + chunkId: "chunk_1", + type: "text", + content: "Tesla storage revenue increased while Tesla deployed Megapack.", + summary: "Storage update", + keywords: ["Energy storage"], + sourceTitle: "report.pdf", + }, + { + chunkId: "chunk_2", + type: "image", + content: "", + summary: "Megapack deployment chart", + keywords: ["Tesla Energy"], + sourceTitle: "report.pdf", + }, + { + chunkId: "chunk_3", + type: "text", + content: "Vehicle deliveries only.", + sourceTitle: "report.pdf", + }, + ] + + expect(chunksPanelState.getChunkSearchMatches(chunks, " tesla ")).toEqual([ + { chunkId: "chunk_1", matchCount: 2 }, + { chunkId: "chunk_2", matchCount: 1 }, + ]) + expect(chunksPanelState.getChunkSearchMatches(chunks, "missing")).toEqual([]) + }) }) diff --git a/src/components/chunks-panel-state.ts b/src/components/chunks-panel-state.ts index 430951a..d1b101b 100644 --- a/src/components/chunks-panel-state.ts +++ b/src/components/chunks-panel-state.ts @@ -9,10 +9,19 @@ type RenderableReference = { readonly connection: ParsedChunkConnection } +export type ChunkSearchMatch = { + readonly chunkId: string + readonly matchCount: number +} + type ChunksPanelStateModule = { readonly formatChunkSectionPath: ( sectionPath: ParsedChunkView["sectionPath"], ) => string | null + readonly getChunkSearchMatches: ( + chunks: readonly ParsedChunkView[], + query: string, + ) => readonly ChunkSearchMatch[] readonly formatReferenceLabel: (ref: string) => string readonly getChunksWithFocusedFirst: ( chunks: readonly ParsedChunkView[], @@ -22,6 +31,7 @@ type ChunksPanelStateModule = { readonly getRenderableReferences: ( chunk: ParsedChunkView, ) => RenderableReference[] + readonly normalizeChunkSearchQuery: (query: string) => string } function getChunksWithFocusedFirst( @@ -129,6 +139,58 @@ function getRenderableReferences( return nonOverlapping } +function getChunkSearchMatches( + chunks: readonly ParsedChunkView[], + query: string, +): readonly ChunkSearchMatch[] { + const normalizedQuery = normalizeChunkSearchQuery(query) + if (!normalizedQuery) return [] + + return chunks.flatMap((chunk): ChunkSearchMatch[] => { + const matchCount = countChunkSearchMatches(chunk, normalizedQuery) + if (matchCount === 0) return [] + return [{ chunkId: chunk.chunkId, matchCount }] + }) +} + +function normalizeChunkSearchQuery(query: string): string { + return query.trim().replace(/\s+/g, " ").toLocaleLowerCase() +} + +function countChunkSearchMatches( + chunk: ParsedChunkView, + normalizedQuery: string, +): number { + return getChunkSearchText(chunk).reduce( + (total, text) => total + countTextMatches(text, normalizedQuery), + 0, + ) +} + +function getChunkSearchText(chunk: ParsedChunkView): readonly string[] { + return [ + chunk.content, + chunk.summary ?? "", + ...(chunk.keywords ?? []), + ].filter((text) => text.trim().length > 0) +} + +function countTextMatches(text: string, normalizedQuery: string): number { + const normalizedText = text.toLocaleLowerCase() + let count = 0 + let cursor = 0 + + while (cursor < normalizedText.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor) + if (matchIndex < 0) return count + + count += 1 + cursor = matchIndex + normalizedQuery.length + } + + return count +} + function getReferenceRange( content: string, connection: ParsedChunkConnection, @@ -191,8 +253,10 @@ function capitalize(value: string): string { export const chunksPanelState: ChunksPanelStateModule = { formatChunkSectionPath, + getChunkSearchMatches, formatReferenceLabel, getChunksWithFocusedFirst, getReferenceLabel, getRenderableReferences, + normalizeChunkSearchQuery, } diff --git a/src/components/chunks-panel-workflow.ts b/src/components/chunks-panel-workflow.ts index 75d3e46..7439077 100644 --- a/src/components/chunks-panel-workflow.ts +++ b/src/components/chunks-panel-workflow.ts @@ -42,7 +42,7 @@ type ChunksPanelWorkflow = { readonly measureVirtualChunkElement: (node: HTMLDivElement | null) => void readonly originalTargetPageNumber: number | null readonly originalTargetPageRequestId: number - readonly requestChunkFocus: (chunkId: string) => void + readonly requestChunkFocus: (chunkId: string | null) => void readonly totalHeight: number readonly viewportRef: RefObject readonly virtualItems: readonly VirtualItem[] @@ -156,7 +156,7 @@ export function useChunksPanelWorkflow({ [chunkVirtualizer], ) - const requestChunkFocus = useCallback((chunkId: string): void => { + const requestChunkFocus = useCallback((chunkId: string | null): void => { setLocalFocusedChunkId(chunkId) }, []) diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index 2719bfa..29e55b6 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -63,6 +63,114 @@ describe("ChunksPanel", () => { expect(screen.getByText(/Showing all parsed chunks from/)).toBeTruthy(); }); + it("searches loaded chunks and jumps between matching chunks", async () => { + mockVisibleVirtualViewport(); + const user = userEvent.setup(); + const { container } = render( + React.createElement(C, { + chunks: [ + { + chunkId: "chunk_1", + type: "text", + content: "Revenue increased.", + sourceTitle: "report.pdf", + pageNums: [1], + }, + { + chunkId: "chunk_2", + type: "text", + content: "Operating margin improved.", + sourceTitle: "report.pdf", + pageNums: [2], + }, + { + chunkId: "chunk_3", + type: "image", + content: "", + summary: "Margin bridge chart.", + keywords: ["gross margin"], + sourceTitle: "report.pdf", + pageNums: [3], + }, + ], + selectedSource: "report.pdf", + }), + ); + + await user.type( + screen.getByRole("searchbox", { name: "Search parsed chunks" }), + "margin", + ); + + expect(screen.getByText("1/2 chunks · 3 hits")).toBeTruthy(); + await waitFor(() => { + expect( + container.querySelector( + '[data-chunk-id="chunk_2"][data-focused-chunk="true"]', + ), + ).toBeTruthy(); + }); + expect( + container.querySelectorAll('mark[data-chunk-search-match="true"]').length, + ).toBeGreaterThan(0); + + await user.click( + screen.getByRole("button", { name: "Next chunk search match" }), + ); + + await waitFor(() => { + expect( + container.querySelector( + '[data-chunk-id="chunk_3"][data-focused-chunk="true"]', + ), + ).toBeTruthy(); + }); + }); + + it("shows a large upload target when no document is selected", async () => { + const user = userEvent.setup(); + + render( + React.createElement(C, { + chunks: [], + selectedSource: null, + onSourceUploaded: vi.fn(), + }), + ); + + await user.click( + screen.getByRole("button", { name: /Upload a document/i }), + ); + + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.getByText("Add source")).toBeTruthy(); + }); + + it("accepts dropped files from the empty chunk upload target", async () => { + render( + React.createElement(C, { + chunks: [], + selectedSource: null, + onSourceUploaded: vi.fn(), + }), + ); + + const dropTarget = screen.getByRole("button", { + name: /Upload a document/i, + }); + const dropEvent = createFileDropEvent( + new File(["hello"], "drop.pdf", { type: "application/pdf" }), + ); + + await act(async () => { + dropTarget.dispatchEvent(dropEvent); + }); + + expect(dropEvent.defaultPrevented).toBe(true); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(await screen.findByText("Selected: drop.pdf")).toBeTruthy(); + }); + it("switches to a download-only original file state for unsupported previews", async () => { mockVisibleVirtualViewport(); const user = userEvent.setup(); @@ -811,6 +919,22 @@ function mockVisibleVirtualViewport(): void { mockVirtualViewportWithChunkHeights({}); } +function createFileDropEvent(file: File): Event { + const event = new Event("drop", { bubbles: true, cancelable: true }); + const files: Pick & { readonly 0: File } = { + 0: file, + length: 1, + item: (index: number): File | null => (index === 0 ? file : null), + }; + Object.defineProperty(event, "dataTransfer", { + value: { + files, + types: ["Files"], + }, + }); + return event; +} + function mockVirtualViewportWithChunkHeights( heightsByChunkId: Readonly>, ): void { diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 9d03d87..85c6714 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -2,20 +2,37 @@ import { type CSSProperties, + type ChangeEvent, type ReactNode, useCallback, + useEffect, + useId, + useMemo, useState, } from "react"; import { type VirtualItem } from "@tanstack/react-virtual"; -import { Layers } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + FilePlus2, + Layers, + Search, + UploadCloud, + X, +} 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 { useChunksPanelWorkflow } from "@/components/chunks-panel-workflow"; +import { + chunksPanelState, + type ChunkSearchMatch, +} from "@/components/chunks-panel-state"; import { ParsedChunkCard } from "@/components/parsed-chunk-card"; import { useSourceOriginalPreviewWarmup } from "@/components/source-original-preview-warmup"; import { sourceOriginalPreviewModel } from "@/components/source-original-preview-model"; import type { ParsedChunkView } from "@/domains/chunks/types"; -import type { SourceOriginalFileView } from "@/domains/sources/types"; +import type { SourceOriginalFileView, SourceView } from "@/domains/sources/types"; import { cn } from "@/lib/utils"; export type ChunksPanelProps = { @@ -28,6 +45,8 @@ export type ChunksPanelProps = { isLoadingMore?: boolean; hasMoreChunks?: boolean; onLoadMore?: () => void; + onLoginClick?: () => void; + onSourceUploaded?: (source: SourceView) => void; }; export function ChunksPanel({ @@ -40,6 +59,8 @@ export function ChunksPanel({ isLoadingMore = false, hasMoreChunks = false, onLoadMore, + onLoginClick, + onSourceUploaded, }: Partial = {}) { const originalPreviewCacheKey = selectedSourceFile?.url ?? null; const isOriginalPreviewAvailable = @@ -50,6 +71,32 @@ export function ChunksPanel({ const [mountedOriginalPreviewKey, setMountedOriginalPreviewKey] = useState< string | null >(null); + const [searchQuery, setSearchQuery] = useState(""); + const [activeSearchMatchIndex, setActiveSearchMatchIndex] = useState(-1); + const normalizedSearchQuery = chunksPanelState.normalizeChunkSearchQuery( + searchQuery, + ); + const searchMatches = useMemo( + () => chunksPanelState.getChunkSearchMatches(chunks, searchQuery), + [chunks, searchQuery], + ); + const totalSearchHitCount = useMemo( + () => + searchMatches.reduce( + (total, searchMatch) => total + searchMatch.matchCount, + 0, + ), + [searchMatches], + ); + const effectiveActiveSearchMatchIndex = getEffectiveSearchMatchIndex( + activeSearchMatchIndex, + searchMatches, + normalizedSearchQuery, + ); + const activeSearchMatch = + effectiveActiveSearchMatchIndex >= 0 + ? (searchMatches[effectiveActiveSearchMatchIndex] ?? null) + : null; const { activeFocusedChunkId, handleChunkSelected: selectChunk, @@ -77,6 +124,21 @@ export function ChunksPanel({ isLoadingMore, onLoadMore, }); + + useEffect(() => { + if (!normalizedSearchQuery) { + requestChunkFocus(null); + return; + } + + if (!activeSearchMatch) { + requestChunkFocus(null); + return; + } + + requestChunkFocus(activeSearchMatch.chunkId); + }, [activeSearchMatch, normalizedSearchQuery, requestChunkFocus]); + useSourceOriginalPreviewWarmup({ sourceTitle: selectedSource, file: selectedSourceFile, @@ -99,6 +161,30 @@ export function ChunksPanel({ rememberOriginalPreview(); selectOriginalView(); }, [rememberOriginalPreview, selectOriginalView]); + const handleSearchQueryChange = useCallback( + (query: string): void => { + const nextMatches = chunksPanelState.getChunkSearchMatches(chunks, query); + setSearchQuery(query); + setActiveSearchMatchIndex(nextMatches.length > 0 ? 0 : -1); + if (visibleView === "original") handleParsedViewSelected(); + }, + [chunks, handleParsedViewSelected, visibleView], + ); + const handleSearchCleared = useCallback((): void => { + setSearchQuery(""); + setActiveSearchMatchIndex(-1); + requestChunkFocus(null); + }, [requestChunkFocus]); + const handlePreviousSearchMatch = useCallback((): void => { + setActiveSearchMatchIndex((currentIndex) => + getRelativeSearchMatchIndex(currentIndex, searchMatches, -1), + ); + }, [searchMatches]); + const handleNextSearchMatch = useCallback((): void => { + setActiveSearchMatchIndex((currentIndex) => + getRelativeSearchMatchIndex(currentIndex, searchMatches, 1), + ); + }, [searchMatches]); const headerTitle = focusedChunkId ? "Referenced Chunks" : "Parsed Chunks"; const shouldMountOriginalPreview = @@ -134,7 +220,7 @@ export function ChunksPanel({ data-testid="chunks-panel" className="z-0 flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background" > -
+

{visibleView === "original" ? "Original File" : headerTitle} @@ -143,24 +229,40 @@ export function ChunksPanel({ {headerSubtitle}

- {hasOriginalFile ? ( -
- - -
- ) : null} +
+ = 0 + ? effectiveActiveSearchMatchIndex + 1 + : 0 + } + matchCount={searchMatches.length} + query={searchQuery} + totalHitCount={totalSearchHitCount} + onClear={handleSearchCleared} + onNext={handleNextSearchMatch} + onPrevious={handlePreviousSearchMatch} + onQueryChange={handleSearchQueryChange} + /> + {hasOriginalFile ? ( +
+ + +
+ ) : null} +
@@ -178,7 +280,14 @@ export function ChunksPanel({ {isLoading ? ( ) : chunks.length === 0 ? ( - + selectedSource ? ( + + ) : ( + + ) ) : (
void; + readonly onNext: () => void; + readonly onPrevious: () => void; + readonly onQueryChange: (query: string) => void; +}): ReactNode { + const searchInputId = useId(); + const hasQuery = query.trim().length > 0; + const hasMatches = matchCount > 0; + const resultLabel = getSearchResultLabel({ + activeMatchOrdinal, + hasQuery, + matchCount, + totalHitCount, + }); + + return ( +
+ +
+
+ + {resultLabel} + +
+ + +
+
+ ); +} + +function getSearchResultLabel({ + activeMatchOrdinal, + hasQuery, + matchCount, + totalHitCount, +}: { + readonly activeMatchOrdinal: number; + readonly hasQuery: boolean; + readonly matchCount: number; + readonly totalHitCount: number; +}): string { + if (!hasQuery) return "Search chunks"; + if (matchCount === 0) return "No matches"; + + const hitLabel = totalHitCount === 1 ? "hit" : "hits"; + return `${activeMatchOrdinal}/${matchCount} chunks · ${totalHitCount} ${hitLabel}`; +} + +function getRelativeSearchMatchIndex( + currentIndex: number, + matches: readonly ChunkSearchMatch[], + delta: number, +): number { + if (matches.length === 0) return -1; + + const startingIndex = currentIndex >= 0 ? currentIndex : 0; + return (startingIndex + delta + matches.length) % matches.length; +} + +function getEffectiveSearchMatchIndex( + currentIndex: number, + matches: readonly ChunkSearchMatch[], + normalizedSearchQuery: string, +): number { + if (!normalizedSearchQuery || matches.length === 0) return -1; + if (currentIndex < 0) return 0; + return Math.min(currentIndex, matches.length - 1); +} + +const searchNavigationButtonClassName = + "flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8e51ff]/25 disabled:pointer-events-none disabled:opacity-40"; + +function EmptySourceUploadState({ + onLoginClick, + onSourceUploaded, +}: { + readonly onLoginClick?: () => void; + readonly onSourceUploaded?: (source: SourceView) => void; +}): ReactNode { + if (onLoginClick) { + return ( + + ); + } + + if (!onSourceUploaded) { + return ; + } + + return ( + ( + + )} + /> + ); +} + +function EmptyUploadPicture(): ReactNode { + return ( +
@@ -320,12 +331,14 @@ function TextChunkCard({ chunk, isFocused, isOriginalPreviewAvailable, + searchQuery, onChunkClick, onReferenceClick, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; readonly isOriginalPreviewAvailable: boolean; + readonly searchQuery: string; readonly onChunkClick?: (chunk: ParsedChunkView) => void; readonly onReferenceClick: (chunkId: string) => void; }): ReactNode { @@ -336,13 +349,13 @@ function TextChunkCard({ isOriginalPreviewAvailable={isOriginalPreviewAvailable} onChunkClick={onChunkClick} > - +
-          {renderTextChunkContent(chunk, onReferenceClick)}
+          {renderTextChunkContent(chunk, searchQuery, onReferenceClick)}
         
- + ); } @@ -351,11 +364,13 @@ function ImageChunkCard({ chunk, isFocused, isOriginalPreviewAvailable, + searchQuery, onChunkClick, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; readonly isOriginalPreviewAvailable: boolean; + readonly searchQuery: string; readonly onChunkClick?: (chunk: ParsedChunkView) => void; }): ReactNode { return ( @@ -365,7 +380,7 @@ function ImageChunkCard({ isOriginalPreviewAvailable={isOriginalPreviewAvailable} onChunkClick={onChunkClick} > - + {chunk.assetUrl ? (
@@ -384,26 +399,33 @@ function ImageChunkCard({ Image chunk

- {chunk.summary ?? "Image content is not available in this view."} + {chunk.summary + ? renderHighlightedSearchText(chunk.summary, searchQuery) + : "Image content is not available in this view."}

)} - + ); } function renderTextChunkContent( chunk: ParsedChunkView, + searchQuery: string, onReferenceClick: (chunkId: string) => void, ): ReactNode { const parts = parsedChunkCardModel.getTextContentParts(chunk); - if (parts.length === 1 && parts[0]?.type === "text") return parts[0].text; + if (parts.length === 1 && parts[0]?.type === "text") { + return renderHighlightedSearchText(parts[0].text, searchQuery); + } return parts.map((part) => { - if (part.type === "text") return part.text; + if (part.type === "text") { + return renderHighlightedSearchText(part.text, searchQuery); + } return ( void; }): ReactNode { const safeHtml = useMemo( @@ -461,7 +485,7 @@ function TableChunkCard({ isOriginalPreviewAvailable={isOriginalPreviewAvailable} onChunkClick={onChunkClick} > - + {safeHtml ? (

- {chunk.summary ?? "Table content is not available in this view."} + {chunk.summary + ? renderHighlightedSearchText(chunk.summary, searchQuery) + : "Table content is not available in this view."}

)}
- + ); } +function renderHighlightedSearchText( + text: string, + searchQuery: string, +): ReactNode { + const normalizedQuery = searchQuery.trim().toLocaleLowerCase(); + if (!normalizedQuery) return text; + + const normalizedText = text.toLocaleLowerCase(); + const parts: ReactNode[] = []; + let cursor = 0; + let matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + + while (matchIndex >= 0) { + if (matchIndex > cursor) { + parts.push(text.slice(cursor, matchIndex)); + } + + const matchEnd = matchIndex + normalizedQuery.length; + parts.push( + + {text.slice(matchIndex, matchEnd)} + , + ); + cursor = matchEnd; + matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + } + + if (cursor < text.length) { + parts.push(text.slice(cursor)); + } + + return parts.length === 1 ? parts[0] : parts; +} + function renderChunkIcon(type: ParsedChunkView["type"]): ReactNode { if (type === "image") return ; if (type === "table") return ; diff --git a/src/components/source-upload-dialog-workflow.ts b/src/components/source-upload-dialog-workflow.ts index 2e41bdb..cd56159 100644 --- a/src/components/source-upload-dialog-workflow.ts +++ b/src/components/source-upload-dialog-workflow.ts @@ -40,8 +40,8 @@ type SourceUploadDialogWorkflow = { readonly isUploading: boolean; readonly message: SourceUploadDialogMessage | null; readonly selectedFileName: string | null; - readonly handleDialogDragOver: (event: DragEvent) => void; - readonly handleDialogDrop: (event: DragEvent) => void; + readonly handleUploadDragOver: (event: DragEvent) => void; + readonly handleUploadDrop: (event: DragEvent) => void; readonly handleDialogOpenChange: (open: boolean) => void; readonly handleFileInputChange: (event: ChangeEvent) => void; readonly handleSubmit: (event: FormEvent) => Promise; @@ -110,13 +110,13 @@ export function useSourceUploadDialogWorkflow({ } } - function handleDialogDragOver(event: DragEvent): void { + function handleUploadDragOver(event: DragEvent): void { if (!hasDraggedFiles(event)) return; event.preventDefault(); event.stopPropagation(); } - function handleDialogDrop(event: DragEvent): void { + function handleUploadDrop(event: DragEvent): void { if (!hasDraggedFiles(event)) return; event.preventDefault(); event.stopPropagation(); @@ -127,6 +127,7 @@ export function useSourceUploadDialogWorkflow({ if (!file) return; setSelectedFileState(file); + setIsDialogOpen(true); } function handleDialogOpenChange(open: boolean): void { @@ -161,8 +162,8 @@ export function useSourceUploadDialogWorkflow({ isUploading, message, selectedFileName, - handleDialogDragOver, - handleDialogDrop, + handleUploadDragOver, + handleUploadDrop, handleDialogOpenChange, handleFileInputChange, handleSubmit, diff --git a/src/components/source-upload-dialog.tsx b/src/components/source-upload-dialog.tsx index 80011b4..55bf8cd 100644 --- a/src/components/source-upload-dialog.tsx +++ b/src/components/source-upload-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { + type DragEvent, useId, type ReactElement, } from "react"; @@ -20,10 +21,19 @@ import type { SourceView } from "@/domains/sources/types"; export type SourceUploadDialogProps = { readonly onSourceUploaded?: (source: SourceView) => void; + readonly renderTrigger?: (props: SourceUploadDialogTriggerProps) => ReactElement; +}; + +export type SourceUploadDialogTriggerProps = { + readonly isUploading: boolean; + readonly onClick: () => void; + readonly onDragOver: (event: DragEvent) => void; + readonly onDrop: (event: DragEvent) => void; }; export function SourceUploadDialog({ onSourceUploaded, + renderTrigger, }: SourceUploadDialogProps): ReactElement { const { inputRef, @@ -31,8 +41,8 @@ export function SourceUploadDialog({ isUploading, message, selectedFileName, - handleDialogDragOver, - handleDialogDrop, + handleUploadDragOver, + handleUploadDrop, handleDialogOpenChange, handleFileInputChange, handleSubmit, @@ -45,19 +55,30 @@ export function SourceUploadDialog({ open={isDialogOpen} onOpenChange={handleDialogOpenChange} > - + {renderTrigger ? ( + renderTrigger({ + isUploading, + onClick: handleUploadDialogOpen, + onDragOver: handleUploadDragOver, + onDrop: handleUploadDrop, + }) + ) : ( + + )} Add source diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..2e81cc2 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,12 @@ +"use client" + +import { ThemeProvider as NextThemesProvider } from "next-themes" +import type { ThemeProviderProps } from "next-themes" +import type { ReactNode } from "react" + +export function ThemeProvider({ + children, + ...props +}: ThemeProviderProps): ReactNode { + return {children} +} diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx new file mode 100644 index 0000000..14ed7e6 --- /dev/null +++ b/src/components/theme-toggle.tsx @@ -0,0 +1,45 @@ +"use client" + +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" +import type { ReactElement } from "react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function ThemeToggle(): ReactElement { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} diff --git a/src/components/top-nav.test.ts b/src/components/top-nav.test.ts new file mode 100644 index 0000000..e67a3fa --- /dev/null +++ b/src/components/top-nav.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { createElement } from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { ThemeProvider } from "@/components/theme-provider" +import { TopNav, type TopNavProps } from "./top-nav" + +describe("TopNav", () => { + beforeEach(() => { + vi.stubGlobal("matchMedia", () => ({ + addEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: "", + onchange: null, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + })) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it("links to the configured Dashboard origin", async () => { + const user = userEvent.setup() + const topNavProps: TopNavProps = { + dashboardUrl: "https://dashboard.example.test", + } + + render( + createElement( + ThemeProvider, + { attribute: "class" }, + createElement(TopNav, topNavProps), + ), + ) + + const link = screen.getByRole("link", { name: "Open Dashboard" }) + + expect(link.getAttribute("href")).toBe("https://dashboard.example.test") + await user.click(screen.getByRole("button", { name: "Toggle theme" })) + + expect(screen.getByRole("menuitem", { name: "Light" })).toBeTruthy() + expect(screen.getByRole("menuitem", { name: "Dark" })).toBeTruthy() + expect(screen.getByRole("menuitem", { name: "System" })).toBeTruthy() + }) +}) diff --git a/src/components/top-nav.tsx b/src/components/top-nav.tsx index 2e1c2ec..e33c20b 100644 --- a/src/components/top-nav.tsx +++ b/src/components/top-nav.tsx @@ -1,7 +1,11 @@ import { NotebookLogoMark } from "@/components/notebook-logo-mark"; import { Separator } from "@/components/ui/separator"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { ExternalLink, LayoutDashboard } from "lucide-react"; +import type { ReactElement } from "react"; export type TopNavProps = { + dashboardUrl?: string | null; userInitials?: string; userName?: string; userTierLabel?: string; @@ -9,11 +13,12 @@ export type TopNavProps = { }; export function TopNav({ + dashboardUrl, userInitials, userName, userTierLabel, workspaceLabel = "Personal Workspace", -}: TopNavProps = {}) { +}: TopNavProps): ReactElement { return (
@@ -30,6 +35,18 @@ export function TopNav({
+ {dashboardUrl ? ( + + + Dashboard + + + ) : null} + {userInitials && ( <>
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 1ede5d6..1c7d6b3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -19,6 +19,10 @@ const buttonVariants = cva( "pill-primary": "text-[#F5F3FF] shadow-none", "pill-secondary": "border-stone-200 text-stone-800 shadow-none", mono: "bg-zinc-800 font-mono text-primary-light shadow-none hover:bg-zinc-700", + "copy-cli": + "bg-zinc-800 font-normal text-[#A684FF] shadow-none hover:bg-zinc-900 hover:font-semibold active:bg-zinc-950 active:font-semibold", + "copy-code": + "bg-zinc-800 font-mono-readable font-normal text-[#A684FF] shadow-none hover:bg-zinc-700 hover:text-[#C4B4FF] active:bg-zinc-600 active:text-[#DDD6FF]", }, size: { default: "h-16 px-7 pb-1 text-base [&_svg]:size-5", @@ -27,6 +31,8 @@ const buttonVariants = cva( icon: "size-10", "pill-md": "h-16 px-7 pb-1 text-base [&_svg]:size-5", "pill-lg": "h-[72px] px-9 pb-1 text-xl [&_svg]:size-6", + "copy-cli": "h-9 w-[72px] px-0 py-2 text-sm", + "copy-code": "h-9 px-4 py-2 text-sm", }, }, compoundVariants: [ diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..a7ccbce --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index bfd48eb..f3d3dc4 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -48,6 +48,7 @@ export type WorkspaceShellLayoutProps = { readonly archivingThreadIds: readonly string[] readonly chat: WorkspaceChatState readonly chatThreads: readonly ChatThreadView[] + readonly dashboardUrl?: string readonly desktopPanelWidths: Readonly readonly focusedChunk: FocusedChunkState readonly hasMessages: boolean @@ -105,6 +106,7 @@ export function WorkspaceShellLayout( return (
>["client"] +const originalDashboardOrigin = process.env.DASHBOARD_ORIGIN + describe("loadWorkspaceShellInitialState", () => { + afterEach(() => { + if (originalDashboardOrigin === undefined) { + delete process.env.DASHBOARD_ORIGIN + return + } + + process.env.DASHBOARD_ORIGIN = originalDashboardOrigin + }) + it("returns guest demo state from the Knowhere demo API only", async () => { const deps = createDependencies({ getOptionalAuthenticated: vi.fn(async () => null), @@ -68,6 +79,14 @@ describe("loadWorkspaceShellInitialState", () => { expect(deps.reconcileSourcesForWorkspace).not.toHaveBeenCalled() }) + it("exposes the configured Dashboard origin to the shell", async () => { + process.env.DASHBOARD_ORIGIN = "https://dashboard.staging.example" + + const state = await loadWorkspaceShellInitialState(createDependencies()) + + expect(state.dashboardUrl).toBe("https://dashboard.staging.example") + }) + it("lists visible API demos before authenticated workspace sources", async () => { const workspace = makeWorkspace() const source = makeSource(workspace.id) diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index 53d743e..43604a6 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -26,6 +26,7 @@ type WorkspaceShellInitialState = { readonly activeChatThreadId?: string | null readonly chatMessages?: ChatMessageView[] readonly chatThreads?: ReturnType[] + readonly dashboardUrl?: string readonly isGuest?: boolean readonly loginUrl?: string readonly sources?: SourceView[] @@ -96,6 +97,7 @@ export async function loadWorkspaceShellInitialState( isGuest: true, sources: demoCatalog.sources.map(demoView.toSourceView), chatMessages: demoView.toChatMessages(demoCatalog), + dashboardUrl: resolveDashboardUrl(), loginUrl: guestContext.loginUrl, } } @@ -139,6 +141,7 @@ export async function loadWorkspaceShellInitialState( id: workspace.id, namespace: workspace.namespace, }, + dashboardUrl: resolveDashboardUrl(), sources: [ ...demoSources, ...demoSourceResolution.workspaceSources.map((source) => @@ -151,6 +154,10 @@ export async function loadWorkspaceShellInitialState( } } +function resolveDashboardUrl(): string | undefined { + return process.env.DASHBOARD_ORIGIN +} + async function fetchOptionalDemoCatalog( fetchDemoCatalog: () => Promise, ): Promise {