Merge upstream/dev into feature/mcp-migration

This commit is contained in:
CREDO23 2026-04-22 19:53:26 +02:00
commit 4915675f45
54 changed files with 2050 additions and 359 deletions

View file

@ -303,5 +303,79 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX_DEFAULTS));
// ============================================================================
// CONNECTOR TELEMETRY REGISTRY
// ----------------------------------------------------------------------------
// Single source of truth for "what does this connector_type look like in
// analytics?". Any connector added to the lists above is automatically
// picked up here, so adding a new integration does NOT require touching
// `lib/posthog/events.ts` or per-connector tracking code.
// ============================================================================
export type ConnectorTelemetryGroup = "oauth" | "composio" | "crawler" | "other" | "unknown";
export interface ConnectorTelemetryMeta {
connector_type: string;
connector_title: string;
connector_group: ConnectorTelemetryGroup;
is_oauth: boolean;
}
const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap<string, ConnectorTelemetryMeta> = (() => {
const map = new Map<string, ConnectorTelemetryMeta>();
for (const c of OAUTH_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "oauth",
is_oauth: true,
});
}
for (const c of COMPOSIO_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "composio",
is_oauth: true,
});
}
for (const c of CRAWLERS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "crawler",
is_oauth: false,
});
}
for (const c of OTHER_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "other",
is_oauth: false,
});
}
return map;
})();
/**
* Returns telemetry metadata for a connector_type, or a minimal "unknown"
* record so tracking never no-ops for connectors that exist in the backend
* but were forgotten in the UI registry.
*/
export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta {
const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType);
if (hit) return hit;
return {
connector_type: connectorType,
connector_title: connectorType,
connector_group: "unknown",
is_oauth: false,
};
}
// Re-export IndexingConfigState from schemas for backward compatibility
export type { IndexingConfigState } from "./connector-popup.schemas";

View file

