Skip to content

feat(Research): Rebuild -- Research page, Papers library and dashboard#115

Merged
CJBshuosi merged 2 commits into
jerry609:masterfrom
CJBshuosi:master
Feb 13, 2026
Merged

feat(Research): Rebuild -- Research page, Papers library and dashboard#115
CJBshuosi merged 2 commits into
jerry609:masterfrom
CJBshuosi:master

Conversation

@CJBshuosi

@CJBshuosi CJBshuosi commented Feb 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #114

Summary by CodeRabbit

  • New Features
    • Track Spotlight now supports switching between multiple tracks via a dropdown
    • Memory drawer added to the research interface
    • Personalized vs Global search mode toggle introduced
    • Saved Papers: track-based filtering, multi-select, and bulk export options
    • Related Work draft generation with copy-to-clipboard
    • Search UI enhanced with improved controls and layout adjustments

@vercel

vercel Bot commented Feb 13, 2026

Copy link
Copy Markdown

@CJBshuosi is attempting to deploy a commit to the Jerry's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Feb 13, 2026

Copy link
Copy Markdown
📝 Walkthrough
🚥 Pre-merge checks | ✅ 5 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changeset: rebuilding the Research page, Papers library, and dashboard components as described in the PR objectives.
Linked Issues check ✅ Passed The PR implements all coding requirements from issue #114: Research page centered layout with memory drawer [#114], SearchBox toolbar redesign with anchor toggle and memory button [#114], SavedPapersList with track filtering and export [#114], and TrackSpotlight dropdown with backend activation [#114].
Out of Scope Changes check ✅ Passed All changes align with issue #114 requirements. The database migration for first_seen_at column is a supporting change for the rebuild but is not directly scoped by #114; however, it appears necessary for the overall feature implementation.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into master

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @CJBshuosi, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a comprehensive rebuild of the research page, papers library, and dashboard components. The changes aim to enhance the user experience by providing more dynamic and interactive elements, such as on-demand track data loading, improved paper management with export and related work generation, and a more streamlined research interface. The underlying database schema is also updated to support new paper metadata.

Highlights

  • Database Schema Update: A new column, first_seen_at, was added to the papers table in the database schema, and existing records were backfilled with data from the created_at column.
  • Dashboard Track Spotlight Refactor: The TrackSpotlight component on the dashboard was refactored into a new TrackSpotlightSection component. This new component now manages its own state for tracks, active track, feed items, and anchors, including dynamic data fetching when a track is selected. The original TrackSpotlight component was updated to be a presentational component, accepting data and handlers as props, and now features a track selection dropdown.
  • Research Page Streamlining: The main research page (ResearchPageNew.tsx) was significantly streamlined. The FeedTab and SavedTab components were removed, and the MemoryTab was integrated into a new side sheet component for better organization. Anchor management logic was also removed from this page, centralizing it within the TrackSpotlightSection.
  • Enhanced Saved Papers Library: The saved papers list (SavedPapersList.tsx) received major enhancements, including multi-selection capabilities for papers, options to export selected papers in various formats (BibTeX, RIS, Markdown, CSL-JSON), and a new feature to generate a 'Related Work' section based on a user-provided topic. A track filter was also added to help users manage their saved papers.
  • Search Box Functionality Expansion: The SearchBox component was updated to include an anchor mode toggle (personalized vs. global) and a dedicated button to open the track memory sheet, improving user control and access to related features.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • alembic/versions/0012_reconcile_papers_schema.py
    • Added first_seen_at column to the papers table.
    • Backfilled first_seen_at with values from created_at for existing records.
  • web/src/app/dashboard/page.tsx
    • Updated import and usage of TrackSpotlight to TrackSpotlightSection.
    • Modified props passed to TrackSpotlightSection to include initial track data and user ID.
  • web/src/components/dashboard/TrackSpotlight.tsx
    • Refactored component to accept tracks, activeTrack, onSelectTrack, and isLoading props.
    • Implemented a dropdown menu for selecting active research tracks.
    • Removed direct navigation buttons for 'Open Track' and 'View full feed'.
  • web/src/components/dashboard/TrackSpotlightSection.tsx
    • Added a new component to manage state and data fetching for the TrackSpotlight.
    • Implemented dynamic loading of feed items and anchors based on the selected track.
    • Included logic to activate a track on the backend upon selection.
  • web/src/components/research/ResearchPageNew.tsx
    • Removed FeedTab and SavedTab components and their related state.
    • Integrated MemoryTab into a new Sheet component for a drawer-like experience.
    • Removed AnchorAuthor type definition and associated anchor state management and effects.
    • Updated SearchBox props to pass anchorMode, onAnchorModeChange, and onOpenMemory.
    • Adjusted CSS classes for main content layout based on search state.
  • web/src/components/research/SavedPapersList.tsx
    • Added state management for paper selection, related work generation, and track filtering.
    • Implemented functions for exporting selected papers in BibTeX, RIS, Markdown, and CSL-JSON formats.
    • Added functionality to generate a 'Related Work' markdown section based on a topic.
    • Introduced checkboxes for multi-selection of papers in the table.
    • Added a dropdown filter for papers by track.
    • Removed pageSize state and refreshTick for paper loading.
  • web/src/components/research/SearchBox.tsx
    • Imported Brain icon for the new memory button.
    • Updated component props to include anchorMode, onAnchorModeChange, and onOpenMemory.
    • Added a toggle button for 'Personalized'/'Global' anchor mode.
    • Included a button to open the track memory sheet.
    • Adjusted styling for the search box, including shadow and padding.
