diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index bd44510f..adc61036 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -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" @@ -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) @@ -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} @@ -656,21 +666,57 @@ export default function ResearchPageNew() { {/* Memory Sheet Drawer */} - - - Track Memory - - {activeTrack - ? `${activeTrack.name} · ${anchorPersonalized ? "Personalized" : "Global"} mode` - : `Global scope · ${anchorPersonalized ? "Personalized" : "Global"} mode`} - -
- {activeTrack?.name || "Global scope"} - Query: {query.trim() || "No active query"} + +
+ +
+ Workspace Library + + {activeTrack?.name ? `Track: ${activeTrack.name}` : "Global scope"} + +
+ + Review saved papers, trigger export handoffs, and inspect track memory without leaving the + research workspace. + +
+ {activeTrack?.name || "Global scope"} + + Mode: {anchorPersonalized ? "Personalized" : "Global"} + + Query: {query.trim() || "No active query"} +
+
+
+ { + if (value === "saved" || value === "memory") { + setWorkspaceTab(value) + } + }} + className="flex min-h-0 flex-1 flex-col" + > + + + Saved + + + Memory + + + + + + + + +
- -
-
diff --git a/web/src/components/research/SavedTab.tsx b/web/src/components/research/SavedTab.tsx index 3941390f..0624fec9 100644 --- a/web/src/components/research/SavedTab.tsx +++ b/web/src/components/research/SavedTab.tsx @@ -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, @@ -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" @@ -47,6 +49,7 @@ type SavedResponse = { interface SavedTabProps { userId: string trackId: number | null + trackName?: string | null } function toPaper(item: SavedItem): Paper { @@ -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([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -73,12 +76,31 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { const [rwLoading, setRwLoading] = useState(false) const [rwMarkdown, setRwMarkdown] = useState(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(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}`) @@ -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) + } + } + const load = async () => { setLoading(true) setError(null) @@ -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()}`) @@ -159,35 +194,59 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { }, [userId, trackId]) return ( -
-
-

Saved papers scoped to current track.

-
- - - - - - - handleExport("bibtex")}>BibTeX - handleExport("ris")}>RIS - handleExport("markdown")}>Markdown - handleExport("csl_json")}>Zotero (CSL-JSON) - - - +
+
+
+
+
+ {obsidianScope} + {papers.length} saved +
+

+ {trackId != null + ? "Saved papers scoped to the current track." + : "Saved papers across your library."} +

