diff --git a/src/app/App.tsx b/src/app/App.tsx index ac5de2a..48cfa68 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -8,7 +8,8 @@ import { Sidebar } from './Sidebar' import type { NavItem } from './Sidebar' import { TabStrip } from './TabStrip' import type { PaperTab } from './TabStrip' -import { RightPanel } from './RightPanel' +import { RightPanel, type RightPanelTab } from './RightPanel' +import type { InjectedPrompt } from './AskPanel' import type { HighlightRecord } from '../../core/highlights/repo' import styles from './App.module.css' @@ -41,6 +42,30 @@ export function App(): JSX.Element { const [highlightActive, setHighlightActive] = useState(false) const [highlightColor, setHighlightColor] = useState('yellow') const [jumpTarget, setJumpTarget] = useState<{ page: number; highlightId: string; nonce: number } | null>(null) + const [rightPanelTab, setRightPanelTab] = useState('Ask') + const [injectedPrompt, setInjectedPrompt] = useState(null) + + function handleAddToChat(quote: string, page: number): void { + setRightPanelTab('Ask') + setInjectedPrompt({ + text: `> "${quote}" (p. ${page}) + +`, + autoSend: false, + nonce: Date.now(), + }) + } + + function handleExplain(quote: string, page: number): void { + setRightPanelTab('Ask') + setInjectedPrompt({ + text: `Explain this passage from page ${page}: + +> "${quote}"`, + autoSend: true, + nonce: Date.now(), + }) + } useEffect(() => { window.vellum?.ping().then(setPong).catch(() => setPong('no-bridge')) @@ -93,11 +118,19 @@ export function App(): JSX.Element { slug={activeTabId ?? undefined} highlightTool={{ active: highlightActive, color: highlightColor }} jumpTarget={jumpTarget} + onAddToChat={handleAddToChat} + onExplain={handleExplain} /> )} - + { expect(await screen.findByRole('alert')).toHaveTextContent(/db locked/i) }) + + it('pre-populates input when injectedPrompt has autoSend false [R1-05]', async () => { + render( + "Attention is all you need" (p. 1)', autoSend: false, nonce: 1 }} + />, + ) + await waitFor(() => expect(askOpen).toHaveBeenCalled()) + const input = await screen.findByLabelText(/ask a question/i) + await waitFor(() => expect(input).toHaveValue('> "Attention is all you need" (p. 1)')) + }) + + it('automatically triggers askStart when injectedPrompt has autoSend true [R1-05]', async () => { + render( + , + ) + await waitFor(() => expect(askOpen).toHaveBeenCalled()) + await waitFor(() => + expect(askStart).toHaveBeenCalledWith({ + chatSessionId: 1, + slug: 'p1', + text: 'Explain this passage: "Transformer"', + }), + ) + }) }) diff --git a/src/app/AskPanel.tsx b/src/app/AskPanel.tsx index 99a3de3..e6a8712 100644 --- a/src/app/AskPanel.tsx +++ b/src/app/AskPanel.tsx @@ -32,12 +32,20 @@ interface DisplayMessage { const STREAMING_ID = 'streaming-reply' +export interface InjectedPrompt { + text: string + autoSend?: boolean + nonce: number +} + interface AskPanelProps { /** Slug of the currently open paper — the chat is scoped to it. */ slug: string + /** [R1-05] Contextual prompt injected from PDF text selection (Add to chat or Explain) */ + injectedPrompt?: InjectedPrompt | null } -export function AskPanel({ slug }: AskPanelProps): JSX.Element { +export function AskPanel({ slug, injectedPrompt }: AskPanelProps): JSX.Element { const [sessionId, setSessionId] = useState(null) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') @@ -47,6 +55,8 @@ export function AskPanel({ slug }: AskPanelProps): JSX.Element { const [backend, setBackend] = useState('claude') const [switchingSession, setSwitchingSession] = useState(false) const activeRequestId = useRef(null) + const inputRef = useRef(null) + const lastHandledNonce = useRef(null) // Reload history whenever the open paper changes. useEffect(() => { @@ -63,11 +73,16 @@ export function AskPanel({ slug }: AskPanelProps): JSX.Element { if (cancelled) return setSessionId(result.session.id) setBackend(result.session.backend === 'codex' ? 'codex' : 'claude') - setMessages(result.messages.map((m) => ({ id: m.id, role: m.role, content: m.content }))) + setMessages( + result.messages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + })), + ) }) .catch((err: unknown) => { - if (cancelled) return - setLoadError(err instanceof Error ? err.message : String(err)) + if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err)) }) return () => { @@ -75,8 +90,9 @@ export function AskPanel({ slug }: AskPanelProps): JSX.Element { } }, [slug]) - // One subscription for the panel's lifetime; every event is routed by - // `activeRequestId` so a stale/late event from a superseded turn is dropped. + // Single subscription for the lifetime of the component. Main broadcasts + // to this window; we filter by `activeRequestId.current` so events from an + // aborted turn or a previous paper's turn are dropped. useEffect(() => { return window.vellum.onAskUpdate(({ requestId, event }) => { if (requestId !== activeRequestId.current) return @@ -125,6 +141,20 @@ export function AskPanel({ slug }: AskPanelProps): JSX.Element { } }, [input, sessionId, sending, slug]) + // [R1-05] Process injected prompt from PDF text selection actions + useEffect(() => { + if (!injectedPrompt || sessionId === null) return + if (lastHandledNonce.current === injectedPrompt.nonce) return + lastHandledNonce.current = injectedPrompt.nonce + + if (injectedPrompt.autoSend) { + void sendTurn(injectedPrompt.text) + } else { + setInput((current) => (current ? `${current}\n\n${injectedPrompt.text}` : injectedPrompt.text)) + inputRef.current?.focus() + } + }, [injectedPrompt, sessionId, sendTurn]) + const startNewChat = useCallback((nextBackend: ChatBackend = backend) => { setError(null) setSending(false) @@ -194,6 +224,7 @@ export function AskPanel({ slug }: AskPanelProps): JSX.Element { }} > { + it('opens SelectionMenu on mouseup with selected text, triggering onAddToChat and onExplain', async () => { + const onAddToChat = vi.fn() + const onExplain = vi.fn() + const { container } = render( + , + ) + await waitFor(() => expect(screen.getByLabelText('Page')).toHaveTextContent('1 of 2')) + + const textLayer = getTextLayer(container) + await waitFor(() => expect(textLayer.querySelector('span')).not.toBeNull()) + + const textNode = textLayer.querySelector('span')!.firstChild! + const range = document.createRange() + range.setStart(textNode, 0) + range.setEnd(textNode, 12) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + fireEvent.mouseUp(textLayer) + + // Selection menu toolbar should appear + const addToChat = await screen.findByRole('button', { name: 'Add to chat' }) + const explain = screen.getByRole('button', { name: 'Explain' }) + expect(addToChat).toBeInTheDocument() + expect(explain).toBeInTheDocument() + + // Clicking Add to chat calls callback with quote and page number + fireEvent.click(addToChat) + expect(onAddToChat).toHaveBeenCalledWith('Introduction', 1) + }) +}) diff --git a/src/app/Reader.tsx b/src/app/Reader.tsx index 3921539..a056933 100644 --- a/src/app/Reader.tsx +++ b/src/app/Reader.tsx @@ -24,6 +24,7 @@ import workerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url' import type { HighlightColor } from './ReaderToolbar' import type { HighlightRecord } from '../../core/highlights/repo' import { CitationTooltip } from './CitationTooltip' +import { SelectionMenu } from './SelectionMenu' import styles from './Reader.module.css' GlobalWorkerOptions.workerSrc = workerSrc @@ -416,11 +417,24 @@ interface ReaderProps { * re-jumping to the same target (clicked twice in a row) re-trigger the * effect even though `page`/`highlightId` didn't change. */ jumpTarget?: { page: number; highlightId: string; nonce: number } | null + /** [R1-05] Callback when user clicks 'Add to chat' on selected text */ + onAddToChat?: (quote: string, page: number) => void + /** [R1-05] Callback when user clicks 'Explain' on selected text */ + onExplain?: (quote: string, page: number) => void } const FLASH_DURATION_MS = 1500 -export function Reader({ slug, highlightTool, jumpTarget }: ReaderProps): JSX.Element { +interface ActiveSelection { + x: number + y: number + quote: string + page: number + anchor: { start: number; end: number } +} + +export function Reader({ slug, highlightTool, jumpTarget, onAddToChat, onExplain }: ReaderProps): JSX.Element { + const [activeSelection, setActiveSelection] = useState(null) const [doc, setDoc] = useState(null) const [numPages, setNumPages] = useState(0) const [pageNumber, setPageNumber] = useState(1) @@ -463,6 +477,7 @@ export function Reader({ slug, highlightTool, jumpTarget }: ReaderProps): JSX.El setReferenceIndex(new Map()) setCitationFlash(null) setHoveredCitation(null) + setActiveSelection(null) if (!slug) return @@ -644,10 +659,12 @@ export function Reader({ slug, highlightTool, jumpTarget }: ReaderProps): JSX.El }, [highlights, pageNumber, textLayerVersion, flashId]) function goToPage(next: number): void { + setActiveSelection(null) setPageNumber(clampPage(next, numPages)) } function zoomBy(delta: number): void { + setActiveSelection(null) setScale((current) => clampScale(current + delta)) } @@ -656,9 +673,12 @@ export function Reader({ slug, highlightTool, jumpTarget }: ReaderProps): JSX.El // highlight from that selection (page, quote, anchor) and clears the // native selection so it doesn't linger visually once the overlay paints. function handleTextLayerMouseUp(): void { - if (!highlightTool?.active || !slug) return + if (!slug) return const selection = window.getSelection() - if (!selection || selection.isCollapsed || selection.rangeCount === 0) return + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + setActiveSelection(null) + return + } const container = textLayerRef.current if (!container) return @@ -666,21 +686,63 @@ export function Reader({ slug, highlightTool, jumpTarget }: ReaderProps): JSX.El if (!container.contains(range.commonAncestorContainer)) return const quote = selection.toString().trim() - if (!quote) return + if (!quote) { + setActiveSelection(null) + return + } const anchor = anchorFromRange(container, range) if (!anchor) return const page = pageNumber - const color = highlightTool.color + if (highlightTool?.active) { + const color = highlightTool.color + void window.vellum + .highlightsCreate({ slug, page, color, quote, anchor: JSON.stringify(anchor) }) + .then((record) => { + setHighlights((current) => [...current, record]) + selection.removeAllRanges() + setActiveSelection(null) + }) + .catch(() => undefined) + return + } + + const rangeRect = typeof range.getBoundingClientRect === 'function' + ? range.getBoundingClientRect() + : { left: 0, top: 0, width: 0, height: 0 } + const containerRect = typeof container.getBoundingClientRect === 'function' + ? container.getBoundingClientRect() + : { left: 0, top: 0, width: 0, height: 0 } + const x = rangeRect.left - containerRect.left + rangeRect.width / 2 + const y = rangeRect.top - containerRect.top + setActiveSelection({ x, y, quote, page, anchor }) + } + + function handleMenuHighlight(color: HighlightColor): void { + if (!slug || !activeSelection) return + const { page, quote, anchor } = activeSelection void window.vellum .highlightsCreate({ slug, page, color, quote, anchor: JSON.stringify(anchor) }) .then((record) => { setHighlights((current) => [...current, record]) - selection.removeAllRanges() + window.getSelection()?.removeAllRanges() + setActiveSelection(null) }) .catch(() => undefined) } + function handleMenuAddToChat(): void { + if (!activeSelection) return + onAddToChat?.(activeSelection.quote, activeSelection.page) + setActiveSelection(null) + } + + function handleMenuExplain(): void { + if (!activeSelection) return + onExplain?.(activeSelection.quote, activeSelection.page) + setActiveSelection(null) + } + // [P2-03] Event delegation on the text layer (rather than imperative // listeners attached per-marker in `injectCitationMarkers`) — React's // synthetic click bubbles up from any ` @@ -53,7 +74,7 @@ export function RightPanel({ defaultTab = 'Ask', slug, onJumpToHighlight }: Righ
- {renderTabContent(activeTab, slug, onJumpToHighlight)} + {renderTabContent(activeTab, slug, onJumpToHighlight, injectedPrompt)}
) @@ -63,10 +84,11 @@ function renderTabContent( tab: RightPanelTab, slug: string | undefined, onJumpToHighlight: ((highlight: HighlightRecord) => void) | undefined, + injectedPrompt: InjectedPrompt | null | undefined, ): JSX.Element { switch (tab) { case 'Ask': - if (slug) return + if (slug) return return (

Open a paper to start asking questions.

diff --git a/src/app/SelectionMenu.module.css b/src/app/SelectionMenu.module.css new file mode 100644 index 0000000..dec58eb --- /dev/null +++ b/src/app/SelectionMenu.module.css @@ -0,0 +1,62 @@ +.menu { + position: absolute; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + background: var(--vellum-surface, #1e1e1e); + border: 1px solid var(--vellum-border, #333333); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + z-index: 20; + transform: translate(-50%, -100%); + margin-top: -8px; + white-space: nowrap; +} + +.actionButton { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--vellum-text, #e0e0e0); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.actionButton:hover { + background: var(--vellum-surface-hover, #2a2a2a); + border-color: var(--vellum-border, #444444); +} + +.divider { + width: 1px; + height: 16px; + background: var(--vellum-border, #333333); + margin: 0 2px; +} + +.swatches { + display: flex; + align-items: center; + gap: 4px; +} + +.swatchButton { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.2); + cursor: pointer; + padding: 0; + transition: transform 0.15s ease; +} + +.swatchButton:hover { + transform: scale(1.15); +} diff --git a/src/app/SelectionMenu.test.tsx b/src/app/SelectionMenu.test.tsx new file mode 100644 index 0000000..b80f39a --- /dev/null +++ b/src/app/SelectionMenu.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SelectionMenu } from './SelectionMenu' + +describe('SelectionMenu', () => { + it('renders actions and triggers onAddToChat and onExplain', () => { + const onAddToChat = vi.fn() + const onExplain = vi.fn() + const onHighlight = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + const addToChatBtn = screen.getByRole('button', { name: 'Add to chat' }) + const explainBtn = screen.getByRole('button', { name: 'Explain' }) + + fireEvent.click(addToChatBtn) + expect(onAddToChat).toHaveBeenCalledTimes(1) + + fireEvent.click(explainBtn) + expect(onExplain).toHaveBeenCalledTimes(1) + + const yellowSwatch = screen.getByRole('button', { name: 'Highlight yellow' }) + fireEvent.click(yellowSwatch) + expect(onHighlight).toHaveBeenCalledWith('yellow') + }) + + it('dismisses on Escape key', () => { + const onClose = vi.fn() + render( + , + ) + + fireEvent.keyDown(window, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/app/SelectionMenu.tsx b/src/app/SelectionMenu.tsx new file mode 100644 index 0000000..fc8e355 --- /dev/null +++ b/src/app/SelectionMenu.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef } from 'react' +import type { HighlightColor } from './ReaderToolbar' +import styles from './SelectionMenu.module.css' + +const SWATCH_HEX: Record = { + yellow: '#facc15', + green: '#4ade80', + blue: '#60a5fa', + pink: '#f472b6', +} + +const HIGHLIGHT_COLORS: HighlightColor[] = ['yellow', 'green', 'blue', 'pink'] + +export interface SelectionMenuProps { + x: number + y: number + quote: string + page: number + onAddToChat: () => void + onExplain: () => void + onHighlight: (color: HighlightColor) => void + onClose: () => void +} + +export function SelectionMenu({ + x, + y, + quote: _quote, + page: _page, + onAddToChat, + onExplain, + onHighlight, + onClose, +}: SelectionMenuProps): JSX.Element { + const menuRef = useRef(null) + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + return ( +
{ + // Prevent selection from collapsing when clicking toolbar buttons + event.stopPropagation() + }} + > + + +
+
+ {HIGHLIGHT_COLORS.map((color) => ( +
+
+ ) +}