diff --git a/alembic/versions/0012_reconcile_papers_schema.py b/alembic/versions/0012_reconcile_papers_schema.py index a2951b08..925bb240 100644 --- a/alembic/versions/0012_reconcile_papers_schema.py +++ b/alembic/versions/0012_reconcile_papers_schema.py @@ -97,6 +97,15 @@ def upgrade() -> None: _add_column_if_missing( "papers", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True) ) + _add_column_if_missing( + "papers", sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=True) + ) + + # Backfill first_seen_at from created_at if missing. + if "first_seen_at" in _columns("papers") and "created_at" in _columns("papers"): + op.execute( + sa.text("UPDATE papers SET first_seen_at = created_at WHERE first_seen_at IS NULL") + ) # Fill empty title_hash so ORM non-null assumptions hold. if "title_hash" in _columns("papers"): diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 2b6075a9..65efb336 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -11,7 +11,7 @@ import { LLMUsageChart } from "@/components/dashboard/LLMUsageChart" import { PipelineStatus } from "@/components/dashboard/PipelineStatus" import { ReadingQueue } from "@/components/dashboard/ReadingQueue" import { ScholarSignalsPanel } from "@/components/dashboard/ScholarSignalsPanel" -import { TrackSpotlight } from "@/components/dashboard/TrackSpotlight" +import { TrackSpotlightSection } from "@/components/dashboard/TrackSpotlightSection" import { fetchDeadlineRadar, fetchLLMUsage, @@ -169,11 +169,13 @@ export default async function DashboardPage() { tokenCalls={usageSummary.totals.calls} /> - diff --git a/web/src/components/dashboard/TrackSpotlight.tsx b/web/src/components/dashboard/TrackSpotlight.tsx index e60cf7bd..5eea8a33 100644 --- a/web/src/components/dashboard/TrackSpotlight.tsx +++ b/web/src/components/dashboard/TrackSpotlight.tsx @@ -1,16 +1,27 @@ +"use client" + import Link from "next/link" -import { ArrowRight, Bookmark, Compass, Hash, Sparkles, Star } from "lucide-react" +import { Bookmark, Check, ChevronDown, Compass, Hash, Loader2, Sparkles, Star } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import type { AnchorPreviewItem, ResearchTrackSummary, TrackFeedItem } from "@/lib/types" interface TrackSpotlightProps { - track: ResearchTrackSummary | null + tracks: ResearchTrackSummary[] + activeTrack: ResearchTrackSummary | null + onSelectTrack: (trackId: number) => void feedItems: TrackFeedItem[] feedTotal: number anchors: AnchorPreviewItem[] + isLoading?: boolean } function toPaperHref(item: TrackFeedItem): string { @@ -22,8 +33,16 @@ function formatScore(score?: number): string { return score.toFixed(1) } -export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSpotlightProps) { - if (!track) { +export function TrackSpotlight({ + tracks, + activeTrack, + onSelectTrack, + feedItems, + feedTotal, + anchors, + isLoading = false, +}: TrackSpotlightProps) { + if (!activeTrack) { return ( @@ -41,7 +60,7 @@ export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSp ) } - const keywords = (track.keywords || []).slice(0, 6) + const keywords = (activeTrack.keywords || []).slice(0, 6) return ( @@ -50,17 +69,42 @@ export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSp
- Track Spotlight · {track.name} + Track Spotlight · {activeTrack.name} + {isLoading && }

- {track.description || "Track-level feed blending keyword match, feedback preference and judge score."} + {activeTrack.description || "Track-level feed blending keyword match, feedback preference and judge score."}

