Merge remote-tracking branch 'upstream/dev' into feat/obsidian-plugin

This commit is contained in:
Anish Sarkar 2026-04-24 21:34:55 +05:30
commit 9b1b9a90c0
175 changed files with 10592 additions and 2302 deletions

View file

@ -123,8 +123,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
@ -225,25 +226,27 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(

View file

@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { ConnectorStatusBadge } from "./connector-status-badge";
@ -55,6 +56,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
onManage,
}) => {
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType);
// Get connector status
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
useConnectorStatus();
@ -123,14 +125,14 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</span>
) : (
<>
<span>{formatDocumentCount(documentCount)}</span>
{!isLive && <span>{formatDocumentCount(documentCount)}</span>}
{!isLive && accountCount !== undefined && accountCount > 0 && (
<span className="text-muted-foreground/50"></span>
)}
{accountCount !== undefined && accountCount > 0 && (
<>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
)}
</>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { CheckCircle2, ChevronDown, ChevronUp, Server, XCircle } from "lucide-react";
import { CheckCircle2, ChevronDown, ChevronUp, Loader2, Server, XCircle } from "lucide-react";
import { type FC, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@ -212,7 +212,14 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
variant="secondary"
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
>
{isTesting ? "Testing Connection" : "Test Connection"}
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
</Button>
</div>

View file

@ -53,8 +53,7 @@ export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [connector?.id, fetchChannels]);
// Separate channels by indexing capability
const readyToIndex = channels.filter((ch) => ch.can_index);
const accessible = channels.filter((ch) => ch.can_index);
const needsPermissions = channels.filter((ch) => !ch.can_index);
// Format last fetched time
@ -80,7 +79,7 @@ export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
</div>
<div className="text-xs sm:text-sm">
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
The bot needs &quot;Read Message History&quot; permission to index channels. Ask a
The bot needs &quot;Read Message History&quot; permission to access channels. Ask a
server admin to grant this permission for channels shown below.
</p>
</div>
@ -127,18 +126,18 @@ export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
</div>
) : (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 overflow-hidden">
{/* Ready to index */}
{readyToIndex.length > 0 && (
{/* Accessible channels */}
{accessible.length > 0 && (
<div className={cn("p-3", needsPermissions.length > 0 && "border-b border-border")}>
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="size-3.5 text-emerald-500" />
<span className="text-[11px] font-medium">Ready to index</span>
<span className="text-[11px] font-medium">Accessible</span>
<span className="text-[10px] text-muted-foreground">
{readyToIndex.length} {readyToIndex.length === 1 ? "channel" : "channels"}
{accessible.length} {accessible.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{readyToIndex.map((channel) => (
{accessible.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
@ -150,7 +149,7 @@ export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="size-3.5 text-amber-500" />
<span className="text-[11px] font-medium">Grant permissions to index</span>
<span className="text-[11px] font-medium">Needs permissions</span>
<span className="text-[10px] text-muted-foreground">
{needsPermissions.length}{" "}
{needsPermissions.length === 1 ? "channel" : "channels"}

View file

@ -1,6 +1,6 @@
"use client";
import { CheckCircle2, ChevronDown, ChevronUp, Server, XCircle } from "lucide-react";
import { CheckCircle2, ChevronDown, ChevronUp, Loader2, Server, XCircle } from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@ -217,7 +217,14 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
variant="secondary"
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
>
{isTesting ? "Testing Connection" : "Test Connection"}
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
</Button>
</div>

View file

@ -0,0 +1,28 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import type { FC } from "react";
import type { ConnectorConfigProps } from "../index";
export const MCPServiceConfig: FC<ConnectorConfigProps> = ({ connector }) => {
const serviceName = connector.config?.mcp_service as string | undefined;
const displayName = serviceName
? serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
: "this service";
return (
<div className="space-y-4">
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
<CheckCircle2 className="size-4 text-emerald-500" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
Your agent can search, read, and take actions in {displayName}.
</p>
</div>
</div>
</div>
);
};

View file

@ -18,9 +18,9 @@ export const TeamsConfig: FC<TeamsConfigProps> = () => {
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
SurfSense will index messages from Teams channels that you have access to. The app can
only read messages from teams and channels where you are a member. Make sure you're a
member of the teams you want to index before connecting.
Your agent can search and read messages from Teams channels you have access to,
and send messages on your behalf. Make sure you&#39;re a member of the teams
you want to interact with.
</p>
</div>
</div>

View file

@ -16,7 +16,9 @@ import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { SummaryConfig } from "../../components/summary-config";
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 { getConnectorConfigComponent } from "../index";
const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([
@ -27,19 +29,6 @@ const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type
EnumConnectorName.OBSIDIAN_CONNECTOR,
]);
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
};
interface ConnectorEditViewProps {
connector: SearchSourceConnector;
startDate: Date | undefined;
@ -93,7 +82,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
}) => {
const searchSpaceIdAtom = useAtomValue(activeSearchSpaceIdAtom);
const isAuthExpired = connector.config?.auth_expired === true;
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
const reauthEndpoint = getReauthEndpoint(connector);
const [reauthing, setReauthing] = useState(false);
const supportsVisionLlm = VISION_LLM_CONNECTOR_TYPES.has(connector.connector_type);
const showsAiToggles =
@ -129,11 +118,14 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
}
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
// Get connector-specific config component
const ConnectorConfigComponent = useMemo(
() => getConnectorConfigComponent(connector.connector_type),
[connector.connector_type]
);
const isMCPBacked = Boolean(connector.config?.server_config);
const isLive = isMCPBacked || LIVE_CONNECTOR_TYPES.has(connector.connector_type);
// Get connector-specific config component (MCP-backed connectors use a generic view)
const ConnectorConfigComponent = useMemo(() => {
if (isMCPBacked) return MCPServiceConfig;
return getConnectorConfigComponent(connector.connector_type);
}, [connector.connector_type, isMCPBacked]);
const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
@ -234,12 +226,14 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{getConnectorDisplayName(connector.name)}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
{isLive
? "Manage your connected account"
: "Manage your connector settings and sync configuration"}
</p>
</div>
</div>
{/* Quick Index Button - hidden when auth is expired */}
{connector.is_indexable && onQuickIndex && !isAuthExpired && (
{/* Quick Index Button - hidden for live connectors and when auth is expired */}
{connector.is_indexable && !isLive && onQuickIndex && !isAuthExpired && (
<Button
variant="secondary"
size="sm"
@ -283,7 +277,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
)}
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
{showsAiToggles && (
{showsAiToggles && !isLive && (
<>
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
@ -355,8 +349,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</>
)}
{/* Info box - only shown for indexable connectors */}
{connector.is_indexable && (
{/* Info box - hidden for live connectors */}
{connector.is_indexable && !isLive && (
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
@ -386,10 +380,12 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-muted border-t border-border">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">
Are you sure?
{isLive
? "Your agent will lose access to this service."
: "This will remove all indexed data."}
</span>
<div className="flex items-center gap-2 sm:gap-3">
<Button
@ -432,7 +428,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<RefreshCw className={cn("size-3.5", reauthing && "animate-spin")} />
Re-authenticate
</Button>
) : (
) : !isLive ? (
<Button
onClick={onSave}
disabled={isSaving || isDisconnecting}
@ -441,7 +437,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<span className={isSaving ? "opacity-0" : ""}>Save Changes</span>
{isSaving && <Spinner size="sm" className="absolute" />}
</Button>
)}
) : null}
</div>
</div>
);

View file

@ -12,7 +12,7 @@ import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { SummaryConfig } from "../../components/summary-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import type { IndexingConfigState } from "../../constants/connector-constants";
import { LIVE_CONNECTOR_TYPES, type IndexingConfigState } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
@ -67,6 +67,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onStartIndexing,
onSkip,
}) => {
const isLive = LIVE_CONNECTOR_TYPES.has(config.connectorType);
// Get connector-specific config component
const ConnectorConfigComponent = useMemo(
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null),
@ -150,7 +152,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
)}
</div>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Configure when to start syncing your data
{isLive
? "Your account is ready to use"
: "Configure when to start syncing your data"}
</p>
</div>
</div>
@ -170,7 +174,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
)}
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
{showsAiToggles && (
{showsAiToggles && !isLive && (
<>
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
@ -220,8 +224,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
</>
)}
{/* Info box - only shown for indexable connectors */}
{connector?.is_indexable && (
{/* Info box - hidden for live connectors */}
{connector?.is_indexable && !isLive && (
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
@ -249,14 +253,20 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-muted">
<Button
onClick={onStartIndexing}
disabled={isStartingIndexing}
className="text-xs sm:text-sm relative"
>
<span className={isStartingIndexing ? "opacity-0" : ""}>Start Indexing</span>
{isStartingIndexing && <Spinner size="sm" className="absolute" />}
</Button>
{isLive ? (
<Button onClick={onSkip} className="text-xs sm:text-sm">
Done
</Button>
) : (
<Button
onClick={onStartIndexing}
disabled={isStartingIndexing}
className="text-xs sm:text-sm relative"
>
<span className={isStartingIndexing ? "opacity-0" : ""}>Start Indexing</span>
{isStartingIndexing && <Spinner size="sm" className="absolute" />}
</Button>
)}
</div>
</div>
);

View file

@ -1,4 +1,24 @@
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/**
* Connectors that operate in real time (no background indexing).
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
*/
export const LIVE_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.LINEAR_CONNECTOR,
EnumConnectorName.SLACK_CONNECTOR,
EnumConnectorName.JIRA_CONNECTOR,
EnumConnectorName.CLICKUP_CONNECTOR,
EnumConnectorName.AIRTABLE_CONNECTOR,
EnumConnectorName.DISCORD_CONNECTOR,
EnumConnectorName.TEAMS_CONNECTOR,
EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR,
EnumConnectorName.LUMA_CONNECTOR,
]);
// OAuth Connectors (Quick Connect)
export const OAUTH_CONNECTORS = [
@ -13,7 +33,7 @@ export const OAUTH_CONNECTORS = [
{
id: "google-gmail-connector",
title: "Gmail",
description: "Search through your emails",
description: "Search, read, draft, and send emails",
connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
authEndpoint: "/api/v1/auth/google/gmail/connector/add/",
selfHostedOnly: true,
@ -21,7 +41,7 @@ export const OAUTH_CONNECTORS = [
{
id: "google-calendar-connector",
title: "Google Calendar",
description: "Search through your events",
description: "Search and manage your events",
connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR,
authEndpoint: "/api/v1/auth/google/calendar/connector/add/",
selfHostedOnly: true,
@ -29,35 +49,35 @@ export const OAUTH_CONNECTORS = [
{
id: "airtable-connector",
title: "Airtable",
description: "Search your Airtable bases",
description: "Browse bases, tables, and records",
connectorType: EnumConnectorName.AIRTABLE_CONNECTOR,
authEndpoint: "/api/v1/auth/airtable/connector/add/",
authEndpoint: "/api/v1/auth/mcp/airtable/connector/add/",
},
{
id: "notion-connector",
title: "Notion",
description: "Search your Notion pages",
connectorType: EnumConnectorName.NOTION_CONNECTOR,
authEndpoint: "/api/v1/auth/notion/connector/add/",
authEndpoint: "/api/v1/auth/notion/connector/add",
},
{
id: "linear-connector",
title: "Linear",
description: "Search issues & projects",
description: "Search, read, and manage issues & projects",
connectorType: EnumConnectorName.LINEAR_CONNECTOR,
authEndpoint: "/api/v1/auth/linear/connector/add/",
authEndpoint: "/api/v1/auth/mcp/linear/connector/add/",
},
{
id: "slack-connector",
title: "Slack",
description: "Search Slack messages",
description: "Search and read channels and threads",
connectorType: EnumConnectorName.SLACK_CONNECTOR,
authEndpoint: "/api/v1/auth/slack/connector/add/",
authEndpoint: "/api/v1/auth/mcp/slack/connector/add/",
},
{
id: "teams-connector",
title: "Microsoft Teams",
description: "Search Teams messages",
description: "Search, read, and send messages",
connectorType: EnumConnectorName.TEAMS_CONNECTOR,
authEndpoint: "/api/v1/auth/teams/connector/add/",
},
@ -78,16 +98,16 @@ export const OAUTH_CONNECTORS = [
{
id: "discord-connector",
title: "Discord",
description: "Search Discord messages",
description: "Search, read, and send messages",
connectorType: EnumConnectorName.DISCORD_CONNECTOR,
authEndpoint: "/api/v1/auth/discord/connector/add/",
},
{
id: "jira-connector",
title: "Jira",
description: "Search Jira issues",
description: "Search, read, and manage issues",
connectorType: EnumConnectorName.JIRA_CONNECTOR,
authEndpoint: "/api/v1/auth/jira/connector/add/",
authEndpoint: "/api/v1/auth/mcp/jira/connector/add/",
},
{
id: "confluence-connector",
@ -99,9 +119,9 @@ export const OAUTH_CONNECTORS = [
{
id: "clickup-connector",
title: "ClickUp",
description: "Search ClickUp tasks",
description: "Search and read tasks",
connectorType: EnumConnectorName.CLICKUP_CONNECTOR,
authEndpoint: "/api/v1/auth/clickup/connector/add/",
authEndpoint: "/api/v1/auth/mcp/clickup/connector/add/",
},
] as const;
@ -138,7 +158,7 @@ export const OTHER_CONNECTORS = [
{
id: "luma-connector",
title: "Luma",
description: "Search Luma events",
description: "Browse, read, and create events",
connectorType: EnumConnectorName.LUMA_CONNECTOR,
},
{
@ -197,14 +217,14 @@ export const COMPOSIO_CONNECTORS = [
{
id: "composio-gmail",
title: "Gmail",
description: "Search through your emails via Composio",
description: "Search, read, draft, and send emails via Composio",
connectorType: EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR,
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=gmail",
},
{
id: "composio-googlecalendar",
title: "Google Calendar",
description: "Search through your events via Composio",
description: "Search and manage your events via Composio",
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=googlecalendar",
},
@ -221,14 +241,14 @@ export const COMPOSIO_TOOLKITS = [
{
id: "gmail",
name: "Gmail",
description: "Search through your emails",
isIndexable: true,
description: "Search, read, draft, and send emails",
isIndexable: false,
},
{
id: "googlecalendar",
name: "Google Calendar",
description: "Search through your events",
isIndexable: true,
description: "Search and manage your events",
isIndexable: false,
},
{
id: "slack",
@ -258,66 +278,6 @@ export interface AutoIndexConfig {
}
export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of emails.",
},
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of emails.",
},
[EnumConnectorName.SLACK_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of messages.",
},
[EnumConnectorName.DISCORD_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of messages.",
},
[EnumConnectorName.TEAMS_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of messages.",
},
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: {
daysBack: 90,
daysForward: 90,
frequencyMinutes: 1440,
syncDescription: "Syncing 90 days of past and upcoming events.",
},
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: {
daysBack: 90,
daysForward: 90,
frequencyMinutes: 1440,
syncDescription: "Syncing 90 days of past and upcoming events.",
},
[EnumConnectorName.LINEAR_CONNECTOR]: {
daysBack: 90,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 90 days of issues.",
},
[EnumConnectorName.JIRA_CONNECTOR]: {
daysBack: 90,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 90 days of issues.",
},
[EnumConnectorName.CLICKUP_CONNECTOR]: {
daysBack: 90,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 90 days of tasks.",
},
[EnumConnectorName.NOTION_CONNECTOR]: {
daysBack: 365,
daysForward: 0,
@ -330,12 +290,6 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
frequencyMinutes: 1440,
syncDescription: "Syncing your documentation.",
},
[EnumConnectorName.AIRTABLE_CONNECTOR]: {
daysBack: 365,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your bases.",
},
};
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX_DEFAULTS));
@ -414,5 +368,45 @@ export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelem
};
}
// =============================================================================
// REAUTH ENDPOINTS
// =============================================================================
/**
* Legacy (non-MCP) OAuth reauth endpoints, keyed by connector type.
* These are used for connectors that were NOT created via MCP OAuth.
*/
export const LEGACY_REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
[EnumConnectorName.TEAMS_CONNECTOR]: "/api/v1/auth/teams/connector/reauth",
[EnumConnectorName.DISCORD_CONNECTOR]: "/api/v1/auth/discord/connector/reauth",
};
/**
* Resolve the reauth endpoint for a connector.
*
* MCP OAuth connectors (those with ``config.mcp_service``) dynamically build
* the URL from the service key. Legacy OAuth connectors fall back to the
* static ``LEGACY_REAUTH_ENDPOINTS`` map.
*/
export function getReauthEndpoint(connector: SearchSourceConnector): string | undefined {
const mcpService = connector.config?.mcp_service as string | undefined;
if (mcpService) {
return `/api/v1/auth/mcp/${mcpService}/connector/reauth`;
}
return LEGACY_REAUTH_ENDPOINTS[connector.connector_type];
}
// Re-export IndexingConfigState from schemas for backward compatibility
export type { IndexingConfigState } from "./connector-popup.schemas";

View file

@ -32,6 +32,7 @@ import {
AUTO_INDEX_CONNECTOR_TYPES,
AUTO_INDEX_DEFAULTS,
COMPOSIO_CONNECTORS,
LIVE_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../constants/connector-constants";
@ -307,7 +308,12 @@ export const useConnectorDialog = () => {
newConnector.id
);
if (
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
if (isLiveConnector) {
toast.success(`${oauthConnector.title} connected successfully!`);
await refetchAllConnectors();
} else if (
newConnector.is_indexable &&
AUTO_INDEX_CONNECTOR_TYPES.has(oauthConnector.connectorType)
) {
@ -316,6 +322,9 @@ export const useConnectorDialog = () => {
oauthConnector.title,
oauthConnector.connectorType
);
} else if (!newConnector.is_indexable) {
toast.success(`${oauthConnector.title} connected successfully!`);
await refetchAllConnectors();
} else {
toast.dismiss("auto-index");
const config = validateIndexingConfigState({
@ -1279,6 +1288,25 @@ export const useConnectorDialog = () => {
[editingConnector, searchSpaceId, deleteConnector, cameFromMCPList, setIsOpen]
);
const handleDisconnectFromList = useCallback(
async (connector: SearchSourceConnector, refreshConnectors: () => void) => {
if (!searchSpaceId) return;
try {
await deleteConnector({ id: connector.id });
trackConnectorDeleted(Number(searchSpaceId), connector.connector_type, connector.id);
toast.success(`${connector.name} disconnected successfully`);
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error disconnecting connector:", error);
toast.error("Failed to disconnect connector");
}
},
[searchSpaceId, deleteConnector]
);
// Handle quick index (index with selected date range, or backend defaults if none selected)
const handleQuickIndexConnector = useCallback(
async (
@ -1452,6 +1480,7 @@ export const useConnectorDialog = () => {
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,

View file

@ -9,7 +9,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils";
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
import { COMPOSIO_CONNECTORS, LIVE_CONNECTOR_TYPES, OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "./all-connectors-tab";
@ -156,6 +156,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{/* OAuth Connectors - Grouped by Type */}
{filteredOAuthConnectorTypes.map(([connectorType, typeConnectors]) => {
const { title } = getOAuthConnectorTypeInfo(connectorType);
const isLive = LIVE_CONNECTOR_TYPES.has(connectorType);
const isAnyIndexing = typeConnectors.some((c: SearchSourceConnector) =>
indexingConnectorIds.has(c.id)
);
@ -202,8 +203,12 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
{!isLive && (
<>
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
</>
)}
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
@ -230,6 +235,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
documentTypeCounts
);
const isMCPConnector = connector.connector_type === "MCP_CONNECTOR";
const isLive = LIVE_CONNECTOR_TYPES.has(connector.connector_type);
return (
<div
key={`connector-${connector.id}`}
@ -261,7 +267,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<Spinner size="xs" />
Syncing
</p>
) : !isMCPConnector ? (
) : !isLive && !isMCPConnector ? (
<p className="text-[10px] text-muted-foreground mt-1">
{formatDocumentCount(documentCount)}
</p>

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react";
import { ArrowLeft, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
@ -13,24 +13,10 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../constants/connector-constants";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth",
[EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
};
interface ConnectorAccountsListViewProps {
connectorType: string;
connectorTitle: string;
@ -38,19 +24,12 @@ interface ConnectorAccountsListViewProps {
indexingConnectorIds: Set<number>;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onDisconnect?: (connector: SearchSourceConnector) => Promise<void> | void;
onAddAccount: () => void;
isConnecting?: boolean;
addButtonText?: string;
}
/**
* Check if a connector type is indexable
*/
function isIndexableConnector(connectorType: string): boolean {
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectorType,
connectorTitle,
@ -58,12 +37,15 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
indexingConnectorIds,
onBack,
onManage,
onDisconnect,
onAddAccount,
isConnecting = false,
addButtonText,
}) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const [reauthingId, setReauthingId] = useState<number | null>(null);
const [confirmDisconnectId, setConfirmDisconnectId] = useState<number | null>(null);
const [disconnectingId, setDisconnectingId] = useState<number | null>(null);
// Get connector status
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
@ -71,16 +53,15 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
const isEnabled = isConnectorEnabled(connectorType);
const statusMessage = getConnectorStatusMessage(connectorType);
const reauthEndpoint = REAUTH_ENDPOINTS[connectorType];
const handleReauth = useCallback(
async (connectorId: number) => {
if (!searchSpaceId || !reauthEndpoint) return;
setReauthingId(connectorId);
async (connector: SearchSourceConnector) => {
const endpoint = getReauthEndpoint(connector);
if (!searchSpaceId || !endpoint) return;
setReauthingId(connector.id);
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const url = new URL(`${backendUrl}${reauthEndpoint}`);
url.searchParams.set("connector_id", String(connectorId));
const url = new URL(`${backendUrl}${endpoint}`);
url.searchParams.set("connector_id", String(connector.id));
url.searchParams.set("space_id", String(searchSpaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
@ -102,7 +83,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
setReauthingId(null);
}
},
[searchSpaceId, reauthEndpoint]
[searchSpaceId]
);
// Filter connectors to only show those of this type
@ -149,7 +130,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
{connectorTitle}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
{statusMessage || "Manage your connector settings and sync configuration"}
{statusMessage || "Manage your connected accounts"}
</p>
</div>
</div>
@ -201,9 +182,11 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const isAuthExpired = !!reauthEndpoint && connector.config?.auth_expired === true;
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const connectorReauthEndpoint = getReauthEndpoint(connector);
const isAuthExpired = !!connectorReauthEndpoint && connector.config?.auth_expired === true;
const isLive = LIVE_CONNECTOR_TYPES.has(connector.connector_type) || Boolean(connector.config?.server_config);
return (
<div
@ -234,38 +217,81 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<Spinner size="xs" />
Syncing
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{isIndexableConnector(connector.connector_type)
? connector.last_indexed_at
? `Last indexed: ${formatRelativeDate(connector.last_indexed_at)}`
: "Never indexed"
: "Active"}
) : !isLive ? (
<p className="text-[10px] mt-1 whitespace-nowrap truncate text-muted-foreground">
{connector.last_indexed_at
? `Last indexed: ${formatRelativeDate(connector.last_indexed_at)}`
: "Never indexed"}
</p>
)}
) : null}
</div>
{isAuthExpired ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector.id)}
disabled={reauthingId === connector.id}
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
{isAuthExpired ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector)}
disabled={reauthingId === connector.id}
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
) : isLive && onDisconnect ? (
confirmDisconnectId === connector.id ? (
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="destructive"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
onClick={async () => {
setDisconnectingId(connector.id);
setConfirmDisconnectId(null);
try {
await onDisconnect(connector);
} finally {
setDisconnectingId(null);
}
}}
disabled={disconnectingId === connector.id}
>
{disconnectingId === connector.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
"Confirm"
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-[11px] px-2 rounded-lg"
onClick={() => setConfirmDisconnectId(null)}
disabled={disconnectingId === connector.id}
>
Cancel
</Button>
</div>
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
onClick={() => setConfirmDisconnectId(connector.id)}
>
Manage
<Trash2 className="size-3.5" />
Disconnect
</Button>
)}
)
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
)}
</div>
);
})}

View file

@ -7,16 +7,20 @@ import {
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import { useSetAtom } from "jotai";
import { ExternalLinkIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import { memo, type ReactNode } from "react";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
import "katex/dist/katex.min.css";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { useElectronAPI } from "@/hooks/use-platform";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@ -222,6 +226,18 @@ function extractDomain(url: string): string {
}
}
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
function isVirtualFilePathToken(value: string): boolean {
if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) {
return false;
}
const normalized = value.replace(/\/+$/, "");
const segments = normalized.split("/").filter(Boolean);
return segments.length >= 2;
}
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
if (!src) return null;
@ -392,7 +408,51 @@ const defaultComponents = memoizeMarkdownComponents({
code: function Code({ className, children, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
const { resolvedTheme } = useTheme();
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const params = useParams();
const electronAPI = useElectronAPI();
const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text";
const codeString = String(children).replace(/\n$/, "");
const isWebLocalFileCodeBlock =
isCodeBlock &&
!electronAPI &&
isVirtualFilePathToken(codeString.trim()) &&
!codeString.trim().startsWith("//") &&
!codeString.includes("\n");
if (!isCodeBlock) {
const inlineValue = String(children ?? "").trim();
const isLocalPath =
!!electronAPI && isVirtualFilePathToken(inlineValue) && !inlineValue.startsWith("//");
const displayLocalPath = inlineValue.replace(/^\/+/, "");
const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
? Number(searchSpaceIdParam[0])
: Number(searchSpaceIdParam);
if (isLocalPath) {
return (
<button
type="button"
className={cn(
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openEditorPanel({
kind: "local_file",
localFilePath: inlineValue,
title: inlineValue.split("/").pop() || inlineValue,
searchSpaceId: Number.isFinite(parsedSearchSpaceId)
? parsedSearchSpaceId
: undefined,
});
}}
title="Open in editor panel"
>
{displayLocalPath}
</button>
);
}
return (
<code
className={cn(
@ -405,8 +465,19 @@ const defaultComponents = memoizeMarkdownComponents({
</code>
);
}
const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text";
const codeString = String(children).replace(/\n$/, "");
if (isWebLocalFileCodeBlock) {
return (
<code
className={cn(
"aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal",
className
)}
{...props}
>
{codeString.trim()}
</code>
);
}
return (
<LazyMarkdownCodeBlock
className={className}

View file

@ -1104,7 +1104,13 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
group.tools.flatMap((t, i) =>
i === 0
? [t.description]
: [<Dot key={i} className="inline h-4 w-4" />, t.description]
: [
<Dot
key={`dot-${group.label}-${t.description}`}
className="inline h-4 w-4"
/>,
t.description,
]
)}
</TooltipContent>
</Tooltip>

View file

@ -1,6 +1,6 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive, useAuiState } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react";
import { CheckIcon, CopyIcon, FileText, Pencil } from "lucide-react";
import Image from "next/image";
import { type FC, useState } from "react";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
@ -136,7 +136,7 @@ const UserActionBar: FC = () => {
{canEdit && (
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
<Pen />
<Pencil />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -29,7 +29,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={onEdit}>
<PenLine className="mr-2 size-4" />
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}

View file

@ -8,7 +8,7 @@ import {
History,
MoreHorizontal,
Move,
PenLine,
Pencil,
Trash2,
} from "lucide-react";
import React, { useCallback, useRef, useState } from "react";
@ -266,7 +266,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
)}
@ -309,7 +309,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
)}

View file

@ -12,7 +12,7 @@ import {
FolderPlus,
MoreHorizontal,
Move,
PenLine,
Pencil,
RefreshCw,
Trash2,
} from "lucide-react";
@ -399,7 +399,7 @@ export const FolderNode = React.memo(function FolderNode({
startRename();
}}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
@ -456,7 +456,7 @@ export const FolderNode = React.memo(function FolderNode({
New subfolder
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>

View file

@ -1,19 +1,31 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Download, FileQuestionMark, FileText, RefreshCw, XIcon } from "lucide-react";
import {
Check,
Copy,
Download,
FileQuestionMark,
FileText,
Pencil,
RefreshCw,
XIcon,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { VersionHistoryButton } from "@/components/documents/version-history";
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
@ -33,6 +45,7 @@ interface EditorContent {
}
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
type EditorRenderMode = "rich_markdown" | "source_code";
function EditorPanelSkeleton() {
return (
@ -55,27 +68,38 @@ function EditorPanelSkeleton() {
}
export function EditorPanelContent({
kind = "document",
documentId,
localFilePath,
searchSpaceId,
title,
onClose,
}: {
documentId: number;
searchSpaceId: number;
kind?: "document" | "local_file";
documentId?: number;
localFilePath?: string;
searchSpaceId?: number;
title: string | null;
onClose?: () => void;
}) {
const electronAPI = useElectronAPI();
const [editorDoc, setEditorDoc] = useState<EditorContent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [downloading, setDownloading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const [localFileContent, setLocalFileContent] = useState("");
const [hasCopied, setHasCopied] = useState(false);
const markdownRef = useRef<string>("");
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadDone = useRef(false);
const changeCountRef = useRef(0);
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
const isLocalFileMode = kind === "local_file";
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
@ -85,17 +109,48 @@ export function EditorPanelContent({
setError(null);
setEditorDoc(null);
setEditedMarkdown(null);
setLocalFileContent("");
setHasCopied(false);
setIsEditing(false);
initialLoadDone.current = false;
changeCountRef.current = 0;
const doFetch = async () => {
const token = getBearerToken();
if (!token) {
redirectToLogin();
return;
}
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
}
if (!electronAPI?.readAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
}
const readResult = await electronAPI.readAgentLocalFileText(localFilePath);
if (!readResult.ok) {
throw new Error(readResult.error || "Failed to read local file");
}
const inferredTitle = localFilePath.split("/").pop() || localFilePath;
const content: EditorContent = {
document_id: -1,
title: inferredTitle,
document_type: "NOTE",
source_markdown: readResult.content,
};
markdownRef.current = content.source_markdown;
setLocalFileContent(content.source_markdown);
setDisplayTitle(title || inferredTitle);
setEditorDoc(content);
initialLoadDone.current = true;
return;
}
if (!documentId || !searchSpaceId) {
throw new Error("Missing document context");
}
const token = getBearerToken();
if (!token) {
redirectToLogin();
return;
}
const url = new URL(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
);
@ -137,7 +192,15 @@ export function EditorPanelContent({
doFetch().catch(() => {});
return () => controller.abort();
}, [documentId, searchSpaceId, title]);
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]);
useEffect(() => {
return () => {
if (copyResetTimeoutRef.current) {
clearTimeout(copyResetTimeoutRef.current);
}
};
}, []);
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
@ -147,16 +210,55 @@ export function EditorPanelContent({
setEditedMarkdown(md);
}, []);
const handleSave = useCallback(async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
const handleCopy = useCallback(async () => {
try {
const textToCopy = markdownRef.current ?? editorDoc?.source_markdown ?? "";
await navigator.clipboard.writeText(textToCopy);
setHasCopied(true);
if (copyResetTimeoutRef.current) {
clearTimeout(copyResetTimeoutRef.current);
}
copyResetTimeoutRef.current = setTimeout(() => {
setHasCopied(false);
}, 1400);
} catch (err) {
console.error("Error copying content:", err);
}
}, [editorDoc?.source_markdown]);
const handleSave = useCallback(async (options?: { silent?: boolean }) => {
setSaving(true);
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
}
if (!electronAPI?.writeAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
}
const contentToSave = markdownRef.current;
const writeResult = await electronAPI.writeAgentLocalFileText(
localFilePath,
contentToSave
);
if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file");
}
setEditorDoc((prev) =>
prev ? { ...prev, source_markdown: contentToSave } : prev
);
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
return true;
}
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
@ -176,39 +278,190 @@ export function EditorPanelContent({
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
return true;
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
return false;
} finally {
setSaving(false);
}
}, [documentId, searchSpaceId]);
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]);
const isEditableType = editorDoc
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "") && !isLargeDocument
? (editorRenderMode === "source_code" ||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
!isLargeDocument
: false;
const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose;
const showEditingActions = isEditableType && isEditing;
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
const handleCancelEditing = useCallback(() => {
const savedContent = editorDoc?.source_markdown ?? "";
markdownRef.current = savedContent;
setLocalFileContent(savedContent);
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(false);
}, [editorDoc?.source_markdown]);
return (
<>
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{isEditableType && editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
{showDesktopHeader ? (
<div className="shrink-0 border-b">
<div className="flex h-14 items-center justify-between px-4">
<h2 className="text-lg font-medium text-muted-foreground select-none">File</h2>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
</div>
</div>
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{showEditingActions ? (
<>
<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({ silent: true });
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={() => {
void handleCopy();
}}
disabled={isLoading || !editorDoc}
>
{hasCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">
{hasCopied ? "Copied file contents" : "Copy file contents"}
</span>
</Button>
{isEditableType && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
changeCountRef.current = 0;
setEditedMarkdown(null);
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit document</span>
</Button>
)}
</>
)}
{!showEditingActions && !isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{editorDoc?.document_type && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
)}
) : (
<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">{displayTitle}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{showEditingActions ? (
<>
<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({ silent: true });
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={() => {
void handleCopy();
}}
disabled={isLoading || !editorDoc}
>
{hasCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">
{hasCopied ? "Copied file contents" : "Copy file contents"}
</span>
</Button>
{isEditableType && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
changeCountRef.current = 0;
setEditedMarkdown(null);
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit document</span>
</Button>
)}
{!isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton
documentId={documentId}
documentType={editorDoc.document_type}
/>
)}
</>
)}
</div>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
{isLoading ? (
@ -235,7 +488,7 @@ export function EditorPanelContent({
</p>
</div>
</div>
) : isLargeDocument ? (
) : isLargeDocument && !isLocalFileMode ? (
<div className="h-full overflow-y-auto px-5 py-4">
<Alert className="mb-4">
<FileText className="size-4" />
@ -253,6 +506,9 @@ export function EditorPanelContent({
onClick={async () => {
setDownloading(true);
try {
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
@ -289,19 +545,36 @@ export function EditorPanelContent({
</Alert>
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : editorRenderMode === "source_code" ? (
<div className="h-full overflow-hidden">
<SourceCodeEditor
path={localFilePath ?? "local-file.txt"}
language={localFileLanguage}
value={localFileContent}
onSave={() => {
void handleSave({ silent: true });
}}
readOnly={!isEditing}
onChange={(next) => {
markdownRef.current = next;
setLocalFileContent(next);
if (!initialLoadDone.current) return;
setEditedMarkdown(next === (editorDoc?.source_markdown ?? "") ? null : next);
}}
/>
</div>
) : isEditableType ? (
<PlateEditor
key={documentId}
key={`${isLocalFileMode ? localFilePath ?? "local-file" : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={false}
readOnly={!isEditing}
placeholder="Start writing..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
defaultEditing={true}
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
) : (
@ -326,13 +599,19 @@ function DesktopEditorPanel() {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [closePanel]);
if (!panelState.isOpen || !panelState.documentId || !panelState.searchSpaceId) return null;
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (!panelState.isOpen || !hasTarget) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<EditorPanelContent
documentId={panelState.documentId}
searchSpaceId={panelState.searchSpaceId}
kind={panelState.kind}
documentId={panelState.documentId ?? undefined}
localFilePath={panelState.localFilePath ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
onClose={closePanel}
/>
@ -344,7 +623,13 @@ function MobileEditorDrawer() {
const panelState = useAtomValue(editorPanelAtom);
const closePanel = useSetAtom(closeEditorPanelAtom);
if (!panelState.documentId || !panelState.searchSpaceId) return null;
if (panelState.kind === "local_file") return null;
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (!hasTarget) return null;
return (
<Drawer
@ -362,8 +647,10 @@ function MobileEditorDrawer() {
<DrawerTitle className="sr-only">{panelState.title || "Editor"}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<EditorPanelContent
documentId={panelState.documentId}
searchSpaceId={panelState.searchSpaceId}
kind={panelState.kind}
documentId={panelState.documentId ?? undefined}
localFilePath={panelState.localFilePath ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
/>
</div>
@ -375,8 +662,13 @@ function MobileEditorDrawer() {
export function EditorPanel() {
const panelState = useAtomValue(editorPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (!panelState.isOpen || !panelState.documentId) return null;
if (!panelState.isOpen || !hasTarget) return null;
if (!isDesktop && panelState.kind === "local_file") return null;
if (isDesktop) {
return <DesktopEditorPanel />;
@ -388,8 +680,12 @@ export function EditorPanel() {
export function MobileEditorPanel() {
const panelState = useAtomValue(editorPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (isDesktop || !panelState.isOpen || !panelState.documentId) return null;
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file") return null;
return <MobileEditorDrawer />;
}

View file

@ -11,12 +11,15 @@ interface EditorSaveContextValue {
isSaving: boolean;
/** Whether the user can toggle between editing and viewing modes */
canToggleMode: boolean;
/** Whether fixed-toolbar space should be reserved even when controls are hidden */
reserveToolbarSpace: boolean;
}
export const EditorSaveContext = createContext<EditorSaveContextValue>({
hasUnsavedChanges: false,
isSaving: false,
canToggleMode: false,
reserveToolbarSpace: false,
});
export function useEditorSave() {

View file

@ -42,6 +42,10 @@ export interface PlateEditorProps {
hasUnsavedChanges?: boolean;
/** Whether a save is in progress */
isSaving?: boolean;
/** Whether edit/view mode toggle UI should be available in toolbars. */
allowModeToggle?: boolean;
/** Reserve fixed-toolbar vertical space even when controls are hidden. */
reserveToolbarSpace?: boolean;
/** Start the editor in editing mode instead of viewing mode. Ignored when readOnly is true. */
defaultEditing?: boolean;
/**
@ -91,6 +95,8 @@ export function PlateEditor({
onSave,
hasUnsavedChanges = false,
isSaving = false,
allowModeToggle = true,
reserveToolbarSpace = false,
defaultEditing = false,
preset = "full",
extraPlugins = [],
@ -174,7 +180,7 @@ export function PlateEditor({
}, [html, markdown, editor]);
// When not forced read-only, the user can toggle between editing/viewing.
const canToggleMode = !readOnly;
const canToggleMode = !readOnly && allowModeToggle;
const contextProviderValue = useMemo(
() => ({
@ -182,8 +188,9 @@ export function PlateEditor({
hasUnsavedChanges,
isSaving,
canToggleMode,
reserveToolbarSpace,
}),
[onSave, hasUnsavedChanges, isSaving, canToggleMode]
[onSave, hasUnsavedChanges, isSaving, canToggleMode, reserveToolbarSpace]
);
return (

View file

@ -1,19 +1,40 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { useEditorReadOnly } from "platejs/react";
import { useEditorSave } from "@/components/editor/editor-save-context";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
import { FixedToolbarButtons } from "@/components/ui/fixed-toolbar-buttons";
function ConditionalFixedToolbar() {
const readOnly = useEditorReadOnly();
const { onSave, hasUnsavedChanges, canToggleMode, reserveToolbarSpace } = useEditorSave();
const hasVisibleControls =
!readOnly || canToggleMode || (!!onSave && hasUnsavedChanges && !readOnly);
if (!hasVisibleControls) {
if (!reserveToolbarSpace) return null;
return (
<FixedToolbar className="pointer-events-none opacity-0">
<div className="h-8 w-full" />
</FixedToolbar>
);
}
return (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
);
}
export const FixedToolbarKit = [
createPlatePlugin({
key: "fixed-toolbar",
render: {
beforeEditable: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
beforeEditable: () => <ConditionalFixedToolbar />,
},
}),
];

View file

@ -0,0 +1,152 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
import { Spinner } from "@/components/ui/spinner";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
});
interface SourceCodeEditorProps {
value: string;
onChange: (next: string) => void;
path?: string;
language?: string;
readOnly?: boolean;
fontSize?: number;
onSave?: () => Promise<void> | void;
}
export function SourceCodeEditor({
value,
onChange,
path,
language = "plaintext",
readOnly = false,
fontSize = 12,
onSave,
}: SourceCodeEditorProps) {
const { resolvedTheme } = useTheme();
const onSaveRef = useRef(onSave);
const monacoRef = useRef<any>(null);
const normalizedModelPath = (() => {
const raw = (path || "local-file.txt").trim();
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
// Monaco model paths should be stable and POSIX-like across platforms.
return withLeadingSlash.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
})();
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
const resolveCssColorToHex = (cssColorValue: string): string | null => {
if (typeof document === "undefined") return null;
const probe = document.createElement("div");
probe.style.color = cssColorValue;
probe.style.position = "absolute";
probe.style.pointerEvents = "none";
probe.style.opacity = "0";
document.body.appendChild(probe);
const computedColor = getComputedStyle(probe).color;
probe.remove();
const match = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!match) return null;
const toHex = (value: string) => Number(value).toString(16).padStart(2, "0");
return `#${toHex(match[1])}${toHex(match[2])}${toHex(match[3])}`;
};
const applySidebarTheme = (monaco: any) => {
const isDark = resolvedTheme === "dark";
const themeName = isDark ? "surfsense-dark" : "surfsense-light";
const fallbackBg = isDark ? "#1e1e1e" : "#ffffff";
const sidebarBgHex = resolveCssColorToHex("var(--sidebar)") ?? fallbackBg;
monaco.editor.defineTheme(themeName, {
base: isDark ? "vs-dark" : "vs",
inherit: true,
rules: [],
colors: {
"editor.background": sidebarBgHex,
"editorGutter.background": sidebarBgHex,
"minimap.background": sidebarBgHex,
"editorLineNumber.background": sidebarBgHex,
"editor.lineHighlightBackground": "#00000000",
},
});
monaco.editor.setTheme(themeName);
};
useEffect(() => {
if (!monacoRef.current) return;
applySidebarTheme(monacoRef.current);
}, [resolvedTheme]);
const isManualSaveEnabled = !!onSave && !readOnly;
return (
<div className="h-full w-full overflow-hidden bg-sidebar [&_.monaco-editor]:!bg-sidebar [&_.monaco-editor_.margin]:!bg-sidebar [&_.monaco-editor_.monaco-editor-background]:!bg-sidebar [&_.monaco-editor-background]:!bg-sidebar [&_.monaco-scrollable-element_.scrollbar_.slider]:rounded-full [&_.monaco-scrollable-element_.scrollbar_.slider]:bg-foreground/25 [&_.monaco-scrollable-element_.scrollbar_.slider:hover]:bg-foreground/40">
<MonacoEditor
path={normalizedModelPath}
language={language}
value={value}
theme={resolvedTheme === "dark" ? "surfsense-dark" : "surfsense-light"}
onChange={(next) => onChange(next ?? "")}
loading={
<div className="flex h-full w-full items-center justify-center">
<Spinner size="md" className="text-muted-foreground" />
</div>
}
beforeMount={(monaco) => {
monacoRef.current = monaco;
applySidebarTheme(monaco);
}}
onMount={(editor, monaco) => {
monacoRef.current = monaco;
applySidebarTheme(monaco);
if (!isManualSaveEnabled) return;
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
void onSaveRef.current?.();
});
}}
options={{
automaticLayout: true,
minimap: { enabled: false },
lineNumbers: "on",
lineNumbersMinChars: 3,
lineDecorationsWidth: 12,
glyphMargin: false,
folding: true,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
scrollBeyondLastLine: false,
renderLineHighlight: "none",
selectionHighlight: false,
occurrencesHighlight: "off",
quickSuggestions: false,
suggestOnTriggerCharacters: false,
acceptSuggestionOnEnter: "off",
parameterHints: { enabled: false },
wordBasedSuggestions: "off",
wordWrap: "off",
scrollbar: {
vertical: "auto",
horizontal: "auto",
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
alwaysConsumeMouseWheel: false,
},
tabSize: 2,
insertSpaces: true,
fontSize,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
renderWhitespace: "selection",
smoothScrolling: true,
readOnly,
}}
/>
</div>
);
}

View file

@ -9,7 +9,7 @@ import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { BACKEND_URL } from "@/lib/env-config";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { cn } from "@/lib/utils";
const ANON_ALLOWED_EXTENSIONS = new Set([
@ -128,24 +128,12 @@ export const FreeComposer: FC = () => {
}
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/upload`, {
method: "POST",
credentials: "include",
body: formData,
});
if (res.status === 409) {
gate("upload more documents");
const result = await anonymousChatApiService.uploadDocument(file);
if (!result.ok) {
if (result.reason === "quota_exceeded") gate("upload more documents");
return;
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Upload failed: ${res.status}`);
}
const data = await res.json();
const data = result.data;
if (anonMode.isAnonymous) {
anonMode.setUploadedDoc({
filename: data.filename,

View file

@ -65,16 +65,15 @@ function EmailsTagField({
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []);
const handleAddTag = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
if (tags.some((tag) => tag.text === trimmed)) return;
const handleAddTag = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setTags((prev) => {
if (prev.some((tag) => tag.text === trimmed)) return prev;
const newTag: TagType = { id: Date.now().toString(), text: trimmed };
setTags((prev) => [...prev, newTag]);
},
[tags]
);
return [...prev, newTag];
});
}, []);
return (
<TagInput

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { PanelRight, PanelRightClose } from "lucide-react";
import { PanelRight } from "lucide-react";
import dynamic from "next/dynamic";
import { startTransition, useEffect } from "react";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -49,11 +49,11 @@ function CollapseButton({ onClick }: { onClick: () => void }) {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClick} className="h-8 w-8 shrink-0">
<PanelRightClose className="h-4 w-4" />
<PanelRight className="h-4 w-4" />
<span className="sr-only">Collapse panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Collapse panel</TooltipContent>
<TooltipContent side="bottom">Collapse panel</TooltipContent>
</Tooltip>
);
}
@ -70,7 +70,11 @@ export function RightPanelExpandButton() {
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
@ -90,7 +94,7 @@ export function RightPanelExpandButton() {
<span className="sr-only">Expand panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Expand panel</TooltipContent>
<TooltipContent side="bottom">Expand panel</TooltipContent>
</Tooltip>
</div>
);
@ -110,7 +114,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
const documentsOpen = documentsPanel?.open ?? false;
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
useEffect(() => {
@ -179,8 +187,10 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
{effectiveTab === "editor" && editorOpen && (
<div className="h-full flex flex-col">
<EditorPanelContent
documentId={editorState.documentId as number}
searchSpaceId={editorState.searchSpaceId as number}
kind={editorState.kind}
documentId={editorState.documentId ?? undefined}
localFilePath={editorState.localFilePath ?? undefined}
searchSpaceId={editorState.searchSpaceId ?? undefined}
title={editorState.title}
onClose={closeEditor}
/>

View file

@ -8,7 +8,7 @@ import {
ChevronLeft,
MessageCircleMore,
MoreHorizontal,
PenLine,
Pencil,
RotateCcwIcon,
Search,
Trash2,
@ -429,7 +429,7 @@ export function AllPrivateChatsSidebarContent({
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}

View file

@ -8,7 +8,7 @@ import {
ChevronLeft,
MessageCircleMore,
MoreHorizontal,
PenLine,
Pencil,
RotateCcwIcon,
Search,
Trash2,
@ -428,7 +428,7 @@ export function AllSharedChatsSidebarContent({
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { ArchiveIcon, MoreHorizontal, PenLine, RotateCcwIcon, Trash2 } from "lucide-react";
import { ArchiveIcon, MoreHorizontal, Pencil, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
@ -106,7 +106,7 @@ export function ChatListItem({
onRename();
}}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}

View file

@ -6,9 +6,14 @@ import {
ChevronLeft,
ChevronRight,
FileText,
Folder,
FolderPlus,
FolderClock,
Laptop,
Lock,
Paperclip,
Search,
Server,
Trash2,
Unplug,
Upload,
@ -58,8 +63,19 @@ import {
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
@ -68,17 +84,39 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
const MAX_LOCAL_FILESYSTEM_ROOTS = 5;
type FilesystemSettings = {
mode: "cloud" | "desktop_local_folder";
localRootPaths: string[];
updatedAt: string;
};
interface WatchedFolderEntry {
path: string;
name: string;
excludePatterns: string[];
fileExtensions: string[] | null;
rootFolderId: number | null;
searchSpaceId: number;
active: boolean;
}
const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath;
const SHOWCASE_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
@ -133,12 +171,119 @@ function AuthenticatedDocumentsSidebar({
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [localSearch, setLocalSearch] = useState("");
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
const localSearchInputRef = useRef<HTMLInputElement>(null);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null);
const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false);
const [pendingLocalPath, setPendingLocalPath] = useState<string | null>(null);
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
const isElectron = typeof window !== "undefined" && !!window.electronAPI;
useEffect(() => {
if (!electronAPI?.getAgentFilesystemSettings) return;
let mounted = true;
electronAPI
.getAgentFilesystemSettings()
.then((settings: FilesystemSettings) => {
if (!mounted) return;
setFilesystemSettings(settings);
})
.catch(() => {
if (!mounted) return;
setFilesystemSettings({
mode: "cloud",
localRootPaths: [],
updatedAt: new Date().toISOString(),
});
});
return () => {
mounted = false;
};
}, [electronAPI]);
const hasLocalFilesystemTrust = useCallback(() => {
try {
return window.localStorage.getItem(LOCAL_FILESYSTEM_TRUST_KEY) === "true";
} catch {
return false;
}
}, []);
const localRootPaths = filesystemSettings?.localRootPaths ?? [];
const canAddMoreLocalRoots = localRootPaths.length < MAX_LOCAL_FILESYSTEM_ROOTS;
const applyLocalRootPath = useCallback(
async (path: string) => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const nextLocalRootPaths = [...localRootPaths, path]
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
if (nextLocalRootPaths.length === localRootPaths.length) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths,
});
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths]
);
const runPickLocalRoot = useCallback(async () => {
if (!electronAPI?.pickAgentFilesystemRoot) return;
const picked = await electronAPI.pickAgentFilesystemRoot();
if (!picked) return;
await applyLocalRootPath(picked);
}, [applyLocalRootPath, electronAPI]);
const handlePickFilesystemRoot = useCallback(async () => {
if (!canAddMoreLocalRoots) return;
if (hasLocalFilesystemTrust()) {
await runPickLocalRoot();
return;
}
if (!electronAPI?.pickAgentFilesystemRoot) return;
const picked = await electronAPI.pickAgentFilesystemRoot();
if (!picked) return;
setPendingLocalPath(picked);
setLocalTrustDialogOpen(true);
}, [canAddMoreLocalRoots, electronAPI, hasLocalFilesystemTrust, runPickLocalRoot]);
const handleRemoveFilesystemRoot = useCallback(
async (rootPathToRemove: string) => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
});
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths]
);
const handleClearFilesystemRoots = useCallback(async () => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: [],
});
setFilesystemSettings(updated);
}, [electronAPI]);
const handleFilesystemTabChange = useCallback(
async (tab: "cloud" | "local") => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
});
setFilesystemSettings(updated);
},
[electronAPI]
);
// AI File Sort state
const { data: searchSpaces, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
const activeSearchSpace = useMemo(
@ -196,7 +341,7 @@ function AuthenticatedDocumentsSidebar({
if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI;
const folders = await api.getWatchedFolders();
const folders = (await api.getWatchedFolders()) as WatchedFolderEntry[];
if (folders.length === 0) {
try {
@ -214,9 +359,11 @@ function AuthenticatedDocumentsSidebar({
active: true,
});
}
const recovered = await api.getWatchedFolders();
const recovered = (await api.getWatchedFolders()) as WatchedFolderEntry[];
const ids = new Set(
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
recovered
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
return;
@ -226,7 +373,9 @@ function AuthenticatedDocumentsSidebar({
}
const ids = new Set(
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
folders
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
}, [searchSpaceId, electronAPI]);
@ -375,8 +524,8 @@ function AuthenticatedDocumentsSidebar({
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -405,8 +554,8 @@ function AuthenticatedDocumentsSidebar({
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -438,8 +587,10 @@ function AuthenticatedDocumentsSidebar({
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
if (electronAPI) {
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (matched) {
await electronAPI.removeWatchedFolder(matched.path);
}
@ -836,59 +987,11 @@ function AuthenticatedDocumentsSidebar({
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
const documentsContent = (
<>
<div className="shrink-0 flex h-14 items-center px-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
</div>
<div className="flex items-center gap-1">
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
onDockedChange(false);
onOpenChange(false);
} else {
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
{headerAction}
</div>
</div>
</div>
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const cloudContent = (
<>
{/* Connected tools strip */}
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button
@ -1039,6 +1142,231 @@ function AuthenticatedDocumentsSidebar({
/>
</div>
</div>
</>
);
const localContent = (
<div className="flex min-h-0 flex-1 flex-col select-none">
<div className="mx-4 mt-4 mb-3">
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
{localRootPaths.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
title={localRootPaths.join("\n")}
aria-label="Manage selected folders"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{localRootPaths.length === 1
? "1 folder selected"
: `${localRootPaths.length} folders selected`}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Selected folders
</DropdownMenuLabel>
<DropdownMenuSeparator className="mx-1 my-0.5" />
{localRootPaths.map((rootPath) => (
<DropdownMenuItem
key={rootPath}
onClick={() => {
void handleRemoveFilesystemRoot(rootPath);
}}
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
>
<Folder className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{getFolderDisplayName(rootPath)}
</span>
<X className="size-3 text-muted-foreground transition-colors group-hover:text-foreground" />
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="mx-1 my-0.5" />
<DropdownMenuItem
variant="destructive"
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
onClick={() => {
void handleClearFilesystemRoots();
}}
>
Clear all folders
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className="min-w-0 flex-1 flex items-center gap-1 px-2"
title="No local folders selected"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">No local folders selected</span>
</div>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-3 self-center bg-border"
/>
<button
type="button"
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
onClick={() => {
void handlePickFilesystemRoot();
}}
disabled={!canAddMoreLocalRoots}
aria-label="Add folder"
title="Add folder"
>
<FolderPlus className="size-3.5" />
</button>
</div>
</div>
<div className="mx-4 mb-2">
<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">
<Search size={13} aria-hidden="true" />
</div>
<Input
ref={localSearchInputRef}
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search local files"
type="text"
aria-label="Search local files"
/>
{Boolean(localSearch) && (
<button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear local search"
onClick={() => {
setLocalSearch("");
localSearchInputRef.current?.focus();
}}
>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
</div>
<LocalFilesystemBrowser
rootPaths={localRootPaths}
searchSpaceId={searchSpaceId}
searchQuery={debouncedLocalSearch.trim() || undefined}
onOpenFile={(localFilePath) => {
openEditorPanel({
kind: "local_file",
localFilePath,
title: localFilePath.split("/").pop() || localFilePath,
searchSpaceId,
});
}}
/>
</div>
);
const documentsContent = (
<>
<div className="shrink-0 flex h-14 items-center px-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
{showFilesystemTabs && (
<Tabs
value={currentFilesystemTab}
onValueChange={(value) => {
void handleFilesystemTabChange(value === "local" ? "local" : "cloud");
}}
>
<TabsList className="h-6 gap-0 rounded-md bg-muted/60 p-0.5 select-none">
<TabsTrigger
value="cloud"
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
title="Cloud"
>
<Server className="size-3" />
<span>Cloud</span>
</TabsTrigger>
<TabsTrigger
value="local"
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
title="Local"
>
<Laptop className="size-3" />
<span>Local</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
</div>
<div className="flex items-center gap-1">
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
onDockedChange(false);
onOpenChange(false);
} else {
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
{headerAction}
</div>
</div>
</div>
{showFilesystemTabs ? (
<Tabs
value={currentFilesystemTab}
onValueChange={(value) => {
void handleFilesystemTabChange(value === "local" ? "local" : "cloud");
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsContent value="cloud" className="mt-0 flex min-h-0 flex-1 flex-col">
{cloudContent}
</TabsContent>
<TabsContent value="local" className="mt-0 flex min-h-0 flex-1 flex-col">
{localContent}
</TabsContent>
</Tabs>
) : (
cloudContent
)}
{versionDocId !== null && (
<VersionHistoryDialog
@ -1062,6 +1390,48 @@ function AuthenticatedDocumentsSidebar({
onSuccess={refreshWatchedIds}
/>
)}
<AlertDialog
open={localTrustDialogOpen}
onOpenChange={(nextOpen) => {
setLocalTrustDialogOpen(nextOpen);
if (!nextOpen) setPendingLocalPath(null);
}}
>
<AlertDialogContent className="sm:max-w-md select-none">
<AlertDialogHeader>
<AlertDialogTitle>Trust this workspace?</AlertDialogTitle>
<AlertDialogDescription>
Local mode can read and edit files inside the folders you select. Continue only if
you trust this workspace and its contents.
</AlertDialogDescription>
{pendingLocalPath && (
<AlertDialogDescription className="mt-1 whitespace-pre-wrap break-words font-mono text-xs">
Folder path: {pendingLocalPath}
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
try {
window.localStorage.setItem(LOCAL_FILESYSTEM_TRUST_KEY, "true");
} catch {}
setLocalTrustDialogOpen(false);
const path = pendingLocalPath;
setPendingLocalPath(null);
if (path) {
await applyLocalRootPath(path);
} else {
await runPickLocalRoot();
}
}}
>
I trust this workspace
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<FolderPickerDialog
open={folderPickerOpen}
@ -1312,24 +1682,12 @@ function AnonymousDocumentsSidebar({
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/upload`, {
method: "POST",
credentials: "include",
body: formData,
});
if (res.status === 409) {
gate("upload more documents");
const result = await anonymousChatApiService.uploadDocument(file);
if (!result.ok) {
if (result.reason === "quota_exceeded") gate("upload more documents");
return;
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Upload failed: ${res.status}`);
}
const data = await res.json();
const data = result.data;
if (anonMode.isAnonymous) {
anonMode.setUploadedDoc({
filename: data.filename,

View file

@ -0,0 +1,314 @@
"use client";
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
interface LocalFilesystemBrowserProps {
rootPaths: string[];
searchSpaceId: number;
searchQuery?: string;
onOpenFile: (fullPath: string) => void;
}
interface LocalFolderFileEntry {
relativePath: string;
fullPath: string;
size: number;
mtimeMs: number;
}
type RootLoadState = {
loading: boolean;
error: string | null;
files: LocalFolderFileEntry[];
};
interface LocalFolderNode {
key: string;
name: string;
folders: Map<string, LocalFolderNode>;
files: LocalFolderFileEntry[];
}
type LocalRootMount = {
mount: string;
rootPath: string;
};
const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath;
function createFolderNode(key: string, name: string): LocalFolderNode {
return {
key,
name,
folders: new Map(),
files: [],
};
}
function getFileName(pathValue: string): string {
return pathValue.split(/[\\/]/).at(-1) || pathValue;
}
function toVirtualPath(relativePath: string): string {
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return `/${normalized}`;
}
function normalizeRootPathForLookup(rootPath: string, isWindows: boolean): string {
const normalized = rootPath.replace(/\\/g, "/").replace(/\/+$/, "");
return isWindows ? normalized.toLowerCase() : normalized;
}
function toMountedVirtualPath(mount: string, relativePath: string): string {
return `/${mount}${toVirtualPath(relativePath)}`;
}
export function LocalFilesystemBrowser({
rootPaths,
searchSpaceId,
searchQuery,
onOpenFile,
}: LocalFilesystemBrowserProps) {
const electronAPI = useElectronAPI();
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set());
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
useEffect(() => {
if (!electronAPI?.listFolderFiles) return;
let cancelled = false;
for (const rootPath of rootPaths) {
setRootStateMap((prev) => ({
...prev,
[rootPath]: {
loading: true,
error: null,
files: prev[rootPath]?.files ?? [],
},
}));
}
void Promise.all(
rootPaths.map(async (rootPath) => {
try {
const files = (await electronAPI.listFolderFiles({
path: rootPath,
name: getFolderDisplayName(rootPath),
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
rootFolderId: null,
searchSpaceId,
active: true,
})) as LocalFolderFileEntry[];
if (cancelled) return;
setRootStateMap((prev) => ({
...prev,
[rootPath]: {
loading: false,
error: null,
files,
},
}));
} catch (error) {
if (cancelled) return;
setRootStateMap((prev) => ({
...prev,
[rootPath]: {
loading: false,
error: error instanceof Error ? error.message : "Failed to read folder",
files: [],
},
}));
}
})
);
return () => {
cancelled = true;
};
}, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]);
useEffect(() => {
if (!electronAPI?.getAgentFilesystemMounts) {
setMountByRootKey(new Map());
return;
}
let cancelled = false;
void electronAPI
.getAgentFilesystemMounts()
.then((mounts: LocalRootMount[]) => {
if (cancelled) return;
const next = new Map<string, string>();
for (const entry of mounts) {
next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount);
}
setMountByRootKey(next);
})
.catch(() => {
if (cancelled) return;
setMountByRootKey(new Map());
});
return () => {
cancelled = true;
};
}, [electronAPI, isWindowsPlatform, rootPaths]);
const treeByRoot = useMemo(() => {
const query = searchQuery?.trim().toLowerCase() ?? "";
const hasQuery = query.length > 0;
return rootPaths.map((rootPath) => {
const rootNode = createFolderNode(rootPath, getFolderDisplayName(rootPath));
const allFiles = rootStateMap[rootPath]?.files ?? [];
const files = hasQuery
? allFiles.filter((file) => {
const relativePath = file.relativePath.toLowerCase();
const fileName = getFileName(file.relativePath).toLowerCase();
return relativePath.includes(query) || fileName.includes(query);
})
: allFiles;
for (const file of files) {
const parts = file.relativePath.split(/[\\/]/).filter(Boolean);
let cursor = rootNode;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
const folderKey = `${cursor.key}/${part}`;
if (!cursor.folders.has(part)) {
cursor.folders.set(part, createFolderNode(folderKey, part));
}
cursor = cursor.folders.get(part) as LocalFolderNode;
}
cursor.files.push(file);
}
return { rootPath, rootNode, matchCount: files.length, totalCount: allFiles.length };
});
}, [rootPaths, rootStateMap, searchQuery]);
const toggleFolder = useCallback((folderKey: string) => {
setExpandedFolderKeys((prev) => {
const next = new Set(prev);
if (next.has(folderKey)) {
next.delete(folderKey);
} else {
next.add(folderKey);
}
return next;
});
}, []);
const renderFolder = useCallback(
(folder: LocalFolderNode, depth: number, mount: string) => {
const isExpanded = expandedFolderKeys.has(folder.key);
const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
const files = [...folder.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
return (
<div key={folder.key} className="select-none">
<button
type="button"
onClick={() => toggleFolder(folder.key)}
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
style={{ paddingInlineStart: `${depth * 12 + 8}px` }}
draggable={false}
>
{isExpanded ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{folder.name}</span>
</button>
{isExpanded && (
<>
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
{files.map((file) => (
<button
key={file.fullPath}
type="button"
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))}
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={file.fullPath}
draggable={false}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getFileName(file.relativePath)}</span>
</button>
))}
</>
)}
</div>
);
},
[expandedFolderKeys, onOpenFile, toggleFolder]
);
if (rootPaths.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-10 text-center text-muted-foreground">
<p className="text-sm font-medium">No local folder selected</p>
<p className="text-xs text-muted-foreground/80">
Add a local folder above to browse files in desktop mode.
</p>
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => {
const state = rootStateMap[rootPath];
const rootKey = normalizeRootPathForLookup(rootPath, isWindowsPlatform);
const mount = mountByRootKey.get(rootKey);
if (!state || state.loading) {
return (
<div key={rootPath} className="flex h-16 items-center gap-2 px-3 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
);
}
if (state.error) {
return (
<div key={rootPath} className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">Failed to load local folder</p>
<p className="mt-1 text-xs text-muted-foreground">{state.error}</p>
</div>
);
}
const isEmpty = totalCount === 0;
return (
<div key={rootPath} className="mb-1">
{mount ? renderFolder(rootNode, 0, mount) : null}
{!mount && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
Unable to resolve mounted root for this folder.
</div>
)}
{isEmpty && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
No supported files found in this folder.
</div>
)}
{!isEmpty && matchCount === 0 && searchQuery && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
No matching files in this folder.
</div>
)}
</div>
);
})}
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { CreditCard, PenSquare, Zap } from "lucide-react";
import { CreditCard, SquarePen, Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@ -139,7 +139,7 @@ export function Sidebar({
{/* New chat button */}
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
<SidebarButton
icon={PenSquare}
icon={SquarePen}
label={t("new_chat")}
onClick={onNewChat}
isCollapsed={isCollapsed}

View file

@ -1,6 +1,6 @@
"use client";
import { PanelLeft, PanelLeftClose } from "lucide-react";
import { PanelLeft } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
@ -23,7 +23,7 @@ export function SidebarCollapseButton({
const button = (
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
<PanelLeft className="h-4 w-4" />
<span className="sr-only">{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}</span>
</Button>
);

View file

@ -7,8 +7,8 @@ import {
ExternalLink,
Info,
Languages,
Laptop,
LogOut,
Monitor,
Moon,
Sun,
UserCog,
@ -49,7 +49,7 @@ const LANGUAGES = [
const THEMES = [
{ value: "light" as const, name: "Light", icon: Sun },
{ value: "dark" as const, name: "Dark", icon: Moon },
{ value: "system" as const, name: "System", icon: Laptop },
{ value: "system" as const, name: "System", icon: Monitor },
];
const LEARN_MORE_LINKS = [

View file

@ -1,6 +1,6 @@
"use client";
import { Download, FileQuestionMark, FileText, PenLine, 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";
@ -259,7 +259,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
onClick={() => setIsEditing(true)}
className="gap-1.5"
>
<PenLine className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { Check, ChevronDownIcon, Copy, Pencil, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@ -116,6 +116,7 @@ export function ReportPanelContent({
const [exporting, setExporting] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const changeCountRef = useRef(0);
useEffect(() => {
return () => {
@ -125,6 +126,7 @@ export function ReportPanelContent({
// Editor state — tracks the latest markdown from the Plate editor
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
// Read-only when public (shareToken) OR shared (SEARCH_SPACE visibility)
const currentThreadState = useAtomValue(currentThreadAtom);
@ -188,8 +190,22 @@ export function ReportPanelContent({
// Reset edited markdown when switching versions or reports
useEffect(() => {
setEditedMarkdown(null);
setIsEditing(false);
changeCountRef.current = 0;
}, [activeReportId]);
const handleReportMarkdownChange = useCallback(
(nextMarkdown: string) => {
if (!isEditing) return;
changeCountRef.current += 1;
// Plate may emit an initial normalize/serialize change on mount.
if (changeCountRef.current <= 1) return;
const savedMarkdown = reportContent?.content ?? "";
setEditedMarkdown(nextMarkdown === savedMarkdown ? null : nextMarkdown);
},
[isEditing, reportContent?.content]
);
// Copy markdown content (uses latest editor content)
const handleCopy = useCallback(async () => {
if (!currentMarkdown) return;
@ -257,7 +273,7 @@ export function ReportPanelContent({
// Save edited report content
const handleSave = useCallback(async () => {
if (!currentMarkdown || !activeReportId) return;
if (!currentMarkdown || !activeReportId) return false;
setSaving(true);
try {
const response = await authenticatedFetch(
@ -278,9 +294,11 @@ export function ReportPanelContent({
setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev));
setEditedMarkdown(null);
toast.success("Report saved successfully");
return true;
} catch (err) {
console.error("Error saving report:", err);
toast.error(err instanceof Error ? err.message : "Failed to save report");
return false;
} finally {
setSaving(false);
}
@ -288,26 +306,21 @@ export function ReportPanelContent({
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
const isPublic = !!shareToken;
const btnBg = isPublic ? "bg-main-panel" : "bg-sidebar";
const isResume = reportContent?.content_type === "typst";
const showReportEditingTier = !isResume;
const hasUnsavedChanges = editedMarkdown !== null;
const handleCancelEditing = useCallback(() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(false);
}, []);
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">
{/* Copy button — hidden for Typst (resume) */}
{reportContent?.content_type !== "typst" && (
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={isLoading || !reportContent?.content}
className={`h-8 min-w-[80px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
>
{copied ? "Copied" : "Copy"}
</Button>
)}
{/* Export — plain button for resume (typst), dropdown for others */}
{reportContent?.content_type === "typst" ? (
<Button
@ -315,7 +328,7 @@ export function ReportPanelContent({
size="sm"
onClick={() => handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
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>
@ -326,7 +339,7 @@ export function ReportPanelContent({
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
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" />
@ -352,7 +365,7 @@ export function ReportPanelContent({
<Button
variant="outline"
size="sm"
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
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" />
@ -383,6 +396,75 @@ export function ReportPanelContent({
)}
</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>
</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>
)}
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
<div className="flex-1 overflow-hidden">
{isLoading ? (
@ -406,15 +488,16 @@ export function ReportPanelContent({
</div>
) : (
<PlateEditor
key={`report-${activeReportId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={reportContent.content}
onMarkdownChange={setEditedMarkdown}
readOnly={false}
onMarkdownChange={handleReportMarkdownChange}
readOnly={!isEditing}
placeholder="Report content..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
)

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pen } from "lucide-react";
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pencil } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
@ -247,7 +247,7 @@ export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) {
onClick={openInput}
className="absolute bottom-3 right-3 z-10 h-[54px] w-[54px] rounded-full border bg-muted/60 backdrop-blur-sm shadow-sm"
>
<Pen className="!h-5 !w-5" />
<Pencil className="!h-5 !w-5" />
</Button>
)}
</div>

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { Brain, CircleUser, Globe, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
import { Brain, CircleUser, Globe, Keyboard, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
@ -51,6 +51,13 @@ const DesktopContent = dynamic(
),
{ ssr: false }
);
const DesktopShortcutsContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent").then(
(m) => ({ default: m.DesktopShortcutsContent })
),
{ ssr: false }
);
const MemoryContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/MemoryContent").then(
@ -93,7 +100,18 @@ export function UserSettingsDialog() {
icon: <ReceiptText className="h-4 w-4" />,
},
...(isDesktop
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
? [
{
value: "desktop",
label: "App Preferences",
icon: <Monitor className="h-4 w-4" />,
},
{
value: "desktop-shortcuts",
label: "Hotkeys",
icon: <Keyboard className="h-4 w-4" />,
},
]
: []),
],
[t, isDesktop]
@ -116,6 +134,7 @@ export function UserSettingsDialog() {
{state.initialTab === "memory" && <MemoryContent />}
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
{state.initialTab === "desktop" && <DesktopContent />}
{state.initialTab === "desktop-shortcuts" && <DesktopShortcutsContent />}
</div>
</SettingsDialog>
);

View file

@ -546,6 +546,7 @@ export function DocumentUploadTab({
</button>
)
) : (
// biome-ignore lint/a11y/useSemanticElements: cannot use <button> here because the contents include nested interactive elements (renderBrowseButton renders a Button), which would be invalid HTML.
<div
role="button"
tabIndex={0}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -222,7 +222,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -241,7 +241,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -224,7 +224,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -20,6 +20,7 @@ const GenerateResumeArgsSchema = z.object({
user_info: z.string(),
user_instructions: z.string().nullish(),
parent_report_id: z.number().nullish(),
max_pages: z.number().int().min(1).max(5).optional(),
});
const GenerateResumeResultSchema = z.object({

View file

@ -1,8 +1,9 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -116,8 +117,8 @@ function GenericApprovalCard({
if (phase !== "pending" || !isMCPTool) return;
setProcessing();
onDecision({ type: "approve" });
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch((err) => {
console.error("Failed to trust MCP tool:", err);
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
toast.error("Failed to save 'Always Allow' preference. The tool will still require approval next time.");
});
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
@ -167,7 +168,7 @@ function GenericApprovalCard({
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => setIsEditing(true)}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { CornerDownLeftIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -251,7 +251,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -250,7 +250,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -283,7 +283,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pen, UsersIcon } from "lucide-react";
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pencil, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -332,7 +332,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -7,7 +7,7 @@ import {
ClockIcon,
CornerDownLeftIcon,
MapPinIcon,
Pen,
Pencil,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
@ -415,7 +415,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -240,7 +240,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -257,7 +257,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -273,7 +273,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -269,7 +269,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -332,7 +332,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -219,7 +219,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -196,7 +196,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -209,7 +209,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { BookOpenIcon, PenLineIcon } from "lucide-react";
import { BookOpenIcon, Pencil } from "lucide-react";
import { usePlateState } from "platejs/react";
import { ToolbarButton } from "./toolbar";
@ -13,7 +13,7 @@ export function ModeToolbarButton() {
tooltip={readOnly ? "Click to edit" : "Click to view"}
onClick={() => setReadOnly(!readOnly)}
>
{readOnly ? <BookOpenIcon /> : <PenLineIcon />}
{readOnly ? <BookOpenIcon /> : <Pencil />}
</ToolbarButton>
);
}