Activity
  • CJBshuosi created the pull request titled "feat(Research): Rebuild -- Research page, Papers library and dashboard".
  • The pull request addresses and closes issue [Feature] Research Page Redesign #114.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@web/src/components/dashboard/TrackSpotlightSection.tsx`:
- Around line 63-68: The activation POST is executed after setting activeTrack
(selectedTrack) so a failed activation can leave the client state inconsistent;
modify the logic in TrackSpotlightSection (where activeTrack and selectedTrack
are used) to either include the activation fetch call in the same Promise.all
that fetches feed/anchors so any failure goes to the existing catch, or if you
prefer to keep it separate, catch errors from the activation fetch and revert
activeTrack back to the previous value (or clear it) and surface an error to the
user; locate the activation call to `/api/research/tracks/${trackId}/activate`
and the state setter activeTrack (and any place selectedTrack is assigned) and
update accordingly.

In `@web/src/components/research/ResearchPageNew.tsx`:
- Around line 203-214: The effect is suppressing exhaustive-deps while
referencing handleSearch, risking stale closures and re-entrant searches;
memoize handleSearch with useCallback (including its real deps like query,
searchSources, setResults, etc.), add isSearchingRef (useRef) and mirror
isSearching into it so the debounce callback checks the current flag before
invoking handleSearch, and implement cancellation using an AbortController
inside handleSearch (or a shared ref) so any pending request is aborted when a
new debounced call runs or on cleanup; finally include handleSearch and
isSearching (or the ref) in the effect dependencies instead of disabling eslint.

