Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -41,6 +42,30 @@ export function App(): JSX.Element {
const [highlightActive, setHighlightActive] = useState(false)
const [highlightColor, setHighlightColor] = useState<HighlightColor>('yellow')
const [jumpTarget, setJumpTarget] = useState<{ page: number; highlightId: string; nonce: number } | null>(null)
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>('Ask')
const [injectedPrompt, setInjectedPrompt] = useState<InjectedPrompt | null>(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'))
Expand Down Expand Up @@ -93,11 +118,19 @@ export function App(): JSX.Element {
slug={activeTabId ?? undefined}
highlightTool={{ active: highlightActive, color: highlightColor }}
jumpTarget={jumpTarget}
onAddToChat={handleAddToChat}
onExplain={handleExplain}
/>
</>
)}
</main>
<RightPanel slug={activeTabId ?? undefined} onJumpToHighlight={jumpToHighlight} />
<RightPanel
slug={activeTabId ?? undefined}
activeTab={rightPanelTab}
onTabChange={setRightPanelTab}
onJumpToHighlight={jumpToHighlight}
injectedPrompt={injectedPrompt}
/>
</div>
<IngestModal
isOpen={isIngestOpen}
Expand Down
29 changes: 29 additions & 0 deletions src/app/AskPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,33 @@ describe('AskPanel', () => {

expect(await screen.findByRole('alert')).toHaveTextContent(/db locked/i)
})

it('pre-populates input when injectedPrompt has autoSend false [R1-05]', async () => {
render(
<AskPanel
slug="p1"
injectedPrompt={{ text: '> "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(
<AskPanel
slug="p1"
injectedPrompt={{ text: 'Explain this passage: "Transformer"', autoSend: true, nonce: 2 }}
/>,
)
await waitFor(() => expect(askOpen).toHaveBeenCalled())
await waitFor(() =>
expect(askStart).toHaveBeenCalledWith({
chatSessionId: 1,
slug: 'p1',
text: 'Explain this passage: "Transformer"',
}),
)
})
})
43 changes: 37 additions & 6 deletions src/app/AskPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null)
const [messages, setMessages] = useState<DisplayMessage[]>([])
const [input, setInput] = useState('')
Expand All @@ -47,6 +55,8 @@ export function AskPanel({ slug }: AskPanelProps): JSX.Element {
const [backend, setBackend] = useState<ChatBackend>('claude')
const [switchingSession, setSwitchingSession] = useState(false)
const activeRequestId = useRef<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const lastHandledNonce = useRef<number | null>(null)

// Reload history whenever the open paper changes.
useEffect(() => {
Expand All @@ -63,20 +73,26 @@ 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 () => {
cancelled = true
}
}, [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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -194,6 +224,7 @@ export function AskPanel({ slug }: AskPanelProps): JSX.Element {
}}
>
<input
ref={inputRef}
type="text"
className={styles.textInput}
placeholder="Ask a question about this paper…"
Expand Down
34 changes: 34 additions & 0 deletions src/app/Reader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,37 @@ describe('Reader highlight overlay + citation marker interaction [P2-02 x P2-03]
expect(rect.style.background).toBe('yellow')
})
})

describe('Reader selection actions [R1-05]', () => {
it('opens SelectionMenu on mouseup with selected text, triggering onAddToChat and onExplain', async () => {
const onAddToChat = vi.fn()
const onExplain = vi.fn()
const { container } = render(
<Reader slug="my-paper" onAddToChat={onAddToChat} onExplain={onExplain} />,
)
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)
})
})
86 changes: 80 additions & 6 deletions src/app/Reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ActiveSelection | null>(null)
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
const [numPages, setNumPages] = useState(0)
const [pageNumber, setPageNumber] = useState(1)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
}

Expand All @@ -656,31 +673,76 @@ 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
const range = selection.getRangeAt(0)
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 `<button data-citation-number>` the
Expand Down Expand Up @@ -875,6 +937,18 @@ export function Reader({ slug, highlightTool, jumpTarget }: ReaderProps): JSX.El
{hoveredCitation ? (
<CitationTooltip text={hoveredCitation.text} left={hoveredCitation.left} top={hoveredCitation.top} />
) : null}
{activeSelection ? (
<SelectionMenu
x={activeSelection.x}
y={activeSelection.y}
quote={activeSelection.quote}
page={activeSelection.page}
onAddToChat={handleMenuAddToChat}
onExplain={handleMenuExplain}
onHighlight={handleMenuHighlight}
onClose={() => setActiveSelection(null)}
/>
) : null}
</div>
</div>
</div>
Expand Down
Loading
Loading