Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp

This commit is contained in:
Anish Sarkar 2026-04-29 12:43:30 +05:30
commit 4f3914b058
302 changed files with 22318 additions and 6067 deletions

View file

@ -0,0 +1,66 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function BlogPostLoading() {
return (
<div className="min-h-screen relative pt-20">
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-10 pb-20">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-8">
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-40" />
</div>
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-4">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
{/* Title */}
<div className="space-y-3 mb-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-4/5" />
</div>
{/* Description */}
<Skeleton className="h-5 w-full mb-2" />
<Skeleton className="h-5 w-3/4 mb-8" />
{/* Author + date */}
<div className="flex items-center gap-3 mb-10">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
{/* Cover image */}
<Skeleton className="w-full aspect-video rounded-xl mb-10" />
{/* Article body paragraphs */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="space-y-2 mb-6">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
))}
{/* Sub-heading */}
<Skeleton className="h-7 w-56 mt-8 mb-4" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2 mb-6">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-3/4" />
</div>
))}
</div>
</div>
);
}

View file

@ -3,7 +3,7 @@
import { format } from "date-fns";
import FuzzySearch from "fuzzy-search";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Container } from "@/components/container";
import type { BlogEntry } from "./page";
@ -127,17 +127,13 @@ function MagazineSearchGrid({
[allBlogs]
);
const [results, setResults] = useState(allBlogs);
useEffect(() => {
setResults(searcher.search(search));
}, [search, searcher]);
const gridItems = useMemo(() => {
const results = search.trim() ? searcher.search(search) : allBlogs;
if (search.trim()) {
return results;
}
return results.filter((b) => b.slug !== featuredSlug);
}, [results, search, featuredSlug]);
}, [search, searcher, allBlogs, featuredSlug]);
return (
<section aria-labelledby="archive-heading">

View file

@ -0,0 +1,50 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function BlogIndexLoading() {
return (
<div className="relative overflow-hidden bg-neutral-50 px-4 pt-20 md:px-8 dark:bg-neutral-950">
<div className="mx-auto max-w-6xl pt-12 pb-24 md:pt-20">
{/* Header */}
<div className="mb-10 md:mb-14">
<Skeleton className="h-10 w-24 rounded-md" />
</div>
{/* Featured post skeleton */}
<div className="mb-14 overflow-hidden rounded-3xl border border-neutral-200/80 dark:border-neutral-800">
<Skeleton className="aspect-[2.4/1] min-h-[220px] w-full rounded-none" />
<div className="p-6 md:p-8 space-y-3">
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-full max-w-lg" />
<div className="flex items-center gap-3 pt-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
</div>
</div>
{/* Search bar skeleton */}
<div className="mb-10">
<Skeleton className="h-11 w-full max-w-md rounded-full" />
</div>
{/* Grid of article cards */}
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="aspect-video w-full rounded-2xl" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<div className="flex items-center gap-2 pt-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ChangelogLoading() {
return (
<div className="min-h-screen relative pt-20">
{/* Header */}
<div className="border-b border-border/50">
<div className="max-w-5xl mx-auto relative">
<div className="p-6 flex items-center justify-between">
<div>
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-20" />
</div>
<Skeleton className="h-10 w-48 mb-2" />
<Skeleton className="h-4 w-80" />
</div>
</div>
</div>
</div>
{/* Timeline */}
<div className="max-w-5xl mx-auto px-6 lg:px-10 pt-10 pb-20">
<div className="relative">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="relative flex flex-col md:flex-row gap-y-6 mb-10">
{/* Left: date + version */}
<div className="md:w-48 flex-shrink-0">
<Skeleton className="h-4 w-24 mb-3" />
<Skeleton className="h-12 w-12 rounded-xl" />
</div>
{/* Right: content */}
<div className="flex-1 md:pl-8 relative pb-10">
<div className="space-y-4">
{/* Title */}
<Skeleton className="h-7 w-2/3" />
{/* Tags */}
<div className="flex gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
{/* Body paragraphs */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function FreeModelLoading() {
return (
<>
{/* Chat area skeleton - fills viewport */}
<div className="h-full flex flex-col">
{/* Chat header */}
<div className="flex items-center gap-3 border-b px-4 py-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-5 w-40" />
</div>
{/* Chat messages area */}
<div className="flex-1 flex flex-col justify-end gap-4 px-4 py-6">
<div className="flex justify-end">
<Skeleton className="h-10 w-56 rounded-2xl" />
</div>
<div className="space-y-2 max-w-lg">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
{/* Input bar */}
<div className="border-t px-4 py-3">
<Skeleton className="h-12 w-full rounded-xl" />
</div>
</div>
{/* SEO section skeleton */}
<div className="border-t bg-background">
<div className="container mx-auto px-4 py-10 max-w-3xl">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-6">
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-7 w-3/4 mb-2" />
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-2/3 mb-8" />
<div className="my-8 h-px bg-border" />
{/* FAQ skeleton */}
<Skeleton className="h-6 w-64 mb-4" />
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-4 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
))}
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,60 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function FreeChatLoading() {
return (
<div className="min-h-screen pt-20">
<article className="container mx-auto px-4 pb-20">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-8">
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-3" />
<Skeleton className="h-4 w-24" />
</div>
{/* Hero section */}
<section className="mt-8 text-center max-w-3xl mx-auto space-y-4">
<Skeleton className="h-12 w-3/4 mx-auto" />
<Skeleton className="h-12 w-2/3 mx-auto" />
<Skeleton className="h-5 w-full max-w-lg mx-auto" />
<Skeleton className="h-5 w-4/5 max-w-lg mx-auto" />
<div className="flex flex-wrap items-center justify-center gap-3 mt-6">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-28 rounded-full" />
))}
</div>
</section>
<div className="my-12 max-w-4xl mx-auto h-px bg-border" />
{/* Model table */}
<section className="max-w-4xl mx-auto">
<Skeleton className="h-7 w-64 mb-2" />
<Skeleton className="h-4 w-80 mb-6" />
<div className="overflow-hidden rounded-lg border">
{/* Table header */}
<div className="flex gap-4 px-4 py-3 bg-muted/50 border-b">
<Skeleton className="h-4 w-[45%]" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
</div>
{/* Table rows */}
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b last:border-0">
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-14 rounded-full" />
<Skeleton className="h-8 w-20 rounded-md" />
</div>
))}
</div>
</section>
</article>
</div>
);
}

View file

