mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
refactor: remove deprecated composer and assistant UI components
- Deleted `composer-action.tsx`, `composer.tsx`, and `thread-welcome.tsx` files to streamline the codebase and eliminate unused components. - This cleanup is part of an effort to improve maintainability and reduce complexity in the assistant UI.
This commit is contained in:
parent
4cde2218b4
commit
85ca04c641
3 changed files with 0 additions and 628 deletions
|
|
@ -1,300 +0,0 @@
|
||||||
import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ArrowUpIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
Loader2,
|
|
||||||
Plug2,
|
|
||||||
Plus,
|
|
||||||
SquareIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
|
||||||
import {
|
|
||||||
globalNewLLMConfigsAtom,
|
|
||||||
llmPreferencesAtom,
|
|
||||||
newLLMConfigsAtom,
|
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
|
||||||
import { ComposerAddAttachment } from "@/components/assistant-ui/attachment";
|
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
||||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const ConnectorIndicator: FC = () => {
|
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
|
||||||
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(
|
|
||||||
false,
|
|
||||||
searchSpaceId ? Number(searchSpaceId) : undefined
|
|
||||||
);
|
|
||||||
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
|
|
||||||
useAtomValue(documentTypeCountsAtom);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const isLoading = connectorsLoading || documentTypesLoading;
|
|
||||||
|
|
||||||
const activeDocumentTypes = documentTypeCounts
|
|
||||||
? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Count only active connectors (matching what's shown in the Active tab)
|
|
||||||
const activeConnectorsCount = connectors.length;
|
|
||||||
const hasConnectors = activeConnectorsCount > 0;
|
|
||||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
|
||||||
// Clear any pending close timeout
|
|
||||||
if (closeTimeoutRef.current) {
|
|
||||||
clearTimeout(closeTimeoutRef.current);
|
|
||||||
closeTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setIsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
// Delay closing by 150ms for better UX
|
|
||||||
closeTimeoutRef.current = setTimeout(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, 150);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!searchSpaceId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
|
||||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
|
||||||
"outline-none focus:outline-none focus-visible:outline-none",
|
|
||||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none",
|
|
||||||
"data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0",
|
|
||||||
"text-muted-foreground"
|
|
||||||
)}
|
|
||||||
aria-label={
|
|
||||||
hasConnectors
|
|
||||||
? `View ${activeConnectorsCount} active connectors`
|
|
||||||
: "Add your first connector"
|
|
||||||
}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plug2 className="size-4" />
|
|
||||||
{activeConnectorsCount > 0 && (
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
|
|
||||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
className="w-64 p-3"
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{hasSources ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{activeConnectorsCount > 0 && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Active Connectors</p>
|
|
||||||
<span className="text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
|
||||||
{activeConnectorsCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeConnectorsCount > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{connectors.map((connector) => (
|
|
||||||
<div
|
|
||||||
key={`connector-${connector.id}`}
|
|
||||||
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
|
|
||||||
>
|
|
||||||
{getConnectorIcon(connector.connector_type, "size-3.5")}
|
|
||||||
<span className="truncate max-w-[100px]">{connector.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeDocumentTypes.length > 0 && (
|
|
||||||
<>
|
|
||||||
{activeConnectorsCount > 0 && (
|
|
||||||
<div className="pt-2 border-t border-border/50">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">Documents</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{activeDocumentTypes.map(([docType, count]) => (
|
|
||||||
<div
|
|
||||||
key={docType}
|
|
||||||
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
|
|
||||||
>
|
|
||||||
{getConnectorIcon(docType, "size-3.5")}
|
|
||||||
<span className="truncate max-w-[100px]">
|
|
||||||
{getDocumentTypeLabel(docType)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-medium rounded-full bg-primary/10 text-primary">
|
|
||||||
{count > 999 ? "999+" : count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="pt-1 border-t border-border/50">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
/* Connector popup should be opened via the connector indicator button */
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="size-3" />
|
|
||||||
Add more sources
|
|
||||||
<ChevronRightIcon className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">No sources yet</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Add documents or connect data sources to enhance search results.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1"
|
|
||||||
onClick={() => {
|
|
||||||
/* Connector popup should be opened via the connector indicator button */
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="size-3" />
|
|
||||||
Add Connector
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComposerAction: FC = () => {
|
|
||||||
// Check if any attachments are still being processed (running AND progress < 100)
|
|
||||||
// When progress is 100, processing is done but waiting for send()
|
|
||||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
|
||||||
composer.attachments?.some((att) => {
|
|
||||||
const status = att.status;
|
|
||||||
if (status?.type !== "running") return false;
|
|
||||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
|
||||||
return progress === undefined || progress < 100;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if composer text is empty
|
|
||||||
const isComposerEmpty = useAssistantState(({ composer }) => {
|
|
||||||
const text = composer.text?.trim() || "";
|
|
||||||
return text.length === 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if a model is configured
|
|
||||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
|
||||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
|
||||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
|
||||||
|
|
||||||
const hasModelConfigured = useMemo(() => {
|
|
||||||
if (!preferences) return false;
|
|
||||||
const agentLlmId = preferences.agent_llm_id;
|
|
||||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
|
||||||
|
|
||||||
// Check if the configured model actually exists
|
|
||||||
if (agentLlmId < 0) {
|
|
||||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
|
||||||
}
|
|
||||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
|
||||||
}, [preferences, globalConfigs, userConfigs]);
|
|
||||||
|
|
||||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ComposerAddAttachment />
|
|
||||||
<ConnectorIndicator />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show processing indicator when attachments are being processed */}
|
|
||||||
{hasProcessingAttachments && (
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
<span>Processing...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show warning when no model is configured */}
|
|
||||||
{!hasModelConfigured && !hasProcessingAttachments && (
|
|
||||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
|
||||||
<AlertCircle className="size-3" />
|
|
||||||
<span>Select a model</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
|
||||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
|
||||||
<TooltipIconButton
|
|
||||||
tooltip={
|
|
||||||
!hasModelConfigured
|
|
||||||
? "Please select a model from the header to start chatting"
|
|
||||||
: hasProcessingAttachments
|
|
||||||
? "Wait for attachments to process"
|
|
||||||
: isComposerEmpty
|
|
||||||
? "Enter a message to send"
|
|
||||||
: "Send message"
|
|
||||||
}
|
|
||||||
side="bottom"
|
|
||||||
type="submit"
|
|
||||||
variant="default"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"aui-composer-send size-8 rounded-full",
|
|
||||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
|
||||||
)}
|
|
||||||
aria-label="Send message"
|
|
||||||
disabled={isSendDisabled}
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</ComposerPrimitive.Send>
|
|
||||||
</AssistantIf>
|
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
|
||||||
<ComposerPrimitive.Cancel asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
size="icon"
|
|
||||||
className="aui-composer-cancel size-8 rounded-full"
|
|
||||||
aria-label="Stop generating"
|
|
||||||
>
|
|
||||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
|
||||||
</Button>
|
|
||||||
</ComposerPrimitive.Cancel>
|
|
||||||
</AssistantIf>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
import { ComposerPrimitive, useAssistantState, useComposerRuntime } from "@assistant-ui/react";
|
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import {
|
|
||||||
mentionedDocumentIdsAtom,
|
|
||||||
mentionedDocumentsAtom,
|
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
|
||||||
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
|
||||||
import { ComposerAction } from "@/components/assistant-ui/composer-action";
|
|
||||||
import {
|
|
||||||
InlineMentionEditor,
|
|
||||||
type InlineMentionEditorRef,
|
|
||||||
} from "@/components/assistant-ui/inline-mention-editor";
|
|
||||||
import {
|
|
||||||
DocumentMentionPicker,
|
|
||||||
type DocumentMentionPickerRef,
|
|
||||||
} from "@/components/new-chat/document-mention-picker";
|
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
|
||||||
|
|
||||||
export const Composer: FC = () => {
|
|
||||||
// ---- State for document mentions (using atoms to persist across remounts) ----
|
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
|
||||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
|
||||||
const { search_space_id } = useParams();
|
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
|
||||||
const composerRuntime = useComposerRuntime();
|
|
||||||
const hasAutoFocusedRef = useRef(false);
|
|
||||||
|
|
||||||
// Check if thread is empty (new chat)
|
|
||||||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
|
||||||
|
|
||||||
// Check if thread is currently running (streaming response)
|
|
||||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
|
||||||
|
|
||||||
// Auto-focus editor when on new chat page
|
|
||||||
useEffect(() => {
|
|
||||||
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
|
||||||
// Small delay to ensure the editor is fully mounted
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
editorRef.current?.focus();
|
|
||||||
hasAutoFocusedRef.current = true;
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [isThreadEmpty]);
|
|
||||||
|
|
||||||
// Sync mentioned document IDs to atom for use in chat request
|
|
||||||
useEffect(() => {
|
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: mentionedDocuments
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: mentionedDocuments
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
|
||||||
|
|
||||||
// Handle text change from inline editor - sync with assistant-ui composer
|
|
||||||
const handleEditorChange = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
composerRuntime.setText(text);
|
|
||||||
},
|
|
||||||
[composerRuntime]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle @ mention trigger from inline editor
|
|
||||||
const handleMentionTrigger = useCallback((query: string) => {
|
|
||||||
setShowDocumentPopover(true);
|
|
||||||
setMentionQuery(query);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle mention close
|
|
||||||
const handleMentionClose = useCallback(() => {
|
|
||||||
if (showDocumentPopover) {
|
|
||||||
setShowDocumentPopover(false);
|
|
||||||
setMentionQuery("");
|
|
||||||
}
|
|
||||||
}, [showDocumentPopover]);
|
|
||||||
|
|
||||||
// Handle keyboard navigation when popover is open
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (showDocumentPopover) {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
documentPickerRef.current?.moveDown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
documentPickerRef.current?.moveUp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
documentPickerRef.current?.selectHighlighted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowDocumentPopover(false);
|
|
||||||
setMentionQuery("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[showDocumentPopover]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle submit from inline editor (Enter key)
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
// Prevent sending while a response is still streaming
|
|
||||||
if (isThreadRunning) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!showDocumentPopover) {
|
|
||||||
composerRuntime.send();
|
|
||||||
// Clear the editor after sending
|
|
||||||
editorRef.current?.clear();
|
|
||||||
setMentionedDocuments([]);
|
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
showDocumentPopover,
|
|
||||||
isThreadRunning,
|
|
||||||
composerRuntime,
|
|
||||||
setMentionedDocuments,
|
|
||||||
setMentionedDocumentIds,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleDocumentRemove = useCallback(
|
|
||||||
(docId: number, docType?: string) => {
|
|
||||||
setMentionedDocuments((prev) => {
|
|
||||||
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
|
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: updated
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: updated
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setMentionedDocuments, setMentionedDocumentIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentsMention = useCallback(
|
|
||||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
|
||||||
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
|
||||||
const newDocs = documents.filter(
|
|
||||||
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const doc of newDocs) {
|
|
||||||
editorRef.current?.insertDocumentChip(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMentionedDocuments((prev) => {
|
|
||||||
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
|
||||||
const uniqueNewDocs = documents.filter(
|
|
||||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
|
||||||
);
|
|
||||||
const updated = [...prev, ...uniqueNewDocs];
|
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: updated
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: updated
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
setMentionQuery("");
|
|
||||||
},
|
|
||||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
|
||||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
|
||||||
<ComposerAttachments />
|
|
||||||
{/* -------- Inline Mention Editor -------- */}
|
|
||||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
|
||||||
<InlineMentionEditor
|
|
||||||
ref={editorRef}
|
|
||||||
placeholder="Ask SurfSense or @mention docs"
|
|
||||||
onMentionTrigger={handleMentionTrigger}
|
|
||||||
onMentionClose={handleMentionClose}
|
|
||||||
onChange={handleEditorChange}
|
|
||||||
onDocumentRemove={handleDocumentRemove}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="min-h-[24px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* -------- Document mention popover (rendered via portal) -------- */}
|
|
||||||
{showDocumentPopover &&
|
|
||||||
typeof document !== "undefined" &&
|
|
||||||
createPortal(
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="fixed inset-0 cursor-default"
|
|
||||||
style={{ zIndex: 9998 }}
|
|
||||||
onClick={() => setShowDocumentPopover(false)}
|
|
||||||
aria-label="Close document picker"
|
|
||||||
/>
|
|
||||||
{/* Popover positioned above input */}
|
|
||||||
<div
|
|
||||||
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
|
|
||||||
style={{
|
|
||||||
zIndex: 9999,
|
|
||||||
bottom: editorContainerRef.current
|
|
||||||
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
|
||||||
: "200px",
|
|
||||||
left: editorContainerRef.current
|
|
||||||
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
|
||||||
: "50%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DocumentMentionPicker
|
|
||||||
ref={documentPickerRef}
|
|
||||||
searchSpaceId={Number(search_space_id)}
|
|
||||||
onSelectionChange={handleDocumentsMention}
|
|
||||||
onDone={() => {
|
|
||||||
setShowDocumentPopover(false);
|
|
||||||
setMentionQuery("");
|
|
||||||
}}
|
|
||||||
initialSelectedDocuments={mentionedDocuments}
|
|
||||||
externalSearch={mentionQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
<ComposerAction />
|
|
||||||
</ComposerPrimitive.AttachmentDropzone>
|
|
||||||
</ComposerPrimitive.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
||||||
import { Composer } from "@/components/assistant-ui/composer";
|
|
||||||
|
|
||||||
const getTimeBasedGreeting = (userEmail?: string): string => {
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
|
|
||||||
// Extract first name from email if available
|
|
||||||
const firstName = userEmail
|
|
||||||
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
|
||||||
userEmail.split("@")[0].split(".")[0].slice(1)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Array of greeting variations for each time period
|
|
||||||
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
|
|
||||||
|
|
||||||
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
|
|
||||||
|
|
||||||
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
|
|
||||||
|
|
||||||
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
|
|
||||||
|
|
||||||
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
|
|
||||||
|
|
||||||
// Select a random greeting based on time
|
|
||||||
let greeting: string;
|
|
||||||
if (hour < 5) {
|
|
||||||
// Late night: midnight to 5 AM
|
|
||||||
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
|
|
||||||
} else if (hour < 12) {
|
|
||||||
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
|
|
||||||
} else if (hour < 18) {
|
|
||||||
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
|
|
||||||
} else if (hour < 22) {
|
|
||||||
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
|
|
||||||
} else {
|
|
||||||
// Night: 10 PM to midnight
|
|
||||||
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add personalization with first name if available
|
|
||||||
if (firstName) {
|
|
||||||
return `${greeting}, ${firstName}!`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${greeting}!`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ThreadWelcome: FC = () => {
|
|
||||||
const { data: user } = useAtomValue(currentUserAtom);
|
|
||||||
|
|
||||||
// Memoize greeting so it doesn't change on re-renders (only on user change)
|
|
||||||
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
|
||||||
{/* Greeting positioned above the composer - fixed position */}
|
|
||||||
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
|
|
||||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-3xl md:text-5xl delay-100 duration-500 ease-out fill-mode-both">
|
|
||||||
{greeting}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
{/* Composer - top edge fixed, expands downward only */}
|
|
||||||
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
|
||||||
<Composer />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue