From 64b8b5b34caf58e5228ddd420a7ba70002038140 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Tue, 12 May 2026 16:23:06 +0000 Subject: [PATCH 1/2] feat: sync notebook dashboard UI --- package.json | 2 + pnpm-lock.yaml | 84 ++++++++ src/app/globals.css | 37 +++- src/app/layout.tsx | 13 +- src/components/chunks-panel.test.ts | 60 ++++++ src/components/chunks-panel.tsx | 98 ++++++++- src/components/notebook-logo-mark.tsx | 51 +---- .../source-upload-dialog-workflow.ts | 13 +- src/components/source-upload-dialog.tsx | 47 +++-- src/components/theme-provider.tsx | 12 ++ src/components/theme-toggle.tsx | 45 ++++ src/components/top-nav.test.ts | 52 +++++ src/components/top-nav.tsx | 19 +- src/components/ui/button.tsx | 6 + src/components/ui/dropdown-menu.tsx | 197 ++++++++++++++++++ src/components/workspace-shell-layout.tsx | 8 + src/components/workspace-shell.tsx | 3 + src/domains/workspace/initial-state.test.ts | 21 +- src/domains/workspace/initial-state.ts | 7 + 19 files changed, 709 insertions(+), 66 deletions(-) create mode 100644 src/components/theme-provider.tsx create mode 100644 src/components/theme-toggle.tsx create mode 100644 src/components/top-nav.test.ts create mode 100644 src/components/ui/dropdown-menu.tsx 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.test.ts b/src/components/chunks-panel.test.ts index 2719bfa..ef4faad 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -63,6 +63,50 @@ describe("ChunksPanel", () => { expect(screen.getByText(/Showing all parsed chunks from/)).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 +855,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..636937d 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -7,15 +7,16 @@ import { useState, } from "react"; import { type VirtualItem } from "@tanstack/react-virtual"; -import { Layers } from "lucide-react"; +import { FilePlus2, Layers, UploadCloud } 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 { 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 +29,8 @@ export type ChunksPanelProps = { isLoadingMore?: boolean; hasMoreChunks?: boolean; onLoadMore?: () => void; + onLoginClick?: () => void; + onSourceUploaded?: (source: SourceView) => void; }; export function ChunksPanel({ @@ -40,6 +43,8 @@ export function ChunksPanel({ isLoadingMore = false, hasMoreChunks = false, onLoadMore, + onLoginClick, + onSourceUploaded, }: Partial = {}) { const originalPreviewCacheKey = selectedSourceFile?.url ?? null; const isOriginalPreviewAvailable = @@ -178,7 +183,14 @@ export function ChunksPanel({ {isLoading ? ( ) : chunks.length === 0 ? ( - + selectedSource ? ( + + ) : ( + + ) ) : (
void; + readonly onSourceUploaded?: (source: SourceView) => void; +}): ReactNode { + if (onLoginClick) { + return ( + + ); + } + + if (!onSourceUploaded) { + return ; + } + + return ( + ( + + )} + /> + ); +} + +function EmptyUploadPicture(): ReactNode { + return ( +
)} - + ); } +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 ;