@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
@ -33,6 +34,7 @@ export function DashboardClientLayout({
const pathname = usePathname();
const { search_space_id } = useParams();
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
const {
data: preferences = {},
@ -142,6 +144,14 @@ export function DashboardClientLayout({
const electronAPI = useElectronAPI();
useEffect(() => {
if (!electronAPI?.onChatScreenCapture) return;
return electronAPI.onChatScreenCapture((dataUrl: string) => {
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:image/")) return;
setPendingUserImageUrls((prev) => [...prev, dataUrl]);
});
}, [electronAPI, setPendingUserImageUrls]);
useEffect(() => {
const activeSeacrhSpaceId =
typeof search_space_id === "string"

View file

@ -25,6 +25,7 @@ import {
mentionedDocumentsAtom,
messageDocumentsMapAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
clearPlanOwnerRegistry,
// extractWriteTodosFromContent,
@ -44,8 +45,8 @@ import {
} from "@/components/assistant-ui/token-usage-context";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesSync } from "@/hooks/use-messages-sync";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getAgentFilesystemSelection } from "@/lib/agent-filesystem";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { convertToThreadMessage } from "@/lib/chat/message-utils";
import {
@ -75,6 +76,10 @@ import {
type ThreadListResponse,
type ThreadRecord,
} from "@/lib/chat/thread-persistence";
import {
extractUserTurnForNewChatApi,
type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts";
import { NotFoundError } from "@/lib/error";
import {
trackChatCreated,
@ -228,6 +233,8 @@ export default function NewChatPage() {
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -489,18 +496,12 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
// Extract user query text from content parts
let userQuery = "";
for (const part of message.content) {
if (part.type === "text") {
userQuery += part.text;
}
}
const urlsSnapshot = [...pendingUserImageUrls];
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, urlsSnapshot);
if (!userQuery.trim()) return;
if (!userQuery.trim() && userImages.length === 0) return;
// Check if podcast is already generating
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
if (userQuery.trim() && isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
toast.warning("A podcast is already being generated.");
return;
}
@ -540,6 +541,10 @@ export default function NewChatPage() {
}
}
if (urlsSnapshot.length > 0) {
setPendingUserImageUrls((prev) => prev.filter((u) => !urlsSnapshot.includes(u)));
}
// Add user message to state
const userMsgId = `msg-user-${Date.now()}`;
@ -555,10 +560,27 @@ export default function NewChatPage() {
}
: undefined;
const existingImageUrls = new Set(
message.content
.filter(
(p): p is { type: "image"; image: string } =>
typeof p === "object" &&
p !== null &&
"type" in p &&
p.type === "image" &&
"image" in p
)
.map((p) => p.image)
);
const extraImageParts = urlsSnapshot
.filter((u) => !existingImageUrls.has(u))
.map((image) => ({ type: "image" as const, image }));
const userDisplayContent = [...message.content, ...extraImageParts];
const userMessage: ThreadMessageLike = {
id: userMsgId,
role: "user",
content: message.content,
content: userDisplayContent,
createdAt: new Date(),
metadata: authorMetadata,
};
@ -566,7 +588,7 @@ export default function NewChatPage() {
// Track message sent
trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: false,
hasAttachments: userImages.length > 0,
hasMentionedDocuments:
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
mentionedDocumentIds.document_ids.length > 0,
@ -590,7 +612,7 @@ export default function NewChatPage() {
}));
}
const persistContent: unknown[] = [...message.content];
const persistContent: unknown[] = [...userDisplayContent];
if (allMentionedDocs.length > 0) {
persistContent.push({
@ -655,8 +677,7 @@ export default function NewChatPage() {
const selection = await getAgentFilesystemSelection(searchSpaceId);
if (
selection.filesystem_mode === "desktop_local_folder" &&
(!selection.local_filesystem_mounts ||
selection.local_filesystem_mounts.length === 0)
(!selection.local_filesystem_mounts || selection.local_filesystem_mounts.length === 0)
) {
toast.error("Select a local folder before using Local Folder mode.");
return;
@ -704,6 +725,7 @@ export default function NewChatPage() {
? mentionedDocumentIds.surfsense_doc_ids
: undefined,
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
...(userImages.length > 0 ? { user_images: userImages } : {}),
}),
signal: controller.signal,
});
@ -835,14 +857,7 @@ export default function NewChatPage() {
});
} else {
const tcId = `interrupt-${action.name}`;
addToolCall(
contentPartsState,
toolsWithUI,
tcId,
action.name,
action.args,
true
);
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, {
result: { __interrupt__: true, ...interruptData },
});
@ -980,6 +995,9 @@ export default function NewChatPage() {
disabledTools,
updateChatTabTitle,
tokenUsageStore,
pendingUserImageUrls,
setPendingUserImageUrls,
toolsWithUI,
]
);
@ -1180,14 +1198,7 @@ export default function NewChatPage() {
});
} else {
const tcId = `interrupt-${action.name}`;
addToolCall(
contentPartsState,
toolsWithUI,
tcId,
action.name,
action.args,
true
);
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, {
result: {
__interrupt__: true,
@ -1252,7 +1263,7 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
},
[pendingInterrupt, messages, searchSpaceId, tokenUsageStore]
[pendingInterrupt, messages, searchSpaceId, tokenUsageStore, toolsWithUI]
);
useEffect(() => {
@ -1320,15 +1331,24 @@ export default function NewChatPage() {
* Handle regeneration (edit or reload) by calling the regenerate endpoint
* and streaming the response. This rewinds the LangGraph checkpointer state.
*
* @param newUserQuery - The new user query (for edit). Pass null/undefined for reload.
* @param newUserQuery - `null` = reload with same turn from the server. A string = edit
* (including an empty string when the edited turn is images-only); pass `editExtras` for images/content.
*/
const handleRegenerate = useCallback(
async (newUserQuery?: string | null) => {
async (
newUserQuery: string | null,
editExtras?: {
userMessageContent: ThreadMessageLike["content"];
userImages: NewChatUserImagePayload[];
}
) => {
if (!threadId) {
toast.error("Cannot regenerate: no active chat thread");
return;
}
const isEdit = newUserQuery !== null;
// Abort any previous streaming request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@ -1342,11 +1362,11 @@ export default function NewChatPage() {
}
// Extract the original user query BEFORE removing messages (for reload mode)
let userQueryToDisplay = newUserQuery;
let userQueryToDisplay: string | undefined;
let originalUserMessageContent: ThreadMessageLike["content"] | null = null;
let originalUserMessageMetadata: ThreadMessageLike["metadata"] | undefined;
if (!newUserQuery) {
if (!isEdit) {
// Reload mode - find and preserve the last user message content
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
if (lastUserMessage) {
@ -1360,6 +1380,8 @@ export default function NewChatPage() {
}
}
}
} else {
userQueryToDisplay = newUserQuery;
}
// Remove the last two messages (user + assistant) from the UI immediately
@ -1395,11 +1417,11 @@ export default function NewChatPage() {
const userMessage: ThreadMessageLike = {
id: userMsgId,
role: "user",
content: newUserQuery
? [{ type: "text", text: newUserQuery }]
content: isEdit
? (editExtras?.userMessageContent ?? [{ type: "text", text: newUserQuery ?? "" }])
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }],
createdAt: new Date(),
metadata: newUserQuery ? undefined : originalUserMessageMetadata,
metadata: isEdit ? undefined : originalUserMessageMetadata,
};
setMessages((prev) => [...prev, userMessage]);
@ -1416,20 +1438,24 @@ export default function NewChatPage() {
try {
const selection = await getAgentFilesystemSelection(searchSpaceId);
const requestBody: Record<string, unknown> = {
search_space_id: searchSpaceId,
user_query: newUserQuery,
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform,
local_filesystem_mounts: selection.local_filesystem_mounts,
};
if (isEdit) {
requestBody.user_images = editExtras?.userImages ?? [];
}
const response = await fetch(getRegenerateUrl(threadId), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
search_space_id: searchSpaceId,
user_query: newUserQuery || null,
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform,
local_filesystem_mounts: selection.local_filesystem_mounts,
}),
body: JSON.stringify(requestBody),
signal: controller.signal,
});
@ -1519,8 +1545,8 @@ export default function NewChatPage() {
if (contentParts.length > 0) {
try {
// Persist user message (for both edit and reload modes, since backend deleted it)
const userContentToPersist = newUserQuery
? [{ type: "text", text: newUserQuery }]
const userContentToPersist = isEdit
? (editExtras?.userMessageContent ?? [{ type: "text", text: newUserQuery ?? "" }])
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }];
const savedUserMessage = await appendMessage(threadId, {
@ -1579,27 +1605,21 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
},
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore]
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore, toolsWithUI]
);
// Handle editing a message - truncates history and regenerates with new query
const onEdit = useCallback(
async (message: AppendMessage) => {
// Extract the new user query from the message content
let newUserQuery = "";
for (const part of message.content) {
if (part.type === "text") {
newUserQuery += part.text;
}
}
if (!newUserQuery.trim()) {
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, []);
const queryForApi = userQuery.trim();
if (!queryForApi && userImages.length === 0) {
toast.error("Cannot edit with empty message");
return;
}
// Call regenerate with the new query
await handleRegenerate(newUserQuery.trim());
const userMessageContent = message.content as unknown as ThreadMessageLike["content"];
await handleRegenerate(queryForApi, { userMessageContent, userImages });
},
[handleRegenerate]
);

View file

@ -0,0 +1,451 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { AlertTriangle, Check, Plus, ShieldCheck, Trash2, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
type AgentPermissionAction,
type AgentPermissionRule,
type AgentPermissionRuleCreate,
agentPermissionsApiService,
} from "@/lib/apis/agent-permissions-api.service";
import { AppError } from "@/lib/error";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
const ACTION_DESCRIPTIONS: Record<AgentPermissionAction, string> = {
allow: "Always run without prompting",
deny: "Block silently",
ask: "Pause and ask for approval",
};
const ACTION_BADGE: Record<AgentPermissionAction, { label: string; className: string }> = {
allow: { label: "Allow", className: "bg-emerald-500/10 text-emerald-600 border-emerald-500/30" },
deny: { label: "Deny", className: "bg-destructive/10 text-destructive border-destructive/30" },
ask: { label: "Ask", className: "bg-amber-500/10 text-amber-600 border-amber-500/30" },
};
const EMPTY_FORM: AgentPermissionRuleCreate = {
permission: "",
pattern: "*",
action: "ask",
user_id: null,
thread_id: null,
};
function permissionRulesQueryKey(searchSpaceId: number) {
return ["agent-permission-rules", searchSpaceId] as const;
}
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
if (rule.thread_id !== null) {
return (
<Badge variant="outline" className="text-[10px]">
Thread #{rule.thread_id}
</Badge>
);
}
if (rule.user_id !== null) {
return (
<Badge variant="outline" className="text-[10px]">
User-specific
</Badge>
);
}
return (
<Badge variant="outline" className="text-[10px]">
Search space
</Badge>
);
}
export function AgentPermissionsContent() {
const searchSpaceIdRaw = useAtomValue(activeSearchSpaceIdAtom);
const searchSpaceId = searchSpaceIdRaw ? Number(searchSpaceIdRaw) : null;
const { data: flags } = useAtomValue(agentFlagsAtom);
const featureEnabled = !!flags?.enable_permission && !flags?.disable_new_agent_stack;
const queryClient = useQueryClient();
const {
data: rules,
isLoading,
isError,
error,
} = useQuery({
queryKey: searchSpaceId
? permissionRulesQueryKey(searchSpaceId)
: ["agent-permission-rules", "none"],
queryFn: () => agentPermissionsApiService.list(searchSpaceId as number),
enabled: !!searchSpaceId && featureEnabled,
staleTime: 60 * 1000,
});
const createMutation = useMutation({
mutationFn: (payload: AgentPermissionRuleCreate) =>
agentPermissionsApiService.create(searchSpaceId as number, payload),
onSuccess: () => {
toast.success("Rule created.");
queryClient.invalidateQueries({
queryKey: permissionRulesQueryKey(searchSpaceId as number),
});
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : "Failed to create rule.");
},
});
const updateMutation = useMutation({
mutationFn: (params: { ruleId: number; action: AgentPermissionAction; pattern?: string }) =>
agentPermissionsApiService.update(searchSpaceId as number, params.ruleId, {
action: params.action,
pattern: params.pattern,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: permissionRulesQueryKey(searchSpaceId as number),
});
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : "Failed to update rule.");
},
});
const deleteMutation = useMutation({
mutationFn: (ruleId: number) =>
agentPermissionsApiService.remove(searchSpaceId as number, ruleId),
onSuccess: () => {
toast.success("Rule deleted.");
queryClient.invalidateQueries({
queryKey: permissionRulesQueryKey(searchSpaceId as number),
});
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : "Failed to delete rule.");
},
});
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState<AgentPermissionRuleCreate>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const sortedRules = useMemo(() => rules ?? [], [rules]);
const handleCreate = useCallback(async () => {
if (!formData.permission.trim()) {
toast.error("Permission is required.");
return;
}
try {
await createMutation.mutateAsync({
...formData,
permission: formData.permission.trim(),
pattern: formData.pattern.trim() || "*",
});
setShowForm(false);
setFormData(EMPTY_FORM);
} catch (err) {
if (err instanceof AppError && err.message) {
// already toasted by onError
}
}
}, [createMutation, formData]);
const handleConfirmDelete = useCallback(async () => {
if (deleteTarget === null) return;
try {
await deleteMutation.mutateAsync(deleteTarget);
} finally {
setDeleteTarget(null);
}
}, [deleteMutation, deleteTarget]);
if (!featureEnabled) {
return (
<Alert className="border-dashed">
<ShieldCheck className="size-4" />
<AlertTitle>Permission middleware is disabled</AlertTitle>
<AlertDescription>
Flip{" "}
<code className="rounded bg-muted px-1 text-[10px]">SURFSENSE_ENABLE_PERMISSION</code> on
the backend to manage allow/deny/ask rules from this panel.
</AlertDescription>
</Alert>
);
}
if (!searchSpaceId) {
return (
<p className="text-sm text-muted-foreground">Open a search space to manage agent rules.</p>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load rules</p>
<p className="text-xs text-muted-foreground">
{error instanceof Error ? error.message : "Unknown error."}
</p>
</div>
);
}
return (
<div className="min-w-0 space-y-6 overflow-hidden">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Tell the agent which tools to allow, deny, or ask before running. Rules use wildcard
patterns and are evaluated at the most specific scope first.
</p>
</div>
{!showForm && (
<Button
size="sm"
onClick={() => {
setShowForm(true);
setFormData(EMPTY_FORM);
}}
className="shrink-0 gap-1.5"
>
<Plus className="size-3.5" />
New rule
</Button>
)}
</div>
{showForm && (
<div className="rounded-lg border border-border/60 bg-card p-6">
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">New permission rule</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="permission-name">Permission</Label>
<Input
id="permission-name"
value={formData.permission}
placeholder="e.g. tool:create_linear_issue or tool:*"
onChange={(e) => setFormData((p) => ({ ...p, permission: e.target.value }))}
/>
<p className="text-[11px] text-muted-foreground">
Match a tool capability. Use <code className="font-mono">*</code> for wildcards.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pattern">Argument pattern</Label>
<Input
id="pattern"
value={formData.pattern}
placeholder="*"
onChange={(e) => setFormData((p) => ({ ...p, pattern: e.target.value }))}
/>
<p className="text-[11px] text-muted-foreground">
Wildcard against the canonical argument (e.g. <code>prod-*</code>).
</p>
</div>
</div>
<div className="space-y-2">
<Label>Action</Label>
<Select
value={formData.action}
onValueChange={(value) =>
setFormData((p) => ({ ...p, action: value as AgentPermissionAction }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow run without asking</SelectItem>
<SelectItem value="ask">Ask pause for approval</SelectItem>
<SelectItem value="deny">Deny block silently</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{ACTION_DESCRIPTIONS[formData.action]}
</p>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowForm(false);
setFormData(EMPTY_FORM);
}}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !formData.permission.trim()}
className="relative"
>
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
</Button>
</div>
</div>
</div>
)}
{sortedRules.length === 0 && !showForm && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
<p className="text-xs text-muted-foreground/60">
Without rules the agent uses the deployment default for every tool.
</p>
</div>
)}
{sortedRules.length > 0 && (
<div className="space-y-2">
{sortedRules.map((rule) => {
const badge = ACTION_BADGE[rule.action];
const isUpdating =
updateMutation.isPending && updateMutation.variables?.ruleId === rule.id;
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
return (
<div
key={rule.id}
className="group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<code className="truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{rule.permission}
</code>
{rule.pattern !== "*" && (
<span className="text-xs text-muted-foreground">
<code className="font-mono">{rule.pattern}</code>
</span>
)}
<ScopeBadge rule={rule} />
</div>
<p className="text-[11px] text-muted-foreground">
Created {formatRelativeDate(rule.created_at)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Select
value={rule.action}
onValueChange={(value) =>
updateMutation.mutate({
ruleId: rule.id,
action: value as AgentPermissionAction,
})
}
disabled={isUpdating || isDeleting}
>
<SelectTrigger
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
>
<SelectValue>
<span className="flex items-center gap-1">
{rule.action === "allow" && <Check className="size-3" />}
{rule.action === "deny" && <X className="size-3" />}
{badge.label}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="deny">Deny</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
className="size-8 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(rule.id)}
disabled={isUpdating || isDeleting}
aria-label="Delete rule"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this rule?</AlertDialogTitle>
<AlertDialogDescription>
The agent will fall back to deployment defaults for matching tool calls.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleConfirmDelete();
}}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,309 @@
"use client";
import { useAtomValue } from "jotai";
import { CircleCheck, CircleSlash, Cog, RotateCcw } from "lucide-react";
import { useMemo } from "react";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
import { cn } from "@/lib/utils";
type FlagKey = keyof AgentFeatureFlags;
interface FlagDef {
key: FlagKey;
label: string;
description: string;
envVar: string;
}
interface FlagGroup {
id: string;
title: string;
subtitle: string;
flags: FlagDef[];
}
const FLAG_GROUPS: FlagGroup[] = [
{
id: "tier1",
title: "Tier 1 — Agent quality",
subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
flags: [
{
key: "enable_context_editing",
label: "Context editing",
description: "Trim tool outputs and spill old text into backend storage.",
envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
},
{
key: "enable_compaction_v2",
label: "Compaction v2",
description: "SurfSense-aware compaction replacing safe summarization.",
envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
},
{
key: "enable_retry_after",
label: "Retry-After",
description: "Honour rate-limit retry-after headers automatically.",
envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
},
{
key: "enable_model_fallback",
label: "Model fallback",
description: "Fail over to a backup model on persistent errors.",
envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
},
{
key: "enable_model_call_limit",
label: "Model call limit",
description: "Cap total model calls per turn to prevent budget run-aways.",
envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
},
{
key: "enable_tool_call_limit",
label: "Tool call limit",
description: "Cap total tool calls per turn.",
envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
},
{
key: "enable_tool_call_repair",
label: "Tool-call name repair",
description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
},
{
key: "enable_doom_loop",
label: "Doom-loop detection",
description: "Detect repeated identical tool calls and ask the user to confirm.",
envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
},
],
},
{
id: "tier2",
title: "Tier 2 — Safety",
subtitle: "Permission rules, busy-mutex, smarter tool selection.",
flags: [
{
key: "enable_permission",
label: "Permission middleware",
description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
envVar: "SURFSENSE_ENABLE_PERMISSION",
},
{
key: "enable_busy_mutex",
label: "Busy mutex",
description: "Prevent two concurrent runs from corrupting the same thread.",
envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
},
{
key: "enable_llm_tool_selector",
label: "LLM tool selector",
description: "Use a smaller model to pre-filter the tool list per turn.",
envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
},
],
},
{
id: "tier4",
title: "Tier 4 — Skills + subagents",
subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
flags: [
{
key: "enable_skills",
label: "Skills",
description: "Load on-demand skill packs (kb-research, report-writing, …).",
envVar: "SURFSENSE_ENABLE_SKILLS",
},
{
key: "enable_specialized_subagents",
label: "Specialized subagents",
description: "Spin up explore / report_writer / connector_negotiator subagents.",
envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
},
{
key: "enable_kb_planner_runnable",
label: "KB planner runnable",
description: "Compile a private planner sub-agent for KB search.",
envVar: "SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE",
},
],
},
{
id: "tier5",
title: "Tier 5 — Audit + revert",
subtitle: "Action log + revert route used by the Agent Actions sheet.",
flags: [
{
key: "enable_action_log",
label: "Action log",
description: "Persist every tool call to agent_action_log.",
envVar: "SURFSENSE_ENABLE_ACTION_LOG",
},
{
key: "enable_revert_route",
label: "Revert route",
description: "Allow reverting reversible actions from the action log.",
envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
},
],
},
{
id: "tier6",
title: "Tier 6 — Plugins",
subtitle: "Optional middleware loaded from entry points.",
flags: [
{
key: "enable_plugin_loader",
label: "Plugin loader",
description: "Load surfsense.plugins entry-point middleware.",
envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
},
],
},
{
id: "obs",
title: "Observability",
subtitle: "Telemetry pipelines (orthogonal to feature gating).",
flags: [
{
key: "enable_otel",
label: "OpenTelemetry",
description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
envVar: "SURFSENSE_ENABLE_OTEL",
},
],
},
];
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
return (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{def.label}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{def.envVar}
</code>
</div>
<p className="text-xs text-muted-foreground">{def.description}</p>
</div>
<Badge
variant={value ? "default" : "secondary"}
className={cn(
"shrink-0 gap-1",
value
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "text-muted-foreground"
)}
>
{value ? <CircleCheck className="size-3" /> : <CircleSlash className="size-3" />}
{value ? "On" : "Off"}
</Badge>
</div>
);
}
export function AgentStatusContent() {
const { data: flags, isLoading, isError, error, refetch } = useAtomValue(agentFlagsAtom);
const enabledCount = useMemo(() => {
if (!flags) return 0;
return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
.length;
}, [flags]);
if (isLoading) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-32 w-full rounded-md" />
<Skeleton className="h-32 w-full rounded-md" />
</div>
);
}
if (isError || !flags) {
return (
<Alert variant="destructive">
<AlertTitle>Failed to load agent status</AlertTitle>
<AlertDescription className="flex items-center gap-2">
{error instanceof Error ? error.message : "Unknown error."}
<button
type="button"
onClick={() => refetch()}
className="ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs hover:bg-background"
>
<RotateCcw className="size-3" />
Retry
</button>
</AlertDescription>
</Alert>
);
}
const masterOff = flags.disable_new_agent_stack;
return (
<div className="space-y-6">
{masterOff ? (
<Alert variant="destructive">
<Cog className="size-4" />
<AlertTitle>Master kill-switch is on</AlertTitle>
<AlertDescription>
<code className="rounded bg-muted px-1 text-[10px]">
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
</code>
forces every new middleware off, regardless of the individual flags below. Restart the
backend after changing it.
</AlertDescription>
</Alert>
) : (
<Alert>
<Cog className="size-4" />
<AlertTitle className="flex items-center gap-2">
Agent stack
<Badge variant="secondary" className="text-[10px]">
{enabledCount} on
</Badge>
</AlertTitle>
<AlertDescription>
Read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an env var and
restart the backend to change a value.
</AlertDescription>
</Alert>
)}
{FLAG_GROUPS.map((group, groupIdx) => {
const allOff = group.flags.every((f) => !flags[f.key]);
return (
<div key={group.id}>
{groupIdx > 0 && <Separator className="my-4" />}
<div className="rounded-lg border border-border/60 bg-card">
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<div>
<p className="text-sm font-semibold">{group.title}</p>
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
</div>
{allOff && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
all off
</Badge>
)}
</div>
<div className="divide-y divide-border/50 px-4">
{group.flags.map((def) => (
<FlagRow key={def.key} def={def} value={flags[def.key]} />
))}
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -20,7 +20,6 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
export function DesktopContent() {
const api = useElectronAPI();
const [loading, setLoading] = useState(true);
const [enabled, setEnabled] = useState(true);
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
@ -41,14 +40,12 @@ export function DesktopContent() {
setAutoLaunchSupported(hasAutoLaunchApi);
Promise.all([
api.getAutocompleteEnabled(),
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
searchSpacesApiService.getSearchSpaces(),
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
])
.then(([autoEnabled, spaceId, spaces, autoLaunch]) => {
.then(([spaceId, spaces, autoLaunch]) => {
if (!mounted) return;
setEnabled(autoEnabled);
setActiveSpaceId(spaceId);
if (spaces) setSearchSpaces(spaces);
if (autoLaunch) {
@ -86,11 +83,6 @@ export function DesktopContent() {
);
}
const handleToggle = async (checked: boolean) => {
setEnabled(checked);
await api.setAutocompleteEnabled(checked);
};
const handleAutoLaunchToggle = async (checked: boolean) => {
if (!autoLaunchSupported || !api.setAutoLaunch) {
toast.error("Please update the desktop app to configure launch on startup");
@ -133,13 +125,12 @@ export function DesktopContent() {
return (
<div className="space-y-4 md:space-y-6">
{/* Default Search Space */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
<CardDescription className="text-xs md:text-sm">
Choose which search space General Assist, Quick Assist, and Extreme Assist operate
against.
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
default.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
@ -164,7 +155,6 @@ export function DesktopContent() {
</CardContent>
</Card>
{/* Launch on Startup */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg flex items-center gap-2">
@ -215,29 +205,6 @@ export function DesktopContent() {
</div>
</CardContent>
</Card>
{/* Extreme Assist Toggle */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Extreme Assist</CardTitle>
<CardDescription className="text-xs md:text-sm">
Get inline writing suggestions powered by your knowledge base as you type in any app.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="autocomplete-toggle" className="text-sm font-medium cursor-pointer">
Enable Extreme Assist
</Label>
<p className="text-xs text-muted-foreground">
Show suggestions while typing in other applications.
</p>
</div>
<Switch id="autocomplete-toggle" checked={enabled} onCheckedChange={handleToggle} />
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { BrainCog, Rocket, RotateCcw, Zap } from "lucide-react";
import { Crop, Rocket, RotateCcw, Zap } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
@ -9,13 +9,13 @@ import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
type ShortcutKey = "generalAssist" | "quickAsk" | "autocomplete";
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; icon: React.ElementType }> = [
{ key: "generalAssist", label: "General Assist", icon: Rocket },
{ key: "screenshotAssist", label: "Screenshot Assist", icon: Crop },
{ key: "quickAsk", label: "Quick Assist", icon: Zap },
{ key: "autocomplete", label: "Extreme Assist", icon: BrainCog },
];
function acceleratorToKeys(accel: string, isMac: boolean): string[] {
@ -111,9 +111,7 @@ function HotkeyRow({
}
>
{recording ? (
<span className="px-2 text-[9px] text-primary whitespace-nowrap">
Press hotkeys...
</span>
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
) : (
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
)}
@ -155,15 +153,14 @@ export function DesktopShortcutsContent() {
if (!api) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">Hotkeys are only available in the SurfSense desktop app.</p>
<p className="text-sm text-muted-foreground">
Hotkeys are only available in the SurfSense desktop app.
</p>
</div>
);
}
const updateShortcut = (
key: "generalAssist" | "quickAsk" | "autocomplete",
accelerator: string
) => {
const updateShortcut = (key: ShortcutKey, accelerator: string) => {
setShortcuts((prev) => {
const updated = { ...prev, [key]: accelerator };
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
@ -178,28 +175,26 @@ export function DesktopShortcutsContent() {
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
};
return (
shortcutsLoaded ? (
<div className="flex flex-col gap-3">
<div>
{HOTKEY_ROWS.map((row) => (
<HotkeyRow
key={row.key}
label={row.label}
value={shortcuts[row.key]}
defaultValue={DEFAULT_SHORTCUTS[row.key]}
icon={row.icon}
isMac={isMac}
onChange={(accel) => updateShortcut(row.key, accel)}
onReset={() => resetShortcut(row.key)}
/>
))}
</div>
return shortcutsLoaded ? (
<div className="flex flex-col gap-3">
<div>
{HOTKEY_ROWS.map((row) => (
<HotkeyRow
key={row.key}
label={row.label}
value={shortcuts[row.key]}
defaultValue={DEFAULT_SHORTCUTS[row.key]}
icon={row.icon}
isMac={isMac}
onChange={(accel) => updateShortcut(row.key, accel)}
onReset={() => resetShortcut(row.key)}
/>
))}
</div>
) : (
<div className="flex justify-center py-4">
<Spinner size="sm" />
</div>
)
</div>
) : (
<div className="flex justify-center py-4">
<Spinner size="sm" />
</div>
);
}

View file

@ -2,7 +2,7 @@
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { BrainCog, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -21,28 +21,33 @@ import { setBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
type ShortcutKey = "generalAssist" | "quickAsk" | "autocomplete";
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; description: string; icon: React.ElementType }> = [
const HOTKEY_ROWS: Array<{
key: ShortcutKey;
label: string;
description: string;
icon: React.ElementType;
}> = [
{
key: "generalAssist",
label: "General Assist",
description: "Launch SurfSense instantly from any application",
icon: Rocket,
},
{
key: "screenshotAssist",
label: "Screenshot Assist",
description: "Draw a region on screen to attach that capture to chat",
icon: Crop,
},
{
key: "quickAsk",
label: "Quick Assist",
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it",
icon: Zap,
},
{
key: "autocomplete",
label: "Extreme Assist",
description: "AI drafts text using your screen context and knowledge base",
icon: BrainCog,
},
];
function acceleratorToKeys(accel: string, isMac: boolean): string[] {
@ -182,7 +187,7 @@ export default function DesktopLoginPage() {
}, [api]);
const updateShortcut = useCallback(
(key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => {
(key: ShortcutKey, accelerator: string) => {
setShortcuts((prev) => {
const updated = { ...prev, [key]: accelerator };
api?.setShortcuts?.({ [key]: accelerator }).catch(() => {
@ -196,7 +201,7 @@ export default function DesktopLoginPage() {
);
const resetShortcut = useCallback(
(key: "generalAssist" | "quickAsk" | "autocomplete") => {
(key: ShortcutKey) => {
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
},
[updateShortcut]
@ -369,7 +374,9 @@ export default function DesktopLoginPage() {
<Button type="submit" disabled={isLoggingIn} className="relative h-9 mt-1">
<span className={isLoggingIn ? "opacity-0" : ""}>Sign in</span>
{isLoggingIn && <Spinner size="sm" className="absolute text-primary-foreground" />}
{isLoggingIn && (
<Spinner size="sm" className="absolute text-primary-foreground" />
)}
</Button>
</form>
)}

View file

@ -19,14 +19,15 @@ const STEPS = [
id: "screen-recording",
title: "Screen Recording",
description:
"Lets SurfSense capture your screen to understand context and provide smart writing suggestions.",
"Lets SurfSense capture a region of your screen, full display, or browser (where supported) to attach to chat in Screenshot Assist, or to capture the full display from the composer.",
action: "requestScreenRecording",
field: "screenRecording" as const,
},
{
id: "accessibility",
title: "Accessibility",
description: "Lets SurfSense insert suggestions seamlessly, right where you\u2019re typing.",
description:
"Lets SurfSense bring the app to the foreground and work with the active application (for example Quick Assist) when you use desktop shortcuts.",
action: "requestAccessibility",
field: "accessibility" as const,
},
@ -131,7 +132,8 @@ export default function DesktopPermissionsPage() {
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">System Permissions</h1>
<p className="text-sm text-muted-foreground">
SurfSense needs two macOS permissions to provide context-aware writing suggestions.
SurfSense needs two macOS permissions for Screenshot Assist and for desktop features that
require focusing the app or the active application.
</p>
</div>
</div>

View file

@ -1,9 +0,0 @@
import "./suggestion.css";
export const metadata = {
title: "SurfSense Suggestion",
};
export default function SuggestionLayout({ children }: { children: React.ReactNode }) {
return <div className="suggestion-body">{children}</div>;
}

View file

@ -1,384 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useElectronAPI } from "@/hooks/use-platform";
import { ensureTokensFromElectron, getBearerToken } from "@/lib/auth-utils";
type SSEEvent =
| { type: "text-delta"; id: string; delta: string }
| { type: "text-start"; id: string }
| { type: "text-end"; id: string }
| { type: "start"; messageId: string }
| { type: "finish" }
| { type: "error"; errorText: string }
| {
type: "data-thinking-step";
data: { id: string; title: string; status: string; items: string[] };
}
| {
type: "data-suggestions";
data: { options: string[] };
};
interface AgentStep {
id: string;
title: string;
status: string;
items: string[];
}
type FriendlyError = { message: string; isSetup?: boolean };
function friendlyError(raw: string | number): FriendlyError {
if (typeof raw === "number") {
if (raw === 401) return { message: "Please sign in to use suggestions." };
if (raw === 403) return { message: "You don\u2019t have permission for this." };
if (raw === 404) return { message: "Suggestion service not found. Is the backend running?" };
if (raw >= 500) return { message: "Something went wrong on the server. Try again." };
return { message: "Something went wrong. Try again." };
}
const lower = raw.toLowerCase();
if (lower.includes("not authenticated") || lower.includes("unauthorized"))
return { message: "Please sign in to use suggestions." };
if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
return {
message: "Configure a vision-capable model (e.g. GPT-4o, Gemini) to enable autocomplete.",
isSetup: true,
};
if (lower.includes("does not support vision"))
return {
message: "The selected model doesn\u2019t support vision. Choose a vision-capable model.",
isSetup: true,
};
if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
return { message: "Can\u2019t reach the server. Check your connection." };
return { message: "Something went wrong. Try again." };
}
const AUTO_DISMISS_MS = 3000;
function StepIcon({ status }: { status: string }) {
if (status === "complete") {
return (
<svg
className="step-icon step-icon-done"
viewBox="0 0 16 16"
fill="none"
aria-label="Step complete"
>
<circle cx="8" cy="8" r="7" stroke="#4ade80" strokeWidth="1.5" />
<path
d="M5 8.5l2 2 4-4.5"
stroke="#4ade80"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
return <span className="step-spinner" />;
}
export default function SuggestionPage() {
const api = useElectronAPI();
const [options, setOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<FriendlyError | null>(null);
const [steps, setSteps] = useState<AgentStep[]>([]);
const [expandedOption, setExpandedOption] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const isDesktop = !!api?.onAutocompleteContext;
useEffect(() => {
if (!api?.onAutocompleteContext) {
setIsLoading(false);
}
}, [api]);
useEffect(() => {
if (!error || error.isSetup) return;
const timer = setTimeout(() => {
api?.dismissSuggestion?.();
}, AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [error, api]);
useEffect(() => {
if (isLoading || error || options.length > 0) return;
const timer = setTimeout(() => {
api?.dismissSuggestion?.();
}, AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [isLoading, error, options, api]);
const fetchSuggestion = useCallback(
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setIsLoading(true);
setOptions([]);
setError(null);
setSteps([]);
setExpandedOption(null);
let token = getBearerToken();
if (!token) {
await ensureTokensFromElectron();
token = getBearerToken();
}
if (!token) {
setError(friendlyError("not authenticated"));
setIsLoading(false);
return;
}
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
try {
const response = await fetch(`${backendUrl}/api/v1/autocomplete/vision/stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
screenshot,
search_space_id: parseInt(searchSpaceId, 10),
app_name: appName || "",
window_title: windowTitle || "",
}),
signal: controller.signal,
});
if (!response.ok) {
setError(friendlyError(response.status));
setIsLoading(false);
return;
}
if (!response.body) {
setError(friendlyError("network error"));
setIsLoading(false);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() || "";
for (const event of events) {
const lines = event.split(/\r?\n/);
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (!data || data === "[DONE]") continue;
try {
const parsed: SSEEvent = JSON.parse(data);
if (parsed.type === "data-suggestions") {
setOptions(parsed.data.options);
} else if (parsed.type === "error") {
setError(friendlyError(parsed.errorText));
} else if (parsed.type === "data-thinking-step") {
const { id, title, status, items } = parsed.data;
setSteps((prev) => {
const existing = prev.findIndex((s) => s.id === id);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = { id, title, status, items };
return updated;
}
return [...prev, { id, title, status, items }];
});
}
} catch {}
}
}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(friendlyError("network error"));
} finally {
setIsLoading(false);
}
},
[]
);
useEffect(() => {
if (!api?.onAutocompleteContext) return;
const cleanup = api.onAutocompleteContext((data) => {
const searchSpaceId = data.searchSpaceId || "1";
if (data.screenshot) {
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
}
});
return cleanup;
}, [fetchSuggestion, api]);
if (!isDesktop) {
return (
<div className="suggestion-tooltip">
<span className="suggestion-error-text">
This page is only available in the SurfSense desktop app.
</span>
</div>
);
}
if (error) {
if (error.isSetup) {
return (
<div className="suggestion-tooltip suggestion-setup">
<div className="setup-icon">
<svg viewBox="0 0 24 24" fill="none" width="28" height="28" aria-hidden="true">
<path
d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
stroke="#a78bfa"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="12"
r="3"
stroke="#a78bfa"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="setup-content">
<span className="setup-title">Vision Model Required</span>
<span className="setup-message">{error.message}</span>
<span className="setup-hint">Settings Vision Models</span>
</div>
<button
type="button"
className="setup-dismiss"
onClick={() => api?.dismissSuggestion?.()}
>
</button>
</div>
);
}
return (
<div className="suggestion-tooltip suggestion-error">
<span className="suggestion-error-text">{error.message}</span>
</div>
);
}
const showLoading = isLoading && options.length === 0;
if (showLoading) {
return (
<div className="suggestion-tooltip">
<div className="agent-activity">
{steps.length === 0 && (
<div className="activity-initial">
<span className="step-spinner" />
<span className="activity-label">Preparing</span>
</div>
)}
{steps.length > 0 && (
<div className="activity-steps">
{steps.map((step) => (
<div key={step.id} className="activity-step">
<StepIcon status={step.status} />
<span className="step-label">
{step.title}
{step.items.length > 0 && (
<span className="step-detail"> · {step.items[0]}</span>
)}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
const handleSelect = (text: string) => {
api?.acceptSuggestion?.(text);
};
const handleDismiss = () => {
api?.dismissSuggestion?.();
};
const TRUNCATE_LENGTH = 120;
if (options.length === 0) {
return (
<div className="suggestion-tooltip suggestion-error">
<span className="suggestion-error-text">No suggestions available.</span>
</div>
);
}
return (
<div className="suggestion-tooltip">
<div className="suggestion-options">
{options.map((option, index) => {
const isExpanded = expandedOption === index;
const needsTruncation = option.length > TRUNCATE_LENGTH;
const displayText =
needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option;
return (
<button
type="button"
key={index}
className="suggestion-option"
onClick={() => handleSelect(option)}
>
<span className="option-number">{index + 1}</span>
<span className="option-text">{displayText}</span>
{needsTruncation && (
<button
type="button"
className="option-expand"
onClick={(e) => {
e.stopPropagation();
setExpandedOption(isExpanded ? null : index);
}}
>
{isExpanded ? "less" : "more"}
</button>
)}
</button>
);
})}
</div>
<div className="suggestion-actions">
<button
type="button"
className="suggestion-btn suggestion-btn-dismiss"
onClick={handleDismiss}
>
Dismiss
</button>
</div>
</div>
);
}

View file

@ -1,352 +0,0 @@
html:has(.suggestion-body),
body:has(.suggestion-body) {
margin: 0 !important;
padding: 0 !important;
background: transparent !important;
overflow: hidden !important;
height: auto !important;
width: 100% !important;
}
.suggestion-body {
margin: 0;
padding: 0;
background: transparent;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-app-region: no-drag;
}
.suggestion-tooltip {
box-sizing: border-box;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
padding: 8px 12px;
margin: 4px;
max-width: 400px;
/* MAX_HEIGHT in suggestion-window.ts is 400px. Subtract 8px for margin
(4px * 2) so the tooltip + margin fits within the Electron window.
box-sizing: border-box ensures padding + border are included. */
max-height: 392px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.suggestion-text {
color: #d4d4d4;
font-size: 13px;
line-height: 1.45;
margin: 0 0 6px 0;
word-wrap: break-word;
white-space: pre-wrap;
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
}
.suggestion-text::-webkit-scrollbar {
width: 5px;
}
.suggestion-text::-webkit-scrollbar-track {
background: transparent;
}
.suggestion-text::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
.suggestion-text::-webkit-scrollbar-thumb:hover {
background: #777;
}
.suggestion-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
border-top: 1px solid #2a2a2a;
padding-top: 6px;
flex-shrink: 0;
}
.suggestion-btn {
padding: 2px 8px;
border-radius: 3px;
border: 1px solid #3c3c3c;
font-family: inherit;
font-size: 10px;
font-weight: 500;
cursor: pointer;
line-height: 16px;
transition:
background 0.15s,
border-color 0.15s;
}
.suggestion-btn-accept {
background: #2563eb;
border-color: #3b82f6;
color: #fff;
}
.suggestion-btn-accept:hover {
background: #1d4ed8;
}
.suggestion-btn-dismiss {
background: #2a2a2a;
color: #999;
}
.suggestion-btn-dismiss:hover {
background: #333;
color: #ccc;
}
.suggestion-error {
border-color: #5c2626;
}
.suggestion-error-text {
color: #f48771;
font-size: 12px;
}
/* --- Setup prompt (vision model not configured) --- */
.suggestion-setup {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
border-color: #3b2d6b;
padding: 10px 14px;
}
.setup-icon {
flex-shrink: 0;
margin-top: 1px;
}
.setup-content {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.setup-title {
font-size: 13px;
font-weight: 600;
color: #c4b5fd;
}
.setup-message {
font-size: 11.5px;
color: #a1a1aa;
line-height: 1.4;
}
.setup-hint {
font-size: 10.5px;
color: #7c6dac;
margin-top: 2px;
}
.setup-dismiss {
flex-shrink: 0;
align-self: flex-start;
background: none;
border: none;
color: #6b6b7b;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
line-height: 1;
border-radius: 4px;
transition:
color 0.15s,
background 0.15s;
}
.setup-dismiss:hover {
color: #c4b5fd;
background: rgba(124, 109, 172, 0.15);
}
/* --- Agent activity indicator --- */
.agent-activity {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
max-height: 340px;
}
.agent-activity::-webkit-scrollbar {
display: none;
}
.activity-initial {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
}
.activity-label {
color: #a1a1aa;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.activity-steps {
display: flex;
flex-direction: column;
gap: 3px;
}
.activity-step {
display: flex;
align-items: center;
gap: 6px;
min-height: 18px;
}
.step-label {
color: #d4d4d4;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-detail {
color: #71717a;
font-size: 11px;
}
/* Spinner (in_progress) */
.step-spinner {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 1.5px solid #3f3f46;
border-top-color: #a78bfa;
border-radius: 50%;
animation: step-spin 0.7s linear infinite;
}
/* Checkmark icon (complete) */
.step-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
@keyframes step-spin {
to {
transform: rotate(360deg);
}
}
/* --- Suggestion option cards --- */
.suggestion-options {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
margin-bottom: 6px;
}
.suggestion-options::-webkit-scrollbar {
width: 5px;
}
.suggestion-options::-webkit-scrollbar-track {
background: transparent;
}
.suggestion-options::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
.suggestion-option {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 5px;
border: 1px solid #333;
background: #262626;
cursor: pointer;
text-align: left;
font-family: inherit;
transition:
background 0.15s,
border-color 0.15s;
width: 100%;
}
.suggestion-option:hover {
background: #2a2d3a;
border-color: #3b82f6;
}
.option-number {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
background: #3f3f46;
color: #d4d4d4;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.suggestion-option:hover .option-number {
background: #2563eb;
color: #fff;
}
.option-text {
color: #d4d4d4;
font-size: 12px;
line-height: 1.45;
word-wrap: break-word;
white-space: pre-wrap;
flex: 1 1 auto;
min-width: 0;
}
.option-expand {
flex-shrink: 0;
background: none;
border: none;
color: #71717a;
font-size: 10px;
cursor: pointer;
padding: 0 2px;
font-family: inherit;
margin-top: 1px;
}
.option-expand:hover {
color: #a1a1aa;
}

View file

@ -0,0 +1,55 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function DocsLoading() {
return (
<div className="flex flex-1 flex-col gap-4 p-6 max-w-4xl mx-auto w-full">
{/* Title */}
<Skeleton className="h-9 w-64" />
{/* Description */}
<Skeleton className="h-5 w-full max-w-md" />
<div className="mt-4 space-y-8">
{/* Paragraph block 1 */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
{/* Sub-heading */}
<Skeleton className="h-7 w-48" />
{/* Paragraph block 2 */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
{/* Code block placeholder */}
<Skeleton className="h-28 w-full rounded-lg" />
{/* Sub-heading */}
<Skeleton className="h-7 w-56" />
{/* List items */}
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="mt-1 h-3 w-3 shrink-0 rounded-full" />
<Skeleton className="h-4 w-full max-w-lg" />
</div>
))}
</div>
{/* Paragraph block 3 */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import { atom } from "jotai";
interface ActionLogSheetState {
open: boolean;
threadId: number | null;
}
export const actionLogSheetAtom = atom<ActionLogSheetState>({
open: false,
threadId: null,
});
export const openActionLogSheetAtom = atom(null, (_get, set, threadId: number) => {
set(actionLogSheetAtom, { open: true, threadId });
});
export const closeActionLogSheetAtom = atom(null, (_get, set) => {
set(actionLogSheetAtom, { open: false, threadId: null });
});

View file

@ -0,0 +1,17 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { agentFlagsApiService } from "@/lib/apis/agent-flags-api.service";
import { getBearerToken } from "@/lib/auth-utils";
export const AGENT_FLAGS_QUERY_KEY = ["agent", "flags"] as const;
/**
* Reads the backend agent feature flags. Cached for the lifetime of the
* page (flags only change on backend restart) so we can drive UI gating
* without re-hitting the API.
*/
export const agentFlagsAtom = atomWithQuery(() => ({
queryKey: AGENT_FLAGS_QUERY_KEY,
staleTime: 10 * 60 * 1000,
enabled: !!getBearerToken(),
queryFn: () => agentFlagsApiService.get(),
}));

View file

@ -9,29 +9,6 @@ import type { Document } from "@/contracts/types/document.types";
*/
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
/**
* Back-compat alias for sidebar checkbox selection.
* This now points to mentionedDocumentsAtom so the app has a single source
* of truth for mentioned/selected documents.
*/
export const sidebarSelectedDocumentsAtom = atom<
Pick<Document, "id" | "title" | "document_type">[],
[
| Pick<Document, "id" | "title" | "document_type">[]
| ((
prev: Pick<Document, "id" | "title" | "document_type">[]
) => Pick<Document, "id" | "title" | "document_type">[]),
],
void
>(
(get) => get(mentionedDocumentsAtom),
(get, set, update) => {
const prev = get(mentionedDocumentsAtom);
const next = typeof update === "function" ? update(prev) : update;
set(mentionedDocumentsAtom, next);
}
);
/**
* Derived read-only atom that maps deduplicated mentioned docs
* into backend payload fields.

View file

@ -0,0 +1,3 @@
import { atom } from "jotai";
export const pendingUserImageDataUrlsAtom = atom<string[]>([]);

View file

@ -0,0 +1,40 @@
import { atom } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
interface CitationPanelState {
isOpen: boolean;
chunkId: number | null;
}
const initialState: CitationPanelState = {
isOpen: false,
chunkId: null,
};
export const citationPanelAtom = atom<CitationPanelState>(initialState);
export const citationPanelOpenAtom = atom((get) => get(citationPanelAtom).isOpen);
const preCitationCollapsedAtom = atom<boolean | null>(null);
export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => {
if (!get(citationPanelAtom).isOpen) {
set(preCitationCollapsedAtom, get(rightPanelCollapsedAtom));
}
set(citationPanelAtom, {
isOpen: true,
chunkId: payload.chunkId,
});
set(rightPanelTabAtom, "citation");
set(rightPanelCollapsedAtom, false);
});
export const closeCitationPanelAtom = atom(null, (get, set) => {
set(citationPanelAtom, initialState);
set(rightPanelTabAtom, "sources");
const prev = get(preCitationCollapsedAtom);
if (prev !== null) {
set(rightPanelCollapsedAtom, prev);
set(preCitationCollapsedAtom, null);
}
});

View file

@ -1,6 +1,6 @@
import { atom } from "jotai";
export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit";
export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit" | "citation";
export const rightPanelTabAtom = atom<RightPanelTab>("sources");

View file

@ -0,0 +1,50 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Activity } from "lucide-react";
import { useCallback } from "react";
import { openActionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface ActionLogButtonProps {
threadId: number | null;
}
/**
* Header button that opens the agent action log sheet for the current
* thread. Renders nothing when:
* - the action log feature flag is off (graceful no-op for older
* deployments), OR
* - there is no active thread (lazy-created chats haven't started).
*/
export function ActionLogButton({ threadId }: ActionLogButtonProps) {
const { data: flags } = useAtomValue(agentFlagsAtom);
const open = useSetAtom(openActionLogSheetAtom);
const enabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
const handleClick = useCallback(() => {
if (threadId !== null) open(threadId);
}, [open, threadId]);
if (!enabled || threadId === null) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="size-8 p-0"
aria-label="Open agent action log"
onClick={handleClick}
>
<Activity className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Agent actions</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,215 @@
"use client";
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
interface ActionLogItemProps {
action: AgentAction;
threadId: number;
onRevertSuccess: () => void;
}
export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const isAlreadyReverted = action.reverted_by_action_id !== null;
const isRevertAction = action.is_revert_action;
const hasError = action.error !== null && action.error !== undefined;
const Icon = getToolIcon(action.tool_name);
const displayName = formatToolName(action.tool_name);
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
const truncatedArgs =
argsPreview && argsPreview.length > 600 ? `${argsPreview.slice(0, 600)}` : argsPreview;
const canRevert = action.reversible && !isAlreadyReverted && !isRevertAction && !hasError;
const handleRevert = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revert(threadId, action.id);
toast.success(response.message || "Action reverted successfully.");
onRevertSuccess();
} catch (err) {
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert action.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<div
className={cn(
"rounded-lg border bg-card transition-colors",
isAlreadyReverted && "opacity-70"
)}
>
<button
type="button"
onClick={() => setIsExpanded((v) => !v)}
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
aria-expanded={isExpanded}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
{isRevertAction ? (
<Undo2 className="size-4 text-muted-foreground" />
) : (
<Icon className="size-4 text-muted-foreground" />
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium">{displayName}</span>
{isRevertAction && (
<Badge variant="secondary" className="text-[10px]">
Revert
</Badge>
)}
{hasError && (
<Badge variant="destructive" className="text-[10px]">
Error
</Badge>
)}
{!isRevertAction && action.reversible && !isAlreadyReverted && (
<Badge variant="outline" className="text-[10px]">
Reversible
</Badge>
)}
{isAlreadyReverted && (
<Badge variant="secondary" className="text-[10px]">
Reverted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{formatRelativeDate(action.created_at)}</p>
</div>
<ChevronRight
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90"
)}
/>
</button>
{isExpanded && (
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3">
{truncatedArgs && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Arguments
</p>
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
{truncatedArgs}
</pre>
</div>
)}
{action.error && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Error
</p>
<pre className="max-h-32 overflow-auto rounded-md bg-destructive/10 p-2 text-[11px] text-destructive">
{JSON.stringify(action.error, null, 2)}
</pre>
</div>
)}
{action.reverse_descriptor && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Reverse plan
</p>
<pre className="max-h-32 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
{JSON.stringify(action.reverse_descriptor, null, 2)}
</pre>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
Action ID: <span className="font-mono">{action.id}</span>
</p>
{canRevert ? (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="gap-1.5">
<RotateCcw className="size-3.5" />
Revert
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
<AlertDialogDescription>
This will undo <span className="font-medium">{displayName}</span> and append a
new audit entry. The agent's chat history is preserved — only the tool's
effects on your knowledge base or connectors will be reversed where possible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevert();
}}
disabled={isReverting}
>
{isReverting ? "Reverting…" : "Revert"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldOff className="size-3.5" />
{isAlreadyReverted
? "Already reverted"
: isRevertAction
? "Revert entry"
: hasError
? "Cannot revert errored action"
: "Not reversible"}
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,185 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue } from "jotai";
import { Activity, RefreshCcw } from "lucide-react";
import { useCallback, useMemo } from "react";
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { ActionLogItem } from "./action-log-item";
const ACTION_LOG_PAGE_SIZE = 50;
function actionLogQueryKey(threadId: number) {
return ["agent-actions", threadId] as const;
}
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Activity className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">No actions logged yet</p>
<p className="text-xs text-muted-foreground">
Once the agent calls a tool in this thread, it will show up here. From the log you can
inspect arguments and revert reversible actions.
</p>
</div>
</div>
);
}
function DisabledState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Activity className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Action log is disabled</p>
<p className="text-xs text-muted-foreground">
This deployment hasn't enabled the agent action log. An admin can flip
<code className="ml-1 rounded bg-muted px-1 text-[10px]">
SURFSENSE_ENABLE_ACTION_LOG
</code>
.
</p>
</div>
</div>
);
}
const SKELETON_KEYS = ["s1", "s2", "s3", "s4"] as const;
function LoadingState() {
return (
<div className="flex flex-col gap-2 p-4">
{SKELETON_KEYS.map((key) => (
<Skeleton key={key} className="h-16 w-full rounded-lg" />
))}
</div>
);
}
export function ActionLogSheet() {
const [state, setState] = useAtom(actionLogSheetAtom);
const queryClient = useQueryClient();
const { data: flags } = useAtomValue(agentFlagsAtom);
const actionLogEnabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
const revertEnabled = !!flags?.enable_revert_route && !flags?.disable_new_agent_stack;
const threadId = state.threadId;
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: threadId !== null ? actionLogQueryKey(threadId) : ["agent-actions", "none"],
queryFn: () =>
agentActionsApiService.listForThread(threadId as number, {
page: 0,
pageSize: ACTION_LOG_PAGE_SIZE,
}),
enabled: state.open && threadId !== null && actionLogEnabled,
staleTime: 15 * 1000,
});
const handleRevertSuccess = useCallback(() => {
if (threadId !== null) {
queryClient.invalidateQueries({ queryKey: actionLogQueryKey(threadId) });
}
}, [queryClient, threadId]);
const items = useMemo(() => data?.items ?? [], [data]);
return (
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
<SheetContent
side="right"
className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md"
>
<SheetHeader className="shrink-0 border-b px-4 py-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Activity className="size-4 text-muted-foreground" />
<SheetTitle className="text-base font-semibold">Agent actions</SheetTitle>
{data?.total !== undefined && data.total > 0 && (
<Badge variant="secondary" className="text-[10px]">
{data.total}
</Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => refetch()}
disabled={isFetching || !actionLogEnabled}
className="size-8 p-0"
aria-label="Refresh action log"
>
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
</Button>
</div>
<SheetDescription className="text-xs text-muted-foreground">
Audit trail of every tool call the agent made in this thread.
{revertEnabled
? " Reversible actions can be undone in place."
: " Reverts are read-only on this deployment."}
</SheetDescription>
</SheetHeader>
<Separator />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-thin">
{!actionLogEnabled ? (
<DisabledState />
) : threadId === null ? (
<EmptyState />
) : isLoading ? (
<LoadingState />
) : isError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-sm font-medium text-destructive">Failed to load actions</p>
<p className="text-xs text-muted-foreground">
{error instanceof Error ? error.message : "Unknown error"}
</p>
<Button size="sm" variant="outline" onClick={() => refetch()}>
Try again
</Button>
</div>
) : items.length === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-2 p-3">
{items.map((action) => (
<ActionLogItem
key={action.id}
action={action}
threadId={threadId}
onRevertSuccess={handleRevertSuccess}
/>
))}
{data?.has_more && (
<p className="py-2 text-center text-[11px] text-muted-foreground">
Showing {items.length} of {data.total}. Older actions are paginated.
</p>
)}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -123,9 +123,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
@ -226,27 +226,31 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onDisconnect={(connector) =>
handleDisconnectFromList(connector, () => refreshConnectors())
}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
onAddAccount={() => {
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onDisconnect={(connector) =>
handleDisconnectFromList(connector, () => refreshConnectors())
}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(

View file

@ -213,13 +213,13 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
</Button>
</div>

View file

@ -218,13 +218,13 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
</Button>
</div>

View file

@ -18,9 +18,9 @@ export const TeamsConfig: FC<TeamsConfigProps> = () => {
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
Your agent can search and read messages from Teams channels you have access to,
and send messages on your behalf. Make sure you&#39;re a member of the teams
you want to interact with.
Your agent can search and read messages from Teams channels you have access to, and send
messages on your behalf. Make sure you&#39;re a member of the teams you want to interact
with.
</p>
</div>
</div>

View file

@ -16,7 +16,7 @@ import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { SummaryConfig } from "../../components/summary-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../../constants/connector-constants";
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { MCPServiceConfig } from "../components/mcp-service-config";
import { getConnectorConfigComponent } from "../index";
@ -314,8 +314,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{connector.is_indexable &&
(() => {
const isGoogleDrive =
connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
@ -327,8 +326,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
(connector.config?.selected_files as
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected =
selectedFolders.length > 0 || selectedFiles.length > 0;
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = requiresFolderSelection && !hasItemsSelected;
return (
@ -380,8 +378,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-muted border-t border-border">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">
{isLive
? "Your agent will lose access to this service."

View file

@ -12,7 +12,10 @@ import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { SummaryConfig } from "../../components/summary-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES, type IndexingConfigState } from "../../constants/connector-constants";
import {
type IndexingConfigState,
LIVE_CONNECTOR_TYPES,
} from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";

View file

@ -9,7 +9,11 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils";
import { COMPOSIO_CONNECTORS, LIVE_CONNECTOR_TYPES, OAUTH_CONNECTORS } from "../constants/connector-constants";
import {
COMPOSIO_CONNECTORS,
LIVE_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
} from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "./all-connectors-tab";

View file

@ -13,7 +13,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../constants/connector-constants";
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
@ -182,11 +182,14 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const connectorReauthEndpoint = getReauthEndpoint(connector);
const isAuthExpired = !!connectorReauthEndpoint && connector.config?.auth_expired === true;
const isLive = LIVE_CONNECTOR_TYPES.has(connector.connector_type) || Boolean(connector.config?.server_config);
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const connectorReauthEndpoint = getReauthEndpoint(connector);
const isAuthExpired =
!!connectorReauthEndpoint && connector.config?.auth_expired === true;
const isLive =
LIVE_CONNECTOR_TYPES.has(connector.connector_type) ||
Boolean(connector.config?.server_config);
return (
<div
@ -225,73 +228,73 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p>
) : null}
</div>
{isAuthExpired ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector)}
disabled={reauthingId === connector.id}
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
) : isLive && onDisconnect ? (
confirmDisconnectId === connector.id ? (
<div className="flex items-center gap-1.5 shrink-0">
{isAuthExpired ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector)}
disabled={reauthingId === connector.id}
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
) : isLive && onDisconnect ? (
confirmDisconnectId === connector.id ? (
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="destructive"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
onClick={async () => {
setDisconnectingId(connector.id);
setConfirmDisconnectId(null);
try {
await onDisconnect(connector);
} finally {
setDisconnectingId(null);
}
}}
disabled={disconnectingId === connector.id}
>
{disconnectingId === connector.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
"Confirm"
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-[11px] px-2 rounded-lg"
onClick={() => setConfirmDisconnectId(null)}
disabled={disconnectingId === connector.id}
>
Cancel
</Button>
</div>
) : (
<Button
variant="destructive"
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
onClick={async () => {
setDisconnectingId(connector.id);
setConfirmDisconnectId(null);
try {
await onDisconnect(connector);
} finally {
setDisconnectingId(null);
}
}}
disabled={disconnectingId === connector.id}
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
onClick={() => setConfirmDisconnectId(connector.id)}
>
{disconnectingId === connector.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
"Confirm"
)}
<Trash2 className="size-3.5" />
Disconnect
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-[11px] px-2 rounded-lg"
onClick={() => setConfirmDisconnectId(null)}
disabled={disconnectingId === connector.id}
>
Cancel
</Button>
</div>
)
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
onClick={() => setConfirmDisconnectId(connector.id)}
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
<Trash2 className="size-3.5" />
Disconnect
Manage
</Button>
)
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
)}
)}
</div>
);
})}

View file

@ -1,26 +1,43 @@
"use client";
import { FileText } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { ExternalLink, FileText } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Citation } from "@/components/tool-ui/citation";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface InlineCitationProps {
chunkId: number;
isDocsChunk?: boolean;
}
const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
/**
* Inline citation for knowledge-base chunks (numeric chunk IDs).
* Renders a clickable badge showing the actual chunk ID that opens the SourceDetailPanel.
* Negative chunk IDs indicate anonymous/synthetic uploads and render as a static badge.
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
* a static "doc" pill (anonymous/synthetic uploads).
*
* Numeric KB chunks: clicking opens the citation panel in the right
* sidebar (alongside the chat does not replace it). The panel shows
* the cited chunk surrounded by adjacent chunks (via the API's
* `chunk_window`), with the cited one highlighted and an option to
* expand the window or jump into the full document via the editor panel.
*
* Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
* lazily fetches and previews the cited chunk inline, since those docs aren't
* indexed into the user's search space and have no tab to open.
*/
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk = false }) => {
const [isOpen, setIsOpen] = useState(false);
if (chunkId < 0) {
return (
<Tooltip>
@ -38,26 +55,131 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
);
}
if (isDocsChunk) {
return <SurfsenseDocCitation chunkId={chunkId} />;
}
return <NumericChunkCitation chunkId={chunkId} />;
};
const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const openCitationPanel = useSetAtom(openCitationPanelAtom);
return (
<SourceDetailPanel
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
description=""
url=""
isDocsChunk={isDocsChunk}
<button
type="button"
onClick={() => openCitationPanel({ chunkId })}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
title={`View source chunk #${chunkId}`}
aria-label={`View cited chunk ${chunkId}`}
>
<button
type="button"
onClick={() => setIsOpen(true)}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
title={`View source chunk #${chunkId}`}
{chunkId}
</button>
);
};
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
cancelClose();
closeTimerRef.current = setTimeout(() => {
setOpen(false);
closeTimerRef.current = null;
}, POPOVER_HOVER_CLOSE_DELAY_MS);
}, [cancelClose]);
useEffect(() => () => cancelClose(), [cancelClose]);
const { data, isLoading, error } = useQuery({
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
enabled: open,
staleTime: 5 * 60 * 1000,
});
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
onFocus={() => {
cancelClose();
setOpen(true);
}}
onBlur={scheduleClose}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
title="Surfsense documentation"
>
<FileText className="size-3" />
doc
</button>
</PopoverTrigger>
<PopoverContent
className="w-96 max-w-[calc(100vw-2rem)] p-0"
align="start"
sideOffset={6}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{chunkId}
</button>
</SourceDetailPanel>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{data?.title ?? "Surfsense documentation"}
</p>
<p className="text-[11px] text-muted-foreground">Chunk #{chunkId}</p>
</div>
{data?.source && (
<a
href={data.source}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-primary/10"
>
<ExternalLink className="size-3" />
Open
</a>
)}
</div>
<div className="max-h-72 overflow-auto px-3 py-2 text-sm">
{isLoading && (
<div className="flex items-center gap-2 py-4 text-muted-foreground">
<Spinner size="xs" />
<span className="text-xs">Loading</span>
</div>
)}
{error && (
<p className="py-4 text-xs text-destructive">
{error instanceof Error ? error.message : "Failed to load chunk"}
</p>
)}
{!isLoading && !error && citedChunk?.content && (
<MarkdownViewer content={citedChunk.content} maxLength={1500} />
)}
{!isLoading && !error && !citedChunk?.content && (
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
)}
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -14,6 +14,7 @@ import {
import { renderToStaticMarkup } from "react-dom/server";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
function renderElementToHTML(element: ReactElement): string {
@ -57,7 +58,6 @@ interface InlineMentionEditorProps {
onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean;
className?: string;
initialDocuments?: MentionedDocument[];
initialText?: string;
}
@ -109,7 +109,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
onKeyDown,
disabled = false,
className,
initialDocuments = [],
initialText,
},
ref
@ -117,17 +116,24 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const editorRef = useRef<HTMLDivElement>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [mentionedDocs, setMentionedDocs] = useState<Map<string, MentionedDocument>>(
() => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
() => new Map()
);
const isComposingRef = useRef(false);
const lastSelectionRangeRef = useRef<Range | null>(null);
const isRangeInsideEditor = useCallback((range: Range | null): range is Range => {
if (!range || !editorRef.current) return false;
return (
editorRef.current.contains(range.startContainer) &&
editorRef.current.contains(range.endContainer)
);
}, []);
const isSelectionInsideEditor = useCallback(
(selection: Selection | null): selection is Selection => {
if (!selection || selection.rangeCount === 0 || !editorRef.current) return false;
const range = selection.getRangeAt(0);
return editorRef.current.contains(range.startContainer);
return isRangeInsideEditor(range);
},
[]
[isRangeInsideEditor]
);
const rememberSelection = useCallback(() => {
@ -139,11 +145,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const restoreRememberedSelection = useCallback((): Selection | null => {
const selection = window.getSelection();
if (!selection) return null;
if (!lastSelectionRangeRef.current) return selection;
if (!isRangeInsideEditor(lastSelectionRangeRef.current)) return null;
selection.removeAllRanges();
selection.addRange(lastSelectionRangeRef.current.cloneRange());
return selection;
}, []);
}, [isRangeInsideEditor]);
useEffect(() => {
const handleSelectionChange = () => {
@ -154,23 +160,13 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
return () => document.removeEventListener("selectionchange", handleSelectionChange);
}, [rememberSelection]);
// Sync initial documents
useEffect(() => {
if (initialDocuments.length > 0) {
setMentionedDocs(
new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
);
}
}, [initialDocuments]);
useEffect(() => {
if (!initialText || !editorRef.current) return;
editorRef.current.innerText = initialText;
editorRef.current.appendChild(document.createElement("br"));
editorRef.current.appendChild(document.createElement("br"));
setIsEmpty(false);
onChange?.(initialText, initialDocuments);
onChange?.(initialText, []);
editorRef.current.focus();
const sel = window.getSelection();
const range = document.createRange();
@ -182,7 +178,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
range.insertNode(anchor);
anchor.scrollIntoView({ block: "end" });
anchor.remove();
}, [initialText, initialDocuments, onChange]);
}, [initialText, onChange]);
// Focus at the end of the editor
const focusAtEnd = useCallback(() => {
@ -284,7 +280,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
e.preventDefault();
e.stopPropagation();
chip.remove();
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
const docKey = getMentionDocKey(doc);
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(docKey);
@ -358,7 +354,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
};
// Add to mentioned docs map using unique key
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
const docKey = getMentionDocKey(doc);
setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc));
const nextDocs = new Map(mentionedDocs);
nextDocs.set(docKey, mentionDoc);
@ -367,12 +363,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const selection = window.getSelection();
const hasActiveSelection = isSelectionInsideEditor(selection);
const resolvedSelection = hasActiveSelection ? selection : restoreRememberedSelection();
if (!resolvedSelection || resolvedSelection.rangeCount === 0) {
// No selection, just append
if (
!resolvedSelection ||
resolvedSelection.rangeCount === 0 ||
!isSelectionInsideEditor(resolvedSelection)
) {
// No valid in-editor selection: deterministically insert at end.
editorRef.current.focus();
const endSelection = window.getSelection();
if (!endSelection) return;
const endRange = document.createRange();
endRange.selectNodeContents(editorRef.current);
endRange.collapse(false);
endSelection.removeAllRanges();
endSelection.addRange(endRange);
const chip = createChipElement(mentionDoc);
editorRef.current.appendChild(chip);
editorRef.current.appendChild(document.createTextNode(" "));
focusAtEnd();
endRange.insertNode(chip);
endRange.setStartAfter(chip);
endRange.collapse(true);
const space = document.createTextNode(" ");
endRange.insertNode(space);
endRange.setStartAfter(space);
endRange.collapse(true);
endSelection.removeAllRanges();
endSelection.addRange(endRange);
syncEditorState(nextDocs);
rememberSelection();
return;
}
@ -456,7 +473,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
},
[
createChipElement,
focusAtEnd,
isSelectionInsideEditor,
mentionedDocs,
rememberSelection,
@ -531,7 +547,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
if (!editorRef.current) return;
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
const chipKey = getMentionDocKey({ id: docId, document_type: docType });
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
`span[${CHIP_DATA_ATTR}="true"]`
);
@ -696,7 +712,10 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const chipDocType = getChipDocType(prevSibling);
if (chipId !== null) {
prevSibling.remove();
const chipKey = `${chipDocType}:${chipId}`;
const chipKey = getMentionDocKey({
id: chipId,
document_type: chipDocType,
});
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipKey);
@ -734,7 +753,10 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const chipDocType = getChipDocType(prevChild);
if (chipId !== null) {
prevChild.remove();
const chipKey = `${chipDocType}:${chipId}`;
const chipKey = getMentionDocKey({
id: chipId,
document_type: chipDocType,
});
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipKey);

View file

@ -20,7 +20,6 @@ import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
import "katex/dist/katex.min.css";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { useElectronAPI } from "@/hooks/use-platform";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@ -30,6 +29,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
function MarkdownCodeBlockSkeleton() {
@ -85,10 +85,13 @@ function preprocessMarkdown(content: string): string {
}
);
// All math forms are normalised to $$...$$ so we can disable single-dollar
// inline math in remark-math (otherwise currency like "$3,120.00 and $0.00"
// gets parsed as a LaTeX expression).
// 1. Block math: \[...\] → $$...$$
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_, inner) => `$$${inner}$$`);
// 2. Inline math: \(...\) → $...$
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$${inner}$`);
// 2. Inline math: \(...\) → $$...$$
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$$${inner}$$`);
// 3. Block: \begin{equation}...\end{equation} → $$...$$
content = content.replace(
/\\begin\{equation\}([\s\S]*?)\\end\{equation\}/g,
@ -99,8 +102,11 @@ function preprocessMarkdown(content: string): string {
/\\begin\{displaymath\}([\s\S]*?)\\end\{displaymath\}/g,
(_, inner) => `$$${inner}$$`
);
// 5. Inline: \begin{math}...\end{math} → $...$
content = content.replace(/\\begin\{math\}([\s\S]*?)\\end\{math\}/g, (_, inner) => `$${inner}$`);
// 5. Inline: \begin{math}...\end{math} → $$...$$
content = content.replace(
/\\begin\{math\}([\s\S]*?)\\end\{math\}/g,
(_, inner) => `$$${inner}$$`
);
// 6. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
@ -180,7 +186,7 @@ const MarkdownTextImpl = () => {
return (
<MarkdownTextPrimitive
smooth={false}
remarkPlugins={[remarkGfm, remarkMath]}
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[rehypeKatex]}
className="aui-md"
components={defaultComponents}
@ -493,10 +499,7 @@ const defaultComponents = memoizeMarkdownComponents({
const mounts = (await electronAPI.getAgentFilesystemMounts(
resolvedSearchSpaceId
)) as AgentFilesystemMount[];
resolvedLocalPath = normalizeLocalVirtualPathForEditor(
inlineValue,
mounts
);
resolvedLocalPath = normalizeLocalVirtualPathForEditor(inlineValue, mounts);
} catch {
// Fall back to the raw inline path if mount lookup fails.
}

View file

@ -12,6 +12,7 @@ import {
AlertCircle,
ArrowDownIcon,
ArrowUpIcon,
Camera,
ChevronDown,
ChevronUp,
Clipboard,
@ -39,6 +40,7 @@ import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import {
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
@ -87,6 +89,8 @@ import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
import { cn } from "@/lib/utils";
@ -293,6 +297,32 @@ const ConnectToolsBanner: FC<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) =
);
};
const PendingScreenImageStrip: FC = () => {
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
if (urls.length === 0) return null;
return (
<div className="mx-3 mt-2 flex flex-wrap gap-2">
{urls.map((url, index) => (
<div
key={url}
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-md border border-border/50 bg-muted"
>
{/* biome-ignore lint/performance/noImgElement: data URL thumbnails from capture */}
<img src={url} alt="" className="size-full object-cover" draggable={false} />
<button
type="button"
onClick={() => setUrls((prev) => prev.filter((_, i) => i !== index))}
className="absolute right-0.5 top-0.5 flex size-5 items-center justify-center rounded-full bg-background/90 text-muted-foreground shadow-sm transition-opacity hover:text-foreground sm:opacity-0 sm:group-hover:opacity-100"
aria-label="Remove screenshot"
>
<X className="size-3" />
</button>
</div>
))}
</div>
);
};
const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDismiss }) => {
const [expanded, setExpanded] = useState(false);
const isLong = text.length > 120;
@ -338,6 +368,9 @@ const Composer: FC = () => {
const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
const prevMentionedDocsRef = useRef<
Map<string, Pick<Document, "id" | "title" | "document_type">>
>(new Map());
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null);
const viewportRef = useRef<Element | null>(null);
@ -624,60 +657,64 @@ const Composer: FC = () => {
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) =>
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
);
setMentionedDocuments((prev) => {
if (!docType) {
// Defensive fallback: keep UI in sync even when chip type is unavailable.
return prev.filter((doc) => doc.id !== docId);
}
const removedKey = getMentionDocKey({ id: docId, document_type: docType });
return prev.filter((doc) => getMentionDocKey(doc) !== removedKey);
});
},
[setMentionedDocuments]
);
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
for (const doc of newDocs) {
for (const doc of documents) {
const key = getMentionDocKey(doc);
if (editorDocKeys.has(key)) continue;
editorRef.current?.insertDocumentChip(doc);
}
setMentionedDocuments((prev) => {
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
);
const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d)));
const uniqueNewDocs = documents.filter((doc) => !existingKeySet.has(getMentionDocKey(doc)));
return [...prev, ...uniqueNewDocs];
});
setMentionQuery("");
},
[mentionedDocuments, setMentionedDocuments]
[setMentionedDocuments]
);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const nextDocsMap = new Map(mentionedDocuments.map((doc) => [getMentionDocKey(doc), doc]));
const prevDocsMap = prevMentionedDocsRef.current;
const toKey = (doc: { id: number; document_type?: string }) =>
`${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
const atomDocs = mentionedDocuments;
const editorDocs = editor.getMentionedDocuments();
const atomKeys = new Set(atomDocs.map(toKey));
const editorKeys = new Set(editorDocs.map(toKey));
for (const doc of atomDocs) {
if (!editorKeys.has(toKey(doc))) {
editor.insertDocumentChip(doc, { removeTriggerText: false });
}
if (!editor) {
prevMentionedDocsRef.current = nextDocsMap;
return;
}
for (const doc of editorDocs) {
if (!atomKeys.has(toKey(doc))) {
const editorKeys = new Set(editor.getMentionedDocuments().map(getMentionDocKey));
for (const [key, doc] of nextDocsMap) {
if (prevDocsMap.has(key) || editorKeys.has(key)) continue;
editor.insertDocumentChip(doc, { removeTriggerText: false });
}
for (const [key, doc] of prevDocsMap) {
if (!nextDocsMap.has(key)) {
editor.removeDocumentChip(doc.id, doc.document_type);
}
}
prevMentionedDocsRef.current = nextDocsMap;
}, [mentionedDocuments]);
return (
@ -722,6 +759,7 @@ const Composer: FC = () => {
</div>
)}
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
<PendingScreenImageStrip />
{clipboardInitialText && (
<ClipboardChip
text={clipboardInitialText}
@ -779,11 +817,23 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
},
[]
);
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
const setPendingScreenImages = useSetAtom(pendingUserImageDataUrlsAtom);
const electronAPI = useElectronAPI();
const isComposerTextEmpty = useAuiState(({ composer }) => {
const text = composer.text?.trim() || "";
return text.length === 0;
});
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
const isComposerEmpty =
isComposerTextEmpty && mentionedDocuments.length === 0 && pendingScreenImages.length === 0;
const handleScreenCapture = useCallback(async () => {
const url = electronAPI?.captureFullScreen
? await electronAPI.captureFullScreen()
: await captureDisplayToPngDataUrl();
if (url) setPendingScreenImages((prev) => [...prev, url]);
}, [electronAPI, setPendingScreenImages]);
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
@ -1210,6 +1260,17 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</div>
)}
<div className="flex items-center gap-2">
<TooltipIconButton
tooltip="Capture screen"
type="button"
variant="ghost"
size="icon"
className="size-8 rounded-full"
aria-label="Capture screen"
onClick={() => void handleScreenCapture()}
>
<Camera className="size-4" />
</TooltipIconButton>
<AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
@ -1219,7 +1280,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: isComposerEmpty
? "Enter a message to send"
? "Enter a message or add a screenshot to send"
: "Send message"
}
side="bottom"

View file

@ -1,6 +1,10 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useMemo, useState } from "react";
import {
DoomLoopApprovalToolUI,
isDoomLoopInterrupt,
} from "@/components/tool-ui/doom-loop-approval";
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import { isInterruptResult } from "@/lib/hitl";
@ -150,6 +154,9 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
if (isInterruptResult(props.result)) {
if (isDoomLoopInterrupt(props.result)) {
return <DoomLoopApprovalToolUI {...props} />;
}
return <GenericHitlApprovalToolUI {...props} />;
}
return <DefaultToolFallbackInner {...props} />;

View file

@ -0,0 +1,230 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { ChevronDown, ChevronUp, ExternalLink, XIcon } from "lucide-react";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { documentsApiService } from "@/lib/apis/documents-api.service";
const DEFAULT_CHUNK_WINDOW = 5;
const EXPANDED_CHUNK_WINDOW = 50;
interface CitationPanelContentProps {
chunkId: number;
onClose?: () => void;
}
/**
* Right-panel citation viewer. Shows the cited chunk surrounded by
* adjacent chunks (±N chunks via the API's `chunk_window` parameter),
* with the cited one visually highlighted and auto-scrolled into view.
* The window can be expanded to a wider range, or the user can jump to
* the full document via the editor panel.
*/
export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, onClose }) => {
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setExpanded(false);
}, []);
const chunkWindow = expanded ? EXPANDED_CHUNK_WINDOW : DEFAULT_CHUNK_WINDOW;
const { data, isLoading, error } = useQuery({
queryKey: ["citation-panel", chunkId, chunkWindow] as const,
queryFn: () =>
documentsApiService.getDocumentByChunk({
chunk_id: chunkId,
chunk_window: chunkWindow,
}),
staleTime: 5 * 60 * 1000,
});
const cited = useMemo(() => data?.chunks.find((c) => c.id === chunkId) ?? null, [data, chunkId]);
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
const startIndex = data?.chunk_start_index ?? 0;
const citedIndexInWindow = data
? Math.max(
0,
data.chunks.findIndex((c) => c.id === chunkId)
)
: 0;
const shownAbove = citedIndexInWindow;
const shownBelow = data ? Math.max(0, data.chunks.length - 1 - citedIndexInWindow) : 0;
const hasMoreAbove = startIndex > 0;
const hasMoreBelow = data ? startIndex + data.chunks.length < totalChunks : false;
// Scroll the cited chunk into view inside the panel's scroll container
// (not the page). We anchor the scroll to the panel's scroll element
// so opening the citation doesn't yank the chat scroll on the left.
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const citedRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!cited) return;
const id = requestAnimationFrame(() => {
const container = scrollContainerRef.current;
const target = citedRef.current;
if (!container || !target) return;
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const offset = targetRect.top - containerRect.top + container.scrollTop;
container.scrollTo({
top: Math.max(0, offset - 16),
behavior: "smooth",
});
});
return () => cancelAnimationFrame(id);
}, [cited]);
const handleOpenFullDocument = () => {
if (!data) return;
openEditorPanel({
documentId: data.id,
searchSpaceId: data.search_space_id,
title: data.title,
});
};
return (
<>
<div className="shrink-0 border-b">
<div className="flex h-14 items-center justify-between px-4">
<h2 className="text-lg font-medium text-muted-foreground select-none">Citation</h2>
<div className="flex items-center gap-1 shrink-0">
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close citation panel</span>
</Button>
)}
</div>
</div>
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
<div className="min-w-0 flex flex-1 items-center gap-2">
<p className="truncate text-sm text-muted-foreground">
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0 text-[11px] text-muted-foreground">
<span>Chunk #{chunkId}</span>
{totalChunks > 0 && <span>· {totalChunks} chunks</span>}
</div>
</div>
</div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
{isLoading && (
<div className="flex items-center gap-2 py-8 text-muted-foreground">
<Spinner size="sm" />
<span className="text-sm">Loading citation</span>
</div>
)}
{error && (
<p className="py-8 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load citation"}
</p>
)}
{!isLoading && !error && data && (
<>
{hasMoreAbove && (
<p className="mb-3 text-center text-[11px] text-muted-foreground">
{startIndex} earlier chunk{startIndex === 1 ? "" : "s"} not shown
</p>
)}
<div className="space-y-3">
{data.chunks.map((chunk) => {
const isCited = chunk.id === chunkId;
return (
<div
key={chunk.id}
ref={isCited ? citedRef : null}
data-cited={isCited || undefined}
className={
isCited
? "rounded-md border-2 border-primary bg-primary/5 px-4 py-3 shadow-sm"
: "rounded-md border border-border/40 bg-muted/20 px-4 py-3 opacity-70 transition-opacity hover:opacity-100"
}
>
<div className="mb-1.5 flex items-center justify-between">
<span
className={
isCited
? "text-[11px] font-semibold text-primary"
: "text-[11px] font-medium text-muted-foreground"
}
>
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`}
</span>
{isCited && (
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span>
)}
</div>
<div className="text-sm">
<MarkdownViewer content={chunk.content} />
</div>
</div>
);
})}
</div>
{hasMoreBelow && (
<p className="mt-3 text-center text-[11px] text-muted-foreground">
{totalChunks - (startIndex + data.chunks.length)} later chunk
{totalChunks - (startIndex + data.chunks.length) === 1 ? "" : "s"} not shown
</p>
)}
</>
)}
</div>
{!isLoading && !error && data && (
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
<div className="text-[11px] text-muted-foreground">
Showing {shownAbove} above · cited · {shownBelow} below
</div>
<div className="flex items-center gap-2">
{(hasMoreAbove || hasMoreBelow) && !expanded && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => setExpanded(true)}
>
<ChevronDown className="mr-1 size-3.5" />
More context
</Button>
)}
{expanded && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => setExpanded(false)}
>
<ChevronUp className="mr-1 size-3.5" />
Less
</Button>
)}
<Button
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleOpenFullDocument}
>
<ExternalLink className="mr-1 size-3.5" />
Open full document
</Button>
</div>
</div>
)}
</>
);
};

View file

@ -38,7 +38,7 @@ export function acceleratorToDisplay(accel: string): string[] {
export const DEFAULT_SHORTCUTS = {
generalAssist: "CommandOrControl+Shift+S",
quickAsk: "CommandOrControl+Alt+S",
autocomplete: "CommandOrControl+Shift+Space",
screenshotAssist: "CommandOrControl+Shift+Space",
};
// ---------------------------------------------------------------------------

View file

@ -7,6 +7,7 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
import { type FolderDisplay, FolderNode } from "./FolderNode";
@ -17,7 +18,7 @@ interface FolderTreeViewProps {
documents: DocumentNodeDoc[];
expandedIds: Set<number>;
onToggleExpand: (folderId: number) => void;
mentionedDocIds: Set<number>;
mentionedDocKeys: Set<string>;
onToggleChatMention: (
doc: { id: number; title: string; document_type: string },
isMentioned: boolean
@ -62,7 +63,7 @@ export function FolderTreeView({
documents,
expandedIds,
onToggleExpand,
mentionedDocIds,
mentionedDocKeys,
onToggleChatMention,
onToggleFolderSelect,
onRenameFolder,
@ -181,7 +182,7 @@ export function FolderTreeView({
function compute(folderId: number): { selected: number; total: number } {
const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable);
let selected = directDocs.filter((d) => mentionedDocIds.has(d.id)).length;
let selected = directDocs.filter((d) => mentionedDocKeys.has(getMentionDocKey(d))).length;
let total = directDocs.length;
for (const child of foldersByParent[folderId] ?? []) {
@ -202,7 +203,7 @@ export function FolderTreeView({
if (states[f.id] === undefined) compute(f.id);
}
return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
}, [folders, docsByFolder, foldersByParent, mentionedDocKeys]);
const folderMap = useMemo(() => {
const map: Record<number, FolderDisplay> = {};
@ -276,7 +277,7 @@ export function FolderTreeView({
key={`doc-${d.id}`}
doc={d}
depth={depth}
isMentioned={mentionedDocIds.has(d.id)}
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
onToggleChatMention={onToggleChatMention}
onPreview={onPreviewDocument}
onEdit={onEditDocument}
@ -356,7 +357,7 @@ export function FolderTreeView({
key={`doc-${d.id}`}
doc={d}
depth={depth}
isMentioned={mentionedDocIds.has(d.id)}
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
onToggleChatMention={onToggleChatMention}
onPreview={onPreviewDocument}
onEdit={onEditDocument}

View file

@ -136,6 +136,7 @@ export function EditorPanelContent({
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
const isLocalFileMode = kind === "local_file";
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
const resolveLocalVirtualPath = useCallback(
async (candidatePath: string): Promise<string> => {
if (!electronAPI?.getAgentFilesystemMounts) {
@ -248,7 +249,15 @@ export function EditorPanelContent({
doFetch().catch(() => {});
return () => controller.abort();
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId, title]);
}, [
documentId,
electronAPI,
isLocalFileMode,
localFilePath,
resolveLocalVirtualPath,
searchSpaceId,
title,
]);
useEffect(() => {
return () => {
@ -282,75 +291,92 @@ export function EditorPanelContent({
}
}, [editorDoc?.source_markdown]);
const handleSave = useCallback(async (_options?: { silent?: boolean }) => {
setSaving(true);
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
const handleSave = useCallback(
async (options?: { silent?: boolean }) => {
setSaving(true);
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
}
if (!electronAPI?.writeAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
}
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
const contentToSave = markdownRef.current;
const writeResult = await electronAPI.writeAgentLocalFileText(
resolvedLocalPath,
contentToSave,
searchSpaceId
);
if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file");
}
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: contentToSave } : prev));
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
return true;
}
if (!electronAPI?.writeAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
const contentToSave = markdownRef.current;
const writeResult = await electronAPI.writeAgentLocalFileText(
resolvedLocalPath,
contentToSave,
searchSpaceId
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: markdownRef.current }),
}
);
if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file");
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
if (!options?.silent) {
toast.success("Document saved! Reindexing in background...");
}
setEditorDoc((prev) =>
prev ? { ...prev, source_markdown: contentToSave } : prev
);
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
return true;
}
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: markdownRef.current }),
} catch (err) {
console.error("Error saving document:", err);
if (!options?.silent) {
toast.error(err instanceof Error ? err.message : "Failed to save document");
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
return false;
} finally {
setSaving(false);
}
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
return true;
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
return false;
} finally {
setSaving(false);
}
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId]);
},
[
documentId,
electronAPI,
isLocalFileMode,
localFilePath,
resolveLocalVirtualPath,
searchSpaceId,
]
);
const isEditableType = editorDoc
? (editorRenderMode === "source_code" ||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
!isLargeDocument
: false;
// Render through PlateEditor for editable doc types (FILE/NOTE).
// Everything else (large docs, non-editable types) falls back to the
// lightweight `MarkdownViewer` — Plate is heavy on multi-MB docs and
// non-editable types don't benefit from its editing UX.
const renderInPlateEditor = isEditableType;
const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose;
const showEditingActions = isEditableType && isEditing;
@ -365,6 +391,60 @@ export function EditorPanelContent({
setIsEditing(false);
}, [editorDoc?.source_markdown]);
const handleDownloadMarkdown = useCallback(async () => {
if (!searchSpaceId || !documentId) return;
setDownloading(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const disposition = response.headers.get("content-disposition");
const match = disposition?.match(/filename="(.+)"/);
a.download = match?.[1] ?? `${editorDoc?.title || "document"}.md`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Download started");
} catch {
toast.error("Failed to download document");
} finally {
setDownloading(false);
}
}, [documentId, editorDoc?.title, searchSpaceId]);
const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && (
<Alert className="mb-4">
<FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>
This document is too large for the editor (
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
</span>
<Button
variant="outline"
size="sm"
className="relative shrink-0"
disabled={downloading}
onClick={handleDownloadMarkdown}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
</AlertDescription>
</Alert>
);
return (
<>
{showDesktopHeader ? (
@ -549,63 +629,6 @@ export function EditorPanelContent({
</p>
</div>
</div>
) : isLargeDocument && !isLocalFileMode ? (
<div className="h-full overflow-y-auto px-5 py-4">
<Alert className="mb-4">
<FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>
This document is too large for the editor (
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
</span>
<Button
variant="outline"
size="sm"
className="relative shrink-0"
disabled={downloading}
onClick={async () => {
setDownloading(true);
try {
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const disposition = response.headers.get("content-disposition");
const match = disposition?.match(/filename="(.+)"/);
a.download = match?.[1] ?? `${editorDoc.title || "document"}.md`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Download started");
} catch {
toast.error("Failed to download document");
} finally {
setDownloading(false);
}
}}
>
<span
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
>
<Download className="size-3.5" />
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
</AlertDescription>
</Alert>
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : editorRenderMode === "source_code" ? (
<div className="h-full overflow-hidden">
<SourceCodeEditor
@ -624,20 +647,32 @@ export function EditorPanelContent({
}}
/>
</div>
) : isEditableType ? (
<PlateEditor
key={`${isLocalFileMode ? localFilePath ?? "local-file" : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={!isEditing}
placeholder="Start writing..."
editorVariant="default"
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
) : isLargeDocument && !isLocalFileMode ? (
// Large doc — fast Streamdown preview + download CTA.
// Plate is heavy on multi-MB docs.
<div className="h-full overflow-y-auto px-5 py-4">
{largeDocAlert}
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : renderInPlateEditor ? (
// Editable doc (FILE/NOTE) — Plate editing UX.
<div className="flex h-full min-h-0 flex-col">
<div className="flex-1 min-h-0 overflow-hidden">
<PlateEditor
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={!isEditing}
placeholder="Start writing..."
editorVariant="default"
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="**:[[role=toolbar]]:bg-sidebar!"
/>
</div>
</div>
) : (
<div className="h-full overflow-y-auto px-5 py-4">
<MarkdownViewer content={editorDoc.source_markdown} />
@ -746,7 +781,8 @@ export function MobileEditorPanel() {
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file") return null;
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file")
return null;
return <MobileEditorDrawer />;
}

View file

@ -12,6 +12,9 @@ import { type EditorPreset, presetMap } from "@/components/editor/presets";
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
import { Editor, EditorContainer } from "@/components/ui/editor";
/** Live editor instance returned by `usePlateEditor`. */
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
export interface PlateEditorProps {
/** Markdown string to load as initial content */
markdown?: string;

View file

@ -1,7 +1,6 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { useEditorReadOnly } from "platejs/react";
import { createPlatePlugin, useEditorReadOnly } from "platejs/react";
import { useEditorSave } from "@/components/editor/editor-save-context";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";

View file

@ -1,8 +1,8 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
import { useEffect, useRef } from "react";
import { Spinner } from "@/components/ui/spinner";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {

View file

@ -63,10 +63,10 @@ const TAB_ITEMS = [
featured: true,
},
{
title: "Extreme Assist",
title: "Screenshot Assist",
description:
"Get inline writing suggestions powered by your knowledge base as you type in any app.",
src: "/homepage/hero_tutorial/extreme_assist.mp4",
"Use a global shortcut to select a region on your screen and attach it to your chat message.",
src: "/homepage/hero_tutorial/screenshot_assist.mp4",
featured: true,
},
{

View file

@ -28,6 +28,7 @@ import {
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog";
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
import {
AlertDialog,
@ -909,6 +910,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
<UserSettingsDialog />
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
{/* Agent action log + revert sheet */}
<ActionLogSheet />
</>
);
}

View file

@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
@ -64,6 +65,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
{/* Right side - Actions */}
<div className="ml-auto flex items-center gap-2">
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
{hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)}

View file

@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
import { startTransition, useEffect } from "react";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
@ -21,6 +22,14 @@ const EditorPanelContent = dynamic(
{ ssr: false, loading: () => null }
);
const CitationPanelContent = dynamic(
() =>
import("@/components/citation-panel/citation-panel").then((m) => ({
default: m.CitationPanelContent,
})),
{ ssr: false, loading: () => null }
);
const HitlEditPanelContent = dynamic(
() =>
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
@ -74,14 +83,14 @@ export function RightPanelExpandButton() {
const reportState = useAtomValue(reportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const citationState = useAtomValue(citationPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
if (!collapsed || !hasContent) return null;
@ -105,7 +114,13 @@ export function RightPanelExpandButton() {
);
}
const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640, "hitl-edit": 640 } as const;
const PANEL_WIDTHS = {
sources: 420,
report: 640,
editor: 640,
"hitl-edit": 640,
citation: 560,
} as const;
export function RightPanel({ documentsPanel }: RightPanelProps) {
const [activeTab] = useAtom(rightPanelTabAtom);
@ -115,47 +130,69 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
const closeEditor = useSetAtom(closeEditorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom);
const citationState = useAtomValue(citationPanelAtom);
const closeCitation = useSetAtom(closeCitationPanelAtom);
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = documentsPanel?.open ?? false;
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
useEffect(() => {
if (!reportOpen && !editorOpen && !hitlEditOpen) return;
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (hitlEditOpen) closeHitlEdit();
else if (citationOpen) closeCitation();
else if (editorOpen) closeEditor();
else if (reportOpen) closeReport();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [reportOpen, editorOpen, hitlEditOpen, closeReport, closeEditor, closeHitlEdit]);
}, [
reportOpen,
editorOpen,
hitlEditOpen,
citationOpen,
closeReport,
closeEditor,
closeHitlEdit,
closeCitation,
]);
const isVisible = (documentsOpen || reportOpen || editorOpen || hitlEditOpen) && !collapsed;
const isVisible =
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen) && !collapsed;
let effectiveTab = activeTab;
if (effectiveTab === "hitl-edit" && !hitlEditOpen) {
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "editor" && !editorOpen) {
effectiveTab = reportOpen ? "report" : "sources";
} else if (effectiveTab === "report" && !reportOpen) {
effectiveTab = editorOpen ? "editor" : "sources";
} else if (effectiveTab === "sources" && !documentsOpen) {
effectiveTab = hitlEditOpen
? "hitl-edit"
effectiveTab = citationOpen
? "citation"
: editorOpen
? "editor"
: reportOpen
? "report"
: "sources";
} else if (effectiveTab === "citation" && !citationOpen) {
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "editor" && !editorOpen) {
effectiveTab = citationOpen ? "citation" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "report" && !reportOpen) {
effectiveTab = citationOpen ? "citation" : editorOpen ? "editor" : "sources";
} else if (effectiveTab === "sources" && !documentsOpen) {
effectiveTab = hitlEditOpen
? "hitl-edit"
: citationOpen
? "citation"
: editorOpen
? "editor"
: reportOpen
? "report"
: "sources";
}
const targetWidth = PANEL_WIDTHS[effectiveTab];
@ -214,6 +251,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
/>
</div>
)}
{effectiveTab === "citation" && citationOpen && citationState.chunkId != null && (
<div className="h-full flex flex-col">
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
</div>
)}
</div>
</aside>
);

View file

@ -1,11 +1,9 @@
"use client";
import { Folder, FolderPlus, Search, X } from "lucide-react";
import { useAtom } from "jotai";
import { Folder, FolderPlus, Search, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
DropdownMenu,
DropdownMenuContent,
@ -14,6 +12,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";

View file

@ -24,7 +24,7 @@ import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
sidebarSelectedDocumentsAtom,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
@ -73,7 +73,8 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { usePlatform, useElectronAPI } from "@/hooks/use-platform";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
@ -210,7 +211,8 @@ function AuthenticatedDocumentsSidebarBase({
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
const isElectron = desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
const isElectron =
desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
useEffect(() => {
if (!electronAPI?.getAgentFilesystemSettings) return;
@ -252,10 +254,13 @@ function AuthenticatedDocumentsSidebarBase({
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
if (nextLocalRootPaths.length === localRootPaths.length) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths,
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths,
},
searchSpaceId
);
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths, searchSpaceId]
@ -284,10 +289,13 @@ function AuthenticatedDocumentsSidebarBase({
const handleRemoveFilesystemRoot = useCallback(
async (rootPathToRemove: string) => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: "desktop_local_folder",
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
},
searchSpaceId
);
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths, searchSpaceId]
@ -295,19 +303,25 @@ function AuthenticatedDocumentsSidebarBase({
const handleClearFilesystemRoots = useCallback(async () => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: [],
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: "desktop_local_folder",
localRootPaths: [],
},
searchSpaceId
);
setFilesystemSettings(updated);
}, [electronAPI, searchSpaceId]);
const handleFilesystemTabChange = useCallback(
async (tab: "cloud" | "local") => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
},
searchSpaceId
);
setFilesystemSettings(updated);
},
[electronAPI, searchSpaceId]
@ -414,8 +428,11 @@ function AuthenticatedDocumentsSidebarBase({
}, [refreshWatchedIds]);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
const mentionedDocKeys = useMemo(
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
[sidebarDocs]
);
// Folder state
const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom);
@ -554,7 +571,9 @@ function AuthenticatedDocumentsSidebarBase({
if (!electronAPI) return;
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -584,7 +603,9 @@ function AuthenticatedDocumentsSidebarBase({
if (!electronAPI) return;
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -859,12 +880,12 @@ function AuthenticatedDocumentsSidebarBase({
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
const key = `${doc.document_type}:${doc.id}`;
const key = getMentionDocKey(doc);
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => `${d.document_type}:${d.id}` !== key));
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
} else {
setSidebarDocs((prev) => {
if (prev.some((d) => `${d.document_type}:${d.id}` === key)) return prev;
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
return [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
@ -895,9 +916,9 @@ function AuthenticatedDocumentsSidebarBase({
if (selectAll) {
setSidebarDocs((prev) => {
const existingDocKeys = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const existingDocKeys = new Set(prev.map((d) => getMentionDocKey(d)));
const newDocs = subtreeDocs
.filter((d) => !existingDocKeys.has(`${d.document_type}:${d.id}`))
.filter((d) => !existingDocKeys.has(getMentionDocKey(d)))
.map((d) => ({
id: d.id,
title: d.title,
@ -906,10 +927,8 @@ function AuthenticatedDocumentsSidebarBase({
return newDocs.length > 0 ? [...prev, ...newDocs] : prev;
});
} else {
const keysToRemove = new Set(subtreeDocs.map((d) => `${d.document_type}:${d.id}`));
setSidebarDocs((prev) =>
prev.filter((d) => !keysToRemove.has(`${d.document_type}:${d.id}`))
);
const keysToRemove = new Set(subtreeDocs.map((d) => getMentionDocKey(d)));
setSidebarDocs((prev) => prev.filter((d) => !keysToRemove.has(getMentionDocKey(d))));
}
},
[treeDocuments, foldersByParent, setSidebarDocs]
@ -1020,7 +1039,8 @@ function AuthenticatedDocumentsSidebarBase({
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const currentFilesystemTab =
filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const showCloudSkeleton =
currentFilesystemTab === "cloud" &&
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
@ -1144,7 +1164,7 @@ function AuthenticatedDocumentsSidebarBase({
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
mentionedDocKeys={mentionedDocKeys}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
@ -1336,8 +1356,8 @@ function AuthenticatedDocumentsSidebarBase({
<AlertDialogHeader>
<AlertDialogTitle>Trust this workspace?</AlertDialogTitle>
<AlertDialogDescription>
Local mode can read and edit files inside the folders you select. Continue only if
you trust this workspace and its contents.
Local mode can read and edit files inside the folders you select. Continue only if you
trust this workspace and its contents.
</AlertDialogDescription>
{pendingLocalPath && (
<AlertDialogDescription className="mt-1 whitespace-pre-wrap break-words font-mono text-xs">
@ -1572,17 +1592,20 @@ function AnonymousDocumentsSidebar({
const [isUploading, setIsUploading] = useState(false);
const [search, setSearch] = useState("");
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
const mentionedDocKeys = useMemo(
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
[sidebarDocs]
);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
const key = `${doc.document_type}:${doc.id}`;
const key = getMentionDocKey(doc);
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => `${d.document_type}:${d.id}` !== key));
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
} else {
setSidebarDocs((prev) => {
if (prev.some((d) => `${d.document_type}:${d.id}` === key)) return prev;
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
return [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
@ -1802,7 +1825,7 @@ function AnonymousDocumentsSidebar({
documents={searchFilteredDocs}
expandedIds={new Set()}
onToggleExpand={() => {}}
mentionedDocIds={mentionedDocIds}
mentionedDocKeys={mentionedDocKeys}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={() => {}}
onRenameFolder={() => gate("rename folders")}

View file

@ -141,7 +141,9 @@ export function LocalFilesystemBrowser({
}: LocalFilesystemBrowserProps) {
const electronAPI = useElectronAPI();
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(new Set());
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(
new Set()
);
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
@ -188,10 +190,7 @@ export function LocalFilesystemBrowser({
}
for (const { rootKey } of rootsToReload) {
const nonce = reloadNonceByRoot[rootKey] ?? 0;
lastLoadedSignatureByRootRef.current.set(
rootKey,
`${searchSpaceId}:${rootKey}:${nonce}`
);
lastLoadedSignatureByRootRef.current.set(rootKey, `${searchSpaceId}:${rootKey}:${nonce}`);
}
let cancelled = false;
@ -257,35 +256,37 @@ export function LocalFilesystemBrowser({
return;
}
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event: {
searchSpaceId: number | null;
reason: "watcher_event" | "safety_poll";
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => {
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
return;
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty(
(event: {
searchSpaceId: number | null;
reason: "watcher_event" | "safety_poll";
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => {
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
return;
}
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
const knownRootKeys = new Set(
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
);
if (!knownRootKeys.has(eventRootKey)) {
setReloadNonceByRoot((prev) => {
const next = { ...prev };
for (const rootKey of knownRootKeys) {
next[rootKey] = (prev[rootKey] ?? 0) + 1;
}
return next;
});
return;
}
setReloadNonceByRoot((prev) => ({
...prev,
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
}));
}
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
const knownRootKeys = new Set(
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
);
if (!knownRootKeys.has(eventRootKey)) {
setReloadNonceByRoot((prev) => {
const next = { ...prev };
for (const rootKey of knownRootKeys) {
next[rootKey] = (prev[rootKey] ?? 0) + 1;
}
return next;
});
return;
}
setReloadNonceByRoot((prev) => ({
...prev,
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
}));
});
);
void electronAPI.startAgentFilesystemTreeWatch({
searchSpaceId,
rootPaths,
@ -378,22 +379,25 @@ export function LocalFilesystemBrowser({
});
}, [rootPaths, rootStateMap, searchQuery]);
const toggleFolder = useCallback((folderKey: string) => {
const update = (prev: Set<string>) => {
const next = new Set(prev);
if (next.has(folderKey)) {
next.delete(folderKey);
} else {
next.add(folderKey);
const toggleFolder = useCallback(
(folderKey: string) => {
const update = (prev: Set<string>) => {
const next = new Set(prev);
if (next.has(folderKey)) {
next.delete(folderKey);
} else {
next.add(folderKey);
}
return next;
};
if (onExpandedFolderKeysChange) {
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
return;
}
return next;
};
if (onExpandedFolderKeysChange) {
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
return;
}
setInternalExpandedFolderKeys(update);
}, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]);
setInternalExpandedFolderKeys(update);
},
[effectiveExpandedFolderKeys, onExpandedFolderKeysChange]
);
const renderFolder = useCallback(
(folder: LocalFolderNode, depth: number, mount: string) => {
@ -436,9 +440,7 @@ export function LocalFilesystemBrowser({
: undefined
}
className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${
isOpenable
? "hover:bg-muted/60"
: "cursor-not-allowed opacity-60"
isOpenable ? "hover:bg-muted/60" : "cursor-not-allowed opacity-60"
}`}
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={
@ -528,7 +530,10 @@ export function LocalFilesystemBrowser({
}
if (state.error) {
return (
<div key={rootPath} className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<div
key={rootPath}
className="rounded-md border border-destructive/20 bg-destructive/5 p-3"
>
<p className="text-sm font-medium text-destructive">Failed to load local folder</p>
<p className="mt-1 text-xs text-muted-foreground">{state.error}</p>
</div>

View file

@ -308,9 +308,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
}
}}
>
<span
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>

View file

@ -10,7 +10,11 @@ const code = createCodePlugin({
});
const math = createMathPlugin({
singleDollarTextMath: true,
// Disabled so currency like "$3,120.00 and ... $0.00" isn't parsed as
// inline LaTeX. convertLatexDelimiters() below normalises any genuine
// inline math (\(...\), $...$ starting with a LaTeX command, etc.) to
// $$...$$, so this flip doesn't lose any math rendering.
singleDollarTextMath: false,
});
interface MarkdownViewerProps {

View file

@ -8,9 +8,9 @@ import {
ChevronLeft,
ChevronRight,
ChevronUp,
Pencil,
ImageIcon,
Layers,
Pencil,
Plus,
ScanEye,
Search,
@ -741,9 +741,7 @@ export function ModelSelector({
<div
className={cn(
"shrink-0 border-border/50 flex relative",
isMobile
? "flex-row items-center border-b border-border/40"
: "flex-col w-10 border-r"
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r"
)}
>
{!isMobile && (
@ -769,9 +767,7 @@ export function ModelSelector({
<div
className={cn(
"absolute left-0 top-0 bottom-0 z-10 w-5 flex items-center justify-center transition-all duration-200 ease-out pointer-events-none",
sidebarScrollPos === "top"
? "opacity-0 -translate-x-1"
: "opacity-100 translate-x-0"
sidebarScrollPos === "top" ? "opacity-0 -translate-x-1" : "opacity-100 translate-x-0"
)}
>
<ChevronLeft className="size-3 text-muted-foreground" />

View file

@ -1,719 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
ChevronDown,
ChevronUp,
ExternalLink,
FileQuestionMark,
FileText,
Hash,
Loader2,
Sparkles,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useTranslations } from "next-intl";
import type React from "react";
import { forwardRef, memo, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
} from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
interface SourceDetailPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chunkId: number;
sourceType: string;
title: string;
description?: string;
url?: string;
children?: ReactNode;
isDocsChunk?: boolean;
}
const formatDocumentType = (type: string) => {
if (!type) return "";
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
};
// Chunk card component
// For large documents (>30 chunks), we disable animation to prevent layout shifts
// which break auto-scroll functionality
interface ChunkCardProps {
chunk: { id: number; content: string };
localIndex: number;
chunkNumber: number;
totalChunks: number;
isCited: boolean;
isActive: boolean;
disableLayoutAnimation?: boolean;
}
const ChunkCard = memo(
forwardRef<HTMLDivElement, ChunkCardProps>(
({ chunk, localIndex, chunkNumber, totalChunks, isCited }, ref) => {
return (
<div
ref={ref}
data-chunk-index={localIndex}
className={cn(
"group relative rounded-2xl border-2 transition-all duration-300",
isCited
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
: "bg-card border-border/50 hover:border-border hover:shadow-md"
)}
>
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
isCited
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
)}
>
{chunkNumber}
</div>
<span className="text-sm text-muted-foreground">
Chunk {chunkNumber} of {totalChunks}
</span>
</div>
{isCited && (
<Badge variant="default" className="gap-1.5 px-3 py-1">
<Sparkles className="h-3 w-3" />
Cited Source
</Badge>
)}
</div>
<div className="p-5 overflow-hidden">
<MarkdownViewer content={chunk.content} maxLength={100_000} />
</div>
</div>
);
}
)
);
ChunkCard.displayName = "ChunkCard";
export function SourceDetailPanel({
open,
onOpenChange,
chunkId,
sourceType,
title,
description,
url,
children,
isDocsChunk = false,
}: SourceDetailPanelProps) {
const t = useTranslations("dashboard");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
setMounted(true);
}, []);
const {
data: documentData,
isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError,
} = useQuery<DocumentData>({
queryKey: isDocsChunk
? cacheKeys.documents.byChunk(`doc-${chunkId}`)
: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: async () => {
if (isDocsChunk) {
return documentsApiService.getSurfsenseDocByChunk(chunkId);
}
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 5 });
},
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000,
});
const totalChunks =
documentData && "total_chunks" in documentData
? (documentData.total_chunks ?? documentData.chunks.length)
: (documentData?.chunks?.length ?? 0);
const [beforeChunks, setBeforeChunks] = useState<
Array<{ id: number; content: string; created_at: string }>
>([]);
const [afterChunks, setAfterChunks] = useState<
Array<{ id: number; content: string; created_at: string }>
>([]);
const [loadingBefore, setLoadingBefore] = useState(false);
const [loadingAfter, setLoadingAfter] = useState(false);
useEffect(() => {
setBeforeChunks([]);
setAfterChunks([]);
}, [chunkId, open]);
const chunkStartIndex =
documentData && "chunk_start_index" in documentData ? (documentData.chunk_start_index ?? 0) : 0;
const initialChunks = documentData?.chunks ?? [];
const allChunks = [...beforeChunks, ...initialChunks, ...afterChunks];
const absoluteStart = chunkStartIndex - beforeChunks.length;
const absoluteEnd = chunkStartIndex + initialChunks.length + afterChunks.length;
const canLoadBefore = absoluteStart > 0;
const canLoadAfter = absoluteEnd < totalChunks;
const EXPAND_SIZE = 10;
const loadBefore = useCallback(async () => {
if (!documentData || !("search_space_id" in documentData) || !canLoadBefore) return;
setLoadingBefore(true);
try {
const count = Math.min(EXPAND_SIZE, absoluteStart);
const result = await documentsApiService.getDocumentChunks({
document_id: documentData.id,
page: 0,
page_size: count,
start_offset: absoluteStart - count,
});
const existingIds = new Set(allChunks.map((c) => c.id));
const newChunks = result.items
.filter((c) => !existingIds.has(c.id))
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
setBeforeChunks((prev) => [...newChunks, ...prev]);
} catch (err) {
console.error("Failed to load earlier chunks:", err);
} finally {
setLoadingBefore(false);
}
}, [documentData, absoluteStart, canLoadBefore, allChunks]);
const loadAfter = useCallback(async () => {
if (!documentData || !("search_space_id" in documentData) || !canLoadAfter) return;
setLoadingAfter(true);
try {
const result = await documentsApiService.getDocumentChunks({
document_id: documentData.id,
page: 0,
page_size: EXPAND_SIZE,
start_offset: absoluteEnd,
});
const existingIds = new Set(allChunks.map((c) => c.id));
const newChunks = result.items
.filter((c) => !existingIds.has(c.id))
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
setAfterChunks((prev) => [...prev, ...newChunks]);
} catch (err) {
console.error("Failed to load later chunks:", err);
} finally {
setLoadingAfter(false);
}
}, [documentData, absoluteEnd, canLoadAfter, allChunks]);
const isDirectRenderSource =
sourceType === "TAVILY_API" ||
sourceType === "LINKUP_API" ||
sourceType === "SEARXNG_API" ||
sourceType === "BAIDU_SEARCH_API";
const citedChunkIndex = allChunks.findIndex((chunk) => chunk.id === chunkId);
// Simple scroll function that scrolls to a chunk by index
const scrollToChunkByIndex = useCallback(
(chunkIndex: number, smooth = true) => {
const scrollContainer = scrollAreaRef.current;
if (!scrollContainer) return;
const viewport = scrollContainer.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement | null;
if (!viewport) return;
const chunkElement = scrollContainer.querySelector(
`[data-chunk-index="${chunkIndex}"]`
) as HTMLElement | null;
if (!chunkElement) return;
// Get positions using getBoundingClientRect for accuracy
const viewportRect = viewport.getBoundingClientRect();
const chunkRect = chunkElement.getBoundingClientRect();
// Calculate where to scroll to center the chunk
const currentScrollTop = viewport.scrollTop;
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
const scrollTarget =
chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
viewport.scrollTo({
top: Math.max(0, scrollTarget),
behavior: smooth && !shouldReduceMotion ? "smooth" : "auto",
});
setActiveChunkIndex(chunkIndex);
},
[shouldReduceMotion]
);
// Callback ref for the cited chunk - scrolls when the element mounts
const citedChunkRefCallback = useCallback(
(node: HTMLDivElement | null) => {
if (node && !hasScrolledRef.current && open) {
hasScrolledRef.current = true; // Mark immediately to prevent duplicate scrolls
// Store the node reference for the delayed scroll
const scrollToCitedChunk = () => {
const scrollContainer = scrollAreaRef.current;
if (!scrollContainer || !node.isConnected) return false;
const viewport = scrollContainer.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement | null;
if (!viewport) return false;
// Get positions
const viewportRect = viewport.getBoundingClientRect();
const chunkRect = node.getBoundingClientRect();
// Calculate scroll position to center the chunk
const currentScrollTop = viewport.scrollTop;
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
const scrollTarget =
chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
viewport.scrollTo({
top: Math.max(0, scrollTarget),
behavior: "auto", // Instant scroll for initial positioning
});
return true;
};
// Scroll multiple times with delays to handle progressive content rendering
// Each subsequent scroll will correct for any layout shifts
const scrollAttempts = [50, 150, 300, 600, 1000];
scrollAttempts.forEach((delay) => {
scrollTimersRef.current.push(
setTimeout(() => {
scrollToCitedChunk();
}, delay)
);
});
// After final attempt, mark the cited chunk as active
scrollTimersRef.current.push(
setTimeout(
() => {
setActiveChunkIndex(citedChunkIndex);
},
scrollAttempts[scrollAttempts.length - 1] + 50
)
);
}
},
[open, citedChunkIndex]
);
// Reset scroll state when panel closes
useEffect(() => {
if (!open) {
scrollTimersRef.current.forEach(clearTimeout);
scrollTimersRef.current = [];
hasScrolledRef.current = false;
setActiveChunkIndex(null);
}
return () => {
scrollTimersRef.current.forEach(clearTimeout);
scrollTimersRef.current = [];
};
}, [open]);
// Handle escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
// Prevent body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
e.preventDefault();
e.stopPropagation();
window.open(clickUrl, "_blank", "noopener,noreferrer");
};
const scrollToChunk = useCallback(
(index: number) => {
scrollToChunkByIndex(index, true);
},
[scrollToChunkByIndex]
);
const panelContent = (
<AnimatePresence mode="wait">
{open && (
<>
{/* Backdrop */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
{/* Panel */}
<motion.div
key="panel"
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
transition={{
type: "spring",
damping: 30,
stiffness: 300,
}}
className="fixed inset-3 sm:inset-6 md:inset-10 lg:inset-16 z-50 flex flex-col bg-background rounded-3xl shadow-2xl border overflow-hidden"
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex items-center justify-between px-6 py-5 border-b bg-linear-to-r from-muted/50 to-muted/30"
>
<div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold truncate">
{documentData?.title || title || "Source Document"}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{documentData && "document_type" in documentData
? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)}
{totalChunks > 0 && (
<span className="ml-2">
{totalChunks} chunk{totalChunks !== 1 ? "s" : ""}
{allChunks.length < totalChunks && ` (showing ${allChunks.length})`}
</span>
)}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
{url && (
<Button
size="sm"
variant="outline"
onClick={(e) => handleUrlClick(e, url)}
className="hidden sm:flex gap-2 rounded-xl"
>
<ExternalLink className="h-4 w-4" />
Open Source
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={() => onOpenChange(false)}
className="h-8 w-8 rounded-full"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</motion.div>
{/* Loading State */}
{!isDirectRenderSource && isDocumentByChunkFetching && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<Spinner size="lg" />
<p className="text-sm text-muted-foreground font-medium">
{t("loading_document")}
</p>
</motion.div>
</div>
)}
{/* Error State */}
{!isDirectRenderSource && documentByChunkFetchingError && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4 text-center px-6"
>
<div className="w-20 h-20 rounded-full bg-muted/50 flex items-center justify-center">
<FileQuestionMark className="h-10 w-10 text-muted-foreground" />
</div>
<div>
<p className="font-semibold text-foreground text-lg">Document unavailable</p>
<p className="text-sm text-muted-foreground mt-2 max-w-md">
{documentByChunkFetchingError.message ||
"An unexpected error occurred. Please try again."}
</p>
</div>
<Button variant="outline" onClick={() => onOpenChange(false)} className="mt-2">
Close Panel
</Button>
</motion.div>
</div>
)}
{/* Direct render for web search providers */}
{isDirectRenderSource && (
<ScrollArea className="flex-1">
<div className="p-6 max-w-3xl mx-auto">
{url && (
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, url)}
className="w-full mb-6 sm:hidden rounded-xl"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
)}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-6 bg-muted/50 rounded-2xl border"
>
<h3 className="text-base font-semibold mb-4 flex items-center gap-2">
<BookOpen className="h-4 w-4" />
Source Information
</h3>
<div className="text-sm text-muted-foreground mb-3 font-medium">
{title || "Untitled"}
</div>
<div className="text-sm text-foreground leading-relaxed">
{description || "No content available"}
</div>
</motion.div>
</div>
</ScrollArea>
)}
{/* API-fetched document content */}
{!isDirectRenderSource && documentData && (
<div className="flex-1 flex overflow-hidden">
{/* Chunk Navigation Sidebar */}
{allChunks.length > 1 && (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="hidden lg:flex flex-col w-16 border-r bg-muted/10 overflow-hidden"
>
<ScrollArea className="flex-1 h-full">
<div className="p-2 pt-3 flex flex-col gap-1.5">
{allChunks.map((chunk, idx) => {
const absNum = absoluteStart + idx + 1;
const isCited = chunk.id === chunkId;
const isActive = activeChunkIndex === idx;
return (
<motion.button
key={chunk.id}
type="button"
onClick={() => scrollToChunk(idx)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: Math.min(idx * 0.02, 0.2) }}
className={cn(
"relative w-11 h-9 mx-auto rounded-lg text-xs font-semibold transition-all duration-200 flex items-center justify-center",
isCited
? "bg-primary text-primary-foreground shadow-md"
: isActive
? "bg-muted text-foreground"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={isCited ? `Chunk ${absNum} (Cited)` : `Chunk ${absNum}`}
>
{absNum}
{isCited && (
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-4 h-4 bg-primary rounded-full border-2 border-background shadow-sm">
<Sparkles className="h-2.5 w-2.5 text-primary-foreground" />
</span>
)}
</motion.button>
);
})}
</div>
</ScrollArea>
</motion.div>
)}
{/* Main Content */}
<ScrollArea className="flex-1" ref={scrollAreaRef}>
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
{/* Document Metadata */}
{"document_metadata" in documentData &&
documentData.document_metadata &&
Object.keys(documentData.document_metadata).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="p-5 bg-muted/30 rounded-2xl border"
>
<h3 className="text-sm font-semibold mb-4 text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<FileText className="h-4 w-4" />
Document Information
</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
{Object.entries(documentData.document_metadata).map(([key, value]) => (
<div key={key} className="space-y-1">
<dt className="font-medium text-muted-foreground capitalize text-xs">
{key.replace(/_/g, " ")}
</dt>
<dd className="text-foreground wrap-break-word">{String(value)}</dd>
</div>
))}
</dl>
</motion.div>
)}
{/* Chunks Header */}
<div className="flex items-center justify-between pt-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<Hash className="h-4 w-4" />
Chunks {absoluteStart + 1}{absoluteEnd} of {totalChunks}
</h3>
{citedChunkIndex !== -1 && (
<Button
variant="ghost"
size="sm"
onClick={() => scrollToChunk(citedChunkIndex)}
className="gap-2 text-primary hover:text-primary"
>
<Sparkles className="h-3.5 w-3.5" />
Jump to cited
</Button>
)}
</div>
{/* Load Earlier */}
{canLoadBefore && (
<div className="flex items-center justify-center">
<Button
variant="outline"
size="sm"
onClick={loadBefore}
disabled={loadingBefore}
className="gap-2"
>
{loadingBefore ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ChevronUp className="h-3.5 w-3.5" />
)}
{loadingBefore
? "Loading..."
: `Load ${Math.min(EXPAND_SIZE, absoluteStart)} earlier chunks`}
</Button>
</div>
)}
{/* Chunks */}
<div className="space-y-4">
{allChunks.map((chunk, idx) => {
const isCited = chunk.id === chunkId;
const chunkNumber = absoluteStart + idx + 1;
return (
<ChunkCard
key={chunk.id}
ref={isCited ? citedChunkRefCallback : undefined}
chunk={chunk}
localIndex={idx}
chunkNumber={chunkNumber}
totalChunks={totalChunks}
isCited={isCited}
isActive={activeChunkIndex === idx}
disableLayoutAnimation={allChunks.length > 30}
/>
);
})}
</div>
{/* Load Later */}
{canLoadAfter && (
<div className="flex items-center justify-center py-3">
<Button
variant="outline"
size="sm"
onClick={loadAfter}
disabled={loadingAfter}
className="gap-2"
>
{loadingAfter ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{loadingAfter
? "Loading..."
: `Load ${Math.min(EXPAND_SIZE, totalChunks - absoluteEnd)} later chunks`}
</Button>
</div>
)}
</div>
</ScrollArea>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return <>{children}</>;
return (
<>
{children}
{createPortal(panelContent, globalThis.document.body)}
</>
);
}

View file

@ -398,7 +398,8 @@ export function ReportPanelContent({
</Button>
);
const editingActions = showReportEditingTier &&
const editingActions =
showReportEditingTier &&
!isReadOnly &&
(isEditing ? (
<>

View file

@ -1,15 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Dot,
FileText,
Info,
Pencil,
RefreshCw,
Trash2,
} from "lucide-react";
import { AlertCircle, Dot, FileText, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";

View file

@ -5,10 +5,8 @@ import { useAtomValue } from "jotai";
import {
Bot,
ChevronRight,
ScanEye,
Pencil,
FileText,
Earth,
FileText,
Image,
Logs,
type LucideIcon,
@ -16,11 +14,13 @@ import {
MessageSquare,
Mic,
MoreHorizontal,
Unplug,
Pencil,
ScanEye,
Settings,
Shield,
SlidersHorizontal,
Trash2,
Unplug,
Users,
Video,
} from "lucide-react";
@ -462,9 +462,19 @@ function RolesContent({
return (
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
{/* biome-ignore lint/a11y/useSemanticElements: row contains nested interactive elements (DropdownMenu); using a <button> would produce invalid nested-button markup */}
<div
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setExpandedRoleId(isExpanded ? null : role.id);
}
}}
>
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-2">
@ -682,9 +692,19 @@ function PermissionsEditor({
return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
{/* biome-ignore lint/a11y/useSemanticElements: row contains a nested interactive Checkbox; using a <button> would produce invalid nested-button markup */}
<div
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
>
<div className="flex-1 flex items-center gap-2.5">
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />

View file

@ -1,7 +1,18 @@
"use client";
import { useAtom } from "jotai";
import { Brain, CircleUser, Globe, Keyboard, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
import {
Activity,
Brain,
CircleUser,
Globe,
Keyboard,
KeyRound,
Monitor,
ReceiptText,
ShieldCheck,
Sparkles,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
@ -53,9 +64,9 @@ const DesktopContent = dynamic(
);
const DesktopShortcutsContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent").then(
(m) => ({ default: m.DesktopShortcutsContent })
),
import(
"@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent"
).then((m) => ({ default: m.DesktopShortcutsContent })),
{ ssr: false }
);
const MemoryContent = dynamic(
@ -65,6 +76,20 @@ const MemoryContent = dynamic(
),
{ ssr: false }
);
const AgentPermissionsContent = dynamic(
() =>
import(
"@/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent"
).then((m) => ({ default: m.AgentPermissionsContent })),
{ ssr: false }
);
const AgentStatusContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent").then(
(m) => ({ default: m.AgentStatusContent })
),
{ ssr: false }
);
export function UserSettingsDialog() {
const t = useTranslations("userSettings");
@ -94,6 +119,16 @@ export function UserSettingsDialog() {
label: "Memory",
icon: <Brain className="h-4 w-4" />,
},
{
value: "agent-permissions",
label: "Agent Permissions",
icon: <ShieldCheck className="h-4 w-4" />,
},
{
value: "agent-status",
label: "Agent Status",
icon: <Activity className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
@ -132,6 +167,8 @@ export function UserSettingsDialog() {
{state.initialTab === "prompts" && <PromptsContent />}
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
{state.initialTab === "memory" && <MemoryContent />}
{state.initialTab === "agent-permissions" && <AgentPermissionsContent />}
{state.initialTab === "agent-status" && <AgentStatusContent />}
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
{state.initialTab === "desktop" && <DesktopContent />}
{state.initialTab === "desktop-shortcuts" && <DesktopShortcutsContent />}

View file

@ -0,0 +1,187 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, OctagonAlert } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
/**
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
* backend signals these by setting ``context.permission === "doom_loop"``
* on the ``permission_ask`` interrupt.
*
* The card replaces the generic "approve/reject" framing with a
* "continue/stop" affordance that better matches the user's mental
* model: the agent is stuck repeating itself, not asking permission
* for a destructive action.
*/
function DoomLoopCard({
toolName,
args,
interruptData,
onDecision,
}: {
toolName: string;
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const context = (interruptData.context ?? {}) as Record<string, unknown>;
const threshold = typeof context.threshold === "number" ? context.threshold : 3;
const stuckTool = (typeof context.tool === "string" && context.tool) || toolName;
const recentSignatures = Array.isArray(context.recent_signatures)
? (context.recent_signatures as string[])
: [];
const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const argPreview = useMemo(() => {
if (!args || Object.keys(args).length === 0) return null;
try {
const json = JSON.stringify(args, null, 2);
return json.length > 600 ? `${json.slice(0, 600)}` : json;
} catch {
return null;
}
}, [args]);
const handleContinue = useCallback(() => {
if (phase !== "pending") return;
setProcessing();
onDecision({ type: "approve" });
}, [phase, setProcessing, onDecision]);
const handleStop = useCallback(() => {
if (phase !== "pending") return;
setRejected();
onDecision({ type: "reject", message: "Doom loop: user requested stop." });
}, [phase, setRejected, onDecision]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (phase !== "pending") return;
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleStop();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [phase, handleStop]);
const isResolved = phase === "complete" || phase === "rejected";
return (
<Alert variant={phase === "rejected" ? "default" : "destructive"} className="my-4 max-w-lg">
<OctagonAlert className="size-4" />
<AlertTitle className="flex items-center gap-2">
<span>
{phase === "rejected"
? "Stopped"
: phase === "processing"
? "Continuing…"
: phase === "complete"
? "Continued"
: "I might be stuck"}
</span>
{!isResolved && (
<Badge variant="outline" className="font-mono text-[10px]">
doom-loop
</Badge>
)}
</AlertTitle>
<AlertDescription className="flex flex-col gap-3">
{phase === "processing" ? (
<TextShimmerLoader text="Resuming…" size="sm" />
) : phase === "rejected" ? (
<p className="text-xs">
I stopped retrying <span className="font-medium">{displayName}</span> as you asked.
</p>
) : phase === "complete" ? (
<p className="text-xs">
Continuing to call <span className="font-medium">{displayName}</span> as you asked.
</p>
) : (
<p className="text-xs">
I called <span className="font-medium">{displayName}</span> {threshold} times in a row
with similar arguments. Should I keep going or stop and rethink?
</p>
)}
{argPreview && phase === "pending" && (
<>
<Separator />
<div className="flex flex-col gap-1">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Last arguments
</p>
<pre className="max-h-32 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] text-foreground/80">
{argPreview}
</pre>
</div>
</>
)}
{recentSignatures.length > 0 && phase === "pending" && (
<details className="text-[11px] text-muted-foreground">
<summary className="cursor-pointer select-none">
Show repeated signatures ({recentSignatures.length})
</summary>
<ul className="mt-1 ml-4 list-disc">
{recentSignatures.map((sig) => (
<li key={sig} className="font-mono break-all">
{sig}
</li>
))}
</ul>
</details>
)}
{phase === "pending" && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="rounded-lg gap-1.5" onClick={handleStop}>
Stop and rethink
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button size="sm" variant="ghost" onClick={handleContinue}>
Continue anyway
</Button>
</div>
)}
</AlertDescription>
</Alert>
);
}
export const DoomLoopApprovalToolUI: ToolCallMessagePartComponent = ({
toolName,
args,
result,
}) => {
const { dispatch } = useHitlDecision();
if (!result || !isInterruptResult(result)) return null;
return (
<DoomLoopCard
toolName={toolName}
args={args as Record<string, unknown>}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};
export function isDoomLoopInterrupt(result: unknown): boolean {
if (!isInterruptResult(result)) return false;
const ctx = (result.context ?? {}) as Record<string, unknown>;
return ctx.permission === "doom_loop";
}

View file

@ -118,7 +118,9 @@ function GenericApprovalCard({
setProcessing();
onDecision({ type: "approve" });
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
toast.error("Failed to save 'Always Allow' preference. The tool will still require approval next time.");
toast.error(
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
);
});
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);

View file

@ -2,7 +2,14 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pencil, UsersIcon } from "lucide-react";
import {
ClockIcon,
CornerDownLeftIcon,
GlobeIcon,
MapPinIcon,
Pencil,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";

View file

@ -0,0 +1,121 @@
---
title: Baidu Search
description: Search the Chinese web with Baidu AI Search in SurfSense
---
# Baidu Search Integration Setup Guide
This guide walks you through connecting Baidu AI Search to SurfSense for Chinese web search and AI-powered research.
## How it works
The Baidu Search connector uses Baidu AI Search through Qianfan AppBuilder's intelligent search generation API. It is a live search connector: SurfSense queries Baidu when the assistant needs current web results instead of periodically indexing content into your knowledge base.
- Baidu Search is best for Simplified Chinese queries and China-focused web content.
- Results are merged with SurfSense's other configured web search engines.
- The connector returns Baidu references as sources that can be cited in chat responses.
---
## Authorization
<Callout type="info" title="API Key Required">
You need a Baidu Qianfan AppBuilder API key to use this connector. The key is encrypted and stored securely by SurfSense.
</Callout>
### Step 1: Get Your Baidu AI Search API Key
1. Open the [Baidu AI Search product page](https://cloud.baidu.com/product/ai-search.html) and sign in with your Baidu Cloud account.
2. Open Qianfan AppBuilder or the AI Search console from Baidu Cloud.
3. Create or select an application that has access to Baidu AI Search.
4. Generate an API key for the application.
5. Copy the API key. SurfSense uses it as the `BAIDU_API_KEY` connector setting.
<Callout type="warn">
Keep this key private. Do not paste it into chat messages, issue reports, screenshots, or public repositories.
</Callout>
---
## Connecting to SurfSense
1. Navigate to **Connectors** → **Add Connector** → **Baidu Search**.
2. Fill in the required fields:
| Field | Description | Example |
|-------|-------------|---------|
| **Connector Name** | A friendly name to identify this connector | `Baidu Search` |
| **Baidu AppBuilder API Key** | Your Qianfan AppBuilder API key | `bce-v3/...` |
3. Click **Connect** to save the connector.
4. Ask a current Chinese web query in chat, such as `今天中国人工智能行业有什么重要新闻?`.
### Optional Advanced Settings
SurfSense stores advanced Baidu options in the connector config. If your deployment exposes these fields, use the following values:
| Setting | Description | Default |
|---------|-------------|---------|
| `BAIDU_MODEL` | The model Baidu AI Search uses for answer generation | `ernie-3.5-8k` |
| `BAIDU_SEARCH_SOURCE` | Baidu search source version | `baidu_search_v2` |
| `BAIDU_ENABLE_DEEP_SEARCH` | Enables Baidu's deeper search mode when supported by your account | `false` |
SurfSense calls Baidu's intelligent search generation endpoint:
```text
POST https://qianfan.baidubce.com/v2/ai_search/chat/completions
```
For request and response details, see Baidu's [intelligent search generation API documentation](https://cloud.baidu.com/doc/qianfan/s/Omh4su4s0).
---
## When to Use Baidu Search
| Use Case | Why Baidu Search Helps |
|----------|------------------------|
| Chinese news and current events | Better coverage for China-focused sources |
| Chinese company, product, or policy research | More local web results than global search engines alone |
| Mandarin-language fact finding | Native Chinese search and summarization behavior |
| Cross-checking web search | Adds another source alongside SearXNG, Tavily, or Linkup |
<Callout type="info" title="Live Search Connector">
Baidu Search does not create indexed documents in your knowledge base. It runs when the assistant calls web search, then returns live sources for that answer.
</Callout>
---
## Troubleshooting
**No Baidu results appear**
- Confirm the Baidu Search connector is active in the current search space.
- Try a Chinese query with clear search intent, for example `百度智能云千帆 AppBuilder 最新功能`.
- Check whether other web search engines are returning results. If none are, review the general [Web Search](/docs/how-to/web-search) setup.
**Authentication failed**
- Verify that the API key was copied from Qianfan AppBuilder, not another Baidu Cloud product.
- Regenerate the API key if it was rotated, expired, or copied with extra whitespace.
- Make sure the related application has access to Baidu AI Search.
**Requests time out**
- Baidu AI Search can take longer than ordinary keyword search because it performs search and summarization.
- Retry with a narrower query.
- If you self-host SurfSense, verify that the backend container can reach `qianfan.baidubce.com`.
**Results are not relevant**
- Use Chinese keywords for China-focused topics.
- Include entity names, dates, or locations in the query.
- Compare with SearXNG or another configured live search connector for broader coverage.
---
## Verification Checklist
- The Baidu Search connector appears in your connector list.
- A Chinese current-events query triggers web search in chat.
- Chat responses include Baidu-backed sources with titles and URLs.
- Invalid API keys fail without breaking other configured search engines.

View file

@ -83,6 +83,11 @@ Connect SurfSense to your favorite tools and services. Browse the available inte
description="Connect your GitHub repositories to SurfSense"
href="/docs/connectors/github"
/>
<Card
title="Baidu Search"
description="Search the Chinese web with Baidu AI Search"
href="/docs/connectors/baidu-search"
/>
<Card
title="Luma"
description="Connect your Luma events to SurfSense"

View file

@ -17,6 +17,7 @@
"airtable",
"clickup",
"github",
"baidu-search",
"luma",
"circleback",
"elasticsearch",

View file

@ -7,6 +7,8 @@ description: How SurfSense web search works and how to configure it for producti
SurfSense uses [SearXNG](https://docs.searxng.org/) as a bundled meta-search engine to provide web search across all search spaces. SearXNG aggregates results from multiple search engines (Google, DuckDuckGo, Brave, Bing, and more) without requiring any API keys.
You can also add live search connectors such as Baidu Search, Tavily, and Linkup to a search space. When those connectors are active, SurfSense queries them in parallel with SearXNG and merges the results before passing sources to the assistant.
## How It Works
When a user triggers a web search in SurfSense:
@ -14,10 +16,25 @@ When a user triggers a web search in SurfSense:
1. The backend sends a query to the bundled SearXNG instance via its JSON API
2. SearXNG fans out the query to all enabled search engines simultaneously
3. Results are aggregated, deduplicated, and ranked by engine weight
4. The backend receives merged results and presents them to the user
4. If the current search space has live search connectors, the backend queries them in parallel
5. The backend deduplicates the merged results and presents them to the user
SearXNG runs as a Docker container alongside the backend. It is never exposed to the internet. Only the backend communicates with it over the internal Docker network.
## Live Search Connectors
Live search connectors are optional API-backed search providers configured per search space. They are useful when you need a specialized index, authenticated search API, or stronger regional coverage.
| Connector | Best For | Setup |
|-----------|----------|-------|
| Baidu Search | Chinese web search and China-focused current information | [Baidu Search connector](/docs/connectors/baidu-search) |
| Tavily | General web research through Tavily's search API | Add the Tavily connector from the Connectors dashboard |
| Linkup | General web search through Linkup's search API | Add the Linkup connector from the Connectors dashboard |
<Callout type="info" title="Search Space Scoped">
Live search connectors only run for the search space where they are configured. They do not replace SearXNG globally.
</Callout>
## Docker Setup
SearXNG is included in both `docker-compose.yml` and `docker-compose.dev.yml` and works out of the box with no configuration needed.

View file

@ -1,8 +1,8 @@
import {
BookOpen,
Brain,
FileUser,
FileText,
FileUser,
Film,
Globe,
ImageIcon,

View file

@ -0,0 +1,64 @@
import { z } from "zod";
import { baseApiService } from "./base-api.service";
const AgentActionReadSchema = z.object({
id: z.number(),
thread_id: z.number(),
user_id: z.string().nullable(),
search_space_id: z.number(),
tool_name: z.string(),
args: z.record(z.string(), z.unknown()).nullable(),
result_id: z.string().nullable(),
reversible: z.boolean(),
reverse_descriptor: z.record(z.string(), z.unknown()).nullable(),
error: z.record(z.string(), z.unknown()).nullable(),
reverse_of: z.number().nullable(),
reverted_by_action_id: z.number().nullable(),
is_revert_action: z.boolean(),
created_at: z.string(),
});
export type AgentAction = z.infer<typeof AgentActionReadSchema>;
const AgentActionListResponseSchema = z.object({
items: z.array(AgentActionReadSchema),
total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
});
export type AgentActionListResponse = z.infer<typeof AgentActionListResponseSchema>;
const RevertResponseSchema = z.object({
status: z.literal("ok"),
message: z.string(),
new_action_id: z.number().nullable().optional(),
});
export type RevertResponse = z.infer<typeof RevertResponseSchema>;
class AgentActionsApiService {
listForThread = async (
threadId: number,
opts: { page?: number; pageSize?: number } = {}
): Promise<AgentActionListResponse> => {
const params = new URLSearchParams();
params.set("page", String(opts.page ?? 0));
params.set("page_size", String(opts.pageSize ?? 50));
return baseApiService.get(
`/api/v1/threads/${threadId}/actions?${params.toString()}`,
AgentActionListResponseSchema
);
};
revert = async (threadId: number, actionId: number): Promise<RevertResponse> => {
return baseApiService.post(
`/api/v1/threads/${threadId}/revert/${actionId}`,
RevertResponseSchema,
{ body: {} }
);
};
}
export const agentActionsApiService = new AgentActionsApiService();

View file

@ -0,0 +1,40 @@
import { z } from "zod";
import { baseApiService } from "./base-api.service";
const AgentFeatureFlagsSchema = z.object({
disable_new_agent_stack: z.boolean(),
enable_context_editing: z.boolean(),
enable_compaction_v2: z.boolean(),
enable_retry_after: z.boolean(),
enable_model_fallback: z.boolean(),
enable_model_call_limit: z.boolean(),
enable_tool_call_limit: z.boolean(),
enable_tool_call_repair: z.boolean(),
enable_doom_loop: z.boolean(),
enable_permission: z.boolean(),
enable_busy_mutex: z.boolean(),
enable_llm_tool_selector: z.boolean(),
enable_skills: z.boolean(),
enable_specialized_subagents: z.boolean(),
enable_kb_planner_runnable: z.boolean(),
enable_action_log: z.boolean(),
enable_revert_route: z.boolean(),
enable_plugin_loader: z.boolean(),
enable_otel: z.boolean(),
});
export type AgentFeatureFlags = z.infer<typeof AgentFeatureFlagsSchema>;
class AgentFlagsApiService {
get = async (): Promise<AgentFeatureFlags> => {
return baseApiService.get(`/api/v1/agent/flags`, AgentFeatureFlagsSchema);
};
}
export const agentFlagsApiService = new AgentFlagsApiService();

View file

@ -0,0 +1,90 @@
import { z } from "zod";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
const ActionEnum = z.enum(["allow", "deny", "ask"]);
export type AgentPermissionAction = z.infer<typeof ActionEnum>;
const AgentPermissionRuleSchema = z.object({
id: z.number(),
search_space_id: z.number(),
user_id: z.string().nullable(),
thread_id: z.number().nullable(),
permission: z.string(),
pattern: z.string(),
action: ActionEnum,
created_at: z.string(),
});
export type AgentPermissionRule = z.infer<typeof AgentPermissionRuleSchema>;
const AgentPermissionRuleListSchema = z.array(AgentPermissionRuleSchema);
const AgentPermissionRuleCreateSchema = z.object({
permission: z
.string()
.min(1, "Permission is required")
.max(255)
.regex(/^[a-zA-Z0-9_:.\-*]+$/, "Use letters, digits, '.', '_', ':', '-', or '*' wildcards."),
pattern: z.string().min(1).max(255).default("*"),
action: ActionEnum,
user_id: z.string().nullable().optional(),
thread_id: z.number().nullable().optional(),
});
export type AgentPermissionRuleCreate = z.infer<typeof AgentPermissionRuleCreateSchema>;
const AgentPermissionRuleUpdateSchema = z.object({
pattern: z.string().min(1).max(255).optional(),
action: ActionEnum.optional(),
});
export type AgentPermissionRuleUpdate = z.infer<typeof AgentPermissionRuleUpdateSchema>;
class AgentPermissionsApiService {
list = async (searchSpaceId: number): Promise<AgentPermissionRule[]> => {
return baseApiService.get(
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`,
AgentPermissionRuleListSchema
);
};
create = async (
searchSpaceId: number,
payload: AgentPermissionRuleCreate
): Promise<AgentPermissionRule> => {
const parsed = AgentPermissionRuleCreateSchema.safeParse(payload);
if (!parsed.success) {
throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", "));
}
return baseApiService.post(
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`,
AgentPermissionRuleSchema,
{ body: parsed.data }
);
};
update = async (
searchSpaceId: number,
ruleId: number,
payload: AgentPermissionRuleUpdate
): Promise<AgentPermissionRule> => {
const parsed = AgentPermissionRuleUpdateSchema.safeParse(payload);
if (!parsed.success) {
throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", "));
}
return baseApiService.patch(
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`,
AgentPermissionRuleSchema,
{ body: parsed.data }
);
};
remove = async (searchSpaceId: number, ruleId: number): Promise<void> => {
await baseApiService.delete(
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`
);
};
}
export const agentPermissionsApiService = new AgentPermissionsApiService();

View file

@ -0,0 +1,120 @@
/** `getDisplayMedia` → single PNG frame (data URL). */
function getImageCaptureCtor():
| (new (
track: MediaStreamTrack
) => { grabFrame: () => Promise<ImageBitmap> })
| undefined {
if (typeof window === "undefined") return undefined;
const IC = (
window as unknown as {
ImageCapture?: new (track: MediaStreamTrack) => { grabFrame: () => Promise<ImageBitmap> };
}
).ImageCapture;
return typeof IC === "function" ? IC : undefined;
}
function stopAllTracks(stream: MediaStream): void {
for (const t of stream.getTracks()) {
t.stop();
}
}
async function captureTrackToPngDataUrl(
track: MediaStreamTrack,
stream: MediaStream
): Promise<string | null> {
const ImageCtor = getImageCaptureCtor();
if (ImageCtor !== undefined) {
try {
const ic = new ImageCtor(track);
const bitmap = await ic.grabFrame();
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
stopAllTracks(stream);
return null;
}
ctx.drawImage(bitmap, 0, 0);
stopAllTracks(stream);
return canvas.toDataURL("image/png");
} finally {
if ("close" in bitmap && typeof bitmap.close === "function") {
bitmap.close();
}
}
} catch {
/* fall through to <video> */
}
}
const videoEl = document.createElement("video");
videoEl.srcObject = stream;
videoEl.muted = true;
const haveCurrentData = 2;
const dataReady = new Promise<void>((resolve) => {
if (videoEl.readyState >= haveCurrentData) {
resolve();
return;
}
videoEl.addEventListener("loadeddata", () => resolve(), { once: true });
});
await videoEl.play();
await Promise.race([
dataReady,
new Promise<void>((resolve) => {
setTimeout(resolve, 500);
}),
]);
const w = videoEl.videoWidth;
const h = videoEl.videoHeight;
if (!w || !h) {
stopAllTracks(stream);
return null;
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) {
stopAllTracks(stream);
return null;
}
ctx.drawImage(videoEl, 0, 0);
stopAllTracks(stream);
return canvas.toDataURL("image/png");
}
export async function captureDisplayToPngDataUrl(): Promise<string | null> {
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
return null;
}
let stream: MediaStream | null = null;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: 1, max: 5 } },
audio: false,
selfBrowserSurface: "exclude",
} as Parameters<MediaDevices["getDisplayMedia"]>[0]);
const track = stream.getVideoTracks()[0];
if (!track) {
stopAllTracks(stream);
return null;
}
const dataUrl = await captureTrackToPngDataUrl(track, stream);
stream = null;
return dataUrl;
} catch (e) {
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
console.warn("[captureDisplayToPngDataUrl]", e);
}
if (stream) {
stopAllTracks(stream);
}
return null;
}
}

View file

@ -0,0 +1,8 @@
type MentionKeyInput = {
id: number;
document_type?: string | null;
};
export function getMentionDocKey(doc: MentionKeyInput): string {
return `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
}

View file

@ -0,0 +1,56 @@
import type { AppendMessage } from "@assistant-ui/react";
const MAX_IMAGES = 4;
export type NewChatUserImagePayload = {
media_type: "image/png" | "image/jpeg" | "image/webp";
data: string;
};
function dataUrlToPayload(dataUrl: string): NewChatUserImagePayload | null {
const m = /^data:(image\/(?:png|jpeg|webp|jpg));base64,([\s\S]+)$/i.exec(dataUrl.trim());
if (!m) return null;
let media = m[1].toLowerCase() as string;
if (media === "image/jpg") media = "image/jpeg";
if (media !== "image/png" && media !== "image/jpeg" && media !== "image/webp") return null;
const data = m[2].replace(/\s/g, "");
if (!data) return null;
return { media_type: media as NewChatUserImagePayload["media_type"], data };
}
function collectImageDataUrlsFromParts(parts: AppendMessage["content"]): string[] {
const out: string[] = [];
for (const part of parts) {
if (typeof part !== "object" || part === null || !("type" in part)) continue;
if (part.type !== "image") continue;
const img = "image" in part && typeof part.image === "string" ? part.image : null;
if (img && dataUrlToPayload(img)) out.push(img);
}
return out;
}
export function extractUserTurnForNewChatApi(
message: AppendMessage,
extraDataUrls: readonly string[]
): { userQuery: string; userImages: NewChatUserImagePayload[] } {
let userQuery = "";
for (const part of message.content) {
if (part.type === "text") {
userQuery += part.text;
}
}
const merged = [...extraDataUrls, ...collectImageDataUrlsFromParts(message.content)];
const payloads: NewChatUserImagePayload[] = [];
const seen = new Set<string>();
for (const url of merged) {
const p = dataUrlToPayload(url);
if (!p) continue;
if (seen.has(p.data)) continue;
seen.add(p.data);
payloads.push(p);
if (payloads.length >= MAX_IMAGES) break;
}
return { userQuery, userImages: payloads };
}

View file

@ -1,12 +1,39 @@
import { loader } from "fumadocs-core/source";
import { icons } from "lucide-react";
import {
BookOpen,
ClipboardCheck,
Compass,
Container,
Download,
FlaskConical,
Heart,
Unplug,
Wrench,
} from "lucide-react";
import { createElement } from "react";
import { docs } from "@/.source/server";
/** Explicit whitelist of Lucide icons used in docs frontmatter / meta.json.
* Importing the full `icons` barrel would pull every Lucide icon (~1 400 SVGs)
* into the docs bundle even though only a handful are referenced. Add new icons
* here as docs pages are added.
*/
const DOCS_ICONS: Record<string, React.ComponentType> = {
BookOpen,
ClipboardCheck,
Compass,
Container,
Download,
FlaskConical,
Heart,
Unplug,
Wrench,
};
export const source = loader({
baseUrl: "/docs",
source: docs.toFumadocsSource(),
icon(icon) {
if (icon && icon in icons) return createElement(icons[icon as keyof typeof icons]);
if (icon && icon in DOCS_ICONS) return createElement(DOCS_ICONS[icon]);
},
});

View file

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 3.5 MiB

Before After
Before After

View file

@ -93,6 +93,7 @@ interface ElectronAPI {
openExternal: (url: string) => void;
getAppVersion: () => Promise<string>;
onDeepLink: (callback: (url: string) => void) => () => void;
onChatScreenCapture: (callback: (dataUrl: string) => void) => () => void;
getQuickAskText: () => Promise<string>;
setQuickAskMode: (mode: string) => Promise<void>;
getQuickAskMode: () => Promise<string>;
@ -104,20 +105,8 @@ interface ElectronAPI {
}>;
requestAccessibility: () => Promise<void>;
requestScreenRecording: () => Promise<void>;
captureFullScreen: () => Promise<string | null>;
restartApp: () => Promise<void>;
// Autocomplete
onAutocompleteContext: (
callback: (data: {
screenshot: string;
searchSpaceId?: string;
appName?: string;
windowTitle?: string;
}) => void
) => () => void;
acceptSuggestion: (text: string) => Promise<void>;
dismissSuggestion: () => Promise<void>;
setAutocompleteEnabled: (enabled: boolean) => Promise<void>;
getAutocompleteEnabled: () => Promise<boolean>;
// Folder sync
selectFolder: () => Promise<string | null>;
addWatchedFolder: (config: WatchedFolderConfig) => Promise<WatchedFolderConfig[]>;
@ -149,10 +138,18 @@ interface ElectronAPI {
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
// Keyboard shortcut configuration
getShortcuts: () => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
getShortcuts: () => Promise<{
generalAssist: string;
quickAsk: string;
screenshotAssist: string;
}>;
setShortcuts: (
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
config: Partial<{ generalAssist: string; quickAsk: string; screenshotAssist: string }>
) => Promise<{
generalAssist: string;
quickAsk: string;
screenshotAssist: string;
}>;
// Launch on system startup
getAutoLaunch: () => Promise<{
enabled: boolean;
@ -179,9 +176,7 @@ interface ElectronAPI {
// Agent filesystem mode
getAgentFilesystemSettings: (searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
getAgentFilesystemMounts: (searchSpaceId?: number | null) => Promise<AgentFilesystemMount[]>;
listAgentFilesystemFiles: (
options: AgentFilesystemListOptions
) => Promise<FolderFileEntry[]>;
listAgentFilesystemFiles: (options: AgentFilesystemListOptions) => Promise<FolderFileEntry[]>;
startAgentFilesystemTreeWatch: (
options: AgentFilesystemTreeWatchOptions
) => Promise<{ ok: true }>;
@ -189,10 +184,13 @@ interface ElectronAPI {
onAgentFilesystemTreeDirty: (
callback: (data: AgentFilesystemTreeDirtyEvent) => void
) => () => void;
setAgentFilesystemSettings: (settings: {
mode?: AgentFilesystemMode;
localRootPaths?: string[] | null;
}, searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
setAgentFilesystemSettings: (
settings: {
mode?: AgentFilesystemMode;
localRootPaths?: string[] | null;
},
searchSpaceId?: number | null
) => Promise<AgentFilesystemSettings>;
pickAgentFilesystemRoot: () => Promise<string | null>;
}