chore: resolve merge conflict by removing legacy all-in-one Docker files

Keeps the deletion of Dockerfile.allinone, docker-compose.yml (root), and
scripts/docker/entrypoint-allinone.sh from fix/docker. Ports the Daytona
sandbox env vars added by upstream/dev into docker/docker-compose.yml and
docker/docker-compose.dev.yml instead.

Made-with: Cursor
This commit is contained in:
Anish Sarkar 2026-02-26 13:57:56 +05:30
commit b5874a587a
126 changed files with 17384 additions and 9088 deletions

View file

@ -5,13 +5,11 @@ import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
import { FeaturesCards } from "@/components/homepage/features-card";
import { HeroSection } from "@/components/homepage/hero-section";
import ExternalIntegrations from "@/components/homepage/integrations";
import { UseCasesGrid } from "@/components/homepage/use-cases-grid";
export default function HomePage() {
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<HeroSection />
<UseCasesGrid />
<FeaturesCards />
<FeaturesBentoGrid />
<ExternalIntegrations />

View file

@ -67,7 +67,7 @@ export function DocumentTypeChip({ type, className }: { type: string; className?
const chip = (
<span
className={`inline-flex items-center gap-1.5 rounded-full bg-accent/80 px-2.5 py-1 text-xs font-medium text-accent-foreground/70 shadow-sm max-w-full overflow-hidden ${className ?? ""}`}
className={`inline-flex items-center gap-1.5 rounded-full bg-accent/80 px-2.5 py-1 text-xs font-medium text-accent-foreground shadow-sm max-w-full overflow-hidden ${className ?? ""}`}
>
<span className="flex-shrink-0">{icon}</span>
<span ref={textRef} className="truncate min-w-0">

View file

@ -3,13 +3,13 @@
import { useSetAtom } from "jotai";
import {
CircleAlert,
CircleX,
FilePlus2,
FileType,
ListFilter,
Search,
SlidersHorizontal,
Trash,
Upload,
X,
} from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
@ -81,7 +81,7 @@ export function DocumentsFilters({
return (
<motion.div
className="flex flex-col gap-4"
className="flex flex-col gap-4 select-none"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
@ -96,7 +96,7 @@ export function DocumentsFilters({
size="sm"
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<FilePlus2 size={16} />
<Upload size={16} />
<span>Upload documents</span>
</Button>
<Button
@ -126,7 +126,7 @@ export function DocumentsFilters({
<Input
id={`${id}-input`}
ref={inputRef}
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30"
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title"
@ -135,7 +135,7 @@ export function DocumentsFilters({
/>
{Boolean(searchValue) && (
<motion.button
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground/60 hover:text-foreground transition-colors"
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear filter"
onClick={() => {
onSearch("");
@ -147,7 +147,7 @@ export function DocumentsFilters({
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<CircleX size={14} strokeWidth={2} aria-hidden="true" />
<X size={14} strokeWidth={2} aria-hidden="true" />
</motion.button>
)}
</motion.div>

View file

@ -336,7 +336,7 @@ export function DocumentsTableShell({
return (
<motion.div
className="rounded-lg border border-border/40 bg-background overflow-hidden"
className="rounded-lg border border-border/40 bg-background overflow-hidden select-none"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
@ -453,7 +453,7 @@ export function DocumentsTableShell({
) : error ? (
<div className="flex h-[50vh] w-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<AlertCircle className="h-8 w-8 text-destructive/60" />
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">{t("error_loading")}</p>
</div>
</div>
@ -482,7 +482,7 @@ export function DocumentsTableShell({
</div>
) : (
<>
{/* Desktop Table View - Notion Style */}
{/* Desktop Table View */}
<div className="hidden md:flex md:flex-col">
{/* Fixed Header */}
<Table className="table-fixed w-full">
@ -629,7 +629,24 @@ export function DocumentsTableShell({
)}
{columnVisibility.created_by && (
<TableCell className="w-36 py-2.5 text-sm text-foreground truncate border-r border-border/40">
{doc.created_by_name || "—"}
{doc.created_by_name ? (
doc.created_by_email ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default truncate block">
{doc.created_by_name}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{doc.created_by_email}
</TooltipContent>
</Tooltip>
) : (
<span className="truncate block">{doc.created_by_name}</span>
)
) : (
<span className="truncate block">{doc.created_by_email || "—"}</span>
)}
</TableCell>
)}
{columnVisibility.created_at && (
@ -765,11 +782,11 @@ export function DocumentsTableShell({
{/* Document Content Viewer - lazy loads content on-demand */}
<Dialog open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col overflow-hidden pb-0">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{viewingDoc?.title}</DialogTitle>
</DialogHeader>
<div className="mt-4">
<div className="mt-4 overflow-y-auto flex-1 min-h-0 px-6 select-text">
{viewingLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" className="text-muted-foreground" />

View file

@ -30,7 +30,7 @@ export function PaginationControls({
return (
<motion.div
className="flex items-center justify-end gap-3 py-3 px-2"
className="flex items-center justify-end gap-3 py-3 px-2 select-none"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.3 }}

View file

@ -16,6 +16,7 @@ export type Document = {
search_space_id: number;
created_by_id?: string | null;
created_by_name?: string | null;
created_by_email?: string | null;
status?: DocumentStatus;
};

View file

@ -115,6 +115,7 @@ export default function DocumentsTable() {
title: item.title,
created_by_id: item.created_by_id ?? null,
created_by_name: item.created_by_name ?? null,
created_by_email: item.created_by_email ?? null,
created_at: item.created_at,
status: (
item as {

View file

@ -29,7 +29,6 @@ import {
ChevronRight,
ChevronUp,
CircleAlert,
CircleX,
Clock,
Columns3,
Filter,
@ -741,7 +740,7 @@ function LogsFilters({
inputRef.current?.focus();
}}
>
<CircleX size={16} strokeWidth={2} />
<X size={16} strokeWidth={2} />
</Button>
)}
</motion.div>

View file

@ -75,7 +75,7 @@ export default function MorePagesPage() {
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
return (
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -174,7 +174,7 @@ export default function MorePagesPage() {
Contact Us
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="select-none sm:max-w-md">
<DialogHeader>
<DialogTitle>Contact Us</DialogTitle>
<DialogDescription>Schedule a meeting or send us an email.</DialogDescription>

View file

@ -38,6 +38,10 @@ import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive";
import {
CreateLinearIssueToolUI,
DeleteLinearIssueToolUI,
@ -49,6 +53,7 @@ import {
DeleteNotionPageToolUI,
UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { Skeleton } from "@/components/ui/skeleton";
@ -151,6 +156,9 @@ const TOOLS_WITH_UI = new Set([
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
"create_google_drive_file",
"delete_google_drive_file",
"execute",
// "write_todos", // Disabled for now
]);
@ -1664,6 +1672,9 @@ export default function NewChatPage() {
<CreateLinearIssueToolUI />
<UpdateLinearIssueToolUI />
<DeleteLinearIssueToolUI />
<CreateGoogleDriveFileToolUI />
<DeleteGoogleDriveFileToolUI />
<SandboxExecuteToolUI />
{/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">

View file

@ -259,7 +259,7 @@ export default function OnboardPage() {
You can add more configurations and customize settings anytime in{" "}
<button
type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?section=general`)}
className="text-violet-500 hover:underline"
>
Settings

View file

@ -12,10 +12,11 @@ import {
Menu,
MessageSquare,
Settings,
Shield,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
@ -24,6 +25,7 @@ import { ImageModelManager } from "@/components/settings/image-model-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager";
import { Button } from "@/components/ui/button";
import { trackSettingsViewed } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
@ -72,6 +74,12 @@ const settingsNavItems: SettingsNavItem[] = [
descriptionKey: "nav_public_links_desc",
icon: Globe,
},
{
id: "team-roles",
labelKey: "nav_team_roles",
descriptionKey: "nav_team_roles_desc",
icon: Shield,
},
];
function SettingsSidebar({
@ -240,7 +248,7 @@ function SettingsContent({
{/* Section Header */}
<AnimatePresence mode="wait">
<motion.div
key={activeSection + "-header"}
key={`${activeSection}-header`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
@ -298,6 +306,7 @@ function SettingsContent({
{activeSection === "public-links" && (
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "team-roles" && <RolesManager searchSpaceId={searchSpaceId} />}
</motion.div>
</AnimatePresence>
</div>
@ -306,14 +315,27 @@ function SettingsContent({
);
}
const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id));
const DEFAULT_SECTION = "general";
export default function SettingsPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const searchSpaceId = Number(params.search_space_id);
const [activeSection, setActiveSection] = useState("general");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Track settings section view
const sectionParam = searchParams.get("section");
const activeSection =
sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION;
const handleSectionChange = useCallback(
(section: string) => {
router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false });
},
[router, searchSpaceId]
);
useEffect(() => {
trackSettingsViewed(searchSpaceId, activeSection);
}, [searchSpaceId, activeSection]);
@ -333,7 +355,7 @@ export default function SettingsPage() {
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<SettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onSectionChange={handleSectionChange}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}

File diff suppressed because it is too large Load diff

View file

@ -221,7 +221,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
</TooltipIconButton>
)}
<DialogContent 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 bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>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">
<DialogContent 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 bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>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">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
@ -374,7 +374,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
</p>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings`}>
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Link>

View file

@ -120,7 +120,7 @@ const DocumentUploadPopupContent: FC<{
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="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 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">
<DialogContent 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 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">
<DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Scrollable container for mobile */}
@ -129,9 +129,6 @@ const DocumentUploadPopupContent: FC<{
<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 h-9 w-9 sm:h-14 sm:w-14 items-center justify-center rounded-lg sm:rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
<Upload className="size-4 sm:size-7 text-primary" />
</div>
<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
@ -156,7 +153,7 @@ const DocumentUploadPopupContent: FC<{
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
</p>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings`}>
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Link>

View file

@ -18,6 +18,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
Dot,
DownloadIcon,
FileWarning,
Paperclip,
@ -81,6 +82,10 @@ const CYCLING_PLACEHOLDERS = [
const CHAT_UPLOAD_ACCEPT =
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
const CHAT_MAX_FILES = 10;
const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file
const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total
type UploadState = "pending" | "processing" | "ready" | "failed";
interface UploadedMentionDoc {
@ -533,6 +538,28 @@ const Composer: FC = () => {
event.target.value = "";
if (files.length === 0 || !search_space_id) return;
if (files.length > CHAT_MAX_FILES) {
toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`);
return;
}
let totalSize = 0;
for (const file of files) {
if (file.size > CHAT_MAX_FILE_SIZE_BYTES) {
toast.error(
`File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.`
);
return;
}
totalSize += file.size;
}
if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) {
toast.error(
`Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.`
);
return;
}
setIsUploadingDocs(true);
try {
const uploadResponse = await documentsApiService.uploadDocument({
@ -745,7 +772,19 @@ const ComposerAction: FC<ComposerActionProps> = ({
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<TooltipIconButton
tooltip={isUploadingDocs ? "Uploading documents..." : "Upload and mention files"}
tooltip={
isUploadingDocs ? (
"Uploading documents..."
) : (
<div className="flex flex-col gap-0.5">
<span className="font-medium">Upload and mention files</span>
<span className="text-xs text-muted-foreground flex items-center">
Max 10 files <Dot className="size-3" /> 50 MB each
</span>
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
</div>
)
}
side="bottom"
variant="ghost"
size="icon"

View file

@ -1,13 +1,13 @@
"use client";
import { Slottable } from "@radix-ui/react-slot";
import { type ComponentPropsWithRef, forwardRef } from "react";
import { type ComponentPropsWithRef, forwardRef, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
tooltip: string;
tooltip: ReactNode;
side?: "top" | "bottom" | "left" | "right";
};

View file

@ -196,7 +196,7 @@ export function DashboardBreadcrumb() {
}
return (
<Breadcrumb>
<Breadcrumb className="select-none">
<BreadcrumbList>
{breadcrumbs.map((item, index) => (
<React.Fragment key={`${index}-${item.href || item.label}`}>

View file

@ -5,7 +5,7 @@ import Link from "next/link";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer";
import { WalkthroughScroll } from "@/components/ui/walkthrough-scroll";
import { HeroCarousel } from "@/components/ui/hero-carousel";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
@ -97,18 +97,18 @@ export function HeroSection() {
)}
</h2>
{/* // TODO:aCTUAL DESCRITION */}
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
<p className="relative z-50 mx-auto mt-4 max-w-xl px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
Connect any AI to your documents, Drive, Notion and more,
</p>
<p className="relative z-50 mx-auto mt-0 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
then chat with it, invite your team, or generate podcasts and reports.
<p className="relative z-50 mx-auto mt-0 max-w-xl px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
then chat with it, generate podcasts and reports, or even invite your team.
</p>
<div className="mb-6 mt-6 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-10">
<GetStartedButton />
{/* <ContactSalesButton /> */}
</div>
<div ref={containerRef} className="relative w-full">
<WalkthroughScroll />
<div ref={containerRef} className="relative w-full z-51">
<HeroCarousel />
</div>
</div>
);

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4.94 4.96a9.97 9.97 0 0 1 10.835-2.182a8.7 8.7 0 0 1 2.033 1.11l-3.006 1.39C12.003 4.101 8.797 4.9 6.84 6.86c-2.564 2.565-3.146 6.954-.36 9.922l.278.284L.124 23c1.875-1.973 3.771-4.427 2.636-7.19c-1.52-3.698-.635-8.03 2.18-10.85M23.9.1c-2.264 3.174-3.184 5.389-2.197 9.64l-.007-.007c.753 3.201-.052 6.75-2.653 9.355c-3.279 3.285-8.526 4.016-12.847 1.06L9.21 18.75c2.758 1.084 5.775.607 7.943-1.564c2.169-2.17 2.655-5.332 1.566-7.963c-.207-.5-.828-.625-1.263-.304L8.59 15.472l12.7-12.77v.01z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>

Before

Width:  |  Height:  |  Size: 589 B

After

Width:  |  Height:  |  Size: 270 B

Before After
Before After

View file

@ -334,7 +334,7 @@ export function LayoutDataProvider({
const handleSearchSpaceSettings = useCallback(
(space: SearchSpace) => {
router.push(`/dashboard/${space.id}/settings`);
router.push(`/dashboard/${space.id}/settings?section=general`);
},
[router]
);
@ -478,7 +478,7 @@ export function LayoutDataProvider({
);
const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings`);
router.push(`/dashboard/${searchSpaceId}/settings?section=general`);
}, [router, searchSpaceId]);
const handleManageMembers = useCallback(() => {
@ -703,7 +703,6 @@ export function LayoutDataProvider({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<PencilIcon className="h-5 w-5" />
<span>{tSidebar("rename_chat") || "Rename Chat"}</span>
</DialogTitle>
<DialogDescription>
@ -736,7 +735,7 @@ export function LayoutDataProvider({
{isRenamingChat ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{tSidebar("renaming") || "Renaming..."}
{tSidebar("renaming") || "Renaming"}
</>
) : (
<>

View file

@ -2,7 +2,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Plus, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -86,9 +85,6 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
<DialogContent className="max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Search className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg">{t("create_title")}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm mt-0.5">
@ -142,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
)}
/>
<DialogFooter className="flex-col sm:flex-row gap-2 pt-2 sm:pt-3">
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
>
{tCommon("cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
>
{isSubmitting ? (
<>
@ -163,10 +159,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
{t("creating")}
</>
) : (
<>
<Plus className="-mr-1 h-4 w-4" />
{t("create_button")}
</>
<>{t("create_button")}</>
)}
</Button>
</DialogFooter>

View file

@ -210,6 +210,26 @@ export function LayoutShell({
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
{/* Mobile All Shared Chats - slide-out panel */}
{allSharedChatsPanel && (
<AllSharedChatsSidebar
open={allSharedChatsPanel.open}
onOpenChange={allSharedChatsPanel.onOpenChange}
searchSpaceId={allSharedChatsPanel.searchSpaceId}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
{/* Mobile All Private Chats - slide-out panel */}
{allPrivateChatsPanel && (
<AllPrivateChatsSidebar
open={allPrivateChatsPanel.open}
onOpenChange={allPrivateChatsPanel.onOpenChange}
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
</div>
</TooltipProvider>
</SidebarProvider>

View file

@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
ChevronLeft,
MessageCircleMore,
MoreHorizontal,
PenLine,
RotateCcwIcon,
Search,
Trash2,
@ -17,6 +19,14 @@ import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -69,6 +79,10 @@ export function AllPrivateChatsSidebar({
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null);
const [newTitle, setNewTitle] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearchQuery.trim();
@ -187,6 +201,35 @@ export function AllPrivateChatsSidebar({
[queryClient, searchSpaceId, t]
);
const handleStartRename = useCallback((threadId: number, title: string) => {
setRenamingThread({ id: threadId, title });
setNewTitle(title);
setShowRenameDialog(true);
}, []);
const handleConfirmRename = useCallback(async () => {
if (!renamingThread || !newTitle.trim()) return;
setIsRenaming(true);
try {
await updateThread(renamingThread.id, { title: newTitle.trim() });
toast.success(t("chat_renamed") || "Chat renamed");
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({
queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)],
});
} catch (error) {
console.error("Error renaming thread:", error);
toast.error(t("error_renaming_chat") || "Failed to rename chat");
} finally {
setIsRenaming(false);
setShowRenameDialog(false);
setRenamingThread(null);
setNewTitle("");
}
}, [renamingThread, newTitle, queryClient, searchSpaceId, t]);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
@ -205,6 +248,17 @@ export function AllPrivateChatsSidebar({
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<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">{t("close") || "Close"}</span>
</Button>
)}
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
@ -356,6 +410,14 @@ export function AllPrivateChatsSidebar({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && (
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<PenLine className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
@ -412,6 +474,51 @@ export function AllPrivateChatsSidebar({
</div>
)}
</div>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span>{t("rename_chat") || "Rename Chat"}</span>
</DialogTitle>
<DialogDescription>
{t("rename_chat_description") || "Enter a new name for this conversation."}
</DialogDescription>
</DialogHeader>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t("chat_title_placeholder") || "Chat title"}
onKeyDown={(e) => {
if (e.key === "Enter" && !isRenaming && newTitle.trim()) {
handleConfirmRename();
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowRenameDialog(false)}
disabled={isRenaming}
>
{t("cancel")}
</Button>
<Button
onClick={handleConfirmRename}
disabled={isRenaming || !newTitle.trim()}
className="gap-2"
>
{isRenaming ? (
<>
<Spinner size="xs" />
<span>{t("renaming") || "Renaming"}</span>
</>
) : (
<span>{t("rename") || "Rename"}</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SidebarSlideOutPanel>
);
}

View file

@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
ChevronLeft,
MessageCircleMore,
MoreHorizontal,
PenLine,
RotateCcwIcon,
Search,
Trash2,
@ -17,6 +19,14 @@ import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -69,6 +79,10 @@ export function AllSharedChatsSidebar({
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null);
const [newTitle, setNewTitle] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearchQuery.trim();
@ -187,6 +201,35 @@ export function AllSharedChatsSidebar({
[queryClient, searchSpaceId, t]
);
const handleStartRename = useCallback((threadId: number, title: string) => {
setRenamingThread({ id: threadId, title });
setNewTitle(title);
setShowRenameDialog(true);
}, []);
const handleConfirmRename = useCallback(async () => {
if (!renamingThread || !newTitle.trim()) return;
setIsRenaming(true);
try {
await updateThread(renamingThread.id, { title: newTitle.trim() });
toast.success(t("chat_renamed") || "Chat renamed");
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({
queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)],
});
} catch (error) {
console.error("Error renaming thread:", error);
toast.error(t("error_renaming_chat") || "Failed to rename chat");
} finally {
setIsRenaming(false);
setShowRenameDialog(false);
setRenamingThread(null);
setNewTitle("");
}
}, [renamingThread, newTitle, queryClient, searchSpaceId, t]);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
@ -205,6 +248,17 @@ export function AllSharedChatsSidebar({
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<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">{t("close") || "Close"}</span>
</Button>
)}
<Users className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div>
@ -356,6 +410,14 @@ export function AllSharedChatsSidebar({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && (
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<PenLine className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
@ -412,6 +474,51 @@ export function AllSharedChatsSidebar({
</div>
)}
</div>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span>{t("rename_chat") || "Rename Chat"}</span>
</DialogTitle>
<DialogDescription>
{t("rename_chat_description") || "Enter a new name for this conversation."}
</DialogDescription>
</DialogHeader>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t("chat_title_placeholder") || "Chat title"}
onKeyDown={(e) => {
if (e.key === "Enter" && !isRenaming && newTitle.trim()) {
handleConfirmRename();
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowRenameDialog(false)}
disabled={isRenaming}
>
{t("cancel")}
</Button>
<Button
onClick={handleConfirmRename}
disabled={isRenaming || !newTitle.trim()}
className="gap-2"
>
{isRenaming ? (
<>
<Spinner size="xs" />
<span>{t("renaming") || "Renaming"}</span>
</>
) : (
<span>{t("rename") || "Rename"}</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SidebarSlideOutPanel>
);
}

View file

@ -4,7 +4,7 @@ import {
ArchiveIcon,
MessageSquare,
MoreHorizontal,
PencilIcon,
PenLine,
RotateCcwIcon,
Trash2,
} from "lucide-react";
@ -74,7 +74,7 @@ export function ChatListItem({
onRename();
}}
>
<PencilIcon className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}

View file

@ -727,7 +727,7 @@ export function InboxSidebar({
</Tooltip>
<DropdownMenuContent
align="end"
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")}
>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"}

View file

@ -131,7 +131,7 @@ export function MobileSidebar({
</div>
{/* Sidebar Content - right side */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden flex flex-col [&>*]:!w-full">
<Sidebar
searchSpace={searchSpace}
isCollapsed={false}

View file

@ -93,7 +93,7 @@ export function Sidebar({
return (
<div
className={cn(
"relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden",
"relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden select-none",
isCollapsed ? "w-[60px] transition-all duration-200" : "",
!isCollapsed && !isResizing ? "transition-all duration-200" : "",
className

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronsUpDown, Settings, Users } from "lucide-react";
import { ChevronsUpDown, Settings, UserPen } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@ -51,14 +51,14 @@ export function SidebarHeader({
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={onManageMembers}>
<Users className="mr-2 h-4 w-4" />
<UserPen className="h-4 w-4" />
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
<Settings className="h-4 w-4" />
{t("search_space_settings")}
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"h-full w-full bg-background flex flex-col pointer-events-auto",
"h-full w-full bg-background flex flex-col pointer-events-auto select-none",
"sm:border-r sm:shadow-xl"
)}
role="dialog"

View file

@ -177,7 +177,7 @@ export function SidebarUserProfile({
<TooltipContent side="right">{displayName}</TooltipContent>
</Tooltip>
<DropdownMenuContent className="w-56" side="right" align="center" sideOffset={8}>
<DropdownMenuContent className="w-48" side="right" align="center" sideOffset={8}>
<DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2">
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -191,14 +191,14 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
<Settings className="h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
{setTheme && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="mr-2 h-4 w-4" />
<Sun className="h-4 w-4" />
{t("theme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
@ -216,7 +216,7 @@ export function SidebarUserProfile({
isSelected && "text-primary"
)}
>
<Icon className="mr-2 h-4 w-4" />
<Icon className="h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
@ -229,7 +229,7 @@ export function SidebarUserProfile({
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="mr-2 h-4 w-4" />
<Languages className="h-4 w-4" />
{t("language")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
@ -262,7 +262,7 @@ export function SidebarUserProfile({
{isLoggingOut ? (
<Spinner size="sm" className="mr-2" />
) : (
<LogOut className="mr-2 h-4 w-4" />
<LogOut className="h-4 w-4" />
)}
{isLoggingOut ? t("loggingOut") : t("logout")}
</DropdownMenuItem>
@ -299,7 +299,7 @@ export function SidebarUserProfile({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" side="top" align="center" sideOffset={4}>
<DropdownMenuContent className="w-48" side="top" align="center" sideOffset={4}>
<DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2">
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -313,14 +313,14 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
<Settings className="h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
{setTheme && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="mr-2 h-4 w-4" />
<Sun className="h-4 w-4" />
{t("theme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
@ -338,7 +338,7 @@ export function SidebarUserProfile({
isSelected && "text-primary"
)}
>
<Icon className="mr-2 h-4 w-4" />
<Icon className="h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
@ -351,7 +351,7 @@ export function SidebarUserProfile({
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="mr-2 h-4 w-4" />
<Languages className="h-4 w-4" />
{t("language")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
@ -381,11 +381,7 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? (
<Spinner size="sm" className="mr-2" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}
{isLoggingOut ? t("loggingOut") : t("logout")}
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -144,7 +144,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<TooltipTrigger asChild>
<button
type="button"
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
onClick={() =>
router.push(`/dashboard/${params.search_space_id}/settings?section=public-links`)
}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<Earth className="h-4 w-4 text-muted-foreground" />
@ -181,7 +183,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="p-1.5 space-y-1">
<div className="p-1.5 space-y-1 select-none">
{/* Visibility Options */}
{visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value;

View file

@ -226,7 +226,7 @@ export function ModelSelector({
size="sm"
role="combobox"
aria-expanded={open}
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
className={cn("h-8 gap-2 px-3 text-sm border-border/60 select-none", className)}
>
{isLoading ? (
<>
@ -280,7 +280,7 @@ export function ModelSelector({
</PopoverTrigger>
<PopoverContent
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60"
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-muted dark:border dark:border-neutral-700 select-none"
align="start"
sideOffset={8}
>
@ -289,18 +289,18 @@ export function ModelSelector({
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
className="w-full"
>
<div className="border-b border-border/40">
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-card h-11 p-0 gap-0">
<div className="border-b border-border/80 dark:border-white/5">
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
<TabsTrigger
value="llm"
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-white data-[state=active]:after:rounded-full"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
>
<Zap className="size-4" />
LLM
</TabsTrigger>
<TabsTrigger
value="image"
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-white data-[state=active]:after:rounded-full"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
>
<ImageIcon className="size-4" />
Image
@ -312,7 +312,7 @@ export function ModelSelector({
<TabsContent value="llm" className="mt-0">
<Command
shouldFilter={false}
className="rounded-none rounded-b-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
className="rounded-none rounded-b-lg relative dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
{totalLLMModels > 3 && (
<div className="px-2 md:px-3 py-1.5 md:py-2">
@ -350,9 +350,9 @@ export function ModelSelector({
onSelect={() => handleSelectLLM(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80",
isAutoMode && "border border-violet-800"
"hover:bg-accent/50 dark:hover:bg-white/10",
isSelected && "bg-accent/80 dark:bg-white/10",
isAutoMode && ""
)}
>
<div className="flex items-center justify-between w-full gap-2">
@ -366,7 +366,7 @@ export function ModelSelector({
{isAutoMode && (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0 h-3.5 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 border-0"
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
>
Recommended
</Badge>
@ -426,8 +426,8 @@ export function ModelSelector({
onSelect={() => handleSelectLLM(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
"hover:bg-accent/50 dark:hover:bg-white/10",
isSelected && "bg-accent/80 dark:bg-white/10"
)}
>
<div className="flex items-center justify-between w-full gap-2">
@ -471,11 +471,11 @@ export function ModelSelector({
)}
{/* Add New LLM Config */}
<div className="p-2 bg-muted/20">
<div className="p-2 bg-muted/20 dark:bg-muted">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
onClick={() => {
setOpen(false);
onAddNewLLM();
@ -493,7 +493,7 @@ export function ModelSelector({
<TabsContent value="image" className="mt-0">
<Command
shouldFilter={false}
className="rounded-none rounded-b-lg [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
className="rounded-none rounded-b-lg dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
{totalImageModels > 3 && (
<div className="px-2 md:px-3 py-1.5 md:py-2">
@ -528,9 +528,9 @@ export function ModelSelector({
value={`img-g-${config.id}`}
onSelect={() => handleSelectImage(config.id)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
isSelected && "bg-accent/80",
isAuto && "border border-violet-800"
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
isSelected && "bg-accent/80 dark:bg-white/10",
isAuto && ""
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
@ -543,7 +543,7 @@ export function ModelSelector({
{isAuto && (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0 h-3.5 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 border-0"
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
>
Recommended
</Badge>
@ -593,8 +593,8 @@ export function ModelSelector({
value={`img-u-${config.id}`}
onSelect={() => handleSelectImage(config.id)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
isSelected && "bg-accent/80"
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
isSelected && "bg-accent/80 dark:bg-white/10"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
@ -634,11 +634,11 @@ export function ModelSelector({
{/* Add New Image Config */}
{onAddNewImage && (
<div className="p-2 bg-muted/20">
<div className="p-2 bg-muted/20 dark:bg-muted">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
onClick={() => {
setOpen(false);
onAddNewImage();

View file

@ -35,12 +35,12 @@ export function PublicChatSnapshotRow({
memberMap,
}: PublicChatSnapshotRowProps) {
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const handleCopyClick = useCallback(() => {
onCopy(snapshot);
setCopied(true);
clearTimeout(copyTimeoutRef.current);
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
}, [onCopy, snapshot]);
@ -117,12 +117,14 @@ export function PublicChatSnapshotRow({
{/* Public URL selectable fallback for manual copy */}
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<p
className="min-w-0 flex-1 text-[10px] font-mono text-muted-foreground break-all select-all cursor-text"
title={snapshot.public_url}
>
{snapshot.public_url}
</p>
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p
className="text-[10px] font-mono text-muted-foreground whitespace-nowrap select-all cursor-text"
title={snapshot.public_url}
>
{snapshot.public_url}
</p>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View file

@ -334,7 +334,7 @@ function ReportPanelContent({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[180px] dark:bg-neutral-800 dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
className={`min-w-[180px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
>
<DropdownMenuItem onClick={() => handleExport("md")}>
Download Markdown
@ -361,56 +361,30 @@ function ReportPanelContent({
</DropdownMenu>
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 &&
(insideDrawer ? (
/* Mobile: compact dropdown */
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
{versions.length > 1 && (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 px-3.5 py-4 text-[15px] gap-1.5">
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[120px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
>
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[120px] z-[100]">
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
/* Desktop: inline version buttons */
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
{versions.map((v, i) => (
<button
key={v.id}
type="button"
onClick={() => setActiveReportId(v.id)}
className={`px-2 py-0.5 rounded-md text-xs font-medium transition-colors ${
v.id === activeReportId
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
v{i + 1}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground tabular-nums ml-1">
{activeVersionIndex + 1} of {versions.length}
</span>
</div>
))}
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
@ -452,7 +452,6 @@ export function DocumentUploadTab({
<AccordionItem value="supported-file-types" className="border-0">
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline !items-center [&>svg]:!translate-y-0">
<div className="flex items-center gap-2 flex-1">
<Tag className="h-4 w-4 sm:h-5 sm:w-5 shrink-0" />
<div className="text-left min-w-0">
<div className="font-semibold text-sm sm:text-base">
{t("supported_file_types")}

View file

@ -0,0 +1,584 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
AlertTriangleIcon,
CheckIcon,
FileIcon,
Loader2Icon,
PencilIcon,
RefreshCwIcon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { authenticatedFetch } from "@/lib/auth-utils";
interface GoogleDriveAccount {
id: number;
name: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GoogleDriveAccount[];
supported_types?: string[];
error?: string;
};
}
interface SuccessResult {
status: "success";
file_id: string;
name: string;
web_view_link?: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface InsufficientPermissionsResult {
status: "insufficient_permissions";
connector_id: number;
message: string;
}
type CreateGoogleDriveFileResult =
| InterruptResult
| SuccessResult
| ErrorResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function isInsufficientPermissionsResult(result: unknown): result is InsufficientPermissionsResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as InsufficientPermissionsResult).status === "insufficient_permissions"
);
}
const FILE_TYPE_LABELS: Record<string, string> = {
google_doc: "Google Doc",
google_sheet: "Google Sheet",
};
function ApprovalCard({
args,
interruptData,
onDecision,
}: {
args: { name: string; file_type: string; content?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
interruptData.__decided__ ?? null
);
const [isEditing, setIsEditing] = useState(false);
const [editedName, setEditedName] = useState(args.name ?? "");
const [editedContent, setEditedContent] = useState(args.content ?? "");
const [committedArgs, setCommittedArgs] = useState<{
name: string;
file_type: string;
content?: string | null;
} | null>(null);
const accounts = interruptData.context?.accounts ?? [];
const defaultAccountId = useMemo(() => {
if (accounts.length === 1) return String(accounts[0].id);
return "";
}, [accounts]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
const [selectedFileType, setSelectedFileType] = useState<string>(args.file_type ?? "google_doc");
const [parentFolderId, setParentFolderId] = useState<string>("");
const isNameValid = useMemo(
() => (isEditing ? editedName.trim().length > 0 : args.name?.trim().length > 0),
[isEditing, editedName, args.name]
);
const canApprove = !!selectedAccountId && isNameValid;
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
function buildFinalArgs() {
return {
name: isEditing ? editedName : args.name,
file_type: selectedFileType,
content: isEditing ? editedContent || null : (args.content ?? null),
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
parent_folder_id: parentFolderId.trim() || null,
};
}
return (
<div
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
decided
? "border border-border bg-card shadow-sm"
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
}`}
>
{/* Header */}
<div
className={`flex items-center gap-3 border-b ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
} px-4 py-3`}
>
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
decided ? "bg-muted" : "bg-muted animate-pulse"
}`}
>
<AlertTriangleIcon
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Create Google Drive File</p>
<p className="truncate text-xs text-muted-foreground">
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
</p>
</div>
</div>
{/* Context section */}
{!decided && interruptData.context && (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{accounts.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Google Drive Account <span className="text-destructive">*</span>
</div>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
File Type <span className="text-destructive">*</span>
</div>
<Select value={selectedFileType} onValueChange={setSelectedFileType}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google_doc">Google Doc</SelectItem>
<SelectItem value="google_sheet">Google Sheet</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Parent Folder ID (optional)
</div>
<Input
value={parentFolderId}
onChange={(e) => setParentFolderId(e.target.value)}
placeholder="Leave blank to create at Drive root"
/>
<p className="text-xs text-muted-foreground">
Paste a Google Drive folder ID to place the file in a specific folder.
</p>
</div>
</>
)}
</div>
)}
{/* Display mode */}
{!isEditing && (
<div className="space-y-2 px-4 py-3 bg-card">
<div>
<p className="text-xs font-medium text-muted-foreground">Name</p>
<p className="text-sm text-foreground">{committedArgs?.name ?? args.name}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Type</p>
<p className="text-sm text-foreground">
{FILE_TYPE_LABELS[committedArgs?.file_type ?? args.file_type] ??
committedArgs?.file_type ??
args.file_type}
</p>
</div>
{(committedArgs?.content ?? args.content) && (
<div>
<p className="text-xs font-medium text-muted-foreground">Content</p>
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
{committedArgs?.content ?? args.content}
</p>
</div>
)}
</div>
)}
{/* Edit mode */}
{isEditing && !decided && (
<div className="space-y-3 px-4 py-3 bg-card">
<div>
<label
htmlFor="gdrive-name"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
Name <span className="text-destructive">*</span>
</label>
<Input
id="gdrive-name"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
placeholder="Enter file name"
className={!isNameValid ? "border-destructive" : ""}
/>
{!isNameValid && <p className="text-xs text-destructive mt-1">Name is required</p>}
</div>
<div>
<label
htmlFor="gdrive-content"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
{selectedFileType === "google_sheet" ? "Content (CSV)" : "Content (Markdown)"}
</label>
<Textarea
id="gdrive-content"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
selectedFileType === "google_sheet"
? "Column A,Column B\nValue 1,Value 2"
: "# Heading\n\nYour content here..."
}
rows={6}
className="resize-none font-mono text-xs"
/>
</div>
</div>
)}
{/* Action buttons */}
<div
className={`flex items-center gap-2 border-t ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
} px-4 py-3`}
>
{decided ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
{decided === "approve" || decided === "edit" ? (
<>
<CheckIcon className="size-3.5 text-green-500" />
{decided === "edit" ? "Approved with Changes" : "Approved"}
</>
) : (
<>
<XIcon className="size-3.5 text-destructive" />
Rejected
</>
)}
</p>
) : isEditing ? (
<>
<Button
size="sm"
onClick={() => {
const finalArgs = buildFinalArgs();
setCommittedArgs(finalArgs);
setDecided("edit");
setIsEditing(false);
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: finalArgs,
},
});
}}
disabled={!canApprove}
>
<CheckIcon />
Approve with Changes
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setEditedName(args.name ?? "");
setEditedContent(args.content ?? "");
}}
>
Cancel
</Button>
</>
) : (
<>
{allowedDecisions.includes("approve") && (
<Button
size="sm"
onClick={() => {
const finalArgs = buildFinalArgs();
setCommittedArgs(finalArgs);
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: finalArgs,
},
});
}}
disabled={!canApprove}
>
<CheckIcon />
Approve
</Button>
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
Edit
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="outline"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
<XIcon />
Reject
</Button>
)}
</>
)}
</div>
</div>
);
}
function InsufficientPermissionsCard({ result }: { result: InsufficientPermissionsResult }) {
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [loading, setLoading] = useState(false);
async function handleReauth() {
setLoading(true);
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const url = new URL(`${backendUrl}/api/v1/auth/google/drive/connector/reauth`);
url.searchParams.set("connector_id", String(result.connector_id));
url.searchParams.set("space_id", searchSpaceId);
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication. Please try again.");
return;
}
const data = await response.json();
if (data.auth_url) {
window.location.href = data.auth_url;
}
} catch {
toast.error("Failed to initiate re-authentication. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
<div className="flex items-center gap-3 border-b border-amber-500/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
<AlertTriangleIcon className="size-4 text-amber-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-600 dark:text-amber-400">
Additional permissions required
</p>
</div>
</div>
<div className="space-y-3 px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
<Button size="sm" onClick={handleReauth} disabled={loading}>
{loading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Re-authenticate Google Drive
</Button>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<XIcon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Failed to create Google Drive file</p>
</div>
</div>
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
<CheckIcon className="size-4 text-green-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[.8rem] text-muted-foreground">
{result.message || "Google Drive file created successfully"}
</p>
</div>
</div>
<div className="space-y-2 px-4 py-3 text-xs">
<div className="flex items-center gap-1.5">
<FileIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium">{result.name}</span>
</div>
{result.web_view_link && (
<div>
<a
href={result.web_view_link}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Open in Google Drive
</a>
</div>
)}
</div>
</div>
);
}
export const CreateGoogleDriveFileToolUI = makeAssistantToolUI<
{ name: string; file_type: string; content?: string },
CreateGoogleDriveFileResult
>({
toolName: "create_google_drive_file",
render: function CreateGoogleDriveFileUI({ args, result, status }) {
if (status.type === "running") {
return (
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Preparing Google Drive file...</p>
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isInsufficientPermissionsResult(result))
return <InsufficientPermissionsCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,2 @@
export { CreateGoogleDriveFileToolUI } from "./create-file";
export { DeleteGoogleDriveFileToolUI } from "./trash-file";

View file

@ -0,0 +1,505 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
AlertTriangleIcon,
CheckIcon,
InfoIcon,
Loader2Icon,
RefreshCwIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { authenticatedFetch } from "@/lib/auth-utils";
interface GoogleDriveAccount {
id: number;
name: string;
}
interface GoogleDriveFile {
file_id: string;
name: string;
mime_type: string;
web_view_link: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GoogleDriveAccount;
file?: GoogleDriveFile;
error?: string;
};
}
interface SuccessResult {
status: "success";
file_id: string;
message?: string;
deleted_from_kb?: boolean;
}
interface WarningResult {
status: "success";
warning: string;
file_id?: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface InsufficientPermissionsResult {
status: "insufficient_permissions";
connector_id: number;
message: string;
}
type DeleteGoogleDriveFileResult =
| InterruptResult
| SuccessResult
| WarningResult
| ErrorResult
| NotFoundResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function isNotFoundResult(result: unknown): result is NotFoundResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function isWarningResult(result: unknown): result is WarningResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as WarningResult).status === "success" &&
"warning" in result &&
typeof (result as WarningResult).warning === "string"
);
}
function isInsufficientPermissionsResult(result: unknown): result is InsufficientPermissionsResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as InsufficientPermissionsResult).status === "insufficient_permissions"
);
}
const MIME_TYPE_LABELS: Record<string, string> = {
"application/vnd.google-apps.document": "Google Doc",
"application/vnd.google-apps.spreadsheet": "Google Sheet",
"application/vnd.google-apps.presentation": "Google Slides",
};
function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) {
const [decided, setDecided] = useState<"approve" | "reject" | null>(
interruptData.__decided__ ?? null
);
const [deleteFromKb, setDeleteFromKb] = useState(false);
const account = interruptData.context?.account;
const file = interruptData.context?.file;
const fileLabel = file?.mime_type ? (MIME_TYPE_LABELS[file.mime_type] ?? "File") : "File";
return (
<div
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
decided
? "border border-border bg-card shadow-sm"
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
}`}
>
{/* Header */}
<div
className={`flex items-center gap-3 border-b ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
} px-4 py-3`}
>
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
decided ? "bg-muted" : "bg-muted animate-pulse"
}`}
>
<AlertTriangleIcon
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Delete Google Drive File</p>
<p className="truncate text-xs text-muted-foreground">
Requires your approval to proceed
</p>
</div>
</div>
{/* Context — read-only file details */}
{!decided && interruptData.context && (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{account && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Google Drive Account
</div>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.name}
</div>
</div>
)}
{file && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">File to Trash</div>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-0.5">
<div className="font-medium">{file.name}</div>
<div className="text-xs text-muted-foreground">{fileLabel}</div>
{file.web_view_link && (
<a
href={file.web_view_link}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Open in Drive
</a>
)}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Trash warning */}
{!decided && (
<div className="px-4 py-3 border-b border-border bg-muted/20">
<p className="text-xs text-muted-foreground">
The file will be moved to Google Drive trash. You can restore it from trash within 30
days.
</p>
</div>
)}
{/* Checkbox for deleting from knowledge base */}
{!decided && (
<div className="px-4 py-3 border-b border-border bg-muted/20">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={deleteFromKb}
onChange={(e) => setDeleteFromKb(e.target.checked)}
className="mt-0.5"
/>
<div className="flex-1">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-1">
This will permanently delete the file from your knowledge base (cannot be undone)
</p>
</div>
</label>
</div>
)}
{/* Action buttons */}
<div
className={`flex items-center gap-2 border-t ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
} px-4 py-3`}
>
{decided ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
{decided === "approve" ? (
<>
<CheckIcon className="size-3.5 text-green-500" />
Approved
</>
) : (
<>
<XIcon className="size-3.5 text-destructive" />
Rejected
</>
)}
</p>
) : (
<>
<Button
size="sm"
variant="destructive"
onClick={() => {
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
file_id: file?.file_id,
connector_id: account?.id,
delete_from_kb: deleteFromKb,
},
},
});
}}
>
<Trash2Icon />
Move to Trash
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
<XIcon />
Reject
</Button>
</>
)}
</div>
</div>
);
}
function InsufficientPermissionsCard({ result }: { result: InsufficientPermissionsResult }) {
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [loading, setLoading] = useState(false);
async function handleReauth() {
setLoading(true);
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const url = new URL(`${backendUrl}/api/v1/auth/google/drive/connector/reauth`);
url.searchParams.set("connector_id", String(result.connector_id));
url.searchParams.set("space_id", searchSpaceId);
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication. Please try again.");
return;
}
const data = await response.json();
if (data.auth_url) {
window.location.href = data.auth_url;
}
} catch {
toast.error("Failed to initiate re-authentication. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
<div className="flex items-center gap-3 border-b border-amber-500/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
<AlertTriangleIcon className="size-4 text-amber-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-600 dark:text-amber-400">
Additional permissions required
</p>
</div>
</div>
<div className="space-y-3 px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
<Button size="sm" onClick={handleReauth} disabled={loading}>
{loading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Re-authenticate Google Drive
</Button>
</div>
</div>
);
}
function WarningCard({ result }: { result: WarningResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
<div className="flex items-center gap-3 border-b border-amber-500/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
<AlertTriangleIcon className="size-4 text-amber-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">Partial success</p>
</div>
</div>
<div className="space-y-2 px-4 py-3">
{result.message && <p className="text-sm text-muted-foreground">{result.message}</p>}
<p className="text-xs text-amber-600 dark:text-amber-500">{result.warning}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<XIcon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Failed to delete file</p>
</div>
</div>
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
<InfoIcon className="size-4 text-amber-500" />
</div>
<div className="min-w-0 flex-1 pt-2">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
<CheckIcon className="size-4 text-green-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[.8rem] text-muted-foreground">
{result.message || "File moved to trash successfully"}
</p>
</div>
</div>
{result.deleted_from_kb && (
<div className="px-4 py-3 text-xs">
<span className="text-green-600 dark:text-green-500">
Also removed from knowledge base
</span>
</div>
)}
</div>
);
}
export const DeleteGoogleDriveFileToolUI = makeAssistantToolUI<
{ file_name: string; delete_from_kb?: boolean },
DeleteGoogleDriveFileResult
>({
toolName: "delete_google_drive_file",
render: function DeleteGoogleDriveFileUI({ result, status }) {
if (status.type === "running") {
return (
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Looking up file in Google Drive...</p>
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isInsufficientPermissionsResult(result))
return <InsufficientPermissionsCard result={result} />;
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isWarningResult(result)) return <WarningCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -32,6 +32,7 @@ export {
} from "./display-image";
export { GeneratePodcastToolUI } from "./generate-podcast";
export { GenerateReportToolUI } from "./generate-report";
export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive";
export {
Image,
ImageErrorBoundary,
@ -77,6 +78,13 @@ export {
type SerializablePlan,
type TodoStatus,
} from "./plan";
export {
type ExecuteArgs,
ExecuteArgsSchema,
type ExecuteResult,
ExecuteResultSchema,
SandboxExecuteToolUI,
} from "./sandbox-execute";
export {
type ScrapeWebpageArgs,
ScrapeWebpageArgsSchema,

View file

@ -0,0 +1,420 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
AlertCircleIcon,
CheckCircle2Icon,
ChevronRightIcon,
DownloadIcon,
FileIcon,
Loader2Icon,
TerminalIcon,
XCircleIcon,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getBearerToken } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { cn } from "@/lib/utils";
// ============================================================================
// Zod Schemas
// ============================================================================
const ExecuteArgsSchema = z.object({
command: z.string(),
timeout: z.number().nullish(),
});
const ExecuteResultSchema = z.object({
result: z.string().nullish(),
exit_code: z.number().nullish(),
output: z.string().nullish(),
error: z.string().nullish(),
status: z.string().nullish(),
thread_id: z.string().nullish(),
});
// ============================================================================
// Types
// ============================================================================
type ExecuteArgs = z.infer<typeof ExecuteArgsSchema>;
type ExecuteResult = z.infer<typeof ExecuteResultSchema>;
interface SandboxFile {
path: string;
name: string;
}
interface ParsedOutput {
exitCode: number | null;
output: string;
displayOutput: string;
truncated: boolean;
isError: boolean;
files: SandboxFile[];
}
// ============================================================================
// Helpers
// ============================================================================
const SANDBOX_FILE_RE = /^SANDBOX_FILE:\s*(.+)$/gm;
function extractSandboxFiles(text: string): SandboxFile[] {
const files: SandboxFile[] = [];
let match: RegExpExecArray | null;
while ((match = SANDBOX_FILE_RE.exec(text)) !== null) {
const filePath = match[1].trim();
if (filePath) {
const name = filePath.includes("/") ? filePath.split("/").pop() || filePath : filePath;
files.push({ path: filePath, name });
}
}
SANDBOX_FILE_RE.lastIndex = 0;
return files;
}
function stripSandboxFileLines(text: string): string {
return text
.replace(/^SANDBOX_FILE:\s*.+$/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function parseExecuteResult(result: ExecuteResult): ParsedOutput {
const raw = result.result || result.output || "";
if (result.error) {
return {
exitCode: null,
output: result.error,
displayOutput: result.error,
truncated: false,
isError: true,
files: [],
};
}
if (result.exit_code !== undefined && result.exit_code !== null) {
const files = extractSandboxFiles(raw);
const displayOutput = stripSandboxFileLines(raw);
return {
exitCode: result.exit_code,
output: raw,
displayOutput,
truncated: raw.includes("[Output was truncated"),
isError: result.exit_code !== 0,
files,
};
}
const exitMatch = raw.match(/^Exit code:\s*(\d+)/);
if (exitMatch) {
const exitCode = parseInt(exitMatch[1], 10);
const outputMatch = raw.match(/\nOutput:\n([\s\S]*)/);
const output = outputMatch ? outputMatch[1] : "";
const files = extractSandboxFiles(output);
const displayOutput = stripSandboxFileLines(output);
return {
exitCode,
output,
displayOutput,
truncated: raw.includes("[Output was truncated"),
isError: exitCode !== 0,
files,
};
}
if (raw.startsWith("Error:")) {
return {
exitCode: null,
output: raw,
displayOutput: raw,
truncated: false,
isError: true,
files: [],
};
}
const files = extractSandboxFiles(raw);
const displayOutput = stripSandboxFileLines(raw);
return { exitCode: null, output: raw, displayOutput, truncated: false, isError: false, files };
}
function truncateCommand(command: string, maxLen = 80): string {
if (command.length <= maxLen) return command;
return command.slice(0, maxLen) + "…";
}
// ============================================================================
// Download helper
// ============================================================================
async function downloadSandboxFile(threadId: string, filePath: string, fileName: string) {
const token = getBearerToken();
const url = `${BACKEND_URL}/api/v1/threads/${threadId}/sandbox/download?path=${encodeURIComponent(filePath)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token || ""}` },
});
if (!res.ok) {
throw new Error(`Download failed: ${res.statusText}`);
}
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(blobUrl);
}
// ============================================================================
// Sub-Components
// ============================================================================
function ExecuteLoading({ command }: { command: string }) {
return (
<div className="my-4 flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<Loader2Icon className="size-4 shrink-0 animate-spin text-muted-foreground" />
<code className="truncate text-sm text-muted-foreground font-mono">
{truncateCommand(command)}
</code>
</div>
);
}
function ExecuteErrorState({ command, error }: { command: string; error: string }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircleIcon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Execution failed</p>
<code className="mt-0.5 block truncate text-xs text-muted-foreground font-mono">
$ {command}
</code>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
</div>
</div>
</div>
);
}
function ExecuteCancelledState({ command }: { command: string }) {
return (
<div className="my-4 max-w-lg rounded-xl border border-muted p-4 text-muted-foreground">
<p className="flex items-center gap-2 font-mono text-sm">
<TerminalIcon className="size-4" />
<span className="line-through truncate">$ {command}</span>
</p>
</div>
);
}
function SandboxFileDownload({ file, threadId }: { file: SandboxFile; threadId: string }) {
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDownload = useCallback(async () => {
setDownloading(true);
setError(null);
try {
await downloadSandboxFile(threadId, file.path, file.name);
} catch (e) {
setError(e instanceof Error ? e.message : "Download failed");
} finally {
setDownloading(false);
}
}, [threadId, file.path, file.name]);
return (
<Button
variant="ghost"
size="sm"
className="h-8 gap-2 rounded-lg bg-zinc-800/60 hover:bg-zinc-700/60 text-zinc-200 text-xs font-mono px-3"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<DownloadIcon className="size-3.5" />
)}
<FileIcon className="size-3 text-zinc-400" />
<span className="truncate max-w-[200px]">{file.name}</span>
{error && <span className="text-destructive text-[10px] ml-1">{error}</span>}
</Button>
);
}
function ExecuteCompleted({
command,
parsed,
threadId,
}: {
command: string;
parsed: ParsedOutput;
threadId: string | null;
}) {
const [open, setOpen] = useState(false);
const isLongCommand = command.length > 80 || command.includes("\n");
const hasTextContent = parsed.displayOutput.trim().length > 0 || isLongCommand;
const hasFiles = parsed.files.length > 0 && !!threadId;
const hasContent = hasTextContent || hasFiles;
const exitBadge = useMemo(() => {
if (parsed.exitCode === null) return null;
const success = parsed.exitCode === 0;
return (
<Badge
variant={success ? "secondary" : "destructive"}
className={cn(
"ml-auto gap-1 text-[10px] px-1.5 py-0",
success &&
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
)}
>
{success ? <CheckCircle2Icon className="size-3" /> : <XCircleIcon className="size-3" />}
{parsed.exitCode}
</Badge>
);
}, [parsed.exitCode]);
return (
<div className="my-4 max-w-lg">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 rounded-xl border bg-card px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
open && "rounded-b-none border-b-0",
parsed.isError && "border-destructive/20"
)}
disabled={!hasContent}
>
<ChevronRightIcon
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
open && "rotate-90",
!hasContent && "invisible"
)}
/>
<TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate text-sm font-mono">
{truncateCommand(command)}
</code>
{hasFiles && !open && (
<Badge
variant="outline"
className="gap-1 text-[10px] px-1.5 py-0 border-blue-500/30 text-blue-500"
>
<FileIcon className="size-2.5" />
{parsed.files.length}
</Badge>
)}
{exitBadge}
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={cn(
"rounded-b-xl border border-t-0 bg-zinc-950 dark:bg-zinc-900/60 px-4 py-3 space-y-3",
parsed.isError && "border-destructive/20"
)}
>
{isLongCommand && (
<div>
<p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">
Command
</p>
<pre className="max-h-60 overflow-auto whitespace-pre-wrap break-all rounded-md bg-zinc-900/80 dark:bg-zinc-800/40 px-3 py-2 text-xs font-mono text-emerald-400 leading-relaxed">
{command}
</pre>
</div>
)}
{parsed.displayOutput.trim().length > 0 && (
<div>
{(isLongCommand || hasFiles) && (
<p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">
Output
</p>
)}
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all text-xs font-mono text-zinc-300 leading-relaxed">
{parsed.displayOutput}
</pre>
</div>
)}
{parsed.truncated && (
<p className="text-[10px] text-zinc-500 italic">
Output was truncated due to size limits
</p>
)}
{hasFiles && threadId && (
<div>
<p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">
Files
</p>
<div className="flex flex-wrap gap-2">
{parsed.files.map((file) => (
<SandboxFileDownload key={file.path} file={file} threadId={threadId} />
))}
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
// ============================================================================
// Tool UI
// ============================================================================
export const SandboxExecuteToolUI = makeAssistantToolUI<ExecuteArgs, ExecuteResult>({
toolName: "execute",
render: function SandboxExecuteUI({ args, result, status }) {
const command = args.command || "…";
if (status.type === "running" || status.type === "requires-action") {
return <ExecuteLoading command={command} />;
}
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return <ExecuteCancelledState command={command} />;
}
if (status.reason === "error") {
return (
<ExecuteErrorState
command={command}
error={typeof status.error === "string" ? status.error : "An error occurred"}
/>
);
}
}
if (!result) {
return <ExecuteLoading command={command} />;
}
if (result.error && !result.result && !result.output) {
return <ExecuteErrorState command={command} error={result.error} />;
}
const parsed = parseExecuteResult(result);
const threadId = result.thread_id || null;
return <ExecuteCompleted command={command} parsed={parsed} threadId={threadId} />;
},
});
export { ExecuteArgsSchema, ExecuteResultSchema, type ExecuteArgs, type ExecuteResult };

View file

@ -2,6 +2,7 @@
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
function ExpandedGifOverlay({
src,
@ -20,7 +21,7 @@ function ExpandedGifOverlay({
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
return (
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -36,10 +37,10 @@ function ExpandedGifOverlay({
transition={{ duration: 0.25, ease: "easeOut" }}
src={src}
alt={alt}
className="max-h-[90vh] max-w-[90vw] rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
/>
</motion.div>
</motion.div>,
document.body
);
}

View file

@ -0,0 +1,325 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
const carouselItems = [
{
title: "Connect & Sync",
description:
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
src: "/homepage/hero_tutorial/ConnectorFlowGif.gif",
},
{
title: "Upload Documents",
description: "Upload documents directly, from images to massive PDFs.",
src: "/homepage/hero_tutorial/DocUploadGif.gif",
},
{
title: "Search & Citation",
description: "Ask questions and get cited responses from your knowledge base.",
src: "/homepage/hero_tutorial/BSNCGif.gif",
},
{
title: "Targeted Document Q&A",
description: "Mention specific documents in chat for targeted answers.",
src: "/homepage/hero_tutorial/BQnaGif_compressed.gif",
},
{
title: "Produce Reports Instantly",
description: "Generate reports from your sources in many formats.",
src: "/homepage/hero_tutorial/ReportGenGif_compressed.gif",
},
{
title: "Create Podcasts",
description: "Turn anything into a podcast in under 20 seconds.",
src: "/homepage/hero_tutorial/PodcastGenGif.gif",
},
{
title: "Image Generation",
description: "Generate high-quality images easily from your conversations.",
src: "/homepage/hero_tutorial/ImageGenGif.gif",
},
{
title: "Collaborative AI Chat",
description: "Collaborate on AI-powered conversations in realtime with your team.",
src: "/homepage/hero_realtime/RealTimeChatGif.gif",
},
{
title: "Realtime Comments",
description: "Add comments and tag teammates on any message.",
src: "/homepage/hero_realtime/RealTimeCommentsFlow.gif",
},
];
function HeroCarouselCard({
index,
title,
description,
src,
isActive,
onExpandedChange,
}: {
index: number;
title: string;
description: string;
src: string;
isActive: boolean;
onExpandedChange?: (expanded: boolean) => void;
}) {
const { expanded, open, close } = useExpandedGif();
useEffect(() => {
onExpandedChange?.(expanded);
}, [expanded, onExpandedChange]);
const imgRef = useRef<HTMLImageElement>(null);
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
const [playKey, setPlayKey] = useState(0);
const captureFrame = useCallback((img: HTMLImageElement) => {
try {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext("2d")?.drawImage(img, 0, 0);
setFrozenFrame(canvas.toDataURL());
} catch {
/* cross-origin or other issue */
}
}, []);
useEffect(() => {
if (isActive) {
setPlayKey((k) => k + 1);
setFrozenFrame(null);
} else {
const img = imgRef.current;
if (img && img.complete && img.naturalWidth > 0) {
captureFrame(img);
}
}
}, [isActive, captureFrame]);
useEffect(() => {
if (!isActive && !frozenFrame) {
const img = new Image();
img.onload = () => captureFrame(img);
img.src = src;
}
}, [isActive, frozenFrame, src, captureFrame]);
return (
<>
<div className="rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
{/* <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-xs font-semibold text-white sm:h-8 sm:w-8 sm:text-sm dark:bg-white dark:text-neutral-900">
{index + 1}
</span> */}
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-xl dark:text-white">
{title}
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
</div>
</div>
<div
className={`bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 ${
isActive ? "cursor-pointer" : "pointer-events-none"
}`}
onClick={isActive ? open : undefined}
>
{isActive ? (
<img
ref={imgRef}
key={`gif_${index}_${playKey}`}
src={src}
alt={title}
className="w-full rounded-lg sm:rounded-xl"
/>
) : frozenFrame ? (
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
) : (
<div className="aspect-video w-full rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
)}
</div>
</div>
<AnimatePresence>
{expanded && <ExpandedGifOverlay src={src} alt={title} onClose={close} />}
</AnimatePresence>
</>
);
}
function HeroCarousel() {
const [activeIndex, setActiveIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const [isGifExpanded, setIsGifExpanded] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const [cardHeight, setCardHeight] = useState(420);
const containerRef = useRef<HTMLDivElement>(null);
const activeCardRef = useRef<HTMLDivElement>(null);
const directionRef = useRef<"forward" | "backward">("forward");
const goTo = useCallback(
(newIndex: number) => {
directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
setActiveIndex(newIndex);
},
[activeIndex]
);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => setContainerWidth(el.offsetWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
const el = activeCardRef.current;
if (!el) return;
const update = () => setCardHeight(el.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [activeIndex, containerWidth]);
useEffect(() => {
if (isPaused || isGifExpanded) return;
const timer = setTimeout(() => {
directionRef.current = "forward";
setActiveIndex((prev) => (prev >= carouselItems.length - 1 ? 0 : prev + 1));
}, 8000);
return () => clearTimeout(timer);
}, [activeIndex, isPaused, isGifExpanded]);
const cardWidth =
containerWidth < 640
? containerWidth * 0.85
: containerWidth < 1024
? Math.min(containerWidth * 0.7, 680)
: Math.min(containerWidth * 0.55, 900);
const baseOffset =
containerWidth < 640
? containerWidth * 0.2
: containerWidth < 1024
? containerWidth * 0.15
: 150;
const stackGap = containerWidth < 640 ? 35 : containerWidth < 1024 ? 45 : 55;
const perspective = containerWidth < 640 ? 800 : containerWidth < 1024 ? 1000 : 1200;
const getCardStyle = useCallback(
(index: number) => {
const diff = index - activeIndex;
if (diff === 0) {
const originX = directionRef.current === "forward" ? 1 : 0;
return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX, overlayOpacity: 0, blur: 0 };
}
const dist = Math.abs(diff);
const isLeft = diff < 0;
const offset = baseOffset + (dist - 1) * stackGap;
const t = Math.min(1, dist / 3);
return {
x: -cardWidth / 2 + (isLeft ? -offset : offset),
rotateY: isLeft ? 90 : -90,
zIndex: 20 - dist,
originX: isLeft ? 0 : 1,
overlayOpacity: t,
blur: t * 6,
};
},
[activeIndex, cardWidth, baseOffset, stackGap]
);
return (
<div className="w-full py-4 sm:py-8">
<div
ref={containerRef}
className="relative mx-auto w-full"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onTouchStart={() => setIsPaused(true)}
onTouchEnd={() => setIsPaused(false)}
>
<div
className="relative z-6 transition-[height] duration-700"
style={{ perspective: `${perspective}px`, height: cardHeight }}
>
{containerWidth > 0 &&
carouselItems.map((item, i) => {
const style = getCardStyle(i);
return (
<motion.div
key={`carousel_${i}`}
ref={i === activeIndex ? activeCardRef : undefined}
className="absolute top-0"
style={{
left: "50%",
width: cardWidth,
transformStyle: "preserve-3d",
zIndex: style.zIndex,
transformOrigin: `${style.originX * 100}% 50%`,
cursor: i !== activeIndex ? "pointer" : undefined,
}}
onClick={i !== activeIndex && !isGifExpanded ? () => goTo(i) : undefined}
animate={{
x: style.x,
rotateY: style.rotateY,
}}
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
>
<motion.div
animate={{ filter: `blur(${style.blur}px)` }}
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
>
<HeroCarouselCard
index={i}
title={item.title}
description={item.description}
src={item.src}
isActive={i === activeIndex}
onExpandedChange={setIsGifExpanded}
/>
</motion.div>
<motion.div
className="pointer-events-none absolute inset-0 rounded-2xl bg-black sm:rounded-3xl"
animate={{ opacity: style.overlayOpacity }}
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
/>
</motion.div>
);
})}
</div>
</div>
<div className="relative z-5 mt-6 flex justify-center gap-2">
{carouselItems.map((_, i) => (
<button
key={`dot_${i}`}
type="button"
onClick={() => !isGifExpanded && goTo(i)}
className={`h-2 rounded-full transition-all duration-300 ${
i === activeIndex
? "w-6 bg-neutral-900 dark:bg-white"
: "w-2 bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-600 dark:hover:bg-neutral-500"
}`}
aria-label={`Go to slide ${i + 1}`}
/>
))}
</div>
</div>
);
}
export { HeroCarousel, HeroCarouselCard };

View file

@ -1,123 +0,0 @@
"use client";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import { useRef } from "react";
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
const walkthroughSteps = [
{
step: 1,
title: "Login",
description: "Login to get started.",
src: "/homepage/hero_tutorial/LoginFlowGif.gif",
},
{
step: 2,
title: "Connect & Sync",
description: "Connect your connectors and sync. Enable periodic syncing to keep them updated.",
src: "/homepage/hero_tutorial/ConnectorFlowGif.gif",
},
{
step: 3,
title: "Upload Documents",
description: "While connectors index, upload your documents directly.",
src: "/homepage/hero_tutorial/DocUploadGif.gif",
},
];
function WalkthroughCard({
i,
step,
title,
description,
src,
progress,
range,
targetScale,
}: {
i: number;
step: number;
title: string;
description: string;
src: string;
progress: ReturnType<typeof useScroll>["scrollYProgress"];
range: [number, number];
targetScale: number;
}) {
const container = useRef<HTMLDivElement>(null);
const scale = useTransform(progress, range, [1, targetScale]);
const { expanded, open, close } = useExpandedGif();
return (
<>
<div
ref={container}
className="sticky top-0 flex items-center justify-center px-4 sm:px-6 lg:px-8"
>
<motion.div
style={{
scale,
top: `calc(10vh + ${i * 30}px)`,
}}
className="relative flex origin-top flex-col overflow-hidden rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900
w-full max-w-[340px] sm:max-w-[520px] md:max-w-[680px] lg:max-w-[900px]"
>
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-xs font-semibold text-white sm:h-8 sm:w-8 sm:text-sm dark:bg-white dark:text-neutral-900">
{step}
</span>
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-neutral-900 sm:text-base dark:text-white">
{title}
</h3>
<p className="hidden text-xs text-neutral-500 sm:block dark:text-neutral-400">
{description}
</p>
</div>
</div>
<div
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950"
onClick={open}
>
<img src={src} alt={title} className="w-full rounded-lg object-cover sm:rounded-xl" />
</div>
</motion.div>
</div>
<AnimatePresence>
{expanded && <ExpandedGifOverlay src={src} alt={title} onClose={close} />}
</AnimatePresence>
</>
);
}
function WalkthroughScroll() {
const container = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: container,
offset: ["start start", "end end"],
});
return (
<div
ref={container}
className="relative flex w-full flex-col items-center justify-center pb-[15vh] pt-[1vh] sm:pb-[18vh] sm:pt-[2vh]"
>
{walkthroughSteps.map((project, i) => {
const targetScale = Math.max(0.6, 1 - (walkthroughSteps.length - i - 1) * 0.05);
return (
<WalkthroughCard
key={`walkthrough_${i}`}
i={i}
{...project}
progress={scrollYProgress}
range={[i * (1 / walkthroughSteps.length), 1]}
targetScale={targetScale}
/>
);
})}
</div>
);
}
export { WalkthroughScroll, WalkthroughCard };

View file

@ -9,6 +9,8 @@
"docker-installation",
"manual-installation",
"connectors",
"how-to"
"how-to",
"---Development---",
"testing"
]
}

View file

@ -0,0 +1,104 @@
---
title: Testing
description: Running and writing end-to-end tests for SurfSense
---
SurfSense uses [pytest](https://docs.pytest.org/) for end-to-end testing. Tests are **self-bootstrapping** — they automatically register a test user and discover search spaces, so no manual database setup is required.
## Prerequisites
Before running tests, make sure the full backend stack is running:
- **FastAPI backend**
- **PostgreSQL + pgvector**
- **Redis**
- **Celery worker**
Your backend must have **`REGISTRATION_ENABLED=TRUE`** in its `.env` (this is the default). The tests register their own user on first run.
Your `global_llm_config.yaml` must have at least one working LLM model with a valid API key — document processing uses Auto mode, which routes through the global config.
## Running Tests
**Run all tests:**
```bash
uv run pytest
```
**Run by marker** (e.g., only document tests):
```bash
uv run pytest -m document
```
**Available markers:**
| Marker | Description |
|---|---|
| `document` | Document upload, processing, and deletion tests |
| `connector` | Connector indexing tests |
| `chat` | Chat and agent tests |
**Useful flags:**
| Flag | Description |
|---|---|
| `-s` | Show live output (useful for debugging polling loops) |
| `--tb=long` | Full tracebacks instead of short summaries |
| `-k "test_name"` | Run a single test by name |
| `-o addopts=""` | Override default flags from `pyproject.toml` |
## Configuration
Default pytest options are configured in `surfsense_backend/pyproject.toml`:
```toml
[tool.pytest.ini_options]
addopts = "-v --tb=short -x --strict-markers -ra --durations=10"
```
- `-v` — verbose test names
- `--tb=short` — concise tracebacks on failure
- `-x` — stop on first failure
- `--strict-markers` — reject unregistered markers
- `-ra` — show summary of all non-passing tests
- `--durations=10` — show the 10 slowest tests
## Environment Variables
All test configuration has sensible defaults. Override via environment variables if needed:
| Variable | Default | Description |
|---|---|---|
| `TEST_BACKEND_URL` | `http://localhost:8000` | Backend URL to test against |
| `TEST_DATABASE_URL` | Falls back to `DATABASE_URL` | Direct DB connection for test cleanup |
| `TEST_USER_EMAIL` | `testuser@surfsense.com` | Test user email |
| `TEST_USER_PASSWORD` | `testpassword123` | Test user password |
These can be configured in `surfsense_backend/.env` (see the Testing section at the bottom of `.env.example`).
## How It Works
Tests are fully self-bootstrapping:
1. **User creation** — on first run, tests try to log in. If the user doesn't exist, they register via `POST /auth/register`, then log in.
2. **Search space discovery** — after authentication, tests call `GET /api/v1/searchspaces` and use the first available search space (auto-created during registration).
3. **Session purge** — before any tests run, a session-scoped fixture deletes all documents in the test search space directly via the database. This handles stuck documents from previous crashed runs that the API refuses to delete (409 Conflict).
4. **Per-test cleanup** — every test that creates documents adds their IDs to a `cleanup_doc_ids` list. An autouse fixture deletes them after each test via the API, falling back to direct DB access for any stuck documents.
This means tests work on both fresh databases and existing ones without any manual setup.
## Writing New Tests
1. Create a test file in the appropriate directory (e.g., `tests/e2e/test_connectors.py`).
2. Add a module-level marker at the top:
```python
import pytest
pytestmark = pytest.mark.connector
```
3. Use fixtures from `conftest.py` — `client`, `headers`, `search_space_id`, and `cleanup_doc_ids` are available to all tests.
4. Register any new markers in `pyproject.toml` under `markers`.

View file

@ -44,6 +44,7 @@ export const document = z.object({
search_space_id: z.number(),
created_by_id: z.string().nullable().optional(),
created_by_name: z.string().nullable().optional(),
created_by_email: z.string().nullable().optional(),
});
export const extensionDocumentContent = z.object({

View file

@ -26,7 +26,7 @@ interface DocumentElectric {
status: DocumentStatusType | null;
}
// Document for display (with resolved user name)
// Document for display (with resolved user name and email)
export interface DocumentDisplay {
id: number;
search_space_id: number;
@ -34,6 +34,7 @@ export interface DocumentDisplay {
title: string;
created_by_id: string | null;
created_by_name: string | null;
created_by_email: string | null;
created_at: string;
status: DocumentStatusType;
}
@ -94,8 +95,9 @@ export function useDocuments(
// Track if initial API load is complete (source of truth)
const apiLoadedRef = useRef(false);
// User cache: userId → displayName
// User cache: userId → displayName / email
const userCacheRef = useRef<Map<string, string>>(new Map());
const emailCacheRef = useRef<Map<string, string>>(new Map());
// Electric sync refs
const syncHandleRef = useRef<SyncHandle | null>(null);
@ -119,10 +121,21 @@ export function useDocuments(
// Populate user cache from API response
const populateUserCache = useCallback(
(items: Array<{ created_by_id?: string | null; created_by_name?: string | null }>) => {
(
items: Array<{
created_by_id?: string | null;
created_by_name?: string | null;
created_by_email?: string | null;
}>
) => {
for (const item of items) {
if (item.created_by_id && item.created_by_name) {
userCacheRef.current.set(item.created_by_id, item.created_by_name);
if (item.created_by_id) {
if (item.created_by_name) {
userCacheRef.current.set(item.created_by_id, item.created_by_name);
}
if (item.created_by_email) {
emailCacheRef.current.set(item.created_by_id, item.created_by_email);
}
}
}
},
@ -138,6 +151,7 @@ export function useDocuments(
title: string;
created_by_id?: string | null;
created_by_name?: string | null;
created_by_email?: string | null;
created_at: string;
status?: DocumentStatusType | null;
}): DocumentDisplay => ({
@ -147,6 +161,7 @@ export function useDocuments(
title: item.title,
created_by_id: item.created_by_id ?? null,
created_by_name: item.created_by_name ?? null,
created_by_email: item.created_by_email ?? null,
created_at: item.created_at,
status: item.status ?? { state: "ready" },
}),
@ -160,6 +175,9 @@ export function useDocuments(
created_by_name: doc.created_by_id
? (userCacheRef.current.get(doc.created_by_id) ?? null)
: null,
created_by_email: doc.created_by_id
? (emailCacheRef.current.get(doc.created_by_id) ?? null)
: null,
status: doc.status ?? { state: "ready" },
}),
[]
@ -351,6 +369,9 @@ export function useDocuments(
created_by_name: doc.created_by_id
? (userCacheRef.current.get(doc.created_by_id) ?? null)
: null,
created_by_email: doc.created_by_id
? (emailCacheRef.current.get(doc.created_by_id) ?? null)
: null,
}))
);
}
@ -455,6 +476,7 @@ export function useDocuments(
setAllDocuments([]);
apiLoadedRef.current = false;
userCacheRef.current.clear();
emailCacheRef.current.clear();
}
prevSearchSpaceIdRef.current = searchSpaceId;
}, [searchSpaceId]);

View file

@ -43,7 +43,7 @@ export function getProviderIcon(
{ isAutoMode, className = "size-4" }: { isAutoMode?: boolean; className?: string } = {}
) {
if (isAutoMode || provider?.toUpperCase() === "AUTO") {
return <Shuffle className={cn(className, "text-violet-800")} />;
return <Shuffle className={cn(className, "text-muted-foreground")} />;
}
switch (provider?.toUpperCase()) {

View file

@ -682,7 +682,7 @@
"rename_chat": "Rename Chat",
"rename_chat_description": "Enter a new name for this conversation.",
"chat_title_placeholder": "Chat title",
"renaming": "Renaming...",
"renaming": "Renaming",
"no_archived_chats": "No archived chats",
"error_archiving_chat": "Failed to archive chat",
"new_chat": "New chat",
@ -720,7 +720,8 @@
"unread": "Unread",
"connectors": "Connectors",
"all_connectors": "All connectors",
"close": "Close"
"close": "Close",
"cancel": "Cancel"
},
"errors": {
"something_went_wrong": "Something went wrong",
@ -746,6 +747,8 @@
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
"nav_public_links": "Public Chat Links",
"nav_public_links_desc": "Manage publicly shared chat links",
"nav_team_roles": "Team Roles",
"nav_team_roles_desc": "Manage team roles & permissions",
"general_name_label": "Name",
"general_name_placeholder": "Enter search space name",
"general_name_description": "A unique name for your search space.",

View file

@ -720,7 +720,8 @@
"unread": "No leído",
"connectors": "Conectores",
"all_connectors": "Todos los conectores",
"close": "Cerrar"
"close": "Cerrar",
"cancel": "Cancelar"
},
"errors": {
"something_went_wrong": "Algo salió mal",
@ -746,6 +747,8 @@
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
"nav_public_links": "Enlaces de chat públicos",
"nav_public_links_desc": "Administrar enlaces de chat compartidos públicamente",
"nav_team_roles": "Team Roles",
"nav_team_roles_desc": "Manage team roles & permissions",
"general_name_label": "Nombre",
"general_name_placeholder": "Ingresa el nombre del espacio de búsqueda",
"general_name_description": "Un nombre único para tu espacio de búsqueda.",

View file

@ -720,7 +720,8 @@
"unread": "अपठित",
"connectors": "कनेक्टर",
"all_connectors": "सभी कनेक्टर",
"close": "बंद करें"
"close": "बंद करें",
"cancel": "रद्द करें"
},
"errors": {
"something_went_wrong": "कुछ गलत हो गया",
@ -746,6 +747,8 @@
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
"nav_public_links": "सार्वजनिक चैट लिंक",
"nav_public_links_desc": "सार्वजनिक रूप से साझा किए गए चैट लिंक प्रबंधित करें",
"nav_team_roles": "Team Roles",
"nav_team_roles_desc": "Manage team roles & permissions",
"general_name_label": "नाम",
"general_name_placeholder": "सर्च स्पेस का नाम दर्ज करें",
"general_name_description": "आपके सर्च स्पेस के लिए एक अद्वितीय नाम।",

View file

@ -720,7 +720,8 @@
"unread": "Não lido",
"connectors": "Conectores",
"all_connectors": "Todos os conectores",
"close": "Fechar"
"close": "Fechar",
"cancel": "Cancelar"
},
"errors": {
"something_went_wrong": "Algo deu errado",
@ -746,6 +747,8 @@
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
"nav_public_links": "Links de chat públicos",
"nav_public_links_desc": "Gerenciar links de chat compartilhados publicamente",
"nav_team_roles": "Team Roles",
"nav_team_roles_desc": "Manage team roles & permissions",
"general_name_label": "Nome",
"general_name_placeholder": "Insira o nome do espaço de pesquisa",
"general_name_description": "Um nome único para seu espaço de pesquisa.",

View file

@ -704,7 +704,8 @@
"unread": "未读",
"connectors": "连接器",
"all_connectors": "所有连接器",
"close": "关闭"
"close": "关闭",
"cancel": "取消"
},
"errors": {
"something_went_wrong": "出错了",
@ -730,6 +731,8 @@
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
"nav_public_links": "公开聊天链接",
"nav_public_links_desc": "管理公开分享的聊天链接",
"nav_team_roles": "Team Roles",
"nav_team_roles_desc": "Manage team roles & permissions",
"general_name_label": "名称",
"general_name_placeholder": "输入搜索空间名称",
"general_name_description": "您的搜索空间的唯一名称。",

File diff suppressed because it is too large Load diff