mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 18:36:23 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/obsidian-plugin
This commit is contained in:
commit
9b1b9a90c0
175 changed files with 10592 additions and 2302 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "Read Message History" permission to index channels. Ask a
|
||||
The bot needs "Read Message History" 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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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're a member of the teams
|
||||
you want to interact with.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
152
surfsense_web/components/editor/source-code-editor.tsx
Normal file
152
surfsense_web/components/editor/source-code-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue