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 ? (
-
-
- Parsed
-
-
- Original
-
-
- ) : null}
+
+
= 0
+ ? effectiveActiveSearchMatchIndex + 1
+ : 0
+ }
+ matchCount={searchMatches.length}
+ query={searchQuery}
+ totalHitCount={totalSearchHitCount}
+ onClear={handleSearchCleared}
+ onNext={handleNextSearchMatch}
+ onPrevious={handlePreviousSearchMatch}
+ onQueryChange={handleSearchQueryChange}
+ />
+ {hasOriginalFile ? (
+
+
+ Parsed
+
+
+ Original
+
+
+ ) : 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 (
+
+
+ Search parsed chunks
+
+
+
+ ) => {
+ onQueryChange(event.target.value);
+ }}
+ />
+ {hasQuery ? (
+
+
+
+ ) : null}
+
+
+ {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 (
+
+
+
+ Log in to add documents
+
+
+ Add your first source to see parsed chunks and ask questions from this
+ workspace.
+
+
+ );
+ }
+
+ if (!onSourceUploaded) {
+ return
;
+ }
+
+ return (
+
(
+
+
+
+ Upload a document
+
+
+ Click to choose a file, or drag a document here to prepare it for
+ parsed chunks and chat.
+
+
+ PDF, DOCX, TXT, MD, spreadsheets, slides, and images up to 100 MB
+
+
+ )}
+ />
+ );
+}
+
+function EmptyUploadPicture(): ReactNode {
+ return (
+
+
+
+
+
+
+
+
+ Drop files
+
+
+ );
+}
+
+const emptyUploadTargetClassName =
+ "mx-auto flex min-h-[440px] w-full max-w-2xl cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-border bg-background-secondary/70 px-6 py-10 text-center transition-colors hover:border-[#8e51ff]/60 hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[#8e51ff]/15 dark:bg-background-secondary/80";
+
function ViewPanel({
children,
isActive,
@@ -291,6 +614,7 @@ function VirtualChunkRow({
chunk,
focusedChunkId,
isOriginalPreviewAvailable,
+ searchQuery,
measureElement,
onChunkClick,
onReferenceClick,
@@ -299,6 +623,7 @@ function VirtualChunkRow({
chunk: ParsedChunkView | undefined;
focusedChunkId: string | null;
isOriginalPreviewAvailable: boolean;
+ searchQuery: string;
measureElement: (node: HTMLDivElement | null) => void;
onChunkClick?: (chunk: ParsedChunkView) => void;
onReferenceClick: (chunkId: string) => void;
@@ -326,6 +651,7 @@ function VirtualChunkRow({
chunk={chunk}
isFocused={chunk.chunkId === focusedChunkId}
isOriginalPreviewAvailable={isOriginalPreviewAvailable}
+ searchQuery={searchQuery}
onChunkClick={onChunkClick}
onReferenceClick={onReferenceClick}
/>
diff --git a/src/components/notebook-logo-mark.tsx b/src/components/notebook-logo-mark.tsx
index a834703..1b3f58a 100644
--- a/src/components/notebook-logo-mark.tsx
+++ b/src/components/notebook-logo-mark.tsx
@@ -1,13 +1,8 @@
import Image from "next/image";
-// Base dimensions from the tablet-sized sidebar brand mark
-const BASE = {
- containerWidth: 28,
- containerHeight: 25.46,
- markWidth: 14.97,
- markHeight: 19.78,
- secondaryLeft: 13.03,
- secondaryTop: 5.68,
+const DASHBOARD_MARK = {
+ height: 84,
+ width: 92,
} as const;
type NotebookLogoMarkProps = {
@@ -16,40 +11,16 @@ type NotebookLogoMarkProps = {
};
export function NotebookLogoMark({ width, className }: NotebookLogoMarkProps) {
- const scale = width / BASE.containerWidth;
- const containerHeight = BASE.containerHeight * scale;
- const markWidth = BASE.markWidth * scale;
- const markHeight = BASE.markHeight * scale;
- const secondaryLeft = BASE.secondaryLeft * scale;
- const secondaryTop = BASE.secondaryTop * scale;
+ const height = width * (DASHBOARD_MARK.height / DASHBOARD_MARK.width);
return (
-
-
-
-
+ width={width}
+ height={height}
+ />
);
}
diff --git a/src/components/parsed-chunk-card.test.ts b/src/components/parsed-chunk-card.test.ts
index 1636f77..85283f7 100644
--- a/src/components/parsed-chunk-card.test.ts
+++ b/src/components/parsed-chunk-card.test.ts
@@ -12,7 +12,7 @@ describe("ParsedChunkCard", () => {
});
it("renders text chunks with source, summary, content, and keywords", () => {
- render(
+ const { container } = render(
React.createElement(ParsedChunkCard, {
chunk: {
chunkId: "text_1",
@@ -24,6 +24,7 @@ describe("ParsedChunkCard", () => {
keywords: ["Supercharging", "AI training capacity"],
},
isFocused: true,
+ searchQuery: "capacity",
onReferenceClick: vi.fn(),
}),
);
@@ -43,6 +44,9 @@ describe("ParsedChunkCard", () => {
expect(screen.getByTestId("chunk-card-shell-text_1").className).toContain(
"min-w-0",
);
+ expect(
+ container.querySelectorAll('mark[data-chunk-search-match="true"]').length,
+ ).toBeGreaterThan(0);
});
it("routes resolved artifact reference clicks to the target chunk", async () => {
diff --git a/src/components/parsed-chunk-card.tsx b/src/components/parsed-chunk-card.tsx
index dbe8e25..18921fd 100644
--- a/src/components/parsed-chunk-card.tsx
+++ b/src/components/parsed-chunk-card.tsx
@@ -23,12 +23,14 @@ export function ParsedChunkCard({
chunk,
isFocused,
isOriginalPreviewAvailable = false,
+ 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 {
@@ -39,6 +41,7 @@ export function ParsedChunkCard({
chunk={chunk}
isFocused={isFocused}
isOriginalPreviewAvailable={isOriginalPreviewAvailable}
+ searchQuery={searchQuery}
onChunkClick={onChunkClick}
/>
@@ -51,6 +54,7 @@ export function ParsedChunkCard({
chunk={chunk}
isFocused={isFocused}
isOriginalPreviewAvailable={isOriginalPreviewAvailable}
+ searchQuery={searchQuery}
onChunkClick={onChunkClick}
/>
@@ -62,6 +66,7 @@ export function ParsedChunkCard({
chunk={chunk}
isFocused={isFocused}
isOriginalPreviewAvailable={isOriginalPreviewAvailable}
+ searchQuery={searchQuery}
onChunkClick={onChunkClick}
onReferenceClick={onReferenceClick}
/>
@@ -225,8 +230,10 @@ function getOpenOriginalButtonLabel(
function ChunkSummaryPanel({
chunk,
+ searchQuery,
}: {
readonly chunk: ParsedChunkView;
+ readonly searchQuery: string;
}): ReactNode {
if (!chunk.summary) return null;
@@ -236,7 +243,9 @@ function ChunkSummaryPanel({
className="rounded-lg border border-border/70 bg-muted/35 p-3"
>
} label="Summary" />
- {chunk.summary}
+
+ {renderHighlightedSearchText(chunk.summary, searchQuery)}
+
);
}
@@ -261,8 +270,10 @@ function ChunkContentPanel({
function ChunkKeywords({
chunk,
+ searchQuery,
}: {
readonly chunk: ParsedChunkView;
+ readonly searchQuery: string;
}): ReactNode {
if (!chunk.keywords || chunk.keywords.length === 0) return null;
@@ -284,7 +295,7 @@ function ChunkKeywords({
variant="secondary"
className={keywordBadgeClassName}
>
- {keyword}
+ {renderHighlightedSearchText(keyword, searchQuery)}
))}
@@ -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}
>
-
-
- Upload Document
-
+ {renderTrigger ? (
+ renderTrigger({
+ isUploading,
+ onClick: handleUploadDialogOpen,
+ onDragOver: handleUploadDragOver,
+ onDrop: handleUploadDrop,
+ })
+ ) : (
+
+
+ Upload Document
+
+ )}
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 {