Merge branch 'dev' into fix/replace-transition-all-with-specific-transitions

This commit is contained in:
Soham Bhattacharjee 2026-04-08 05:38:30 +05:30 committed by GitHub
commit e404b05b11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
295 changed files with 25773 additions and 10799 deletions

View file

@ -87,8 +87,14 @@ import {
} from "@/components/ui/drawer";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
// Captured once at module load — survives client-side navigations that strip the query param.
const IS_QUICK_ASSIST_WINDOW =
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("quickAssist") === "true";
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
const GenerateVideoPresentationToolUI = dynamic(
() =>
@ -463,16 +469,9 @@ export const AssistantMessage: FC = () => {
const AssistantActionBar: FC = () => {
const isLast = useAuiState((s) => s.message.isLast);
const aui = useAui();
const [quickAskMode, setQuickAskMode] = useState("");
const api = useElectronAPI();
useEffect(() => {
if (!isLast || !window.electronAPI?.getQuickAskMode) return;
window.electronAPI.getQuickAskMode().then((mode) => {
if (mode) setQuickAskMode(mode);
});
}, [isLast]);
const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW;
return (
<ActionBarPrimitive.Root
@ -482,7 +481,7 @@ const AssistantActionBar: FC = () => {
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<TooltipIconButton tooltip="Copy to clipboard">
<AuiIf condition={({ message }) => message.isCopied}>
<CheckIcon />
</AuiIf>
@ -492,29 +491,27 @@ const AssistantActionBar: FC = () => {
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.ExportMarkdown asChild>
<TooltipIconButton tooltip="Download">
<TooltipIconButton tooltip="Download as Markdown">
<DownloadIcon />
</TooltipIconButton>
</ActionBarPrimitive.ExportMarkdown>
{isLast && (
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<TooltipIconButton tooltip="Regenerate response">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
)}
{isTransform && (
<button
type="button"
{isQuickAssist && (
<TooltipIconButton
tooltip="Paste back into source app"
onClick={() => {
const text = aui.message().getCopyText();
window.electronAPI?.replaceText(text);
api?.replaceText(text);
}}
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<ClipboardPaste className="size-3.5" />
Paste back
</button>
<ClipboardPaste />
</TooltipIconButton>
)}
</ActionBarPrimitive.Root>
);

View file

@ -11,7 +11,6 @@ import {
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@ -47,7 +46,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
(_props, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
useAtomValue(currentUserAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -218,7 +216,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onPointerDownOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
@ -376,14 +374,17 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
{/* LLM Configuration Warning */}
{!llmConfigLoading && !hasDocumentSummaryLLM && (
<Alert variant="destructive" className="mb-6">
<Alert
variant="destructive"
className="mb-6 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
{isAutoMode && !hasGlobalConfigs
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"

View file

@ -58,7 +58,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return MCPConnectForm;
case "OBSIDIAN_CONNECTOR":
return ObsidianConnectForm;
// Add other connector types here as needed
default:
return null;
}

View file

@ -34,9 +34,12 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
const [isLoading, setIsLoading] = useState(true);
const [copied, setCopied] = useState(false);
// Fetch webhook info
// Fetch webhook info
useEffect(() => {
const fetchWebhookInfo = async () => {
const controller = new AbortController();
const doFetch = async () => {
if (!connector.search_space_id) return;
const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL;
@ -49,8 +52,11 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
setIsLoading(true);
try {
const response = await authenticatedFetch(
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`,
{ signal: controller.signal }
);
if (controller.signal.aborted) return;
if (response.ok) {
const data: unknown = await response.json();
// Runtime validation with zod schema
@ -59,16 +65,18 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
setWebhookUrl(validatedData.webhook_url);
}
} catch (error) {
if (controller.signal.aborted) return;
console.error("Failed to fetch webhook info:", error);
// Reset state on error
setWebhookInfo(null);
setWebhookUrl("");
} finally {
setIsLoading(false);
if (!controller.signal.aborted) setIsLoading(false);
}
};
fetchWebhookInfo();
doFetch().catch(() => {});
return () => controller.abort();
}, [connector.search_space_id]);
const handleNameChange = (value: string) => {

View file

@ -144,18 +144,14 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
type="button"
onClick={handleFormSubmit}
disabled={isSubmitting}
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
className="relative text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
>
{isSubmitting ? (
<>
<Spinner size="sm" className="mr-2" />
Connecting
</>
) : connectorType === "MCP_CONNECTOR" ? (
"Connect"
) : (
`Connect ${getConnectorTypeDisplay(connectorType)}`
)}
<span className={isSubmitting ? "opacity-0" : ""}>
{connectorType === "MCP_CONNECTOR"
? "Connect"
: `Connect ${getConnectorTypeDisplay(connectorType)}`}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div>

View file

@ -272,7 +272,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "DROPBOX_CONNECTOR" &&
@ -293,9 +293,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
/>
)}
{/* Periodic sync - shown for all indexable connectors */}
{(() => {
// Check if Google Drive (regular or Composio) has folders/files selected
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
@ -371,16 +369,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
size="sm"
onClick={handleDisconnectConfirm}
disabled={isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-10 sm:h-auto py-2 sm:py-2"
className="relative text-xs sm:text-sm flex-1 sm:flex-initial h-10 sm:h-auto py-2 sm:py-2"
>
{isDisconnecting ? (
<>
<Spinner size="sm" className="mr-2" />
Disconnecting
</>
) : (
"Confirm Disconnect"
)}
<span className={isDisconnecting ? "opacity-0" : ""}>Confirm Disconnect</span>
{isDisconnecting && <Spinner size="sm" className="absolute" />}
</Button>
<Button
variant="ghost"
@ -417,16 +409,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<Button
onClick={onSave}
disabled={isSaving || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
className="relative text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
>
{isSaving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (
"Save Changes"
)}
<span className={isSaving ? "opacity-0" : ""}>Save Changes</span>
{isSaving && <Spinner size="sm" className="absolute" />}
</Button>
)}
</div>

View file

@ -158,7 +158,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "DROPBOX_CONNECTOR" &&
@ -179,9 +179,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
/>
)}
{/* Periodic sync - not shown for Google Drive (regular and Composio) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "DROPBOX_CONNECTOR" &&
config.connectorType !== "ONEDRIVE_CONNECTOR" && (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}

View file

@ -1,8 +1,8 @@
"use client";
import { Cable } from "lucide-react";
import { Search, Unplug } from "lucide-react";
import type { FC } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { getDocumentTypeLabel } from "@/components/documents/DocumentTypeIcon";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { TabsContent } from "@/components/ui/tabs";
@ -134,9 +134,17 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
const hasFilteredResults = hasActiveConnectors || standaloneDocuments.length > 0;
return (
<TabsContent value="active" className="m-0">
{hasSources ? (
{hasSources && !hasFilteredResults && searchQuery ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
) : hasSources ? (
<div className="space-y-6">
{/* Active Connectors Section */}
{hasActiveConnectors && (
@ -302,7 +310,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
) : (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<Cable className="size-8 text-muted-foreground" />
<Unplug className="size-8 text-muted-foreground" />
</div>
<h4 className="text-lg font-semibold">No active sources</h4>
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">

View file

@ -1,8 +1,10 @@
"use client";
import { Search } from "lucide-react";
import type { FC } from "react";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { usePlatform } from "@/hooks/use-platform";
import { isSelfHosted } from "@/lib/env-config";
import { ConnectorCard } from "../components/connector-card";
import {
@ -74,31 +76,27 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Check if self-hosted mode (for showing self-hosted only connectors)
const selfHosted = isSelfHosted();
const { isDesktop } = usePlatform();
const matchesSearch = (title: string, description: string) =>
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
description.toLowerCase().includes(searchQuery.toLowerCase());
const passesDeploymentFilter = (c: { selfHostedOnly?: boolean; desktopOnly?: boolean }) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
// Filter connectors based on search and deployment mode
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) =>
// Filter by search query
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
// Filter self-hosted only connectors in cloud mode
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredCrawlers = CRAWLERS.filter(
(c) =>
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredOther = OTHER_CONNECTORS.filter(
(c) =>
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
// Filter Composio connectors
@ -290,6 +288,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
moreIntegrationsOther.length > 0 ||
moreIntegrationsCrawlers.length > 0;
const hasAnyResults = hasDocumentFileConnectors || hasMoreIntegrations;
if (!hasAnyResults && searchQuery) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Document/Files Connectors */}

View file

@ -173,9 +173,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<Plus className="size-3 text-primary" />
)}
</div>
<span className="text-xs sm:text-sm font-medium">
{isConnecting ? "Connecting" : buttonText}
</span>
<span className="text-xs sm:text-sm font-medium">{buttonText}</span>
</button>
</div>
</div>

View file

@ -335,16 +335,10 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
<Button
onClick={handleSubmit}
disabled={isSubmitting || isFetchingPlaylist || videoTags.length === 0}
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
className="relative text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
>
{isSubmitting ? (
<>
<Spinner size="sm" className="mr-2" />
{t("processing")}
</>
) : (
t("submit")
)}
<span className={isSubmitting ? "opacity-0" : ""}>{t("submit")}</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div>

View file

@ -125,38 +125,33 @@ const DocumentUploadPopupContent: FC<{
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-5 sm:[&>button]:top-8 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button]:z-[100] [&>button>svg]:size-4 sm:[&>button>svg]:size-5"
>
<DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Scrollable container for mobile */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
{/* Header - scrolls with content on mobile */}
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-12 pt-4 sm:pt-10 pb-2 sm:pb-0">
{/* Upload header */}
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
<div className="flex-1 min-w-0 pr-8 sm:pr-0">
<h2 className="text-base sm:text-2xl font-semibold tracking-tight">
Upload Documents
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1 line-clamp-1 sm:line-clamp-none">
Upload and sync your documents to your search space
</p>
</div>
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-6 sm:pt-8 pb-10">
<div className="flex items-center gap-2 mb-1 pr-8 sm:pr-0">
<h2 className="text-xl sm:text-3xl font-semibold tracking-tight">Upload Documents</h2>
</div>
<p className="text-xs sm:text-base text-muted-foreground/80 line-clamp-1">
Upload and sync your documents to your search space
</p>
</div>
{/* Content */}
<div className="px-4 sm:px-12 pb-4 sm:pb-16">
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
{!isLoading && !hasDocumentSummaryLLM ? (
<Alert variant="destructive" className="mb-4">
<Alert
variant="destructive"
className="mb-4 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
{isAutoMode && !hasGlobalConfigs
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents."
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
@ -179,9 +174,6 @@ const DocumentUploadPopupContent: FC<{
)}
</div>
</div>
{/* Bottom fade shadow - hidden on very small screens */}
<div className="hidden sm:block absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</DialogContent>
</Dialog>
);

View file

@ -3,6 +3,7 @@
import type { ImageMessagePartComponent } from "@assistant-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import { ImageIcon, ImageOffIcon } from "lucide-react";
import NextImage from "next/image";
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
@ -86,8 +87,8 @@ function ImagePreview({
>
<ImageOffIcon className="size-8 text-muted-foreground" />
</div>
) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
) : isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
ref={imgRef}
src={src}
@ -103,6 +104,40 @@ function ImagePreview({
}}
{...props}
/>
) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
// <img
// ref={imgRef}
// src={src}
// alt={alt}
// className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
// onLoad={(e) => {
// if (typeof src === "string") setLoadedSrc(src);
// onLoad?.(e);
// }}
// onError={(e) => {
// if (typeof src === "string") setErrorSrc(src);
// onError?.(e);
// }}
// {...props}
// />
<NextImage
fill
src={src || ""}
alt={alt}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
className={cn("block object-contain", !loaded && "invisible", className)}
onLoad={() => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.();
}}
onError={() => {
if (typeof src === "string") setErrorSrc(src);
onError?.();
}}
unoptimized={false}
{...props}
/>
)}
</div>
);
@ -126,7 +161,10 @@ type ImageZoomProps = PropsWithChildren<{
src: string;
alt?: string;
}>;
function isDataOrBlobUrl(src: string | undefined): boolean {
if (!src || typeof src !== "string") return false;
return src.startsWith("data:") || src.startsWith("blob:");
}
function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
const [isMounted, setIsMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
@ -177,22 +215,39 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
aria-label="Close zoomed image"
>
{/** biome-ignore lint/performance/noImgElement: <explanation> */}
<img
data-slot="image-zoom-content"
src={src}
alt={alt}
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
{isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
data-slot="image-zoom-content"
src={src}
alt={alt}
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}
}}
/>
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
handleClose();
}
}}
/>
) : (
<NextImage
data-slot="image-zoom-content"
fill
src={src}
alt={alt}
sizes="90vw"
className="aui-image-zoom-content fade-in zoom-in-95 object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
unoptimized={false}
/>
)}
</button>,
document.body
)}

View file

@ -32,7 +32,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
<button
type="button"
onClick={() => setIsOpen(true)}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-super shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
title={`View source chunk #${chunkId}`}
>
{chunkId}

View file

@ -24,6 +24,7 @@ export interface MentionedDocument {
export interface InlineMentionEditorRef {
focus: () => void;
clear: () => void;
setText: (text: string) => void;
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}
}, []);
// Replace editor content with plain text and place cursor at end
const setText = useCallback(
(text: string) => {
if (!editorRef.current) return;
editorRef.current.innerText = text;
const empty = text.length === 0;
setIsEmpty(empty);
onChange?.(text, Array.from(mentionedDocs.values()));
focusAtEnd();
},
[focusAtEnd, onChange, mentionedDocs]
);
const setDocumentChipStatus = useCallback(
(
docId: number,
@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
clear,
setText,
getText,
getMentionedDocuments,
insertDocumentChip,

View file

@ -9,7 +9,7 @@ import {
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { memo, useCallback, useEffect, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -224,6 +224,11 @@ const ThreadListItemComponent = memo(function ThreadListItemComponent({
onUnarchive,
onDelete,
}: ThreadListItemComponentProps) {
const relativeTime = useMemo(
() => formatRelativeTime(new Date(thread.updatedAt)),
[thread.updatedAt]
);
return (
<button
type="button"
@ -236,9 +241,7 @@ const ThreadListItemComponent = memo(function ThreadListItemComponent({
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{thread.title || "New Chat"}</p>
<p className="truncate text-xs text-muted-foreground">
{formatRelativeTime(new Date(thread.updatedAt))}
</p>
<p className="truncate text-xs text-muted-foreground">{relativeTime}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -15,6 +15,7 @@ import {
ChevronDown,
ChevronUp,
Clipboard,
Dot,
Globe,
Plus,
Settings2,
@ -88,17 +89,10 @@ import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */
const CYCLING_PLACEHOLDERS = [
"Ask SurfSense anything or @mention docs",
"Generate a podcast from my vacation ideas in Notion",
"Sum up last week's meeting notes from Drive in a bulleted list",
"Give me a brief overview of the most urgent tickets in Jira and Linear",
"Briefly, what are today's top ten important emails and calendar events?",
"Check if this week's Slack messages reference any GitHub issues",
];
const COMPOSER_PLACEHOLDER = "Ask anything · Type / for prompts · Type @ to mention docs";
export const Thread: FC = () => {
return <ThreadContent />;
@ -267,7 +261,7 @@ const ConnectToolsBanner: FC<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) =
<div className="flex w-full items-center gap-2.5 px-4 py-2.5">
<button
type="button"
className="flex flex-1 items-center gap-2.5 text-left cursor-pointer"
className="flex flex-1 items-center gap-2.5 text-left cursor-pointer select-none"
onClick={() => setConnectorDialogOpen(true)}
>
<Unplug className="size-4 text-muted-foreground shrink-0" />
@ -393,45 +387,23 @@ const Composer: FC = () => {
return () => ro.disconnect();
}, []);
const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false);
useEffect(() => {
if (!window.electronAPI || clipboardLoadedRef.current) return;
if (!electronAPI || clipboardLoadedRef.current) return;
clipboardLoadedRef.current = true;
window.electronAPI.getQuickAskText().then((text) => {
electronAPI.getQuickAskText().then((text) => {
if (text) {
setClipboardInitialText(text);
setShowPromptPicker(true);
}
});
}, []);
}, [electronAPI]);
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
// Cycling placeholder state - only cycles in new chats
const [placeholderIndex, setPlaceholderIndex] = useState(0);
// Cycle through placeholders every 4 seconds when thread is empty (new chat)
useEffect(() => {
// Only cycle when thread is empty (new chat)
if (!isThreadEmpty) {
// Reset to first placeholder when chat becomes active
setPlaceholderIndex(0);
return;
}
const intervalId = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length);
}, 6000);
return () => clearInterval(intervalId);
}, [isThreadEmpty]);
// Compute current placeholder - only cycle in new chats
const currentPlaceholder = isThreadEmpty
? CYCLING_PLACEHOLDERS[placeholderIndex]
: CYCLING_PLACEHOLDERS[0];
const currentPlaceholder = COMPOSER_PLACEHOLDER;
// Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -535,34 +507,28 @@ const Composer: FC = () => {
: userText
? `${action.prompt}\n\n${userText}`
: action.prompt;
editorRef.current?.setText(finalPrompt);
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setMentionedDocuments([]);
setSidebarDocs([]);
},
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
[actionQuery, aui]
);
const handleQuickAskSelect = useCallback(
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
if (!clipboardInitialText) return;
window.electronAPI?.setQuickAskMode(action.mode);
electronAPI?.setQuickAskMode(action.mode);
const finalPrompt = action.prompt.includes("{selection}")
? action.prompt.replace("{selection}", () => clipboardInitialText)
: `${action.prompt}\n\n${clipboardInitialText}`;
editorRef.current?.setText(finalPrompt);
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setClipboardInitialText(undefined);
setMentionedDocuments([]);
setSidebarDocs([]);
},
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
[clipboardInitialText, electronAPI, aui]
);
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
@ -838,12 +804,23 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const isDesktop = useMediaQuery("(min-width: 640px)");
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top");
const toolsRafRef = useRef<number>();
const handleToolsScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
if (toolsRafRef.current) return;
toolsRafRef.current = requestAnimationFrame(() => {
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
toolsRafRef.current = undefined;
});
}, []);
useEffect(
() => () => {
if (toolsRafRef.current) cancelAnimationFrame(toolsRafRef.current);
},
[]
);
const isComposerTextEmpty = useAuiState(({ composer }) => {
const text = composer.text?.trim() || "";
return text.length === 0;
@ -856,6 +833,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const { data: agentTools } = useAtomValue(agentToolsAtom);
const disabledTools = useAtomValue(disabledToolsAtom);
const disabledToolsSet = useMemo(() => new Set(disabledTools), [disabledTools]);
const toggleTool = useSetAtom(toggleToolAtom);
const setDisabledTools = useSetAtom(disabledToolsAtom);
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
@ -868,18 +846,18 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const toggleToolGroup = useCallback(
(toolNames: string[]) => {
const allDisabled = toolNames.every((name) => disabledTools.includes(name));
const allDisabled = toolNames.every((name) => disabledToolsSet.has(name));
if (allDisabled) {
setDisabledTools((prev) => prev.filter((t) => !toolNames.includes(t)));
} else {
setDisabledTools((prev) => [...new Set([...prev, ...toolNames])]);
}
},
[disabledTools, setDisabledTools]
[disabledToolsSet, setDisabledTools]
);
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
const isWebSearchEnabled = hasWebSearchTool && !disabledToolsSet.has("web_search");
const filteredTools = useMemo(
() => agentTools?.filter((t) => t.name !== "web_search"),
[agentTools]
@ -979,7 +957,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
@ -1011,7 +989,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
return (
<div
key={group.label}
@ -1085,7 +1063,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
>
<div className="sr-only">Manage Tools</div>
<div
className="max-h-48 sm:max-h-64 overflow-y-auto py-0.5 sm:py-1"
className="max-h-48 sm:max-h-64 overflow-y-auto overscroll-none py-0.5 sm:py-1"
onScroll={handleToolsScroll}
style={{
maskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
@ -1100,7 +1078,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
@ -1137,7 +1115,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
@ -1168,7 +1146,11 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-72 text-xs">
{groupDef?.tooltip ??
group.tools.map((t) => t.description).join(" · ")}
group.tools.flatMap((t, i) =>
i === 0
? [t.description]
: [<Dot key={i} className="inline h-4 w-4" />, t.description]
)}
</TooltipContent>
</Tooltip>
);

View file

@ -26,7 +26,8 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
);
const serializedResult = useMemo(
() => (result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null),
() =>
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
[result]
);