Merge pull request #1286 from AnishSarkar22/feat/obsidian-plugin

feat: introduce Obsidian vault sync plugin
This commit is contained in:
Rohan Verma 2026-04-27 13:34:33 -07:00 committed by GitHub
commit f607636ba6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 12540 additions and 1837 deletions

View file

@ -15,7 +15,7 @@ import {
DownloadIcon,
ExternalLink,
Globe,
MessageSquare,
MessageCircleReply,
MoreHorizontalIcon,
RefreshCwIcon,
} from "lucide-react";
@ -657,7 +657,7 @@ export const AssistantMessage: FC = () => {
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<MessageSquare className={cn("size-3.5", hasComments && "fill-current")} />
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
{hasComments ? (
<span>
{commentCount} {commentCount === 1 ? "comment" : "comments"}

View file

@ -1,311 +1,187 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Check, Copy, Info } from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const obsidianConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
vault_path: z.string().min(1, {
message: "Vault path is required.",
}),
vault_name: z.string().min(1, {
message: "Vault name is required.",
}),
exclude_folders: z.string().optional(),
include_attachments: z.boolean(),
});
const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
type ObsidianConnectorFormValues = z.infer<typeof obsidianConnectorFormSchema>;
const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://surfsense.com";
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [periodicEnabled, setPeriodicEnabled] = useState(true);
const [frequencyMinutes, setFrequencyMinutes] = useState("60");
const form = useForm<ObsidianConnectorFormValues>({
resolver: zodResolver(obsidianConnectorFormSchema),
defaultValues: {
name: "Obsidian Vault",
vault_path: "",
vault_name: "",
exclude_folders: ".obsidian,.trash",
include_attachments: false,
},
});
/**
* Obsidian connect form for the plugin-only architecture.
*
* The legacy `vault_path` form was removed because it only worked on
* self-hosted with a server-side bind mount and broke for everyone else.
* The plugin pushes data over HTTPS so this UI is purely instructional
* there is no backend create call here. The connector row is created
* server-side the first time the plugin calls `POST /obsidian/connect`.
*
* The footer "Connect" button in `ConnectorConnectView` triggers this
* form's submit; we just close the dialog (`onBack()`) since there's
* nothing to validate or persist from this side.
*/
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUrl, setCopiedUrl] = useState(false);
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleSubmit = async (values: ObsidianConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
const copyServerUrl = useCallback(async () => {
const ok = await copyToClipboardUtil(BACKEND_URL);
if (!ok) return;
setCopiedUrl(true);
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
}, []);
isSubmittingRef.current = true;
try {
// Parse exclude_folders into an array
const excludeFolders = values.exclude_folders
? values.exclude_folders
.split(",")
.map((f) => f.trim())
.filter(Boolean)
: [".obsidian", ".trash"];
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.OBSIDIAN_CONNECTOR,
config: {
vault_path: values.vault_path,
vault_name: values.vault_name,
exclude_folders: excludeFolders,
include_attachments: values.include_attachments,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? Number.parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
onBack();
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3">
{/* Form is intentionally empty so the footer Connect button is a no-op
that just closes the dialog (see component-level docstring). */}
<form id="obsidian-connect-form" onSubmit={handleSubmit} />
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0 text-purple-500" />
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">Plugin-based sync</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
This connector requires direct file system access and only works with self-hosted
SurfSense installations.
SurfSense now syncs Obsidian via an official plugin that runs inside Obsidian itself.
Works on desktop and mobile, in cloud and self-hosted deployments.
</AlertDescription>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="obsidian-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Obsidian Vault"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vault_path"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Vault Path</FormLabel>
<FormControl>
<Input
placeholder="/path/to/your/obsidian/vault"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
The absolute path to your Obsidian vault on the server. This must be accessible
from the SurfSense backend.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vault_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Vault Name</FormLabel>
<FormControl>
<Input
placeholder="My Knowledge Base"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A display name for your vault. This will be used in search results.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exclude_folders"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Exclude Folders</FormLabel>
<FormControl>
<Input
placeholder=".obsidian,.trash,templates"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list of folder names to exclude from indexing.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include_attachments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-slate-400/20 p-3">
<div className="space-y-0.5">
<FormLabel className="text-xs sm:text-sm">Include Attachments</FormLabel>
<FormDescription className="text-[10px] sm:text-xs">
Index attachment folders and embedded files (images, PDFs, etc.)
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-100">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<section className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<div className="space-y-5 sm:space-y-6">
{/* Step 1 — Install plugin */}
<article>
<header className="mb-3 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
1
</div>
</div>
</form>
</Form>
</div>
<h3 className="text-sm font-medium sm:text-base">Install the plugin</h3>
</header>
<p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
Grab the latest SurfSense plugin release. Once it's in the community store, you'll
also be able to install it from{" "}
<span className="font-medium">Settings Community plugins</span> inside Obsidian.
</p>
<a
href={PLUGIN_RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<Button
type="button"
variant="secondary"
size="sm"
className="gap-2 text-xs sm:text-sm"
>
Open plugin releases
</Button>
</a>
</article>
<div className="h-px bg-border/60" />
{/* Step 2 — Copy API key */}
<article>
<header className="mb-3 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
2
</div>
<h3 className="text-sm font-medium sm:text-base">Copy your API key</h3>
</header>
<p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
Paste this into the plugin's <span className="font-medium">API token</span> setting.
The token expires after 24 hours. Long-lived personal access tokens are coming in a
future release.
</p>
{isLoading ? (
<div className="h-10 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
) : apiKey ? (
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
{apiKey}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={copyToClipboard}
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={copied ? "Copied" : "Copy API key"}
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</Button>
</div>
) : (
<p className="text-center text-xs text-muted-foreground/60">
No API key available try refreshing the page.
</p>
)}
</article>
<div className="h-px bg-border/60" />
{/* Step 3 — Server URL */}
<article>
<header className="mb-3 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
3
</div>
<h3 className="text-sm font-medium sm:text-base">Point the plugin at this server</h3>
</header>
<p className="text-[11px] text-muted-foreground sm:text-xs">
For SurfSense Cloud, use the default{" "}
<span className="font-medium">surfsense.com</span>. If you are self-hosting, set the
plugin's <span className="font-medium">Server URL</span> to your frontend domain.
</p>
</article>
<div className="h-px bg-border/60" />
{/* Step 4 — Pick search space */}
<article>
<header className="mb-3 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
4
</div>
<h3 className="text-sm font-medium sm:text-base">Pick this search space</h3>
</header>
<p className="text-[11px] text-muted-foreground sm:text-xs">
In the plugin's <span className="font-medium">Search space</span> setting, choose the
search space you want this vault to sync into. The connector will appear here
automatically once the plugin makes its first sync.
</p>
</article>
</div>
</section>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">
<div className="space-y-2 rounded-xl border border-border bg-slate-400/5 px-3 py-4 sm:px-6 dark:bg-white/5">
<h4 className="text-xs font-medium sm:text-sm">
What you get with Obsidian integration:
</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<ul className="list-disc space-y-1 pl-5 text-[10px] text-muted-foreground sm:text-xs">
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}

View file

@ -104,11 +104,11 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
"No manual indexing required - meetings are added automatically",
],
OBSIDIAN_CONNECTOR: [
"Search through all your Obsidian notes and knowledge base",
"Access note content with YAML frontmatter metadata preserved",
"Wiki-style links ([[note]]) and #tags are indexed",
"Connect your personal knowledge base directly to your search space",
"Incremental sync - only changed files are re-indexed",
"Search through all of your Obsidian notes",
"Realtime sync as you create, edit, rename, or delete notes",
"YAML frontmatter, [[wiki links]], and #tags are preserved and indexed",
"Open any chat citation straight back in Obsidian via deep links",
"Each device is identifiable, so you can revoke a vault from one machine",
"Full support for your vault's folder structure",
],
};

View file

@ -1,167 +1,162 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { AlertTriangle, Info } from "lucide-react";
import { type FC, useEffect, useMemo, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { connectorsApiService, type ObsidianStats } from "@/lib/apis/connectors-api.service";
import type { ConnectorConfigProps } from "../index";
export interface ObsidianConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
const OBSIDIAN_SETUP_DOCS_URL = "/docs/connectors/obsidian";
function formatTimestamp(value: unknown): string {
if (typeof value !== "string" || !value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();
}
export const ObsidianConfig: FC<ObsidianConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [vaultPath, setVaultPath] = useState<string>(
(connector.config?.vault_path as string) || ""
);
const [vaultName, setVaultName] = useState<string>(
(connector.config?.vault_name as string) || ""
);
const [excludeFolders, setExcludeFolders] = useState<string>(() => {
const folders = connector.config?.exclude_folders;
if (Array.isArray(folders)) {
return folders.join(", ");
}
return (folders as string) || ".obsidian, .trash";
});
const [includeAttachments, setIncludeAttachments] = useState<boolean>(
(connector.config?.include_attachments as boolean) || false
);
const [name, setName] = useState<string>(connector.name || "");
/**
* Obsidian connector config view.
*
* Read-only on purpose: the plugin owns vault identity, so the connector's
* display name is auto-derived from `payload.vault_name` server-side on
* every `/connect` (see `obsidian_plugin_routes.obsidian_connect`). The
* web UI doesn't expose a Name input or a Save button for Obsidian (the
* latter is suppressed in `connector-edit-view.tsx`).
*
* Renders one of three modes depending on the connector's `config`:
*
* 1. **Plugin connector** (`config.source === "plugin"`) read-only stats
* panel showing what the plugin most recently reported.
* 2. **Legacy server-path connector** (`config.legacy === true`, set by the
* migration) migration warning + docs link + explicit disconnect data-loss
* warning so users move to the plugin flow safely.
* 3. **Unknown** fallback for rows that escaped migration; suggests a
* clean re-install.
*/
export const ObsidianConfig: FC<ConnectorConfigProps> = ({ connector }) => {
const config = (connector.config ?? {}) as Record<string, unknown>;
const isLegacy = config.legacy === true;
const isPlugin = config.source === "plugin";
const handleVaultPathChange = (value: string) => {
setVaultPath(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
vault_path: value,
});
}
};
const handleVaultNameChange = (value: string) => {
setVaultName(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
vault_name: value,
});
}
};
const handleExcludeFoldersChange = (value: string) => {
setExcludeFolders(value);
const foldersArray = value
.split(",")
.map((f) => f.trim())
.filter(Boolean);
if (onConfigChange) {
onConfigChange({
...connector.config,
exclude_folders: foldersArray,
});
}
};
const handleIncludeAttachmentsChange = (value: boolean) => {
setIncludeAttachments(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
include_attachments: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
if (isLegacy) return <LegacyBanner />;
if (isPlugin) return <PluginStats config={config} />;
return <UnknownConnectorState />;
};
const LegacyBanner: FC = () => {
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Obsidian Vault"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
<Alert className="border-amber-500/40 bg-amber-500/10">
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
<AlertTitle className="text-xs sm:text-sm">
Sync stopped, install the plugin to migrate
</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
This Obsidian connector used the legacy server-path scanner, which has been removed. The
notes already indexed remain searchable, but they no longer reflect changes made in your
vault.
</AlertDescription>
</Alert>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
Vault Configuration
</h3>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Vault Path</Label>
<Input
value={vaultPath}
onChange={(e) => handleVaultPathChange(e.target.value)}
placeholder="/path/to/your/obsidian/vault"
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The absolute path to your Obsidian vault on the server.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Vault Name</Label>
<Input
value={vaultName}
onChange={(e) => handleVaultNameChange(e.target.value)}
placeholder="My Knowledge Base"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A display name for your vault in search results.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Exclude Folders</Label>
<Input
value={excludeFolders}
onChange={(e) => handleExcludeFoldersChange(e.target.value)}
placeholder=".obsidian, .trash, templates"
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Comma-separated list of folder names to exclude from indexing.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3">
<div className="space-y-0.5">
<Label className="text-xs sm:text-sm">Include Attachments</Label>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Index attachment folders and embedded files
</p>
</div>
<Switch checked={includeAttachments} onCheckedChange={handleIncludeAttachmentsChange} />
</div>
</div>
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<h3 className="mb-3 text-sm font-medium sm:text-base">Migration required</h3>
<p className="mb-3 text-[11px] leading-relaxed text-muted-foreground sm:text-xs">
Follow the{" "}
<a
href={OBSIDIAN_SETUP_DOCS_URL}
className="font-medium text-primary underline underline-offset-4 hover:text-primary/80"
>
Obsidian setup guide
</a>{" "}
to reconnect this vault through the plugin.
</p>
<p className="text-[11px] leading-relaxed text-amber-600 dark:text-amber-400 sm:text-xs">
Heads up: Disconnect also deletes every document this connector previously indexed.
</p>
</div>
</div>
);
};
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
const vaultId = typeof config.vault_id === "string" ? config.vault_id : null;
const [stats, setStats] = useState<ObsidianStats | null>(null);
const [statsError, setStatsError] = useState(false);
useEffect(() => {
if (!vaultId) return;
let cancelled = false;
setStats(null);
setStatsError(false);
connectorsApiService
.getObsidianStats(vaultId)
.then((result) => {
if (!cancelled) setStats(result);
})
.catch((err) => {
if (!cancelled) {
console.error("Failed to fetch Obsidian stats", err);
setStatsError(true);
}
});
return () => {
cancelled = true;
};
}, [vaultId]);
const tileRows = useMemo(() => {
const placeholder = statsError ? "—" : stats ? null : "…";
return [
{ label: "Vault name", value: (config.vault_name as string) || "—" },
{
label: "Last sync",
value: placeholder ?? formatTimestamp(stats?.last_sync_at ?? null),
},
{
label: "Files synced",
value:
placeholder ??
(typeof stats?.files_synced === "number" ? stats.files_synced.toLocaleString() : "—"),
},
];
}, [config.vault_name, stats, statsError]);
return (
<div className="space-y-4">
<Alert className="border-emerald-500/30 bg-emerald-500/10">
<Info className="size-4 shrink-0 text-emerald-500" />
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
Your notes stay synced automatically. To stop syncing, disable or uninstall the plugin in
Obsidian, or delete this connector.
</AlertDescription>
</Alert>
<div className="rounded-xl bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<h3 className="mb-3 text-sm font-medium sm:text-base">Vault Status</h3>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{tileRows.map((stat) => (
<div key={stat.label} className="rounded-lg bg-background/50 p-3">
<dt className="text-xs tracking-wide text-muted-foreground sm:text-sm">
{stat.label}
</dt>
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">{stat.value}</dd>
</div>
))}
</dl>
</div>
</div>
);
};
const UnknownConnectorState: FC = () => (
<Alert>
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
This connector has neither plugin metadata nor a legacy marker. It may predate migration you
can safely delete it and re-install the SurfSense Obsidian plugin to resume syncing.
</AlertDescription>
</Alert>
);