@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
trackConnectorSetupFailure,
trackConnectorSetupStarted,
trackIndexWithDateRangeOpened,
trackIndexWithDateRangeStarted,
trackPeriodicIndexingStarted,
@ -232,10 +234,20 @@ export const useConnectorDialog = () => {
if (result.error) {
const oauthConnector = result.connector
? OAUTH_CONNECTORS.find((c) => c.id === result.connector)
? OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector)
: null;
const name = oauthConnector?.title || "connector";
if (oauthConnector) {
trackConnectorSetupFailure(
Number(searchSpaceId),
oauthConnector.connectorType,
result.error,
"oauth_callback"
);
}
if (result.error === "duplicate_account") {
toast.error(`This ${name} account is already connected`, {
description: "Please use a different account or manage the existing connection.",
@ -351,6 +363,8 @@ export const useConnectorDialog = () => {
// Set connecting state immediately to disable button and show spinner
setConnectingId(connector.id);
trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click");
try {
// Check if authEndpoint already has query parameters
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
@ -372,6 +386,12 @@ export const useConnectorDialog = () => {
window.location.href = validatedData.auth_url;
} catch (error) {
console.error(`Error connecting to ${connector.title}:`, error);
trackConnectorSetupFailure(
Number(searchSpaceId),
connector.connectorType,
error instanceof Error ? error.message : "oauth_initiation_failed",
"oauth_init"
);
if (error instanceof Error && error.message.includes("Invalid auth URL")) {
toast.error(`Invalid response from ${connector.title} OAuth endpoint`);
} else {
@ -395,6 +415,11 @@ export const useConnectorDialog = () => {
if (!searchSpaceId) return;
setConnectingId("webcrawler-connector");
trackConnectorSetupStarted(
Number(searchSpaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
"webcrawler_quick_add"
);
try {
await createConnector({
data: {
@ -444,6 +469,12 @@ export const useConnectorDialog = () => {
}
} catch (error) {
console.error("Error creating webcrawler connector:", error);
trackConnectorSetupFailure(
Number(searchSpaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
error instanceof Error ? error.message : "webcrawler_create_failed",
"webcrawler_quick_add"
);
toast.error("Failed to create web crawler connector");
} finally {
setConnectingId(null);
@ -455,6 +486,8 @@ export const useConnectorDialog = () => {
(connectorType: string) => {
if (!searchSpaceId) return;
trackConnectorSetupStarted(Number(searchSpaceId), connectorType, "non_oauth_click");
// Handle Obsidian specifically on Desktop & Cloud
if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) {
setIsOpen(false);
@ -683,6 +716,12 @@ export const useConnectorDialog = () => {
}
} catch (error) {
console.error("Error creating connector:", error);
trackConnectorSetupFailure(
Number(searchSpaceId),
connectingConnectorType ?? formData.connector_type,
error instanceof Error ? error.message : "connector_create_failed",
"non_oauth_form"
);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
isCreatingConnectorRef.current = false;

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -29,12 +29,16 @@ export function CreateFolderDialog({
const [name, setName] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setName("");
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) {
setName("");
setTimeout(() => inputRef.current?.focus(), 0);
}
onOpenChange(next);
},
[onOpenChange]
);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
@ -50,7 +54,7 @@ export function CreateFolderDialog({
const isSubfolder = !!parentFolderName;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none 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">

View file

@ -1,7 +1,7 @@
"use client";
import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -36,12 +36,16 @@ export function FolderPickerDialog({
const [selectedId, setSelectedId] = useState<number | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
useEffect(() => {
if (open) {
setSelectedId(null);
setExpandedIds(new Set());
}
}, [open]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) {
setSelectedId(null);
setExpandedIds(new Set());
}
onOpenChange(next);
},
[onOpenChange]
);
const foldersByParent = useMemo(() => {
const map: Record<string, FolderDisplay[]> = {};
@ -123,7 +127,7 @@ export function FolderPickerDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none 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">

View file

@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar";
import { QuotaWarningBanner } from "./quota-warning-banner";
@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
textareaRef.current.style.height = "auto";
}
trackAnonymousChatMessageSent({
modelSlug: model.seo_slug,
messageLength: trimmed.length,
surface: "free_model_page",
});
const controller = new AbortController();
abortRef.current = controller;

View file

@ -28,6 +28,7 @@ import {
updateToolCall,
} from "@/lib/chat/streaming-state";
import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread";
@ -206,6 +207,13 @@ export function FreeChatPage() {
}
if (!userQuery.trim()) return;
trackAnonymousChatMessageSent({
modelSlug,
messageLength: userQuery.trim().length,
hasUploadedDoc: anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false,
surface: "free_chat_page",
});
const userMsgId = `msg-user-${Date.now()}`;
setMessages((prev) => [
...prev,

View file

@ -27,13 +27,14 @@ export function FreeModelSelector({ className }: { className?: string }) {
anonymousChatApiService.getModels().then(setModels).catch(console.error);
}, []);
useEffect(() => {
if (open) {
const handleOpenChange = useCallback((next: boolean) => {
if (next) {
setSearchQuery("");
setFocusedIndex(-1);
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}, [open]);
setOpen(next);
}, []);
const currentModel = useMemo(
() => models.find((m) => m.seo_slug === currentSlug) ?? null,
@ -94,7 +95,7 @@ export function FreeModelSelector({ className }: { className?: string }) {
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="ghost"

View file

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

View file

@ -426,15 +426,50 @@ const AiSortIllustration = () => (
<title>AI File Sorting illustration showing automatic folder organization</title>
{/* Scattered documents on the left */}
<g opacity="0.5">
<rect x="20" y="40" width="35" height="45" rx="4" className="fill-neutral-200 dark:fill-neutral-700" transform="rotate(-8 37 62)" />
<rect x="50" y="80" width="35" height="45" rx="4" className="fill-neutral-200 dark:fill-neutral-700" transform="rotate(5 67 102)" />
<rect x="15" y="110" width="35" height="45" rx="4" className="fill-neutral-200 dark:fill-neutral-700" transform="rotate(-3 32 132)" />
<rect
x="20"
y="40"
width="35"
height="45"
rx="4"
className="fill-neutral-200 dark:fill-neutral-700"
transform="rotate(-8 37 62)"
/>
<rect
x="50"
y="80"
width="35"
height="45"
rx="4"
className="fill-neutral-200 dark:fill-neutral-700"
transform="rotate(5 67 102)"
/>
<rect
x="15"
y="110"
width="35"
height="45"
rx="4"
className="fill-neutral-200 dark:fill-neutral-700"
transform="rotate(-3 32 132)"
/>
</g>
{/* AI sparkle / magic in the center */}
<g transform="translate(140, 90)">
<path d="M 0,-18 L 4,-6 L 16,-4 L 6,4 L 8,16 L 0,10 L -8,16 L -6,4 L -16,-4 L -4,-6 Z" className="fill-emerald-500 dark:fill-emerald-400" opacity="0.85">
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="10s" repeatCount="indefinite" />
<path
d="M 0,-18 L 4,-6 L 16,-4 L 6,4 L 8,16 L 0,10 L -8,16 L -6,4 L -16,-4 L -4,-6 Z"
className="fill-emerald-500 dark:fill-emerald-400"
opacity="0.85"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0"
to="360"
dur="10s"
repeatCount="indefinite"
/>
</path>
<circle cx="0" cy="0" r="3" className="fill-white dark:fill-emerald-200">
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
@ -442,51 +477,208 @@ const AiSortIllustration = () => (
</g>
{/* Animated sorting arrows */}
<g className="stroke-emerald-500 dark:stroke-emerald-400" strokeWidth="2" fill="none" opacity="0.6">
<g
className="stroke-emerald-500 dark:stroke-emerald-400"
strokeWidth="2"
fill="none"
opacity="0.6"
>
<path d="M 100 70 Q 140 60, 180 50" strokeDasharray="4,4">
<animate attributeName="stroke-dashoffset" from="8" to="0" dur="1s" repeatCount="indefinite" />
<animate
attributeName="stroke-dashoffset"
from="8"
to="0"
dur="1s"
repeatCount="indefinite"
/>
</path>
<path d="M 100 100 Q 140 100, 180 100" strokeDasharray="4,4">
<animate attributeName="stroke-dashoffset" from="8" to="0" dur="1s" repeatCount="indefinite" />
<animate
attributeName="stroke-dashoffset"
from="8"
to="0"
dur="1s"
repeatCount="indefinite"
/>
</path>
<path d="M 100 130 Q 140 140, 180 150" strokeDasharray="4,4">
<animate attributeName="stroke-dashoffset" from="8" to="0" dur="1s" repeatCount="indefinite" />
<animate
attributeName="stroke-dashoffset"
from="8"
to="0"
dur="1s"
repeatCount="indefinite"
/>
</path>
</g>
{/* Organized folder tree on the right */}
{/* Root folder */}
<g>
<rect x="220" y="30" width="160" height="28" rx="6" className="fill-white dark:fill-neutral-800" opacity="0.9" />
<rect x="228" y="36" width="16" height="14" rx="3" className="fill-emerald-500 dark:fill-emerald-400" />
<line x1="252" y1="43" x2="330" y2="43" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2.5" strokeLinecap="round" />
<rect
x="220"
y="30"
width="160"
height="28"
rx="6"
className="fill-white dark:fill-neutral-800"
opacity="0.9"
/>
<rect
x="228"
y="36"
width="16"
height="14"
rx="3"
className="fill-emerald-500 dark:fill-emerald-400"
/>
<line
x1="252"
y1="43"
x2="330"
y2="43"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
{/* Subfolder 1 */}
<g>
<line x1="240" y1="58" x2="240" y2="76" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" />
<line x1="240" y1="76" x2="250" y2="76" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" />
<rect x="250" y="64" width="130" height="24" rx="5" className="fill-white dark:fill-neutral-800" opacity="0.85" />
<rect x="257" y="70" width="12" height="11" rx="2" className="fill-teal-400 dark:fill-teal-500" />
<line x1="276" y1="76" x2="340" y2="76" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2" strokeLinecap="round" />
<line
x1="240"
y1="58"
x2="240"
y2="76"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<line
x1="240"
y1="76"
x2="250"
y2="76"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<rect
x="250"
y="64"
width="130"
height="24"
rx="5"
className="fill-white dark:fill-neutral-800"
opacity="0.85"
/>
<rect
x="257"
y="70"
width="12"
height="11"
rx="2"
className="fill-teal-400 dark:fill-teal-500"
/>
<line
x1="276"
y1="76"
x2="340"
y2="76"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2"
strokeLinecap="round"
/>
</g>
{/* Subfolder 2 */}
<g>
<line x1="240" y1="76" x2="240" y2="108" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" />
<line x1="240" y1="108" x2="250" y2="108" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" />
<rect x="250" y="96" width="130" height="24" rx="5" className="fill-white dark:fill-neutral-800" opacity="0.85" />
<rect x="257" y="102" width="12" height="11" rx="2" className="fill-cyan-400 dark:fill-cyan-500" />
<line x1="276" y1="108" x2="350" y2="108" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2" strokeLinecap="round" />
<line
x1="240"
y1="76"
x2="240"
y2="108"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<line
x1="240"
y1="108"
x2="250"
y2="108"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<rect
x="250"
y="96"
width="130"
height="24"
rx="5"
className="fill-white dark:fill-neutral-800"
opacity="0.85"
/>
<rect
x="257"
y="102"
width="12"
height="11"
rx="2"
className="fill-cyan-400 dark:fill-cyan-500"
/>
<line
x1="276"
y1="108"
x2="350"
y2="108"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2"
strokeLinecap="round"
/>
</g>
{/* Subfolder 3 */}
<g>
<line x1="240" y1="108" x2="240" y2="140" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" />
<line x1="240" y1="140" x2="250" y2="140" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" />
<rect x="250" y="128" width="130" height="24" rx="5" className="fill-white dark:fill-neutral-800" opacity="0.85" />
<rect x="257" y="134" width="12" height="11" rx="2" className="fill-emerald-400 dark:fill-emerald-500" />
<line x1="276" y1="140" x2="325" y2="140" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2" strokeLinecap="round" />
<line
x1="240"
y1="108"
x2="240"
y2="140"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<line
x1="240"
y1="140"
x2="250"
y2="140"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<rect
x="250"
y="128"
width="130"
height="24"
rx="5"
className="fill-white dark:fill-neutral-800"
opacity="0.85"
/>
<rect
x="257"
y="134"
width="12"
height="11"
rx="2"
className="fill-emerald-400 dark:fill-emerald-500"
/>
<line
x1="276"
y1="140"
x2="325"
y2="140"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2"
strokeLinecap="round"
/>
</g>
{/* Sparkle accents */}
@ -495,10 +687,22 @@ const AiSortIllustration = () => (
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
</circle>
<circle cx="190" cy="155" r="1.5" className="fill-teal-400">
<animate attributeName="opacity" values="0;1;0" dur="2.5s" begin="0.8s" repeatCount="indefinite" />
<animate
attributeName="opacity"
values="0;1;0"
dur="2.5s"
begin="0.8s"
repeatCount="indefinite"
/>
</circle>
<circle cx="155" cy="120" r="1.5" className="fill-cyan-400">
<animate attributeName="opacity" values="0;1;0" dur="3s" begin="0.4s" repeatCount="indefinite" />
<animate
attributeName="opacity"
values="0;1;0"
dur="3s"
begin="0.4s"
repeatCount="indefinite"
/>
</circle>
</g>
</svg>

View file

@ -2,8 +2,10 @@
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useCallback, useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
@ -25,9 +27,20 @@ import {
Sidebar,
} from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import { DocumentTabContent } from "../tabs/DocumentTabContent";
import { TabBar } from "../tabs/TabBar";
const DocumentTabContent = dynamic(
() => import("../tabs/DocumentTabContent").then((m) => ({ default: m.DocumentTabContent })),
{
ssr: false,
loading: () => (
<div className="flex-1 flex items-center justify-center h-full">
<Spinner size="lg" />
</div>
),
}
);
// Per-tab data source
interface TabDataSource {
items: InboxItem[];

View file

@ -478,7 +478,7 @@ function AuthenticatedDocumentsSidebar({
setFolderPickerOpen(true);
}, []);
const [, setIsExportingKB] = useState(false);
const isExportingKBRef = useRef(false);
const [exportWarningOpen, setExportWarningOpen] = useState(false);
const [exportWarningContext, setExportWarningContext] = useState<{
folder: FolderDisplay;
@ -508,7 +508,7 @@ function AuthenticatedDocumentsSidebar({
const ctx = exportWarningContext;
if (!ctx?.folder) return;
setIsExportingKB(true);
isExportingKBRef.current = true;
try {
const safeName =
ctx.folder.name
@ -524,7 +524,7 @@ function AuthenticatedDocumentsSidebar({
console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
isExportingKBRef.current = false;
}
setExportWarningContext(null);
}, [exportWarningContext, searchSpaceId, doExport]);
@ -560,7 +560,7 @@ function AuthenticatedDocumentsSidebar({
return;
}
setIsExportingKB(true);
isExportingKBRef.current = true;
try {
const safeName =
folder.name
@ -576,7 +576,7 @@ function AuthenticatedDocumentsSidebar({
console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
isExportingKBRef.current = false;
}
},
[searchSpaceId, getPendingCountInSubtree, doExport]

View file

@ -269,6 +269,34 @@ export function ModelSelector({
const searchInputRef = useRef<HTMLInputElement>(null);
const isMobile = useIsMobile();
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) {
setSearchQuery("");
setSelectedProvider("all");
if (!isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}
setOpen(next);
},
[isMobile]
);
const handleTabChange = useCallback(
(next: "llm" | "image" | "vision") => {
setActiveTab(next);
setSelectedProvider("all");
setSearchQuery("");
setFocusedIndex(-1);
setModelScrollPos("top");
if (open && !isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
},
[open, isMobile]
);
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
@ -292,43 +320,19 @@ export function ModelSelector({
[isMobile]
);
// Reset search + provider when tab changes
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
useEffect(() => {
setSelectedProvider("all");
setSearchQuery("");
setFocusedIndex(-1);
setModelScrollPos("top");
}, [activeTab]);
// Reset on open
useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedProvider("all");
}
}, [open]);
// Cmd/Ctrl+M shortcut (desktop only)
useEffect(() => {
if (isMobile) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
e.preventDefault();
setOpen((prev) => !prev);
// setOpen((prev) => !prev);
handleOpenChange(!open);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isMobile]);
// Focus search input on open
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch
useEffect(() => {
if (open && !isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}, [open, isMobile, activeTab]);
}, [isMobile, open, handleOpenChange]);
// ─── Data ───
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
@ -971,7 +975,8 @@ export function ModelSelector({
<button
key={value}
type="button"
onClick={() => setActiveTab(value)}
// onClick={() => setActiveTab(value)}
onClick={() => handleTabChange(value)}
className={cn(
"flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
activeTab === value
@ -1208,7 +1213,7 @@ export function ModelSelector({
// ─── Shell: Drawer on mobile, Popover on desktop ───
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
<DrawerContent className="max-h-[85vh]">
<DrawerHandle />
@ -1222,7 +1227,7 @@ export function ModelSelector({
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
<PopoverContent
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"

View file

@ -546,35 +546,36 @@ export function DocumentUploadTab({
</button>
)
) : (
<div
role="button"
tabIndex={0}
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none"
onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
// biome-ignore lint/a11y/useSemanticElements: cannot use <button> here because the contents include nested interactive elements (renderBrowseButton renders a Button), which would be invalid HTML.
<div
role="button"
tabIndex={0}
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none"
onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}
}}
>
<Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<fieldset
className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isElectron) fileInputRef.current?.click();
}
}}
>
{renderBrowseButton({ fullWidth: true })}
</fieldset>
</div>
<Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<fieldset
className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{renderBrowseButton({ fullWidth: true })}
</fieldset>
</div>
)}
</div>

View file

@ -586,7 +586,7 @@ export const useThemeToggle = ({
}, []);
const toggleTheme = useCallback(() => {
setIsDark(!isDark);
setIsDark((prev) => !prev);
const animation = createAnimation(variant, start, blur, gifUrl);
@ -604,7 +604,7 @@ export const useThemeToggle = ({
}
document.startViewTransition(switchTheme);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazyLightTheme = useCallback(() => {
setIsDark(false);