In `@web/src/components/research/SavedPapersList.tsx`:
- Around line 278-281: The arrow callback in handleExport that builds the query
string uses an expression arrow (selectedIds.forEach((id) =>
qs.append("paper_id", String(id)))) which implicitly returns a value and
triggers a lint warning; change the forEach callback to a block-bodied arrow
function that explicitly performs the append without returning a value (e.g.,
selectedIds.forEach((id) => { qs.append("paper_id", String(id)); })), keeping
the rest of handleExport, qs, and selectedIds unchanged.
- Around line 122-128: The fetch in the useEffect for tracks calls res.json()
without checking res.ok, which can cause parse errors on non-2xx responses;
update the useEffect fetch handler (the anonymous fetch in the useEffect that
currently calls res.json() then setTracks) to first check if res.ok, and if not
handle the error path (e.g., throw or call setTracks([]) and optionally log the
response status/message) before calling res.json(), and ensure the catch block
still sets setTracks([]) for failures.
- Around line 193-201: The current logic compares selectedIds.size to
pagedItems.length which can wrongly mark a page as fully selected; update the
membership checks to verify that every visible item is actually selected.
Replace the isAllSelected expression with a true membership test (e.g.,
pagedItems.every(item => selectedIds.has(item.paper.id))) and update
toggleSelectAll’s condition to use the same membership test when deciding to
clear or select the page; ensure you still call setSelectedIds(new Set(...))
when selecting and setSelectedIds(new Set()) when clearing, and keep appropriate
dependencies for the useCallback on selectedIds and pagedItems.
- Around line 322-327: The handler handleCopyRw currently awaits
navigator.clipboard.writeText(rwMarkdown) with no error handling; wrap the
clipboard call in a try/catch inside handleCopyRw (which already depends on
rwMarkdown) so failures (insecure context or permission denied) are caught, log
or report the error (e.g., console.error or trigger existing UI notification),
and only setRwCopied(true) on success; ensure the catch resets any transient
state or shows a user-facing error instead of leaving the promise unhandled.
🧹 Nitpick comments (4)
web/src/components/research/ResearchPageNew.tsx (1)

73-75: Move ALL_SOURCES outside the component.

This constant is redefined on every render but never depends on props or state. Hoist it to module scope for clarity and to avoid creating a new array reference each render.