+
+
+ + + + + + + + handleExport("bibtex")}>BibTeX + handleExport("ris")}>RIS + handleExport("markdown")}>Markdown + handleExport("csl_json")}>Zotero (CSL-JSON) + + + +
@@ -200,7 +259,9 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { {loading && !papers.length ? (
Loading saved papers...
) : !papers.length ? ( -
No saved papers in this track.
+
+ {trackId != null ? "No saved papers in this track yet." : "No saved papers in your library yet."} +
) : (
{papers.map((paper, idx) => ( @@ -247,6 +308,81 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { )} + + + +
+ + Export to Obsidian + + 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. + + + +
+ + +
+ {obsidianScope} + {papers.length} saved paper{papers.length === 1 ? "" : "s"} +
+

+ Markdown fallback stays available from the normal export menu if you only need a flat bundle. +

+
+
+ +
+ + +
+ +
+
{obsidianCommand}
+
+ {obsidianCopyError ? ( +

{obsidianCopyError}

+ ) : null} + +
+ + + Direct dashboard-triggered vault writes stay out of scope until we have a trusted local bridge or + allowlisted directory model. See issue #346. + +
+
+ + + + + +
+
+
) -} \ No newline at end of file +} diff --git a/web/src/components/research/SearchBox.tsx b/web/src/components/research/SearchBox.tsx index 0b973072..8d942ed1 100644 --- a/web/src/components/research/SearchBox.tsx +++ b/web/src/components/research/SearchBox.tsx @@ -1,7 +1,7 @@ "use client" import { KeyboardEvent, useRef, useState } from "react" -import { Brain, CalendarRange, Check, Loader2, Search } from "lucide-react" +import { BookCopy, Brain, CalendarRange, Check, Loader2, Search } from "lucide-react" import { cn } from "@/lib/utils" import { showTrackMemoryButton } from "@/config/features" @@ -116,6 +116,7 @@ interface SearchBoxProps { anchorMode?: "personalized" | "global" onAnchorModeChange?: (mode: "personalized" | "global") => void onOpenMemory?: () => void + onOpenLibrary?: () => void yearFrom?: string yearTo?: string onYearFromChange?: (value: string) => void @@ -138,6 +139,7 @@ export function SearchBox({ anchorMode = "personalized", onAnchorModeChange, onOpenMemory, + onOpenLibrary, yearFrom = "", yearTo = "", onYearFromChange, @@ -248,14 +250,28 @@ export function SearchBox({ {/* Right side - Memory, Track selector and Search button */}
+ {onOpenLibrary && ( + + )} {onOpenMemory && showTrackMemoryButton() && ( diff --git a/web/src/lib/obsidian.test.ts b/web/src/lib/obsidian.test.ts new file mode 100644 index 00000000..326d3bd1 --- /dev/null +++ b/web/src/lib/obsidian.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" + +import { buildObsidianExportCommand, describeObsidianScope } from "./obsidian" + +describe("buildObsidianExportCommand", () => { + it("builds a track-scoped command with quoted values", () => { + expect( + buildObsidianExportCommand({ + vaultPath: "~/vaults/research", + rootDir: "Research Notes", + userId: "default", + trackId: 7, + }) + ).toBe( + "paperbot export obsidian --user-id 'default' --track-id 7 --root-dir 'Research Notes' --vault '~/vaults/research'" + ) + }) + + it("falls back to a placeholder path when the vault is empty", () => { + expect( + buildObsidianExportCommand({ + vaultPath: " ", + }) + ).toBe("paperbot export obsidian --user-id 'default' --vault '/path/to/your/vault'") + }) + + it("keeps zero track ids instead of dropping them as falsy", () => { + expect( + buildObsidianExportCommand({ + vaultPath: "~/vaults/research", + trackId: 0, + }) + ).toBe("paperbot export obsidian --user-id 'default' --track-id 0 --vault '~/vaults/research'") + }) +}) + +describe("describeObsidianScope", () => { + it("describes track scope when a track is selected", () => { + expect(describeObsidianScope(12, "Agent Memory")).toBe("Track: Agent Memory") + }) + + it("falls back to global scope text without a track", () => { + expect(describeObsidianScope(null, null)).toBe("Global saved library") + }) + + it("renders zero track ids as track scope", () => { + expect(describeObsidianScope(0, null)).toBe("Track #0") + }) +}) diff --git a/web/src/lib/obsidian.ts b/web/src/lib/obsidian.ts new file mode 100644 index 00000000..8a7cb648 --- /dev/null +++ b/web/src/lib/obsidian.ts @@ -0,0 +1,44 @@ +export interface ObsidianCommandOptions { + vaultPath: string + rootDir?: string + userId?: string + trackId?: number | null +} + +function shellQuote(value: string): string { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'` +} + +export function buildObsidianExportCommand({ + vaultPath, + rootDir = "PaperBot", + userId = "default", + trackId = null, +}: ObsidianCommandOptions): string { + const trimmedVault = vaultPath.trim() + const trimmedRootDir = rootDir.trim() || "PaperBot" + const args = ["paperbot", "export", "obsidian", "--user-id", shellQuote(userId)] + + if (trackId != null) { + args.push("--track-id", String(trackId)) + } + + if (trimmedRootDir && trimmedRootDir !== "PaperBot") { + args.push("--root-dir", shellQuote(trimmedRootDir)) + } + + if (trimmedVault) { + args.push("--vault", shellQuote(trimmedVault)) + } else { + args.push("--vault", shellQuote("/path/to/your/vault")) + } + + return args.join(" ") +} + +export function describeObsidianScope(trackId: number | null, trackName?: string | null): string { + if (trackId != null) { + return trackName?.trim() ? `Track: ${trackName.trim()}` : `Track #${trackId}` + } + return "Global saved library" +}