View file

@ -111,7 +111,9 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
: getConnectorTypeDisplay(connectorType)}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Enter your connection details
{connectorType === "OBSIDIAN_CONNECTOR"
? "Follow the plugin setup steps below"
: "Enter your connection details"}
</p>
</div>
</div>
@ -149,7 +151,9 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
<span className={isSubmitting ? "opacity-0" : ""}>
{connectorType === "MCP_CONNECTOR"
? "Connect"
: `Connect ${getConnectorTypeDisplay(connectorType)}`}
: connectorType === "OBSIDIAN_CONNECTOR"
? "Done"
: `Connect ${getConnectorTypeDisplay(connectorType)}`}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>

View file

@ -1,12 +1,13 @@
"use client";
import { useAtomValue } from "jotai";
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
import { ArrowLeft, Info, RefreshCw } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
@ -18,7 +19,15 @@ import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { MCPServiceConfig } from "../components/mcp-service-config";
import { type ConnectorConfigProps, getConnectorConfigComponent } from "../index";
import { getConnectorConfigComponent } from "../index";
const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.DROPBOX_CONNECTOR,
EnumConnectorName.ONEDRIVE_CONNECTOR,
EnumConnectorName.OBSIDIAN_CONNECTOR,
]);
interface ConnectorEditViewProps {
connector: SearchSourceConnector;
@ -75,6 +84,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
const isAuthExpired = connector.config?.auth_expired === true;
const reauthEndpoint = getReauthEndpoint(connector);
const [reauthing, setReauthing] = useState(false);
const supportsVisionLlm = VISION_LLM_CONNECTOR_TYPES.has(connector.connector_type);
const showsAiToggles =
connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
const handleReauth = useCallback(async () => {
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
@ -264,25 +276,23 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
/>
)}
{/* Summary and sync settings - hidden for live connectors */}
{connector.is_indexable && !isLive && (
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
{showsAiToggles && !isLive && (
<>
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
{/* Vision LLM toggle - only for file-based connectors */}
{(connector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
connector.connector_type === "DROPBOX_CONNECTOR" ||
connector.connector_type === "ONEDRIVE_CONNECTOR") && (
{/* Vision LLM toggle for file/attachment connectors */}
{supportsVisionLlm && (
<VisionLLMConfig
enabled={enableVisionLlm}
onEnabledChange={onEnableVisionLlmChange}
/>
)}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
{/* Date-range and periodic sync stay indexable-only */}
{connector.is_indexable &&
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "DROPBOX_CONNECTOR" &&
connector.connector_type !== "ONEDRIVE_CONNECTOR" &&
@ -302,37 +312,40 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
/>
)}
{(() => {
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
const selectedFolders =
(connector.config?.selected_folders as
| Array<{ id: string; name: string }>
| undefined) || [];
const selectedFiles =
(connector.config?.selected_files as
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = requiresFolderSelection && !hasItemsSelected;
{connector.is_indexable &&
(() => {
const isGoogleDrive =
connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
const selectedFolders =
(connector.config?.selected_folders as
| Array<{ id: string; name: string }>
| undefined) || [];
const selectedFiles =
(connector.config?.selected_files as
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected =
selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = requiresFolderSelection && !hasItemsSelected;
return (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
disabled={isDisabled}
disabledMessage={
isDisabled
? "Select at least one folder or file above to enable periodic sync"
: undefined
}
/>
);
})()}
return (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
disabled={isDisabled}
disabledMessage={
isDisabled
? "Select at least one folder or file above to enable periodic sync"
: undefined
}
/>
);
})()}
</>
)}
@ -403,7 +416,6 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
disabled={isSaving || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
>
<Trash2 className="mr-2 h-4 w-4" />
Disconnect
</Button>
)}

View file

@ -4,6 +4,7 @@ import { ArrowLeft, Check, Info } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { cn } from "@/lib/utils";
@ -15,6 +16,14 @@ import { LIVE_CONNECTOR_TYPES, type IndexingConfigState } from "../../constants/
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
const VISION_LLM_CONNECTOR_TYPES = new Set<string>([
"GOOGLE_DRIVE_CONNECTOR",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
"DROPBOX_CONNECTOR",
"ONEDRIVE_CONNECTOR",
"OBSIDIAN_CONNECTOR",
]);
interface IndexingConfigurationViewProps {
config: IndexingConfigState;
connector?: SearchSourceConnector;
@ -65,6 +74,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null),
[connector]
);
const showsAiToggles =
(connector?.is_indexable ?? false) ||
connector?.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -161,25 +173,23 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<ConnectorConfigComponent connector={connector} onConfigChange={onConfigChange} />
)}
{/* Summary and sync settings - hidden for live connectors */}
{connector?.is_indexable && !isLive && (
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
{showsAiToggles && !isLive && (
<>
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
{/* Vision LLM toggle - only for file-based connectors */}
{(config.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
config.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
config.connectorType === "DROPBOX_CONNECTOR" ||
config.connectorType === "ONEDRIVE_CONNECTOR") && (
{/* Vision LLM toggle for file/attachment connectors */}
{VISION_LLM_CONNECTOR_TYPES.has(config.connectorType) && (
<VisionLLMConfig
enabled={enableVisionLlm}
onEnabledChange={onEnableVisionLlmChange}
/>
)}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
{/* Date-range and periodic sync stay indexable-only */}
{connector?.is_indexable &&
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "DROPBOX_CONNECTOR" &&
config.connectorType !== "ONEDRIVE_CONNECTOR" &&
@ -199,7 +209,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
/>
)}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
{connector?.is_indexable &&
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "DROPBOX_CONNECTOR" &&
config.connectorType !== "ONEDRIVE_CONNECTOR" && (

View file

@ -200,7 +200,7 @@ export const OTHER_CONNECTORS = [
{
id: "obsidian-connector",
title: "Obsidian",
description: "Index your Obsidian vault (Local folder scan on Desktop)",
description: "Sync your Obsidian vault on desktop or mobile",
connectorType: EnumConnectorName.OBSIDIAN_CONNECTOR,
},
] as const;

View file

@ -1,5 +1,5 @@
import { format } from "date-fns";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
@ -10,17 +10,11 @@ import {
updateConnectorMutationAtom,
} from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import {
folderWatchDialogOpenAtom,
folderWatchInitialFolderAtom,
} from "@/atoms/folder-sync/folder-sync.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { searchSourceConnector } from "@/contracts/types/connector.types";
import { usePlatform } from "@/hooks/use-platform";
import { authenticatedFetch } from "@/lib/auth-utils";
import { isSelfHosted } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
@ -71,10 +65,6 @@ export const useConnectorDialog = () => {
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
const setFolderWatchOpen = useSetAtom(folderWatchDialogOpenAtom);
const setFolderWatchInitialFolder = useSetAtom(folderWatchInitialFolderAtom);
const { isDesktop } = usePlatform();
const selfHosted = isSelfHosted();
// Use global atom for dialog open state so it can be controlled from anywhere
const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom);
@ -439,6 +429,7 @@ export const useConnectorDialog = () => {
indexing_frequency_minutes: null,
next_scheduled_at: null,
enable_summary: false,
enable_vision_llm: false,
},
queryParams: {
search_space_id: searchSpaceId,
@ -487,31 +478,16 @@ export const useConnectorDialog = () => {
}
}, [searchSpaceId, createConnector, refetchAllConnectors, setIsOpen]);
// Handle connecting non-OAuth connectors (like Tavily API)
// Handle connecting non-OAuth connectors (like Tavily API, Obsidian plugin, etc.)
const handleConnectNonOAuth = useCallback(
(connectorType: string) => {
if (!searchSpaceId) return;
trackConnectorSetupStarted(Number(searchSpaceId), connectorType, "non_oauth_click");
// Handle Obsidian specifically on Desktop & Cloud
if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) {
setIsOpen(false);
setFolderWatchInitialFolder(null);
setFolderWatchOpen(true);
return;
}
setConnectingConnectorType(connectorType);
},
[
searchSpaceId,
selfHosted,
isDesktop,
setIsOpen,
setFolderWatchOpen,
setFolderWatchInitialFolder,
]
[searchSpaceId]
);
// Handle submitting connect form
@ -555,6 +531,7 @@ export const useConnectorDialog = () => {
is_active: true,
next_scheduled_at: connectorData.next_scheduled_at as string | null,
enable_summary: false,
enable_vision_llm: false,
},
queryParams: {
search_space_id: searchSpaceId,

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { MessageSquare } from "lucide-react";
import { MessageCircleReply } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@ -216,7 +216,7 @@ export function CommentItem({
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => onReply(comment.id)}
>
<MessageSquare className="mr-1 size-3" />
<MessageCircleReply className="mr-1 size-3" />
Reply
</Button>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { MessageSquare } from "lucide-react";
import { MessageCircleReply } from "lucide-react";
import {
Drawer,
DrawerContent,
@ -30,7 +30,7 @@ export function CommentSheet({
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<MessageSquare className="size-5" />
<MessageCircleReply className="size-5" />
Comments
{commentCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
@ -56,7 +56,7 @@ export function CommentSheet({
>
<SheetHeader className="flex-shrink-0 px-4 py-4">
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
<MessageSquare className="size-5" />
<MessageCircleReply className="size-5" />
Comments
{commentCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronDown, ChevronRight, MessageSquare } from "lucide-react";
import { ChevronDown, ChevronRight, MessageCircleReply } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CommentComposer } from "../comment-composer/comment-composer";
@ -143,7 +143,7 @@ export function CommentThread({
</div>
) : (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1 size-3" />
<MessageCircleReply className="mr-1 size-3" />
Reply
</Button>
)}
@ -155,7 +155,7 @@ export function CommentThread({
{!hasReplies && !isReplyComposerOpen && (
<div className="ml-7 mt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1 size-3" />
<MessageCircleReply className="mr-1 size-3" />
Reply
</Button>
</div>

View file

@ -84,7 +84,7 @@ export function DocumentsFilters({
<TooltipTrigger asChild>
<ToggleGroupItem
value="folder"
className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar"
className="h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
onClick={(e) => {
e.preventDefault();
onCreateFolder();
@ -104,11 +104,11 @@ export function DocumentsFilters({
value="ai-sort"
disabled={aiSortBusy}
className={cn(
"h-9 w-9 shrink-0 border-sidebar-border bg-sidebar",
"h-9 w-9 shrink-0 border bg-muted/50 transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
aiSortEnabled
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:border-sidebar-border"
? "bg-accent text-accent-foreground hover:bg-accent"
: "text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
onClick={(e) => {
e.preventDefault();
@ -142,11 +142,11 @@ export function DocumentsFilters({
<PopoverTrigger asChild>
<ToggleGroupItem
value="filter"
className="relative h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar overflow-visible"
className="relative h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground overflow-visible"
>
<ListFilter size={14} />
{activeTypes.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar-border text-[9px] font-medium text-sidebar-foreground">
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-neutral-300 text-[9px] font-medium text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200">
{activeTypes.length}
</span>
)}
@ -226,13 +226,13 @@ export function DocumentsFilters({
{/* Search Input */}
<div className="relative flex-1 min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={14} aria-hidden="true" />
</div>
<Input
id={`${id}-input`}
ref={inputRef}
className="peer h-9 w-full pl-9 pr-9 text-sm bg-sidebar border-border/60 select-none focus:select-text"
className="h-9 w-full pl-9 pr-8 text-sm select-none focus:select-text"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search docs"
@ -242,7 +242,7 @@ export function DocumentsFilters({
{Boolean(searchValue) && (
<button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
aria-label="Clear filter"
onClick={() => {
onSearch("");
@ -260,7 +260,7 @@ export function DocumentsFilters({
onClick={handleUpload}
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
className="h-9 shrink-0 gap-1.5 border-0 shadow-none bg-white text-gray-700 hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<Upload size={14} />
<span>Upload</span>

View file

@ -379,7 +379,7 @@ export function EditorPanelContent({
</div>
</div>
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
<div className="min-w-0 flex-1">
<div className="min-w-0 flex flex-1 items-center gap-2">
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
@ -410,6 +410,12 @@ export function EditorPanelContent({
</>
) : (
<>
{!isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton
documentId={documentId}
documentType={editorDoc.document_type}
/>
)}
<Button
variant="ghost"
size="icon"
@ -441,15 +447,12 @@ export function EditorPanelContent({
)}
</>
)}
{!showEditingActions && !isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
</div>
</div>
</div>
) : (
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
<div className="flex-1 min-w-0">
<div className="flex flex-1 min-w-0 items-center gap-2">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
@ -480,6 +483,12 @@ export function EditorPanelContent({
</>
) : (
<>
{!isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton
documentId={documentId}
documentType={editorDoc.document_type}
/>
)}
<Button
variant="ghost"
size="icon"
@ -509,12 +518,6 @@ export function EditorPanelContent({
<span className="sr-only">Edit document</span>
</Button>
)}
{!isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton
documentId={documentId}
documentType={editorDoc.document_type}
/>
)}
</>
)}
</div>
@ -559,7 +562,7 @@ export function EditorPanelContent({
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
className="relative shrink-0"
disabled={downloading}
onClick={async () => {
setDownloading(true);
@ -591,12 +594,13 @@ export function EditorPanelContent({
}
}}
>
{downloading ? (
<Spinner size="xs" />
) : (
<span
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
>
<Download className="size-3.5" />
)}
{downloading ? "Preparing..." : "Download .md"}
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
</AlertDescription>
</Alert>

View file

@ -1829,10 +1829,13 @@ function AnonymousDocumentsSidebar({
type="button"
onClick={handleAnonUploadClick}
disabled={isUploading}
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
className="relative flex w-full items-center justify-center rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
>
<Upload className="size-4" />
{isUploading ? "Uploading..." : "Upload a document"}
<span className={`flex items-center gap-2 ${isUploading ? "opacity-0" : ""}`}>
<Upload className="size-4" />
Upload a document
</span>
{isUploading && <Spinner size="sm" className="absolute" />}
</button>
<p className="mt-2 text-[11px] text-muted-foreground leading-relaxed">
Text, code, CSV, and HTML files only. Create an account for PDFs, images, and 30+

View file

@ -14,7 +14,7 @@ import {
Inbox,
LayoutGrid,
ListFilter,
MessageSquare,
MessageCircleReply,
Search,
X,
} from "lucide-react";
@ -847,7 +847,7 @@ export function InboxSidebarContent({
<TabsList stretch showBottomBorder size="sm">
<TabsTrigger value="comments">
<span className="inline-flex items-center gap-1.5">
<MessageSquare className="h-4 w-4" />
<MessageCircleReply className="h-4 w-4" />
<span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(comments.unreadCount)}
@ -1032,7 +1032,7 @@ export function InboxSidebarContent({
) : (
<div className="text-center py-8">
{activeTab === "comments" ? (
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<MessageCircleReply className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)}

View file

@ -1,6 +1,6 @@
"use client";
import { Download, FileQuestionMark, FileText, Loader2, Pencil, RefreshCw } from "lucide-react";
import { Download, FileQuestionMark, FileText, Pencil, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@ -8,6 +8,7 @@ import { PlateEditor } from "@/components/editor/plate-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
@ -278,7 +279,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
className="relative shrink-0"
disabled={downloading}
onClick={async () => {
setDownloading(true);
@ -307,12 +308,13 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
}
}}
>
{downloading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<span
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
>
<Download className="size-3.5" />
)}
{downloading ? "Preparing..." : "Download .md"}
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
</AlertDescription>
</Alert>

View file

@ -8,7 +8,7 @@ import {
ChevronLeft,
ChevronRight,
ChevronUp,
Edit3,
Pencil,
ImageIcon,
Layers,
Plus,
@ -320,6 +320,30 @@ export function ModelSelector({
[isMobile]
);
const scrollProviderSidebar = useCallback(
(direction: "backward" | "forward") => {
const el = providerSidebarRef.current;
if (!el) return;
const delta = isMobile
? Math.max(56, Math.floor(el.clientWidth * 0.5))
: Math.max(44, Math.floor(el.clientHeight * 0.4));
if (isMobile) {
el.scrollBy({
left: direction === "backward" ? -delta : delta,
behavior: "smooth",
});
return;
}
el.scrollBy({
top: direction === "backward" ? -delta : delta,
behavior: "smooth",
});
},
[isMobile]
);
// Cmd/Ctrl+M shortcut (desktop only)
useEffect(() => {
if (isMobile) return;
@ -716,17 +740,40 @@ export function ModelSelector({
return (
<div
className={cn(
"shrink-0 border-border/50 flex",
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r"
"shrink-0 border-border/50 flex relative",
isMobile
? "flex-row items-center border-b border-border/40"
: "flex-col w-10 border-r"
)}
>
{!isMobile && sidebarScrollPos !== "top" && (
<div className="flex items-center justify-center py-0.5 pointer-events-none">
<ChevronUp className="size-3 text-muted-foreground" />
{!isMobile && (
<div
className={cn(
"absolute top-0 left-0 right-0 z-10 h-5 flex items-center justify-center transition-all duration-200 ease-out",
sidebarScrollPos === "top"
? "opacity-0 -translate-y-1 pointer-events-none"
: "opacity-100 translate-y-0 pointer-events-auto"
)}
>
<button
type="button"
aria-label="Scroll providers up"
onClick={() => scrollProviderSidebar("backward")}
className="flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground/90 hover:text-foreground hover:bg-accent/60 transition-colors"
>
<ChevronUp className="size-3" />
</button>
</div>
)}
{isMobile && sidebarScrollPos !== "top" && (
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
{isMobile && (
<div
className={cn(
"absolute left-0 top-0 bottom-0 z-10 w-5 flex items-center justify-center transition-all duration-200 ease-out pointer-events-none",
sidebarScrollPos === "top"
? "opacity-0 -translate-x-1"
: "opacity-100 translate-x-0"
)}
>
<ChevronLeft className="size-3 text-muted-foreground" />
</div>
)}
@ -802,13 +849,34 @@ export function ModelSelector({
);
})}
</div>
{!isMobile && sidebarScrollPos !== "bottom" && (
<div className="flex items-center justify-center py-0.5 pointer-events-none">
<ChevronDown className="size-3 text-muted-foreground" />
{!isMobile && (
<div
className={cn(
"absolute bottom-0 left-0 right-0 z-10 h-5 flex items-center justify-center transition-all duration-200 ease-out",
sidebarScrollPos === "bottom"
? "opacity-0 translate-y-1 pointer-events-none"
: "opacity-100 translate-y-0 pointer-events-auto"
)}
>
<button
type="button"
aria-label="Scroll providers down"
onClick={() => scrollProviderSidebar("forward")}
className="flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground/90 hover:text-foreground hover:bg-accent/60 transition-colors"
>
<ChevronDown className="size-3" />
</button>
</div>
)}
{isMobile && sidebarScrollPos !== "bottom" && (
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
{isMobile && (
<div
className={cn(
"absolute right-0 top-0 bottom-0 z-10 w-5 flex items-center justify-center transition-all duration-200 ease-out pointer-events-none",
sidebarScrollPos === "bottom"
? "opacity-0 translate-x-1"
: "opacity-100 translate-x-0"
)}
>
<ChevronRight className="size-3 text-muted-foreground" />
</div>
)}
@ -923,7 +991,7 @@ export function ModelSelector({
className="size-7 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleEditItem(e, item)}
>
<Edit3 className="size-3.5 text-muted-foreground" />
<Pencil className="size-3.5 text-muted-foreground" />
</Button>
)}
{isSelected && <Check className="size-4 text-primary shrink-0" />}

View file

@ -79,8 +79,11 @@ export function PublicChatSnapshotRow({
variant="ghost"
size="icon"
className={cn(
"absolute right-0 h-6 w-6 shrink-0 hover:bg-transparent",
dropdownOpen ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100"
"absolute right-0 h-6 w-6 shrink-0",
"hover:bg-accent",
dropdownOpen
? "opacity-100 bg-accent hover:bg-accent"
: "sm:opacity-0 sm:group-hover:opacity-100"
)}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />

View file

@ -3,7 +3,7 @@
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import * as pdfjsLib from "pdfjs-dist";
import { useCallback, useEffect, useRef, useState } from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getAuthHeaders } from "@/lib/auth-utils";
@ -16,6 +16,8 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
interface PdfViewerProps {
pdfUrl: string;
isPublic?: boolean;
/** Extra actions rendered on the right side of the zoom toolbar (e.g. download, version switcher) */
toolbarActions?: ReactNode;
}
interface PageDimensions {
@ -30,7 +32,7 @@ const PAGE_GAP = 12;
const SCROLL_DEBOUNCE_MS = 30;
const BUFFER_PAGES = 1;
export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
export function PdfViewer({ pdfUrl, isPublic = false, toolbarActions }: PdfViewerProps) {
const [numPages, setNumPages] = useState(0);
const [scale, setScale] = useState(1);
const [loading, setLoading] = useState(true);
@ -286,29 +288,33 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
<div className="flex flex-col h-full">
{numPages > 0 && (
<div
className={`flex items-center justify-center gap-2 px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
className={`flex items-center px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
>
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
disabled={scale <= MIN_ZOOM}
className="size-7"
>
<ZoomOutIcon className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
{Math.round(scale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
disabled={scale >= MAX_ZOOM}
className="size-7"
>
<ZoomInIcon className="size-4" />
</Button>
<div className="flex-1" aria-hidden="true" />
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
disabled={scale <= MIN_ZOOM}
className="size-7"
>
<ZoomOutIcon className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
{Math.round(scale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
disabled={scale >= MAX_ZOOM}
className="size-7"
>
<ZoomInIcon className="size-4" />
</Button>
</div>
<div className="flex flex-1 items-center justify-end gap-1">{toolbarActions}</div>
</div>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Check, ChevronDownIcon, Copy, Pencil, XIcon } from "lucide-react";
import { Check, ChevronDownIcon, Copy, Download, Pencil, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@ -309,6 +309,7 @@ export function ReportPanelContent({
const isResume = reportContent?.content_type === "typst";
const showReportEditingTier = !isResume;
const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose;
const handleCancelEditing = useCallback(() => {
setEditedMarkdown(null);
@ -316,153 +317,177 @@ export function ReportPanelContent({
setIsEditing(false);
}, []);
const exportButton = !isEditing && (
<>
{isResume ? (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
>
{exporting === "pdf" ? <Spinner size="xs" /> : <Download className="size-3.5" />}
<span className="sr-only">Download report</span>
</Button>
) : (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={isLoading || !reportContent?.content}
>
<Download className="size-3.5" />
<span className="sr-only">Export report</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
);
const versionSwitcher = !isEditing && versions.length > 1 && (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-1.5 text-xs">
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const copyButton = !isEditing && showReportEditingTier && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
void handleCopy();
}}
disabled={isLoading || !reportContent?.content}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">{copied ? "Copied report content" : "Copy report content"}</span>
</Button>
);
const editingActions = showReportEditingTier &&
!isReadOnly &&
(isEditing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleCancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
className="relative h-6 w-[56px] px-0 text-xs"
onClick={async () => {
const saveSucceeded = await handleSave();
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
<span className={saving ? "opacity-0" : ""}>Save</span>
{saving && <Spinner size="xs" className="absolute" />}
</Button>
</>
) : (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit report</span>
</Button>
));
return (
<>
{/* Action bar — always visible; buttons are disabled while loading */}
<div className="flex h-14 items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
{/* Export — plain button for resume (typst), dropdown for others */}
{reportContent?.content_type === "typst" ? (
<Button
variant="outline"
size="sm"
onClick={() => handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
{exporting === "pdf" ? <Spinner size="xs" /> : "Download"}
</Button>
) : (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
Export
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 && (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
{showReportEditingTier && (
<div className="flex h-10 items-center justify-between gap-2 border-t border-b px-4 shrink-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">
{reportContent?.title || title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{!isEditing && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
void handleCopy();
}}
disabled={isLoading || !reportContent?.content}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">
{copied ? "Copied report content" : "Copy report content"}
</span>
{showDesktopHeader ? (
<>
{/* Header — matches the editor panel "File" header pattern */}
<div className="flex h-14 items-center justify-between px-4 shrink-0">
<h2 className="text-lg font-medium text-muted-foreground select-none">
{isResume ? "Resume" : "Report"}
</h2>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
{!isReadOnly &&
(isEditing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleCancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
className="relative h-6 w-[56px] px-0 text-xs"
onClick={async () => {
const saveSucceeded = await handleSave();
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
<span className={saving ? "opacity-0" : ""}>Save</span>
{saving && <Spinner size="xs" className="absolute" />}
</Button>
</>
) : (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit report</span>
</Button>
))}
</div>
</div>
{!isResume && (
<div className="flex h-10 items-center justify-between gap-2 border-t border-b px-4 shrink-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">
{reportContent?.title || title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{versionSwitcher}
{exportButton}
{copyButton}
{editingActions}
</div>
</div>
)}
</>
) : (
!isResume && (
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{reportContent?.title || title}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{versionSwitcher}
{exportButton}
{copyButton}
{editingActions}
</div>
</div>
)
)}
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
@ -480,6 +505,12 @@ export function ReportPanelContent({
<PdfViewer
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
isPublic={isPublic}
toolbarActions={
<>
{versionSwitcher}
{exportButton}
</>
}
/>
) : reportContent.content ? (
isReadOnly ? (

View file

@ -4,10 +4,9 @@ import { useAtomValue } from "jotai";
import {
AlertCircle,
Dot,
Edit3,
FileText,
Info,
MessageSquareQuote,
Pencil,
RefreshCw,
Trash2,
} from "lucide-react";
@ -288,7 +287,7 @@ export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
onClick={() => openEditDialog(config)}
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
@ -323,7 +322,6 @@ export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
<MessageSquareQuote className="h-2.5 w-2.5 mr-1" />
Citations
</Badge>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import {
@ -116,8 +116,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
return (
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
{/* Header actions */}
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
@ -284,7 +284,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
onClick={() => openEditDialog(config)}
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>

View file

@ -4,21 +4,25 @@ import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
Bot,
ChevronDown,
Edit2,
ChevronRight,
ScanEye,
Pencil,
FileText,
Globe,
Earth,
Image,
Logs,
type LucideIcon,
MessageCircle,
MessageCircleReply,
MessageSquare,
Mic,
MoreHorizontal,
Plug,
Unplug,
Settings,
Shield,
SlidersHorizontal,
Trash2,
Users,
Video,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@ -88,7 +92,7 @@ const CATEGORY_CONFIG: Record<
},
comments: {
label: "Comments",
icon: MessageCircle,
icon: MessageCircleReply,
description: "Add annotations to documents",
order: 3,
},
@ -98,6 +102,24 @@ const CATEGORY_CONFIG: Record<
description: "Configure AI model settings",
order: 4,
},
image_generations: {
label: "Image Models",
icon: Image,
description: "Configure image generation model settings",
order: 4.1,
},
vision_configs: {
label: "Vision Models",
icon: ScanEye,
description: "Configure vision model settings",
order: 4.2,
},
video_presentations: {
label: "Video Presentations",
icon: Video,
description: "Generate and manage video presentations",
order: 4.3,
},
podcasts: {
label: "Podcasts",
icon: Mic,
@ -105,8 +127,8 @@ const CATEGORY_CONFIG: Record<
order: 5,
},
connectors: {
label: "Integrations",
icon: Plug,
label: "Connectors",
icon: Unplug,
description: "Connect external data sources",
order: 6,
},
@ -136,10 +158,16 @@ const CATEGORY_CONFIG: Record<
},
public_sharing: {
label: "Public Chat Sharing",
icon: Globe,
icon: Earth,
description: "Share chats publicly via links",
order: 11,
},
general: {
label: "General",
icon: SlidersHorizontal,
description: "General search space permissions",
order: 12,
},
};
const ACTION_LABELS: Record<string, string> = {
@ -434,12 +462,11 @@ function RolesContent({
return (
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30">
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<div
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{role.name}</span>
{role.is_system_role && (
@ -458,14 +485,14 @@ function RolesContent({
{role.description}
</p>
)}
</button>
</div>
<div className="shrink-0">
<PermissionsBadge permissions={role.permissions} />
</div>
{!role.is_system_role && (
<div className="shrink-0" role="none">
<div className="shrink-0" role="none" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
@ -475,7 +502,7 @@ function RolesContent({
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
{canUpdate && (
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
<Edit2 className="h-4 w-4 mr-2" />
<Pencil className="h-4 w-4 mr-2" />
Edit Role
</DropdownMenuItem>
)}
@ -515,18 +542,14 @@ function RolesContent({
</div>
)}
<button
type="button"
className="shrink-0 p-1 cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<ChevronDown
<div className="shrink-0 p-1">
<ChevronRight
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
isExpanded && "rotate-180"
isExpanded && "rotate-90"
)}
/>
</button>
</div>
</div>
{isExpanded && (
@ -659,52 +682,30 @@ function PermissionsEditor({
return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors">
<button
type="button"
className="flex-1 flex items-center gap-2.5 cursor-pointer"
onClick={() => toggleCategoryExpanded(category)}
>
<div
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer"
onClick={() => toggleCategoryExpanded(category)}
>
<div className="flex-1 flex items-center gap-2.5">
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm">{config.label}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{stats.selected}/{stats.total}
</span>
</button>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={stats.allSelected}
onClick={(e) => e.stopPropagation()}
onCheckedChange={() => onToggleCategory(category)}
aria-label={`Select all ${config.label} permissions`}
/>
<button
type="button"
className="cursor-pointer"
onClick={() => toggleCategoryExpanded(category)}
>
<div
className={cn(
"transition-transform duration-200",
isExpanded && "rotate-180"
)}
>
<svg
className="h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<title>Toggle</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</button>
<ChevronRight
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
isExpanded && "rotate-90"
)}
/>
</div>
</div>
@ -726,7 +727,7 @@ function PermissionsEditor({
>
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
className="flex-1 min-w-0 text-left cursor-pointer focus:outline-none focus-visible:outline-none"
onClick={() => onTogglePermission(perm.value)}
>
<span className="text-sm font-medium">{actionLabel}</span>
@ -855,7 +856,8 @@ function CreateRoleDialog({
type="button"
onClick={() => applyPreset(key as keyof typeof ROLE_PRESETS)}
className={cn(
"p-3 rounded-lg border text-left transition-colors hover:bg-muted/40",
"p-3 rounded-lg border transition-colors hover:bg-muted/40",
"flex items-center justify-center text-center sm:block sm:text-left",
selectedPermissions.length > 0 &&
preset.permissions.every((p) => selectedPermissions.includes(p))
? "border-foreground/30 bg-muted/40"
@ -863,7 +865,7 @@ function CreateRoleDialog({
)}
>
<span className="font-medium text-sm">{preset.name}</span>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
<p className="hidden sm:block text-xs text-muted-foreground mt-0.5 line-clamp-2">
{preset.description}
</p>
</button>

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
@ -121,7 +121,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
return (
<div className="space-y-4 md:space-y-6">
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
@ -282,7 +282,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
onClick={() => openEditDialog(config)}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>

View file

@ -764,22 +764,16 @@ export function DocumentUploadTab({
</div>
<Button
className="w-full"
className="w-full relative"
onClick={handleUpload}
disabled={isAnyUploading || fileCount === 0}
>
{isAnyUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
{folderUpload
? t("upload_folder_button", { count: fileCount })
: t("upload_button", { count: fileCount })}
</span>
)}
<span className={isAnyUploading ? "opacity-0" : ""}>
{folderUpload
? t("upload_folder_button", { count: fileCount })
: t("upload_button", { count: fileCount })}
</span>
{isAnyUploading && <Spinner size="sm" className="absolute" />}
</Button>
</div>
)}

View file

@ -137,10 +137,9 @@ function ReportCard({
const autoOpenedRef = useRef(false);
const [metadata, setMetadata] = useState<{
title: string;
wordCount: number | null;
versionLabel: string | null;
content: string | null;
}>({ title, wordCount: wordCount ?? null, versionLabel: null, content: null });
}>({ title, versionLabel: null, content: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -169,10 +168,8 @@ function ReportCard({
}
}
const resolvedTitle = parsed.data.title || title;
const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
setMetadata({
title: resolvedTitle,
wordCount: resolvedWordCount,
versionLabel,
content: parsed.data.content ?? null,
});
@ -182,7 +179,7 @@ function ReportCard({
openPanel({
reportId,
title: resolvedTitle,
wordCount: resolvedWordCount ?? undefined,
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? undefined,
shareToken,
});
}
@ -210,7 +207,6 @@ function ReportCard({
openPanel({
reportId,
title: metadata.title,
wordCount: metadata.wordCount ?? undefined,
shareToken,
});
};
@ -233,10 +229,8 @@ function ReportCard({
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
) : (
<>
{metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`}
{metadata.wordCount != null && metadata.versionLabel && (
<Dot className="inline size-4" />
)}
Markdown
{metadata.versionLabel && <Dot className="inline size-4" />}
{metadata.versionLabel}
</>
)}

View file

@ -2,6 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Dot } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import * as pdfjsLib from "pdfjs-dist";
import { useCallback, useEffect, useRef, useState } from "react";
@ -9,6 +10,7 @@ import { z } from "zod";
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { getAuthHeaders } from "@/lib/auth-utils";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
@ -32,6 +34,18 @@ const GenerateResumeResultSchema = z.object({
error: z.string().nullish(),
});
const ResumeVersionsResponseSchema = z.object({
id: z.number(),
versions: z
.array(
z.object({
id: z.number(),
created_at: z.string().nullish(),
})
)
.nullish(),
});
type GenerateResumeArgs = z.infer<typeof GenerateResumeArgsSchema>;
type GenerateResumeResult = z.infer<typeof GenerateResumeResultSchema>;
@ -201,6 +215,7 @@ function ResumeCard({
const autoOpenedRef = useRef(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading");
const [versionLabel, setVersionLabel] = useState<string | null>(null);
useEffect(() => {
const previewPath = shareToken
@ -219,6 +234,35 @@ function ResumeCard({
}
}, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]);
useEffect(() => {
let cancelled = false;
const fetchVersions = async () => {
try {
const url = shareToken
? `/api/v1/public/${shareToken}/reports/${reportId}/content`
: `/api/v1/reports/${reportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return;
const parsed = ResumeVersionsResponseSchema.safeParse(rawData);
if (parsed.success) {
const versions = parsed.data.versions;
if (versions && versions.length > 1) {
const idx = versions.findIndex((v) => v.id === reportId);
if (idx >= 0) {
setVersionLabel(`version ${idx + 1}`);
}
}
}
} catch {
// silently ignore — version label is non-critical
}
};
fetchVersions();
return () => {
cancelled = true;
};
}, [reportId, shareToken]);
const onThumbLoad = useCallback(() => setThumbState("ready"), []);
const onThumbError = useCallback(() => setThumbState("error"), []);
@ -243,8 +287,12 @@ function ResumeCard({
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer select-none"
>
<div className="px-5 pt-5 pb-4">
<p className="text-base font-semibold text-foreground line-clamp-2">{title}</p>
<p className="text-sm text-muted-foreground mt-0.5">PDF</p>
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
PDF
{versionLabel && <Dot className="inline size-4" />}
{versionLabel}
</p>
</div>
<div className="mx-5 h-px bg-border/50" />