- + + + + + + {tracks.map((track) => ( + onSelectTrack(track.id)} + className="flex items-center gap-2" + > + {track.id === activeTrack.id ? ( + + ) : ( + + )} + {track.name} + + ))} + {tracks.length === 0 && ( + No tracks available + )} + + - {!!keywords.length && ( + {keywords.length > 0 && (
{keywords.map((keyword) => ( @@ -108,13 +152,6 @@ export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSp ))}
)} - -
diff --git a/web/src/components/dashboard/TrackSpotlightSection.tsx b/web/src/components/dashboard/TrackSpotlightSection.tsx new file mode 100644 index 00000000..7d0983ce --- /dev/null +++ b/web/src/components/dashboard/TrackSpotlightSection.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useCallback, useState } from "react" + +import { TrackSpotlight } from "./TrackSpotlight" +import type { AnchorPreviewItem, ResearchTrackSummary, TrackFeedItem } from "@/lib/types" + +interface TrackSpotlightSectionProps { + initialTracks: ResearchTrackSummary[] + initialActiveTrack: ResearchTrackSummary | null + initialFeedItems: TrackFeedItem[] + initialFeedTotal: number + initialAnchors: AnchorPreviewItem[] + userId?: string +} + +export function TrackSpotlightSection({ + initialTracks, + initialActiveTrack, + initialFeedItems, + initialFeedTotal, + initialAnchors, + userId = "default", +}: TrackSpotlightSectionProps) { + const [tracks] = useState(initialTracks) + const [activeTrack, setActiveTrack] = useState(initialActiveTrack) + const [feedItems, setFeedItems] = useState(initialFeedItems) + const [feedTotal, setFeedTotal] = useState(initialFeedTotal) + const [anchors, setAnchors] = useState(initialAnchors) + const [isLoading, setIsLoading] = useState(false) + + const handleSelectTrack = useCallback( + async (trackId: number) => { + const selectedTrack = tracks.find((t) => t.id === trackId) + if (!selectedTrack || selectedTrack.id === activeTrack?.id) return + + setIsLoading(true) + setActiveTrack(selectedTrack) + + try { + // Fetch feed and anchors for the new track + 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`), + ]) + + if (feedRes.ok) { + const feedData = await feedRes.json() + setFeedItems(feedData.items || []) + setFeedTotal(feedData.total || 0) + } else { + setFeedItems([]) + setFeedTotal(0) + } + + if (anchorsRes.ok) { + const anchorsData = await anchorsRes.json() + setAnchors(anchorsData.items || []) + } else { + setAnchors([]) + } + + // 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: "{}", + }) + } catch (error) { + console.error("Failed to fetch track data:", error) + setFeedItems([]) + setFeedTotal(0) + setAnchors([]) + } finally { + setIsLoading(false) + } + }, + [tracks, activeTrack?.id, userId] + ) + + return ( + + ) +} diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index fc0bfb25..888c9dc2 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -14,14 +14,17 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" import { Button } from "@/components/ui/button" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { SearchBox } from "./SearchBox" import { TrackPills } from "./TrackPills" import { SearchResults } from "./SearchResults" -import { FeedTab } from "./FeedTab" -import { SavedTab } from "./SavedTab" import { MemoryTab } from "./MemoryTab" import { CreateTrackModal } from "./CreateTrackModal" import { EditTrackModal } from "./EditTrackModal" @@ -29,34 +32,6 @@ import { ManageTracksModal } from "./ManageTracksModal" import type { Track } from "./TrackSelector" import type { Paper } from "./PaperCard" -type AnchorAuthor = { - author_id: number - author_ref?: string - name: string - slug: string - anchor_score: number - anchor_level: string - intrinsic_score: number - relevance_score: number - score_breakdown?: { - intrinsic: number - relevance: number - network: number - personalization: number - total: number - } - user_action?: "follow" | "ignore" | null - evidence_status?: "ok" | "missing" - evidence_note?: string | null - evidence_papers: Array<{ - paper_id: number - title: string - year?: number | null - url?: string | null - citation_count?: number - }> -} - type ContextPack = { context_run_id?: number | null routing: { @@ -95,18 +70,20 @@ export default function ResearchPageNew() { const [tracks, setTracks] = useState([]) const [activeTrackId, setActiveTrackId] = useState(null) + // All available sources + const ALL_SOURCES = ["semantic_scholar", "arxiv", "openalex", "papers_cool", "hf_daily"] + // Search state const [query, setQuery] = useState("") const [hasSearched, setHasSearched] = useState(false) const [isSearching, setIsSearching] = useState(false) const [contextPack, setContextPack] = useState(null) - const [activeTab, setActiveTab] = useState("search") - const [searchSources, setSearchSources] = useState(["semantic_scholar"]) + const [searchSources, setSearchSources] = useState(ALL_SOURCES) + + // Memory drawer state + const [memoryOpen, setMemoryOpen] = useState(false) - // Anchor state - const [anchors, setAnchors] = useState([]) - const [anchorsLoading, setAnchorsLoading] = useState(false) - const [anchorsError, setAnchorsError] = useState(null) + // Anchor mode state (used for search filtering) const [anchorPersonalized, setAnchorPersonalized] = useState(true) // UI state @@ -145,41 +122,6 @@ export default function ResearchPageNew() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [routeTrackId, tracks, activeTrackId]) - useEffect(() => { - if (!activeTrackId) { - setAnchors([]) - setAnchorsError(null) - setAnchorsLoading(false) - return - } - - let cancelled = false - setAnchorsLoading(true) - setAnchorsError(null) - - fetchJson<{ items: AnchorAuthor[] }>( - `/api/research/tracks/${activeTrackId}/anchors/discover?user_id=${encodeURIComponent(userId)}&limit=5&window_days=730&personalized=${anchorPersonalized ? "true" : "false"}` - ) - .then((data) => { - if (cancelled) return - setAnchors(data.items || []) - }) - .catch((e) => { - if (cancelled) return - setAnchors([]) - setAnchorsError(String(e)) - }) - .finally(() => { - if (!cancelled) { - setAnchorsLoading(false) - } - }) - - return () => { - cancelled = true - } - }, [activeTrackId, userId, anchorPersonalized]) - async function refreshTracks(): Promise { const data = await fetchJson<{ tracks: Track[] }>( `/api/research/tracks?user_id=${encodeURIComponent(userId)}` @@ -239,7 +181,6 @@ export default function ResearchPageNew() { ) setContextPack(data.context_pack) - setActiveTab("search") } catch (e) { setError(String(e)) } finally { @@ -252,12 +193,26 @@ export default function ResearchPageNew() { const exists = prev.includes(source) if (exists) { const next = prev.filter((x) => x !== source) - return next.length ? next : ["semantic_scholar"] + // Keep at least one source selected + return next.length ? next : prev } return [...prev, source] }) } + // 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]) + async function handleCreateTrack(data: { name: string description: string @@ -375,31 +330,6 @@ export default function ResearchPageNew() { } } - async function handleAnchorAction(authorId: number, action: "follow" | "ignore"): Promise { - if (!activeTrackId) return - - try { - await fetchJson(`/api/research/tracks/${activeTrackId}/anchors/${authorId}/action`, { - method: "POST", - body: JSON.stringify({ user_id: userId, action }), - headers: { "Content-Type": "application/json" }, - }) - - setAnchors((prev) => - prev.map((row) => - row.author_id === authorId - ? { - ...row, - user_action: action, - } - : row - ) - ) - } catch (e) { - setError(String(e)) - } - } - async function handleFeedback( paperId: string, action: string, @@ -440,7 +370,13 @@ export default function ResearchPageNew() { const trackToClearName = tracks.find((t) => t.id === trackToClear)?.name || "this track" return ( -
+
{/* Confirm Clear Memory Dialog */} @@ -516,8 +452,8 @@ export default function ResearchPageNew() { {/* Main Content */}
{/* Greeting - only show before search */} @@ -551,12 +487,15 @@ export default function ResearchPageNew() { onManageTracks={() => setManageModalOpen(true)} isSearching={isSearching} disabled={loading} + anchorMode={anchorPersonalized ? "personalized" : "global"} + onAnchorModeChange={(mode) => setAnchorPersonalized(mode === "personalized")} + onOpenMemory={() => setMemoryOpen(true)} />
{/* Track Pills - only show before search */} {!hasSearched && tracks.length > 0 && ( -
+
)} - {activeTrack && ( - - -
- Anchor Authors · {activeTrack.name} - -
-
- - {anchorsLoading ? ( -

Loading anchor authors...

- ) : anchorsError ? ( -

Failed to load anchors: {anchorsError}

- ) : anchors.length === 0 ? ( -

- No anchor authors yet for this track. Try searching and saving a few papers first. -

- ) : ( -
- {anchors.map((anchor) => { - const evidence = anchor.evidence_papers?.[0] - return ( -
-
-
{anchor.name}
-
- - {anchor.anchor_level} - - - score {anchor.anchor_score.toFixed(2)} - -
-
-
- - -
- {evidence ? ( -
- Evidence: {evidence.url ? ( - - {evidence.title} - - ) : ( - {evidence.title} - )} -
- ) : ( -
- {anchor.evidence_note || "No evidence papers available yet."} -
- )} -
- ) - })} -
- )} -
-
- )} - {/* Error Display */} {error && ( @@ -671,46 +518,32 @@ export default function ResearchPageNew() { )} - - - Search - Feed - Saved - Memory - - - - handleFeedback(paperId, "like", rank)} - onSave={(paperId, rank, paper) => handleFeedback(paperId, "save", rank, paper)} - onDislike={(paperId, rank) => handleFeedback(paperId, "dislike", rank)} - /> - - - - handleFeedback(paperId, "like", rank)} - onSave={(paperId, rank, paper) => handleFeedback(paperId, "save", rank, paper)} - onDislike={(paperId, rank) => handleFeedback(paperId, "dislike", rank)} - /> - - - - - + {/* Search Results - only shown after search */} + {hasSearched && ( + handleFeedback(paperId, "like", rank)} + onSave={(paperId, rank, paper) => handleFeedback(paperId, "save", rank, paper)} + onDislike={(paperId, rank) => handleFeedback(paperId, "dislike", rank)} + /> + )} - - - - + {/* Memory Sheet Drawer */} + + + + Track Memory + +
+ +
+
+
) diff --git a/web/src/components/research/SavedPapersList.tsx b/web/src/components/research/SavedPapersList.tsx index a3bbed52..a205e4ad 100644 --- a/web/src/components/research/SavedPapersList.tsx +++ b/web/src/components/research/SavedPapersList.tsx @@ -2,11 +2,28 @@ import { useCallback, useEffect, useMemo, useState } from "react" import Link from "next/link" -import { Loader2, RefreshCw } from "lucide-react" +import { Check, ChevronDown, Copy, Download, FileText, Filter, Loader2 } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -50,7 +67,13 @@ type SavedPapersResponse = { type UpdatingAction = "toggleRead" | "unsave" -const PAGE_SIZE_OPTIONS = [10, 20, 50] +type Track = { + id: number + name: string + is_active?: boolean +} + +const PAGE_SIZE = 20 const SORT_OPTIONS: Array<{ value: SavedPaperSort; label: string }> = [ { value: "saved_at", label: "Saved Time" }, { value: "judge_score", label: "Judge Score" }, @@ -77,13 +100,33 @@ function normalizeStatus(value?: string | null): ReadingStatus { export default function SavedPapersList() { const [items, setItems] = useState([]) const [sortBy, setSortBy] = useState("saved_at") - const [pageSize, setPageSize] = useState(20) const [page, setPage] = useState(1) const [loading, setLoading] = useState(true) - const [refreshTick, setRefreshTick] = useState(0) const [error, setError] = useState(null) const [updatingAction, setUpdatingAction] = useState<{ paperId: number; action: UpdatingAction } | null>(null) + // Related Work state + const [rwOpen, setRwOpen] = useState(false) + const [rwTopic, setRwTopic] = useState("") + const [rwLoading, setRwLoading] = useState(false) + const [rwMarkdown, setRwMarkdown] = useState(null) + const [rwCopied, setRwCopied] = useState(false) + + // Selection state + const [selectedIds, setSelectedIds] = useState>(new Set()) + + // Track filter state + const [tracks, setTracks] = useState([]) + const [selectedTrackId, setSelectedTrackId] = useState(null) + + // Fetch tracks on mount + useEffect(() => { + fetch("/api/research/tracks?user_id=default", { cache: "no-store" }) + .then((res) => res.json()) + .then((data) => setTracks(data.tracks || [])) + .catch(() => setTracks([])) + }, []) + const loadSavedPapers = useCallback(async () => { setLoading(true) setError(null) @@ -94,7 +137,10 @@ export default function SavedPapersList() { limit: "500", user_id: "default", }) - const res = await fetch(`/api/papers/library?${qs.toString()}`) + if (selectedTrackId) { + qs.set("track_id", String(selectedTrackId)) + } + const res = await fetch(`/api/papers/library?${qs.toString()}`, { cache: "no-store" }) if (!res.ok) { const errorText = await res.text() // Avoid showing raw HTML in error messages @@ -106,6 +152,7 @@ export default function SavedPapersList() { const payload = (await res.json()) as SavedPapersResponse setItems(payload.papers || []) setPage(1) + setSelectedIds(new Set()) // Clear selection on reload } catch (err) { const detail = err instanceof Error ? err.message : String(err) setError(detail) @@ -113,21 +160,45 @@ export default function SavedPapersList() { } finally { setLoading(false) } - }, [sortBy]) + }, [sortBy, selectedTrackId]) useEffect(() => { loadSavedPapers().catch(() => {}) - }, [loadSavedPapers, refreshTick]) + }, [loadSavedPapers]) const totalPages = useMemo(() => { - return Math.max(1, Math.ceil(items.length / pageSize)) - }, [items.length, pageSize]) + return Math.max(1, Math.ceil(items.length / PAGE_SIZE)) + }, [items.length]) const pagedItems = useMemo(() => { const safePage = Math.min(page, totalPages) - const start = (safePage - 1) * pageSize - return items.slice(start, start + pageSize) - }, [items, page, pageSize, totalPages]) + const start = (safePage - 1) * PAGE_SIZE + return items.slice(start, start + PAGE_SIZE) + }, [items, page, totalPages]) + + const hasSelection = selectedIds.size > 0 + + const toggleSelect = useCallback((paperId: number) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(paperId)) { + next.delete(paperId) + } else { + next.add(paperId) + } + return next + }) + }, []) + + 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 const unsavePaper = useCallback(async (paperId: number) => { setUpdatingAction({ paperId, action: "unsave" }) @@ -204,6 +275,57 @@ export default function SavedPapersList() { [], ) + 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))) + try { + const res = await fetch(`/api/papers/export?${qs.toString()}`, { cache: "no-store" }) + if (!res.ok) throw new Error(`${res.status}`) + const blob = await res.blob() + const extMap: Record = { bibtex: "bib", ris: "ris", markdown: "md", csl_json: "csl.json" } + const ext = extMap[format] || "txt" + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `papers.${ext}` + a.click() + URL.revokeObjectURL(url) + } catch { + setError("Export failed") + } + }, [selectedIds]) + + const handleGenerateRelatedWork = useCallback(async () => { + if (!rwTopic.trim()) return + setRwLoading(true) + setRwMarkdown(null) + try { + const res = await fetch("/api/research/papers/related-work", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user_id: "default", + topic: rwTopic.trim(), + }), + }) + if (!res.ok) throw new Error(`${res.status}`) + const data = await res.json() + setRwMarkdown(data.markdown || "No output generated.") + } catch { + setRwMarkdown("Failed to generate related work. Please try again.") + } finally { + setRwLoading(false) + } + }, [rwTopic]) + + const handleCopyRw = useCallback(async () => { + if (!rwMarkdown) return + await navigator.clipboard.writeText(rwMarkdown) + setRwCopied(true) + setTimeout(() => setRwCopied(false), 2000) + }, [rwMarkdown]) + return ( @@ -230,33 +352,62 @@ export default function SavedPapersList() { ))} - - + + + + + + setSelectedTrackId(null)} + className="flex items-center gap-2" + > + {selectedTrackId === null ? : } + All Tracks + + {tracks.length > 0 && } + {tracks.map((track) => ( + setSelectedTrackId(track.id)} + className="flex items-center gap-2" + > + {selectedTrackId === track.id ? : } + {track.name} + + ))} + + + {hasSelection && ( + + + + + + handleExport("bibtex")}>BibTeX + handleExport("ris")}>RIS + handleExport("markdown")}>Markdown + handleExport("csl_json")}>Zotero (CSL-JSON) + + + )}
{error ?

{error}

: null} @@ -274,6 +425,13 @@ export default function SavedPapersList() { + + + Title Source Saved @@ -293,7 +451,14 @@ export default function SavedPapersList() { const rowUpdating = togglingRead || unsaving return ( - + + toggleSelect(paper.id)} + aria-label={`Select ${paper.title}`} + /> + +
{paper.title}
{(paper.authors || []).slice(0, 4).join(", ") || "Unknown authors"} @@ -345,8 +510,8 @@ export default function SavedPapersList() {
- Showing {(Math.min(page, totalPages) - 1) * pageSize + 1} -{" "} - {Math.min(Math.min(page, totalPages) * pageSize, items.length)} of {items.length} + Showing {(Math.min(page, totalPages) - 1) * PAGE_SIZE + 1} -{" "} + {Math.min(Math.min(page, totalPages) * PAGE_SIZE, items.length)} of {items.length}
+
+ {rwMarkdown && ( +
+
{rwMarkdown}
+
+ )} +
+ {rwMarkdown && ( + + + + )} + + ) } diff --git a/web/src/components/research/SearchBox.tsx b/web/src/components/research/SearchBox.tsx index 46e734aa..7275a368 100644 --- a/web/src/components/research/SearchBox.tsx +++ b/web/src/components/research/SearchBox.tsx @@ -1,7 +1,7 @@ "use client" import { KeyboardEvent, useRef } from "react" -import { Loader2, Search } from "lucide-react" +import { Brain, Loader2, Search } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -22,6 +22,9 @@ interface SearchBoxProps { disabled?: boolean placeholder?: string className?: string + anchorMode?: "personalized" | "global" + onAnchorModeChange?: (mode: "personalized" | "global") => void + onOpenMemory?: () => void } export function SearchBox({ @@ -37,6 +40,9 @@ export function SearchBox({ disabled = false, placeholder = "Search for papers on RAG, transformers, security...", className, + anchorMode = "personalized", + onAnchorModeChange, + onOpenMemory, }: SearchBoxProps) { const textareaRef = useRef(null) @@ -65,8 +71,9 @@ export function SearchBox({ >
@@ -79,18 +86,42 @@ export function SearchBox({ placeholder={placeholder} disabled={disabled || isSearching} className={cn( - "min-h-[70px] sm:min-h-[80px] max-h-[200px] resize-none border-0 bg-transparent", - "px-4 sm:px-5 pt-4 sm:pt-5 pb-14", - "text-base sm:text-lg placeholder:text-muted-foreground/60", + "min-h-[56px] max-h-[200px] resize-none border-0 bg-transparent", + "px-5 sm:px-6 pt-4 pb-[56px]", + "text-base placeholder:text-muted-foreground/50", "focus-visible:ring-0 focus-visible:ring-offset-0" )} rows={1} /> {/* Bottom Toolbar */} -
- {/* Right side - Track selector and Search button */} +
+ {/* Left side - Anchor mode toggle */} +
+ +
+ + {/* Right side - Memory, Track selector and Search button */}
+ +