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
76 changes: 61 additions & 15 deletions web/src/components/research/ResearchPageNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"

import { SearchBox } from "./SearchBox"
import { TrackPills } from "./TrackPills"
import { SearchResults } from "./SearchResults"
import { MemoryTab } from "./MemoryTab"
import { SavedTab } from "./SavedTab"
import { CreateTrackModal } from "./CreateTrackModal"
import { EditTrackModal } from "./EditTrackModal"
import { ManageTracksModal } from "./ManageTracksModal"
Expand Down Expand Up @@ -84,6 +86,7 @@ export default function ResearchPageNew() {

// Memory drawer state
const [memoryOpen, setMemoryOpen] = useState(false)
const [workspaceTab, setWorkspaceTab] = useState<"saved" | "memory">("saved")

// Anchor mode state (used for search filtering)
const [anchorPersonalized, setAnchorPersonalized] = useState(true)
Expand Down Expand Up @@ -559,7 +562,14 @@ export default function ResearchPageNew() {
disabled={loading}
anchorMode={anchorPersonalized ? "personalized" : "global"}
onAnchorModeChange={(mode) => setAnchorPersonalized(mode === "personalized")}
onOpenMemory={() => setMemoryOpen(true)}
onOpenLibrary={() => {
setWorkspaceTab("saved")
setMemoryOpen(true)
}}
onOpenMemory={() => {
setWorkspaceTab("memory")
setMemoryOpen(true)
}}
yearFrom={yearFrom}
yearTo={yearTo}
onYearFromChange={setYearFrom}
Expand Down Expand Up @@ -656,21 +666,57 @@ export default function ResearchPageNew() {

{/* Memory Sheet Drawer */}
<Sheet open={memoryOpen} onOpenChange={setMemoryOpen}>
<SheetContent className="w-full p-0 sm:max-w-xl">
<SheetHeader className="border-b border-slate-200 bg-slate-50/80 px-5 py-4 pr-12">
<SheetTitle>Track Memory</SheetTitle>
<SheetDescription>
{activeTrack
? `${activeTrack.name} · ${anchorPersonalized ? "Personalized" : "Global"} mode`
: `Global scope · ${anchorPersonalized ? "Personalized" : "Global"} mode`}
</SheetDescription>
<div className="flex flex-wrap gap-2 pt-2">
<Badge variant="outline">{activeTrack?.name || "Global scope"}</Badge>
<Badge variant="outline">Query: {query.trim() || "No active query"}</Badge>
<SheetContent className="w-full border-l border-border/70 p-0 sm:max-w-2xl">
<div className="flex h-full flex-col bg-gradient-to-b from-slate-50 via-white to-white">
<SheetHeader className="border-b border-border/70 bg-white/90 px-5 py-4 backdrop-blur">
<div className="flex flex-wrap items-center gap-2">
<SheetTitle>Workspace Library</SheetTitle>
<Badge variant="secondary">
{activeTrack?.name ? `Track: ${activeTrack.name}` : "Global scope"}
</Badge>
</div>
<SheetDescription>
Review saved papers, trigger export handoffs, and inspect track memory without leaving the
research workspace.
</SheetDescription>
<div className="flex flex-wrap gap-2 pt-2">
<Badge variant="outline">{activeTrack?.name || "Global scope"}</Badge>
<Badge variant="outline">
Mode: {anchorPersonalized ? "Personalized" : "Global"}
</Badge>
<Badge variant="outline">Query: {query.trim() || "No active query"}</Badge>
</div>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col px-5 pb-5 pt-4">
<Tabs
value={workspaceTab}
onValueChange={(value) => {
if (value === "saved" || value === "memory") {
setWorkspaceTab(value)
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="grid h-auto w-full grid-cols-2 rounded-2xl bg-slate-100 p-1">
<TabsTrigger value="saved" className="rounded-xl">
Saved
</TabsTrigger>
<TabsTrigger value="memory" className="rounded-xl">
Memory
</TabsTrigger>
</TabsList>
<TabsContent value="saved" className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
<SavedTab
userId={userId}
trackId={activeTrackId}
trackName={activeTrack?.name ?? null}
/>
</TabsContent>
<TabsContent value="memory" className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
<MemoryTab userId={userId} trackId={activeTrackId} />
</TabsContent>
</Tabs>
</div>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5 pt-4">
<MemoryTab userId={userId} trackId={activeTrackId} />
</div>
</SheetContent>
</Sheet>
Expand Down
206 changes: 171 additions & 35 deletions web/src/components/research/SavedTab.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client"

import { useEffect, useMemo, useState } from "react"
import { Copy, Download, FileText, Loader2, RefreshCw } from "lucide-react"
import { Copy, Download, ExternalLink, FileText, FolderSync, Loader2, RefreshCw } from "lucide-react"

import { Button } from "@/components/ui/button"
import { getErrorMessage } from "@/lib/fetch"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
Expand All @@ -21,6 +22,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { buildObsidianExportCommand, describeObsidianScope } from "@/lib/obsidian"

import { PaperCard, type Paper } from "./PaperCard"

Expand All @@ -47,6 +49,7 @@ type SavedResponse = {
interface SavedTabProps {
userId: string
trackId: number | null
trackName?: string | null
}

function toPaper(item: SavedItem): Paper {
Expand All @@ -64,7 +67,7 @@ function toPaper(item: SavedItem): Paper {
}
}

export function SavedTab({ userId, trackId }: SavedTabProps) {
export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) {
const [items, setItems] = useState<SavedItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
Expand All @@ -73,12 +76,31 @@ export function SavedTab({ userId, trackId }: SavedTabProps) {
const [rwLoading, setRwLoading] = useState(false)
const [rwMarkdown, setRwMarkdown] = useState<string | null>(null)
const [rwCopied, setRwCopied] = useState(false)
const [obsidianOpen, setObsidianOpen] = useState(false)
const [obsidianVaultPath, setObsidianVaultPath] = useState("~/Obsidian/MyVault")
const [obsidianRootDir, setObsidianRootDir] = useState("PaperBot")
const [obsidianCopied, setObsidianCopied] = useState(false)
const [obsidianCopyError, setObsidianCopyError] = useState<string | null>(null)

const papers = useMemo(() => items.map(toPaper), [items])
const obsidianCommand = useMemo(
() =>
buildObsidianExportCommand({
vaultPath: obsidianVaultPath,
rootDir: obsidianRootDir,
userId,
trackId,
}),
[obsidianRootDir, obsidianVaultPath, trackId, userId]
)
const obsidianScope = useMemo(
() => describeObsidianScope(trackId, trackName),
[trackId, trackName]
)

const handleExport = async (format: "bibtex" | "ris" | "markdown" | "csl_json") => {
const qs = new URLSearchParams({ format, user_id: userId })
if (trackId) qs.set("track_id", String(trackId))
if (trackId != null) qs.set("track_id", String(trackId))
try {
const res = await fetch(`/api/papers/export?${qs.toString()}`)
if (!res.ok) throw new Error(`${res.status}`)
Expand Down Expand Up @@ -127,6 +149,19 @@ export function SavedTab({ userId, trackId }: SavedTabProps) {
setTimeout(() => setRwCopied(false), 2000)
}

const handleCopyObsidianCommand = async () => {
try {
await navigator.clipboard.writeText(obsidianCommand)
setObsidianCopyError(null)
setObsidianCopied(true)
setTimeout(() => setObsidianCopied(false), 2000)
} catch {
setObsidianCopied(false)
setObsidianCopyError("Clipboard access failed. Copy the command manually.")
setTimeout(() => setObsidianCopyError(null), 3000)
}
}
Comment on lines +152 to +163

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

navigator.clipboard.writeText(...) can reject (permissions/HTTP, insecure context), and this handler doesn’t catch errors, which can cause an unhandled promise rejection and no user feedback. Wrap the call in try/catch and surface a failure state (e.g., set an error message or keep the button label unchanged).

Copilot uses AI. Check for mistakes.

const load = async () => {
setLoading(true)
setError(null)
Expand All @@ -136,7 +171,7 @@ export function SavedTab({ userId, trackId }: SavedTabProps) {
sort_by: "saved_at",
limit: "100",
})
if (trackId) {
if (trackId != null) {
qs.set("track_id", String(trackId))
}
const res = await fetch(`/api/research/papers/saved?${qs.toString()}`)
Expand All @@ -159,35 +194,59 @@ export function SavedTab({ userId, trackId }: SavedTabProps) {
}, [userId, trackId])

return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Saved papers scoped to current track.</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={loading || !papers.length}
onClick={() => { setRwOpen(true); setRwMarkdown(null); setRwTopic(""); }}
>
<FileText className="h-3.5 w-3.5" /> Related Work
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={loading || !papers.length}>
<Download className="h-3.5 w-3.5" /> Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("bibtex")}>BibTeX</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("ris")}>RIS</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("markdown")}>Markdown</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("csl_json")}>Zotero (CSL-JSON)</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={() => load().catch(() => {})} disabled={loading}>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
Refresh
</Button>
<div className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/90 p-4 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{obsidianScope}</Badge>
<Badge variant="outline">{papers.length} saved</Badge>
</div>
<p className="text-sm text-muted-foreground">
{trackId != null
? "Saved papers scoped to the current track."
: "Saved papers across your library."}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={loading || !papers.length}
onClick={() => setObsidianOpen(true)}
>
<FolderSync className="h-3.5 w-3.5" /> Obsidian
</Button>
<Button
variant="outline"
size="sm"
disabled={loading || !papers.length}
onClick={() => {
setRwOpen(true)
setRwMarkdown(null)
setRwTopic("")
}}
>
<FileText className="h-3.5 w-3.5" /> Related Work
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={loading || !papers.length}>
<Download className="h-3.5 w-3.5" /> Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("bibtex")}>BibTeX</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("ris")}>RIS</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("markdown")}>Markdown</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("csl_json")}>Zotero (CSL-JSON)</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={() => load().catch(() => {})} disabled={loading}>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
Refresh
</Button>
</div>
</div>
</div>

Expand All @@ -200,7 +259,9 @@ export function SavedTab({ userId, trackId }: SavedTabProps) {
{loading && !papers.length ? (
<div className="py-8 text-sm text-muted-foreground">Loading saved papers...</div>
) : !papers.length ? (
<div className="py-8 text-sm text-muted-foreground">No saved papers in this track.</div>
<div className="rounded-2xl border border-dashed bg-muted/20 py-10 text-center text-sm text-muted-foreground">
{trackId != null ? "No saved papers in this track yet." : "No saved papers in your library yet."}
</div>
) : (
<div className="space-y-3">
{papers.map((paper, idx) => (
Expand Down Expand Up @@ -247,6 +308,81 @@ export function SavedTab({ userId, trackId }: SavedTabProps) {
)}
</DialogContent>
</Dialog>

<Dialog open={obsidianOpen} onOpenChange={setObsidianOpen}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden border-border/70 bg-white/95 p-0 shadow-2xl backdrop-blur">
<div className="flex h-full max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/70 px-6 pb-4 pt-6 pr-14">
<DialogTitle>Export to Obsidian</DialogTitle>
<DialogDescription>
This dashboard does not write vault files directly. Copy the local CLI command below and run
it on the machine that owns your Obsidian vault.
</DialogDescription>
</DialogHeader>

<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<Card className="border-border/70 bg-slate-50">
<CardContent className="space-y-3 py-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{obsidianScope}</Badge>
<Badge variant="outline">{papers.length} saved paper{papers.length === 1 ? "" : "s"}</Badge>
</div>
<p className="text-sm text-muted-foreground">
Markdown fallback stays available from the normal export menu if you only need a flat bundle.
</p>
</CardContent>
</Card>

<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-2 text-sm">
<span className="font-medium">Vault path</span>
<Input
aria-label="Vault path"
value={obsidianVaultPath}
onChange={(e) => setObsidianVaultPath(e.target.value)}
placeholder="~/Obsidian/MyVault"
/>
</label>
<label className="space-y-2 text-sm">
<span className="font-medium">Root directory</span>
<Input
aria-label="Root directory"
value={obsidianRootDir}
onChange={(e) => setObsidianRootDir(e.target.value)}
placeholder="PaperBot"
/>
</label>
</div>

<div className="rounded-2xl border bg-slate-950 p-4 text-sm text-slate-50 shadow-inner">
<pre className="whitespace-pre-wrap break-all font-mono">{obsidianCommand}</pre>
</div>
{obsidianCopyError ? (
<p className="text-xs text-red-600">{obsidianCopyError}</p>
) : null}

<div className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<ExternalLink className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Direct dashboard-triggered vault writes stay out of scope until we have a trusted local bridge or
allowlisted directory model. See issue #346.
</span>
</div>
</div>

<DialogFooter className="border-t border-border/70 px-6 py-4 sm:justify-between">
<Button variant="outline" size="sm" onClick={() => handleExport("markdown")}>
<Download className="h-3.5 w-3.5" />
Download Markdown Fallback
</Button>
<Button size="sm" onClick={handleCopyObsidianCommand}>
<Copy className="h-3.5 w-3.5" />
{obsidianCopied ? "Copied" : "Copy CLI Command"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</div>
)
}
}
Loading
Loading