diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 107925a..25c9b76 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -5,8 +5,12 @@ + Notebook LM diff --git a/apps/desktop/src/components/chat/ChatView.tsx b/apps/desktop/src/components/chat/ChatView.tsx index 722c5b0..35088e3 100644 --- a/apps/desktop/src/components/chat/ChatView.tsx +++ b/apps/desktop/src/components/chat/ChatView.tsx @@ -37,6 +37,8 @@ export function ChatView({ pendingSuggest, onSuggestConsumed, onCitationClick, o const setCrossNotebookMode = useAppStore((s) => s.setCrossNotebookMode); const activeNotebook = notebooks.find((nb) => nb.notebook_id === activeNotebookId) ?? null; + const documents = useAppStore((s) => s.documents); + const hasDocuments = documents.length > 0 || crossNotebookMode; const [input, setInput] = useState(''); const [isScrolledUp, setIsScrolledUp] = useState(false); @@ -125,9 +127,16 @@ export function ChatView({ pendingSuggest, onSuggestConsumed, onCitationClick, o } }; + // BibTeX is available whenever the visible conversation has at least one + // message with sources. Sticking to activeSources alone hid the action + // after any context reset, even when the persisted messages still had + // sources we could export. + const hasExportableSources = + activeSources.length > 0 || messages.some((m) => m.role === 'assistant'); + const overflowItems = [ { label: 'Export conversation', onClick: handleExport, disabled: messages.length === 0 }, - { label: 'Export BibTeX', onClick: handleExportBibtex, disabled: activeSources.length === 0 }, + { label: 'Export BibTeX', onClick: handleExportBibtex, disabled: !hasExportableSources }, { label: 'Toggle sources', onClick: toggleSourcePanel }, { label: 'Clear chat', onClick: clearChat, disabled: messages.length === 0 }, ]; @@ -175,8 +184,19 @@ export function ChatView({ pendingSuggest, onSuggestConsumed, onCitationClick, o
{messages.length === 0 && (
-

What would you like to know?

- + {hasDocuments ? ( + <> +

What would you like to know?

+ + + ) : ( + <> +

No documents here yet.

+

+ Drop a PDF, DOCX, or Markdown file anywhere on this window — or use the + Add button in the sidebar. +

+ + )}
)} {messages.map((msg, i) => ( diff --git a/apps/desktop/src/components/documents/DropZone.tsx b/apps/desktop/src/components/documents/DropZone.tsx deleted file mode 100644 index be3f6f6..0000000 --- a/apps/desktop/src/components/documents/DropZone.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useRef, useState } from 'react'; -import './documents.css'; - -interface DropZoneProps { - onDrop: (files: FileList) => void; - isUploading: boolean; -} - -export function DropZone({ onDrop, isUploading }: DropZoneProps) { - const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef(null); - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }; - - const handleDragLeave = () => setIsDragging(false); - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - if (e.dataTransfer.files.length > 0) { - onDrop(e.dataTransfer.files); - } - }; - - const handleClick = () => fileInputRef.current?.click(); - - const handleChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - onDrop(e.target.files); - e.target.value = ''; - } - }; - - const className = [ - 'drop-zone', - isDragging && 'drop-zone-active', - isUploading && 'drop-zone-uploading', - ] - .filter(Boolean) - .join(' '); - - return ( -
- - {isUploading ? 'Processing...' : 'Drop files here or click to upload'} -
- ); -} diff --git a/apps/desktop/src/components/layout/AppShell.tsx b/apps/desktop/src/components/layout/AppShell.tsx index 8863919..c145b77 100644 --- a/apps/desktop/src/components/layout/AppShell.tsx +++ b/apps/desktop/src/components/layout/AppShell.tsx @@ -16,6 +16,7 @@ import { KeyboardShortcutsOverlay } from '../ui/KeyboardShortcuts'; import { ZoteroImportDialog } from '../ui/ZoteroImport'; import type { SourceChunk } from '../../types'; import { humanizeError } from '../../utils/errorMessages'; +import { usePaneResize } from '../../hooks/usePaneResize'; import './layout.css'; function isWizardComplete(): boolean { @@ -41,6 +42,21 @@ export function AppShell() { // the two-way link between a sentence and its source card is visible. const [hoveredSourceIndex, setHoveredSourceIndex] = useState(null); + const sidebarResize = usePaneResize({ + storageKey: 'notebook-lm-sidebar-width', + min: 200, + max: 420, + from: 'left', + initial: 240, + }); + const sourcePanelResize = usePaneResize({ + storageKey: 'notebook-lm-source-panel-width', + min: 240, + max: 520, + from: 'right', + initial: 280, + }); + // Global keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -71,8 +87,17 @@ export function AppShell() { useAppStore.getState().newChat(); return; } - // ? — keyboard shortcuts (only when not typing in an input) - if (e.key === '?' && !paletteOpen) { + // ⌘-Shift-/ — keyboard shortcuts overlay. The bare `?` form used to + // be blocked whenever the chat textarea had focus (which is almost + // always), so the shortcut was effectively unreachable. A modifier + // shortcut is always reachable. The bare ? still works when no text + // input is focused. + if (e.metaKey && e.shiftKey && e.key === '?') { + e.preventDefault(); + setShortcutsOpen((v) => !v); + return; + } + if (e.key === '?' && !paletteOpen && !e.metaKey && !e.ctrlKey) { const tag = (e.target as HTMLElement)?.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA') { e.preventDefault(); @@ -238,8 +263,15 @@ export function AppShell() { return ( <> -
+
setZoteroOpen(true)} /> +
setPendingSuggest(null)} @@ -249,6 +281,11 @@ export function AppShell() { }} onCitationHover={setHoveredSourceIndex} /> +
openSource(source)} hoveredIndex={hoveredSourceIndex} diff --git a/apps/desktop/src/components/layout/layout.css b/apps/desktop/src/components/layout/layout.css index ff4d865..be7f835 100644 --- a/apps/desktop/src/components/layout/layout.css +++ b/apps/desktop/src/components/layout/layout.css @@ -7,6 +7,31 @@ background: var(--color-bg-primary); } +/* Drag handle between panes. Invisible until hover so the default look + * matches the pre-resize app. Expands to 3px with a sage tint on hover so + * the affordance is obvious once the user moves the cursor there. */ +.pane-resizer { + flex-shrink: 0; + width: 3px; + margin: 0 -1px; + cursor: col-resize; + background: transparent; + -webkit-app-region: no-drag; + transition: background var(--duration-fast) var(--ease-out); + position: relative; + z-index: 1; +} + +.pane-resizer:hover, +.pane-resizer:focus-visible { + background: var(--color-accent-subtle); +} + +.pane-resizer:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--color-accent); +} + /* ---- Sidebar ---- */ .sidebar { @@ -755,7 +780,7 @@ z-index: 1000; background: var(--color-bg-elevated); border: 1px solid var(--color-border); - border-radius: var(--radius-card); + border-radius: var(--radius-modal); box-shadow: var(--shadow-lg); padding: var(--space-1); min-width: 160px; diff --git a/apps/desktop/src/components/ui/CommandPalette.tsx b/apps/desktop/src/components/ui/CommandPalette.tsx index 53698a5..d8ec14a 100644 --- a/apps/desktop/src/components/ui/CommandPalette.tsx +++ b/apps/desktop/src/components/ui/CommandPalette.tsx @@ -7,21 +7,47 @@ interface PaletteItem { id: string; label: string; section: 'Notebooks' | 'Documents' | 'Actions'; + /** Optional keyboard shortcut hint rendered on the right side of the row. */ + shortcut?: string; onSelect: () => void; } +/** + * Tokenized fuzzy-ish scorer. Splits the query on whitespace and requires + * every token to be present in the label (case-insensitive). Rewards token + * prefix matches and contiguous runs. `"note sum"` → matches "Summarize + * this notebook" and "Notebook — Summary". + */ function matchScore(query: string, label: string): number { - const q = query.toLowerCase(); + const q = query.toLowerCase().trim(); + if (!q) return 1; const l = label.toLowerCase(); - if (l.startsWith(q)) return 2; // prefix match - if (l.includes(q)) return 1; // substring match - return 0; + const tokens = q.split(/\s+/); + let score = 0; + for (const t of tokens) { + if (!l.includes(t)) return 0; + // Prefix at the start of the label is the strongest signal. + if (l.startsWith(t)) score += 3; + // Prefix of any word inside the label is next. + else if (new RegExp(`\\b${t}`).test(l)) score += 2; + else score += 1; + } + return score; } -export function CommandPalette({ open, onClose, onZoteroImport }: { open: boolean; onClose: () => void; onZoteroImport?: () => void }) { +export function CommandPalette({ + open, + onClose, + onZoteroImport, +}: { + open: boolean; + onClose: () => void; + onZoteroImport?: () => void; +}) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); + const containerRef = useRef(null); const notebooks = useAppStore((s) => s.notebooks); const documents = useAppStore((s) => s.documents); const messages = useAppStore((s) => s.messages); @@ -43,6 +69,25 @@ export function CommandPalette({ open, onClose, onZoteroImport }: { open: boolea if (e.key === 'Escape') { e.preventDefault(); onClose(); + return; + } + // Minimal focus trap: keep Tab inside the palette container. + if (e.key === 'Tab') { + const root = containerRef.current; + if (!root) return; + const focusables = root.querySelectorAll( + 'input, button, [tabindex]:not([tabindex="-1"])', + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } } }; window.addEventListener('keydown', handler); @@ -60,30 +105,45 @@ export function CommandPalette({ open, onClose, onZoteroImport }: { open: boolea id: `nb-${nb.notebook_id}`, label: nb.title, section: 'Notebooks' as const, - onSelect: () => { setActiveNotebookId(nb.notebook_id); onClose(); }, + onSelect: () => { + setActiveNotebookId(nb.notebook_id); + onClose(); + }, })), ...documents.map((doc) => ({ id: `doc-${doc.source_path}`, label: doc.filename, section: 'Documents' as const, - onSelect: () => { setPreviewDocument(doc); onClose(); }, + onSelect: () => { + setPreviewDocument(doc); + onClose(); + }, })), { id: 'action-new-chat', label: 'New chat', section: 'Actions', - onSelect: () => { newChat(); onClose(); }, + shortcut: '⌘N', + onSelect: () => { + newChat(); + onClose(); + }, }, { id: 'action-toggle-sources', label: 'Toggle source panel', section: 'Actions', - onSelect: () => { toggleSourcePanel(); onClose(); }, + shortcut: '⌘/', + onSelect: () => { + toggleSourcePanel(); + onClose(); + }, }, { id: 'action-export', label: 'Export conversation', section: 'Actions', + shortcut: '⌘⇧E', onSelect: () => { if (messages.length > 0) { exportConversation('Notebook LM Conversation', messages); @@ -91,12 +151,19 @@ export function CommandPalette({ open, onClose, onZoteroImport }: { open: boolea onClose(); }, }, - ...(onZoteroImport ? [{ - id: 'action-zotero', - label: 'Import from Zotero', - section: 'Actions' as const, - onSelect: () => { onZoteroImport(); onClose(); }, - }] : []), + ...(onZoteroImport + ? [ + { + id: 'action-zotero', + label: 'Import from Zotero', + section: 'Actions' as const, + onSelect: () => { + onZoteroImport(); + onClose(); + }, + }, + ] + : []), ]; const filtered = query @@ -136,7 +203,14 @@ export function CommandPalette({ open, onClose, onZoteroImport }: { open: boolea return (
-
e.stopPropagation()}> +
e.stopPropagation()} + > setQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="Search notebooks, documents, actions..." + aria-label="Command palette input" />
- {sections.length === 0 && ( -
No results
- )} + {sections.length === 0 &&
No results
} {sections.map((section) => (
{section.name}
@@ -162,7 +235,10 @@ export function CommandPalette({ open, onClose, onZoteroImport }: { open: boolea onClick={item.onSelect} onMouseEnter={() => setSelectedIndex(idx)} > - {item.label} + {item.label} + {item.shortcut && ( + {item.shortcut} + )} ); })} diff --git a/apps/desktop/src/components/ui/command-palette.css b/apps/desktop/src/components/ui/command-palette.css index 874a1f4..ca731f8 100644 --- a/apps/desktop/src/components/ui/command-palette.css +++ b/apps/desktop/src/components/ui/command-palette.css @@ -62,21 +62,50 @@ } .palette-item { - display: block; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); width: 100%; padding: var(--space-2) var(--space-4); background: transparent; border: none; + border-left: 2px solid transparent; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-secondary); text-align: left; cursor: pointer; - transition: background 80ms ease; + transition: background 80ms ease, border-color 80ms ease, color 80ms ease; } -.palette-item:hover, -.palette-item.selected { +.palette-item:hover { background: var(--color-bg-hover); color: var(--color-text-primary); } + +.palette-item.selected { + background: var(--color-bg-active); + color: var(--color-text-primary); + border-left-color: var(--color-accent); +} + +.palette-item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.palette-item-shortcut { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 11px; + letter-spacing: 0.02em; + color: var(--color-text-muted); + padding: 2px 8px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-xs); + flex-shrink: 0; +} diff --git a/apps/desktop/src/components/ui/setup-wizard.css b/apps/desktop/src/components/ui/setup-wizard.css index 3d22186..2dd4543 100644 --- a/apps/desktop/src/components/ui/setup-wizard.css +++ b/apps/desktop/src/components/ui/setup-wizard.css @@ -15,6 +15,10 @@ display: flex; flex-direction: column; gap: var(--space-6); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-modal); + box-shadow: var(--shadow-lg); } .wizard-progress { @@ -28,7 +32,7 @@ width: 10px; height: 10px; border-radius: 50%; - background: var(--color-border); + background: var(--color-border-hover); transition: background 200ms ease; } @@ -240,7 +244,7 @@ .wizard-btn-primary { padding: 10px 32px; background: var(--color-accent); - color: #fff; + color: #0c0a09; } .wizard-btn-primary:hover:not(:disabled) { diff --git a/apps/desktop/src/components/ui/zotero-import.css b/apps/desktop/src/components/ui/zotero-import.css index 5b293ed..07e6c78 100644 --- a/apps/desktop/src/components/ui/zotero-import.css +++ b/apps/desktop/src/components/ui/zotero-import.css @@ -148,7 +148,7 @@ .zotero-btn-primary { padding: 10px 24px; background: var(--color-accent); - color: #fff; + color: #0c0a09; } .zotero-btn-primary:hover:not(:disabled) { diff --git a/apps/desktop/src/design-system/tokens.css b/apps/desktop/src/design-system/tokens.css index 00a4eb9..7d4d268 100644 --- a/apps/desktop/src/design-system/tokens.css +++ b/apps/desktop/src/design-system/tokens.css @@ -217,13 +217,17 @@ border-radius: 0 var(--radius-xs) var(--radius-xs) 0; } -/* ===== Reduced motion ===== */ +/* ===== Reduced motion ===== + Stop animations entirely rather than playing them at 0.01ms — the prior + rule caused shimmer/pulse keyframes to flicker at near-seizure speed + instead of ceasing. Transitions collapse to an imperceptible fade so + state changes still register without visible motion. +*/ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; + animation: none !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } diff --git a/apps/desktop/src/hooks/usePaneResize.ts b/apps/desktop/src/hooks/usePaneResize.ts new file mode 100644 index 0000000..8daada5 --- /dev/null +++ b/apps/desktop/src/hooks/usePaneResize.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface Options { + /** localStorage key — width persists across launches. */ + storageKey: string; + /** Clamp range — prevents a panel from being dragged off-screen. */ + min: number; + max: number; + /** Drag direction: `from-left` means dragging left shrinks, drag right grows. + * `from-right` flips it for the right-side panel. */ + from: 'left' | 'right'; + /** Initial width if localStorage has nothing saved. */ + initial: number; +} + +interface Result { + width: number; + setWidth: (w: number) => void; + handleProps: { + onMouseDown: (e: React.MouseEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + role: 'separator'; + tabIndex: 0; + 'aria-valuenow': number; + 'aria-valuemin': number; + 'aria-valuemax': number; + 'aria-orientation': 'vertical'; + }; +} + +export function usePaneResize({ storageKey, min, max, from, initial }: Options): Result { + const [width, setWidthState] = useState(() => { + const saved = typeof window !== 'undefined' ? localStorage.getItem(storageKey) : null; + const parsed = saved ? parseInt(saved, 10) : NaN; + return Number.isFinite(parsed) ? clamp(parsed, min, max) : initial; + }); + + const setWidth = useCallback( + (w: number) => { + const clamped = clamp(w, min, max); + setWidthState(clamped); + try { + localStorage.setItem(storageKey, String(clamped)); + } catch { + // localStorage may be disabled in some contexts (private window); + // resize still works in-session, just won't persist. + } + }, + [storageKey, min, max], + ); + + const dragStartRef = useRef<{ startX: number; startWidth: number } | null>(null); + + const onMouseMove = useCallback( + (e: MouseEvent) => { + const state = dragStartRef.current; + if (!state) return; + const delta = e.clientX - state.startX; + const next = from === 'left' ? state.startWidth + delta : state.startWidth - delta; + setWidth(next); + }, + [from, setWidth], + ); + + const onMouseUp = useCallback(() => { + dragStartRef.current = null; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }, [onMouseMove]); + + useEffect(() => { + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, [onMouseMove, onMouseUp]); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragStartRef.current = { startX: e.clientX, startWidth: width }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + // Prevent text selection while dragging and show resize cursor everywhere. + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + [width, onMouseMove, onMouseUp], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Arrow-key resize keeps the handle keyboard-accessible. + const step = e.shiftKey ? 40 : 10; + if (e.key === 'ArrowLeft') { + e.preventDefault(); + setWidth(width + (from === 'left' ? -step : step)); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + setWidth(width + (from === 'left' ? step : -step)); + } + }, + [width, from, setWidth], + ); + + return { + width, + setWidth, + handleProps: { + onMouseDown, + onKeyDown, + role: 'separator', + tabIndex: 0, + 'aria-valuenow': width, + 'aria-valuemin': min, + 'aria-valuemax': max, + 'aria-orientation': 'vertical', + }, + }; +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)); +} diff --git a/backend/notebooklm_backend/services/chat.py b/backend/notebooklm_backend/services/chat.py index 4e71a4e..7180576 100644 --- a/backend/notebooklm_backend/services/chat.py +++ b/backend/notebooklm_backend/services/chat.py @@ -203,7 +203,6 @@ async def stream_reply( metrics["llm_ms"] = (time.perf_counter() - llm_start) * 1000 metrics["total_ms"] = metrics.get("total_ms", 0.0) + metrics["llm_ms"] final_reply = "".join(aggregated).strip() - final_reply = "".join(aggregated).strip() source_count = len(rag_context.sources) if rag_context else None self._record_metrics(prompt, notebook_id, metrics, source_count=source_count) yield {