♻️ Proposed fix
+const ALL_SOURCES = ["semantic_scholar", "arxiv", "openalex", "papers_cool", "hf_daily"]
+
 export default function ResearchPageNew() {
   const searchParams = useSearchParams()
 
   // User state
   const [userId] = useState("default")
 
   // Track state
   const [tracks, setTracks] = useState<Track[]>([])
   const [activeTrackId, setActiveTrackId] = useState<number | null>(null)
 
-  // All available sources
-  const ALL_SOURCES = ["semantic_scholar", "arxiv", "openalex", "papers_cool", "hf_daily"]
-
   // Search state
web/src/components/dashboard/TrackSpotlightSection.tsx (1)

25-25: tracks state is initialized but never updated.

const [tracks] = useState(initialTracks) — without a setter, the track list is frozen at SSR time. If this is intentional (server-driven list, no client mutations), adding a brief comment would help future maintainers.

alembic/versions/0012_reconcile_papers_schema.py (2)

100-108: New first_seen_at column added and backfilled — consider adding an index if it will be queried.

The column addition and backfill from created_at are correctly guarded with idempotent helpers. However, unlike other columns added in this migration (e.g., year, citation_count), no index is created for first_seen_at. If downstream queries filter or sort by this column, an index would be beneficial.

Add index if needed
     _create_index_if_possible("ix_papers_primary_source", "papers", ["primary_source"])
+    _create_index_if_possible("ix_papers_first_seen_at", "papers", ["first_seen_at"])

And add it to the downgrade as well:

     for idx in [
         "ix_papers_semantic_scholar_id",
         ...
         "ix_papers_primary_source",
+        "ix_papers_first_seen_at",
     ]:

136-148: Downgrade doesn't drop first_seen_at column nor revert its backfill.

The existing pattern intentionally keeps columns for backward compatibility, which is fine for columns that pre-existed. But first_seen_at is introduced by this migration — a downgrade that leaves it behind could be confusing. Consider documenting this decision in the downgrade docstring or adding a comment.

Comment on lines +63 to +68
// Optionally activate the track on the backend
await fetch(`/api/research/tracks/${trackId}/activate?user_id=${encodeURIComponent(userId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Backend activation failure silently leaves stale activeTrack state.

If the activation POST fails, activeTrack has already been set to selectedTrack (line 38) and feed/anchors are populated. The failure is only logged, not surfaced to the user. More importantly, if the feed/anchor fetches succeed but activation fails, the user sees the correct data but the backend's "active" flag is out of sync — which could cause a different track to appear active on page reload.

Consider either moving the activation call into the Promise.all so its failure triggers the catch block, or reverting activeTrack on activation failure.

🤖 Prompt for AI Agents
In `@web/src/components/dashboard/TrackSpotlightSection.tsx` around lines 63 - 68,
The activation POST is executed after setting activeTrack (selectedTrack) so a
failed activation can leave the client state inconsistent; modify the logic in
TrackSpotlightSection (where activeTrack and selectedTrack are used) to either
include the activation fetch call in the same Promise.all that fetches
feed/anchors so any failure goes to the existing catch, or if you prefer to keep
it separate, catch errors from the activation fetch and revert activeTrack back
to the previous value (or clear it) and surface an error to the user; locate the
activation call to `/api/research/tracks/${trackId}/activate` and the state
setter activeTrack (and any place selectedTrack is assigned) and update
accordingly.

Comment on lines +203 to +214
// Auto-refresh search when sources change (after initial search)
useEffect(() => {
if (!hasSearched || !query.trim() || isSearching) return

// Debounce to avoid rapid re-fetching
const timer = setTimeout(() => {
handleSearch()
}, 300)

return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchSources])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Auto-refresh effect references handleSearch but suppresses the exhaustive-deps warning — risk of stale closures and re-entrant calls.

handleSearch is not memoized, so the eslint-disable hides a real dependency. Additionally, if the user manually triggers a search while the debounce timer is pending (e.g., toggling a source then immediately pressing Enter), both calls can race and the UI may show results from whichever resolves last, potentially for a different source set.

Consider wrapping handleSearch in useCallback (with its deps) and adding an AbortController so a new search cancels the in-flight one. At minimum, add isSearching as a guard inside the timeout callback by reading it from a ref, since the closure captures a potentially stale value.

Sketch of a safer pattern
+  const abortRef = useRef<AbortController | null>(null)
+
+  const handleSearch = useCallback(async () => {
+    if (!query.trim()) return
+    abortRef.current?.abort()
+    const controller = new AbortController()
+    abortRef.current = controller
+
+    setIsSearching(true)
+    setHasSearched(true)
+    setError(null)
+    try {
+      const body = { /* ... */ }
+      const data = await fetchJson<{ context_pack: ContextPack }>(
+        `/api/research/context`,
+        { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, signal: controller.signal }
+      )
+      if (!controller.signal.aborted) setContextPack(data.context_pack)
+    } catch (e) {
+      if (!controller.signal.aborted) setError(String(e))
+    } finally {
+      if (!controller.signal.aborted) setIsSearching(false)
+    }
+  }, [query, searchSources, /* other deps */])

   useEffect(() => {
     if (!hasSearched || !query.trim() || isSearching) return
     const timer = setTimeout(() => {
       handleSearch()
     }, 300)
     return () => clearTimeout(timer)
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [searchSources])
+  }, [searchSources, handleSearch])
🤖 Prompt for AI Agents
In `@web/src/components/research/ResearchPageNew.tsx` around lines 203 - 214, The
effect is suppressing exhaustive-deps while referencing handleSearch, risking
stale closures and re-entrant searches; memoize handleSearch with useCallback
(including its real deps like query, searchSources, setResults, etc.), add
isSearchingRef (useRef) and mirror isSearching into it so the debounce callback
checks the current flag before invoking handleSearch, and implement cancellation
using an AbortController inside handleSearch (or a shared ref) so any pending
request is aborted when a new debounced call runs or on cleanup; finally include
handleSearch and isSearching (or the ref) in the effect dependencies instead of
disabling eslint.

Comment on lines +122 to +128
// Fetch tracks on mount
useEffect(() => {
fetch("/api/research/tracks?user_id=default")
.then((res) => res.json())
.then((data) => setTracks(data.tracks || []))
.catch(() => setTracks([]))
}, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Track fetch doesn't check res.ok before parsing JSON.

If the API returns a non-2xx status, res.json() may throw a confusing parse error instead of a handled failure.

Proposed fix
  useEffect(() => {
    fetch("/api/research/tracks?user_id=default")
-     .then((res) => res.json())
+     .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`${res.status}`))))
      .then((data) => setTracks(data.tracks || []))
      .catch(() => setTracks([]))
  }, [])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Fetch tracks on mount
useEffect(() => {
fetch("/api/research/tracks?user_id=default")
.then((res) => res.json())
.then((data) => setTracks(data.tracks || []))
.catch(() => setTracks([]))
}, [])
// Fetch tracks on mount
useEffect(() => {
fetch("/api/research/tracks?user_id=default")
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`${res.status}`))))
.then((data) => setTracks(data.tracks || []))
.catch(() => setTracks([]))
}, [])
🤖 Prompt for AI Agents
In `@web/src/components/research/SavedPapersList.tsx` around lines 122 - 128, The
fetch in the useEffect for tracks calls res.json() without checking res.ok,
which can cause parse errors on non-2xx responses; update the useEffect fetch
handler (the anonymous fetch in the useEffect that currently calls res.json()
then setTracks) to first check if res.ok, and if not handle the error path
(e.g., throw or call setTracks([]) and optionally log the response
status/message) before calling res.json(), and ensure the catch block still sets
setTracks([]) for failures.

Comment on lines +193 to +201
const toggleSelectAll = useCallback(() => {
if (selectedIds.size === pagedItems.length && pagedItems.length > 0) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(pagedItems.map((item) => item.paper.id)))
}
}, [selectedIds.size, pagedItems])

const isAllSelected = pagedItems.length > 0 && selectedIds.size === pagedItems.length

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isAllSelected uses size comparison instead of membership check — can show a false "all selected" state.

If the user selects N items on page 1, then navigates to page 2 which happens to also have N items, selectedIds.size === pagedItems.length evaluates to true even though none of page 2's items are in selectedIds. The "select all" checkbox would appear checked when no visible rows are actually selected.

Check actual membership instead of comparing counts:

🐛 Proposed fix
-  const isAllSelected = pagedItems.length > 0 && selectedIds.size === pagedItems.length
+  const isAllSelected = pagedItems.length > 0 && pagedItems.every((item) => selectedIds.has(item.paper.id))
🤖 Prompt for AI Agents
In `@web/src/components/research/SavedPapersList.tsx` around lines 193 - 201, The
current logic compares selectedIds.size to pagedItems.length which can wrongly
mark a page as fully selected; update the membership checks to verify that every
visible item is actually selected. Replace the isAllSelected expression with a
true membership test (e.g., pagedItems.every(item =>
selectedIds.has(item.paper.id))) and update toggleSelectAll’s condition to use
the same membership test when deciding to clear or select the page; ensure you
still call setSelectedIds(new Set(...)) when selecting and setSelectedIds(new
Set()) when clearing, and keep appropriate dependencies for the useCallback on
selectedIds and pagedItems.

Comment on lines +278 to +281
const handleExport = useCallback(async (format: "bibtex" | "ris" | "markdown" | "csl_json") => {
const qs = new URLSearchParams({ format, user_id: "default" })
// Add selected paper IDs
selectedIds.forEach((id) => qs.append("paper_id", String(id)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Arrow function in forEach implicitly returns a value (static analysis flag).

(id) => qs.append(...) implicitly returns the result of qs.append(). While harmless, wrapping in braces silences the lint error cleanly.

Proposed fix
-    selectedIds.forEach((id) => qs.append("paper_id", String(id)))
+    selectedIds.forEach((id) => { qs.append("paper_id", String(id)) })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleExport = useCallback(async (format: "bibtex" | "ris" | "markdown" | "csl_json") => {
const qs = new URLSearchParams({ format, user_id: "default" })
// Add selected paper IDs
selectedIds.forEach((id) => qs.append("paper_id", String(id)))
const handleExport = useCallback(async (format: "bibtex" | "ris" | "markdown" | "csl_json") => {
const qs = new URLSearchParams({ format, user_id: "default" })
// Add selected paper IDs
selectedIds.forEach((id) => { qs.append("paper_id", String(id)) })
🧰 Tools
🪛 Biome (2.3.14)

[error] 281-281: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@web/src/components/research/SavedPapersList.tsx` around lines 278 - 281, The
arrow callback in handleExport that builds the query string uses an expression
arrow (selectedIds.forEach((id) => qs.append("paper_id", String(id)))) which
implicitly returns a value and triggers a lint warning; change the forEach
callback to a block-bodied arrow function that explicitly performs the append
without returning a value (e.g., selectedIds.forEach((id) => {
qs.append("paper_id", String(id)); })), keeping the rest of handleExport, qs,
and selectedIds unchanged.

Comment on lines +322 to +327
const handleCopyRw = useCallback(async () => {
if (!rwMarkdown) return
await navigator.clipboard.writeText(rwMarkdown)
setRwCopied(true)
setTimeout(() => setRwCopied(false), 2000)
}, [rwMarkdown])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

navigator.clipboard.writeText can reject — unhandled promise.

handleCopyRw awaits navigator.clipboard.writeText but has no try/catch. This will throw in insecure contexts (non-HTTPS) or if clipboard permission is denied, leaving the user with no feedback.

Proposed fix
  const handleCopyRw = useCallback(async () => {
    if (!rwMarkdown) return
-   await navigator.clipboard.writeText(rwMarkdown)
-   setRwCopied(true)
-   setTimeout(() => setRwCopied(false), 2000)
+   try {
+     await navigator.clipboard.writeText(rwMarkdown)
+     setRwCopied(true)
+     setTimeout(() => setRwCopied(false), 2000)
+   } catch {
+     setError("Failed to copy to clipboard")
+   }
  }, [rwMarkdown])
🤖 Prompt for AI Agents
In `@web/src/components/research/SavedPapersList.tsx` around lines 322 - 327, The
handler handleCopyRw currently awaits navigator.clipboard.writeText(rwMarkdown)
with no error handling; wrap the clipboard call in a try/catch inside
handleCopyRw (which already depends on rwMarkdown) so failures (insecure context
or permission denied) are caught, log or report the error (e.g., console.error
or trigger existing UI notification), and only setRwCopied(true) on success;
ensure the catch resets any transient state or shows a user-facing error instead
of leaving the promise unhandled.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant refactoring and new features across several frontend components and a database migration. The database migration adds a first_seen_at column to the papers table and backfills it from created_at. On the frontend, the TrackSpotlight component has been converted to a client component, renamed to TrackSpotlightSection, and refactored to manage its own state for tracks, feed items, and anchors, including a new dropdown for selecting active tracks. The ResearchPageNew component has undergone a major overhaul, removing the tab-based navigation for 'Feed' and 'Saved' sections, eliminating the dedicated 'Anchor Authors' card, and replacing the 'Memory' tab with a Sheet (drawer) component. The SearchBox component now includes controls for toggling 'Personalized'/'Global' anchor mode and opening the 'Track Memory' drawer. The SavedPapersList component has been enhanced with new features such as paper selection via checkboxes, track filtering, and the ability to export selected papers in various formats (BibTeX, RIS, Markdown, CSL-JSON). It also introduces a new 'Related Work' dialog that allows users to generate a related work section based on a topic from their saved papers. Review comments highlight the need to add cache: "no-store" to all client-side API fetch calls to prevent stale data, address a hardcoded userId value, and fix an exhaustive-deps warning for the useEffect that debounces search. Additionally, a suggestion was made to improve error handling in the 'Related Work' dialog by using a dedicated error state instead of displaying error messages within the markdown output area.

Comment on lines +42 to +45
const [feedRes, anchorsRes] = await Promise.all([
fetch(`/api/research/tracks/${trackId}/feed?user_id=${encodeURIComponent(userId)}&limit=6`),
fetch(`/api/research/tracks/${trackId}/anchors/discover?user_id=${encodeURIComponent(userId)}&limit=4`),
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The fetch calls to the API are missing the cache: "no-store" option. This can lead to the browser serving stale data from its cache, which is undesirable for a dynamic dashboard. Other parts of the application, like in web/src/lib/api.ts, consistently use this option to ensure data freshness. I recommend adding it to all fetch calls in this component, including the one to activate the track.

Suggested change
const [feedRes, anchorsRes] = await Promise.all([
fetch(`/api/research/tracks/${trackId}/feed?user_id=${encodeURIComponent(userId)}&limit=6`),
fetch(`/api/research/tracks/${trackId}/anchors/discover?user_id=${encodeURIComponent(userId)}&limit=4`),
])
const [feedRes, anchorsRes] = await Promise.all([
fetch(`/api/research/tracks/${trackId}/feed?user_id=${encodeURIComponent(userId)}&limit=6`, { cache: "no-store" }),
fetch(`/api/research/tracks/${trackId}/anchors/discover?user_id=${encodeURIComponent(userId)}&limit=4`, { cache: "no-store" }),
])

Comment on lines +123 to +128
useEffect(() => {
fetch("/api/research/tracks?user_id=default")
.then((res) => res.json())
.then((data) => setTracks(data.tracks || []))
.catch(() => setTracks([]))
}, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This fetch call is missing the cache: "no-store" option, which can lead to stale data being displayed. This is a recurring issue across multiple new fetch calls in this file (e.g., in loadSavedPapers, handleExport, handleGenerateRelatedWork). To ensure data freshness, please add { cache: "no-store" } to all client-side API fetches.

Suggested change
useEffect(() => {
fetch("/api/research/tracks?user_id=default")
.then((res) => res.json())
.then((data) => setTracks(data.tracks || []))
.catch(() => setTracks([]))
}, [])
useEffect(() => {
fetch("/api/research/tracks?user_id=default", { cache: "no-store" })
.then((res) => res.json())
.then((data) => setTracks(data.tracks || []))
.catch(() => setTracks([]))
}, [])

initialFeedItems={feedItems}
initialFeedTotal={feedTotal}
initialAnchors={anchors}
userId="default"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The userId is hardcoded as "default". While this might be acceptable for initial development or a single-user context, it will need to be replaced with a dynamic value from an authentication context or session when multi-user support is implemented. This hardcoded value appears in several places in this PR.

Comment on lines +204 to +214
useEffect(() => {
if (!hasSearched || !query.trim() || isSearching) return

// Debounce to avoid rapid re-fetching
const timer = setTimeout(() => {
handleSearch()
}, 300)

return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchSources])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This useEffect has a disabled exhaustive-deps warning because handleSearch is not in its dependency array. This can lead to bugs with stale closures and makes the code brittle.

To fix this and improve the code's robustness, you should:

  1. Wrap the handleSearch function in useCallback with its dependencies (e.g., query, userId, searchSources).
  2. Add handleSearch to this useEffect's dependency array and remove the eslint-disable comment.

This change will also improve the user experience by triggering a debounced search as the user types, which is a common and helpful pattern.

  useEffect(() => {
    if (!hasSearched || !query.trim() || isSearching) return

    // Debounce to avoid rapid re-fetching
    const timer = setTimeout(() => {
      handleSearch()
    }, 300)

    return () => clearTimeout(timer)
  }, [searchSources, handleSearch])

Comment on lines +315 to +317
} catch {
setRwMarkdown("Failed to generate related work. Please try again.")
} finally {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the catch block for handleGenerateRelatedWork, the error message is being set into rwMarkdown. This will render the error text inside the <pre> block intended for markdown content, which is not ideal for user experience. It would be better to use a dedicated state variable for errors within the dialog and display it as a proper error message (e.g., using an Alert component).

@vercel

vercel Bot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
paper-bot Ready Ready Preview, Comment Feb 13, 2026 2:59pm

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@web/src/components/research/SavedPapersList.tsx`:
- Around line 140-142: The current guard in SavedPapersList.tsx uses a falsy
check (if (selectedTrackId)) which incorrectly skips valid ID 0; change the
condition around qs.set("track_id", String(selectedTrackId)) to explicitly check
for null/undefined (e.g., selectedTrackId != null or selectedTrackId !== null &&
selectedTrackId !== undefined) so a track with id === 0 is included while still
excluding missing values.
- Around line 553-558: The Enter key handler on the Input triggers
handleGenerateRelatedWork even while IME composition is active; update the
onKeyDown handler to ignore Enter when composing by checking e.isComposing or
e.nativeEvent.isComposing before calling handleGenerateRelatedWork. Locate the
Input with props value={rwTopic}, onChange={(e) => setRwTopic(e.target.value)},
onKeyDown={(e) => { if (e.key === "Enter") handleGenerateRelatedWork() }} and
change onKeyDown to first return if e.isComposing || (e.nativeEvent &&
(e.nativeEvent as any).isComposing), otherwise proceed to call
handleGenerateRelatedWork when e.key === "Enter" and not rwLoading.
🧹 Nitpick comments (1)
web/src/components/research/SavedPapersList.tsx (1)

130-163: Loading all papers client-side (limit=500) to paginate in the browser.

The query hard-codes limit: "500" and pages entirely on the client. This is fine for small libraries, but if the paper count grows significantly, this will degrade both network and rendering performance. Consider noting this as a known scaling limitation or switching to server-side pagination in a follow-up.

Comment on lines +140 to +142
if (selectedTrackId) {
qs.set("track_id", String(selectedTrackId))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

if (selectedTrackId) is falsy — a track with id === 0 would be silently ignored.

While database IDs are typically ≥ 1, the safer check is an explicit null comparison.

Proposed fix
-      if (selectedTrackId) {
+      if (selectedTrackId !== null) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (selectedTrackId) {
qs.set("track_id", String(selectedTrackId))
}
if (selectedTrackId !== null) {
qs.set("track_id", String(selectedTrackId))
}
🤖 Prompt for AI Agents
In `@web/src/components/research/SavedPapersList.tsx` around lines 140 - 142, The
current guard in SavedPapersList.tsx uses a falsy check (if (selectedTrackId))
which incorrectly skips valid ID 0; change the condition around
qs.set("track_id", String(selectedTrackId)) to explicitly check for
null/undefined (e.g., selectedTrackId != null or selectedTrackId !== null &&
selectedTrackId !== undefined) so a track with id === 0 is included while still
excluding missing values.

Comment on lines +553 to +558
<Input
placeholder="Enter research topic..."
value={rwTopic}
onChange={(e) => setRwTopic(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleGenerateRelatedWork() }}
disabled={rwLoading}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Enter key handler doesn't guard against IME composition events.

When using CJK (Chinese/Japanese/Korean) input methods, pressing Enter to confirm a composition candidate will also fire keyDown with key === "Enter", inadvertently triggering handleGenerateRelatedWork mid-composition. Guard with e.isComposing (or e.nativeEvent.isComposing).

Proposed fix
-                onKeyDown={(e) => { if (e.key === "Enter") handleGenerateRelatedWork() }}
+                onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) handleGenerateRelatedWork() }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Input
placeholder="Enter research topic..."
value={rwTopic}
onChange={(e) => setRwTopic(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleGenerateRelatedWork() }}
disabled={rwLoading}
<Input
placeholder="Enter research topic..."
value={rwTopic}
onChange={(e) => setRwTopic(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) handleGenerateRelatedWork() }}
disabled={rwLoading}
🤖 Prompt for AI Agents
In `@web/src/components/research/SavedPapersList.tsx` around lines 553 - 558, The
Enter key handler on the Input triggers handleGenerateRelatedWork even while IME
composition is active; update the onKeyDown handler to ignore Enter when
composing by checking e.isComposing or e.nativeEvent.isComposing before calling
handleGenerateRelatedWork. Locate the Input with props value={rwTopic},
onChange={(e) => setRwTopic(e.target.value)}, onKeyDown={(e) => { if (e.key ===
"Enter") handleGenerateRelatedWork() }} and change onKeyDown to first return if
e.isComposing || (e.nativeEvent && (e.nativeEvent as any).isComposing),
otherwise proceed to call handleGenerateRelatedWork when e.key === "Enter" and
not rwLoading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Research Page Redesign

1 participant