Merge branch 'dev' into fix/env-config-connector-forms

This commit is contained in:
Varun Shukla 2026-05-20 03:26:12 +05:30 committed by GitHub
commit 81ce9e4071
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
291 changed files with 8271 additions and 7022 deletions

View file

@ -1,9 +1,9 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Activity } from "lucide-react";
import { Workflow } from "lucide-react";
import { useCallback } from "react";
import { openActionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
import { openActionLogDialogAtom } from "@/atoms/agent/action-log-dialog.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -13,7 +13,7 @@ interface ActionLogButtonProps {
}
/**
* Header button that opens the agent action log sheet for the current
* Header button that opens the agent action log dialog for the current
* thread. Renders nothing when:
* - the action log feature flag is off (graceful no-op for older
* deployments), OR
@ -21,7 +21,7 @@ interface ActionLogButtonProps {
*/
export function ActionLogButton({ threadId }: ActionLogButtonProps) {
const { data: flags } = useAtomValue(agentFlagsAtom);
const open = useSetAtom(openActionLogSheetAtom);
const open = useSetAtom(openActionLogDialogAtom);
const enabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
@ -41,7 +41,7 @@ export function ActionLogButton({ threadId }: ActionLogButtonProps) {
aria-label="Open agent action log"
onClick={handleClick}
>
<Activity className="size-4" />
<Workflow className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Agent actions</TooltipContent>

View file

@ -2,35 +2,25 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue } from "jotai";
import { Activity, RefreshCcw } from "lucide-react";
import { RefreshCcw, Workflow } from "lucide-react";
import { useCallback } from "react";
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
import { actionLogDialogAtom } from "@/atoms/agent/action-log-dialog.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { agentActionsQueryKey, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { ActionLogItem } from "./action-log-item";
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.
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 pb-12 text-center">
<div className="flex max-w-[260px] flex-col gap-1.5">
<p className="text-sm font-semibold tracking-tight">No actions logged yet</p>
<p className="text-xs leading-relaxed text-muted-foreground">
A complete audit trail of every tool the agent uses in this thread will appear here
</p>
</div>
</div>
@ -39,15 +29,15 @@ function EmptyState() {
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 className="flex flex-1 flex-col items-center justify-center gap-4 px-6 pb-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full border border-popover-border bg-muted/40">
<Workflow className="size-5 text-muted-foreground" strokeWidth={1.75} />
</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]">
<div className="flex max-w-[280px] flex-col gap-1.5">
<p className="text-sm font-semibold tracking-tight">Action log is disabled</p>
<p className="text-xs leading-relaxed text-muted-foreground">
This deployment hasn't enabled the agent action log. An admin can enable{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-foreground">
SURFSENSE_ENABLE_ACTION_LOG
</code>
.
@ -69,13 +59,12 @@ function LoadingState() {
);
}
export function ActionLogSheet() {
const [state, setState] = useAtom(actionLogSheetAtom);
export function ActionLogDialog() {
const [state, setState] = useAtom(actionLogDialogAtom);
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;
@ -84,6 +73,13 @@ export function ActionLogSheet() {
{ enabled: state.open && actionLogEnabled }
);
const handleOpenChange = useCallback(
(open: boolean) => {
setState((current) => (open ? { ...current, open } : { open: false, threadId: null }));
},
[setState]
);
const handleRevertSuccess = useCallback(() => {
if (threadId !== null) {
queryClient.invalidateQueries({ queryKey: agentActionsQueryKey(threadId) });
@ -91,42 +87,33 @@ export function ActionLogSheet() {
}, [queryClient, threadId]);
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>
<Dialog open={state.open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none flex h-[90vh] max-h-[640px] w-[95vw] max-w-[900px] flex-col gap-0 overflow-hidden p-0 [--card:var(--popover)] md:h-[80vh]">
<div className="shrink-0 px-6 pb-3 pt-6 pr-28">
<div className="flex items-center gap-2">
<DialogTitle className="text-lg font-semibold">Agent actions</DialogTitle>
{data?.total !== undefined && data.total > 0 ? (
<Badge variant="secondary" className="text-[10px]">
{data.total}
</Badge>
) : null}
</div>
<SheetDescription className="text-xs text-muted-foreground">
<DialogDescription className="sr-only">
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>
</DialogDescription>
<Separator className="mt-4" />
</div>
<Separator />
<Button
size="sm"
variant="ghost"
onClick={() => refetch()}
disabled={isFetching || !actionLogEnabled}
className="absolute right-14 top-4 size-8 rounded-full p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Refresh action log"
>
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
</Button>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-thin">
{!actionLogEnabled ? (
@ -148,7 +135,7 @@ export function ActionLogSheet() {
) : items.length === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-2 p-3">
<div className="flex flex-col gap-2 px-4 pb-4">
{items.map((action) => (
<ActionLogItem
key={action.id}
@ -157,15 +144,15 @@ export function ActionLogSheet() {
onRevertSuccess={handleRevertSuccess}
/>
))}
{data?.has_more && (
{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>
)}
) : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
</DialogContent>
</Dialog>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react";
import { Check, ChevronRight, Copy, RotateCcw, Undo2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
@ -16,7 +16,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
@ -29,10 +28,55 @@ interface ActionLogItemProps {
onRevertSuccess: () => void;
}
function formatPrimitiveValue(value: unknown) {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value, null, 2);
}
function ArgumentValue({ value }: { value: unknown }) {
const formatted = formatPrimitiveValue(value);
const isBlockValue =
typeof value === "object" ||
(typeof value === "string" && (value.includes("\n") || value.length > 120));
if (isBlockValue) {
return (
<pre className="mt-2 whitespace-pre-wrap break-words bg-popover px-4 py-3 text-[11px] leading-relaxed text-popover-foreground/80">
{formatted}
</pre>
);
}
return (
<p className="mt-1 break-words font-mono text-[11px] leading-relaxed text-popover-foreground/80">
{formatted}
</p>
);
}
function StructuredArguments({ args }: { args: Record<string, unknown> }) {
return (
<div className="divide-y divide-popover-border border-t border-popover-border">
{Object.entries(args).map(([key, value]) => (
<div key={key} className="bg-popover">
<div className="px-4 py-3">
<p className="font-mono text-[10px] font-medium text-muted-foreground">{key}</p>
<ArgumentValue value={value} />
</div>
</div>
))}
</div>
);
}
export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [copiedSection, setCopiedSection] = useState<"arguments" | null>(null);
const isAlreadyReverted = action.reverted_by_action_id !== null;
const isRevertAction = action.is_revert_action;
@ -42,11 +86,22 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
const displayName = getToolDisplayName(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 handleCopyArguments = async () => {
if (!argsPreview) return;
try {
await navigator.clipboard.writeText(argsPreview);
setCopiedSection("arguments");
toast.success("Arguments copied");
window.setTimeout(() => setCopiedSection(null), 1200);
} catch {
toast.error("Failed to copy arguments.");
}
};
const handleRevert = async () => {
setIsReverting(true);
try {
@ -70,17 +125,18 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
return (
<div
className={cn(
"rounded-lg border bg-card transition-colors",
"overflow-hidden rounded-lg border border-popover-border bg-popover text-popover-foreground transition-colors",
isAlreadyReverted && "opacity-70"
)}
>
<button
<Button
type="button"
variant="ghost"
onClick={() => setIsExpanded((v) => !v)}
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
className="h-auto w-full items-start justify-start gap-3 rounded-none p-3 text-left hover:bg-accent hover:text-accent-foreground"
aria-expanded={isExpanded}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-accent">
{isRevertAction ? (
<Undo2 className="size-4 text-muted-foreground" />
) : (
@ -101,7 +157,10 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</Badge>
)}
{!isRevertAction && action.reversible && !isAlreadyReverted && (
<Badge variant="outline" className="text-[10px]">
<Badge
variant="secondary"
className="border-0 bg-neutral-200 px-1.5 py-0.5 text-[10px] text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200"
>
Reversible
</Badge>
)}
@ -115,55 +174,67 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</div>
<ChevronRight
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
"size-4 shrink-0 self-center text-muted-foreground transition-transform",
isExpanded && "rotate-90"
)}
/>
</button>
</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 className="flex flex-col border-t border-popover-border bg-accent/80">
{action.args && argsPreview && (
<div className="border-b border-popover-border">
<div className="flex items-center justify-between px-4 py-2">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Arguments
</p>
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleCopyArguments}
className="size-6 rounded-lg p-0 text-muted-foreground hover:bg-popover hover:text-popover-foreground"
aria-label={copiedSection === "arguments" ? "Arguments copied" : "Copy arguments"}
>
{copiedSection === "arguments" ? (
<Check className="size-3" />
) : (
<Copy className="size-3" />
)}
</Button>
</div>
<StructuredArguments args={action.args} />
</div>
)}
{action.error && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="border-b border-popover-border">
<p className="px-4 py-2 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">
<pre className="max-h-32 overflow-auto border-t border-popover-border bg-destructive/10 px-4 py-3 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">
<div className="border-b border-popover-border">
<p className="px-4 py-2 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">
<pre className="max-h-32 overflow-auto border-t border-popover-border bg-popover px-4 py-3 text-[11px] text-popover-foreground/80">
{JSON.stringify(action.reverse_descriptor, null, 2)}
</pre>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center justify-between px-4 py-3">
<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">
<Button size="sm" variant="secondary" className="gap-1.5">
<RotateCcw className="size-3.5" />
Revert
</Button>
@ -185,6 +256,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
handleRevert();
}}
disabled={isReverting}
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 focus-visible:ring-0"
>
{isReverting ? "Reverting…" : "Revert"}
</AlertDialogAction>
@ -193,7 +265,6 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</AlertDialog>
) : (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldOff className="size-3.5" />
{isAlreadyReverted
? "Already reverted"
: isRevertAction

View file

@ -0,0 +1,50 @@
"use client";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { useAnnouncements } from "@/hooks/use-announcements";
export function AnnouncementsDialog() {
const [open, setOpen] = useAtom(announcementsDialogAtom);
const { announcements, markAllRead } = useAnnouncements();
// Auto-mark all visible announcements as read when the dialog opens
useEffect(() => {
if (open) {
markAllRead();
}
}, [open, markAllRead]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col p-0 gap-0 overflow-hidden bg-popover text-popover-foreground">
<DialogTitle className="sr-only">What's New</DialogTitle>
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
<div className="px-6 md:px-8 pt-6 pb-2 shrink-0">
<h2 className="text-lg font-semibold">What's New</h2>
<Separator className="mt-4" />
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<div className="px-4 md:px-8 pt-4 pb-6 min-w-0">
{announcements.length === 0 ? (
<AnnouncementsEmptyState />
) : (
<div className="flex flex-col gap-4">
{announcements.map((announcement) => (
<AnnouncementCard key={announcement.id} announcement={announcement} />
))}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -2,13 +2,13 @@ import { BellOff } from "lucide-react";
export function AnnouncementsEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<BellOff className="h-7 w-7 text-muted-foreground" />
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BellOff className="h-5 w-5 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">No announcements</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
You're all caught up! New announcements will appear here.
<h3 className="text-sm font-semibold">Nothing new yet</h3>
<p className="mt-1 max-w-xs text-xs text-muted-foreground">
You're all caught up! New updates will appear here.
</p>
</div>
);

View file

@ -58,6 +58,7 @@ import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { getProviderIcon } from "@/lib/provider-icons";
import { tryGetHostname } from "@/lib/url";
import { cn } from "@/lib/utils";
// Captured once at module load — survives client-side navigations that strip the query param.
@ -99,20 +100,12 @@ const GenerateImageToolUI = dynamic(
import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })),
{ ssr: false }
);
function extractDomain(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
function useCitationsFromMetadata(): SerializableCitation[] {
const allCitations = useAllCitationMetadata();
return useMemo(() => {
const result: SerializableCitation[] = [];
for (const [url, meta] of allCitations) {
const domain = extractDomain(url);
const domain = tryGetHostname(url);
result.push({
id: `url-cite-${url}`,
href: url,
@ -144,14 +137,15 @@ const MobileCitationDrawer: FC = () => {
return (
<>
<button
<Button
type="button"
variant="ghost"
onClick={() => setOpen(true)}
className={cn(
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
"isolate h-auto cursor-pointer gap-2 rounded-lg px-3 py-2",
"bg-muted/40 outline-none",
"transition-colors duration-150",
"hover:bg-muted/70",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:ring-ring focus-visible:ring-2"
)}
>
@ -194,7 +188,7 @@ const MobileCitationDrawer: FC = () => {
<span className="text-muted-foreground text-sm tabular-nums">
{citations.length} source{citations.length !== 1 && "s"}
</span>
</button>
</Button>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[85vh] flex flex-col">
@ -204,11 +198,12 @@ const MobileCitationDrawer: FC = () => {
</DrawerHeader>
<div className="overflow-y-auto flex-1 min-h-0 px-1 pb-6">
{citations.map((citation) => (
<button
<Button
key={citation.id}
type="button"
variant="ghost"
onClick={() => handleNavigate(citation)}
className="group flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
className="group h-auto w-full justify-start gap-2.5 px-3 py-2.5 text-left hover:bg-accent hover:text-accent-foreground focus-visible:bg-muted"
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
@ -230,7 +225,7 @@ const MobileCitationDrawer: FC = () => {
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
</div>
<ExternalLink className="text-muted-foreground size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
</Button>
))}
</div>
</DrawerContent>
@ -272,7 +267,7 @@ function formatTurnCost(micros: number): string {
return "$0";
}
const MessageInfoDropdown: FC = () => {
const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ chatTurnId }) => {
const messageId = useAuiState(({ message }) => message?.id);
const createdAt = useAuiState(({ message }) => message?.createdAt);
const usage = useTokenUsage(messageId);
@ -311,7 +306,7 @@ const MessageInfoDropdown: FC = () => {
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content
align="start"
className="bg-muted text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[180px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
className="bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[180px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
{createdAt && (
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal select-none">
@ -320,7 +315,7 @@ const MessageInfoDropdown: FC = () => {
)}
{hasUsage && (
<>
<ActionBarMorePrimitive.Separator className="bg-border mx-2 my-1 h-px" />
<ActionBarMorePrimitive.Separator className="bg-popover-border mx-1 my-1 h-px" />
{models.length > 0 ? (
models.map(([model, counts]) => {
const { name, icon } = resolveModel(model);
@ -328,7 +323,7 @@ const MessageInfoDropdown: FC = () => {
return (
<ActionBarMorePrimitive.Item
key={model}
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onSelect={(e) => e.preventDefault()}
>
<span className="flex items-center gap-1.5 text-xs font-medium">
@ -344,7 +339,7 @@ const MessageInfoDropdown: FC = () => {
})
) : (
<ActionBarMorePrimitive.Item
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onSelect={(e) => e.preventDefault()}
>
<span className="text-xs text-muted-foreground">
@ -357,6 +352,7 @@ const MessageInfoDropdown: FC = () => {
)}
</>
)}
<RevertTurnButton chatTurnId={chatTurnId} variant="menu-item" />
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
);
@ -506,9 +502,10 @@ export const AssistantMessage: FC = () => {
>
{/* Fixed trigger slot prevents any vertical reflow when visibility changes */}
<div className="mr-2 mb-1 flex h-7 justify-end">
<button
<Button
ref={isDesktop ? commentTriggerRef : undefined}
type="button"
variant="ghost"
onClick={
showCommentTrigger
? isDesktop
@ -519,14 +516,14 @@ export const AssistantMessage: FC = () => {
aria-hidden={!showCommentTrigger}
tabIndex={showCommentTrigger ? 0 : -1}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
"h-auto gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
"opacity-0 pointer-events-none",
showCommentTrigger && "opacity-100 pointer-events-auto",
isDesktop && isInlineOpen
? "bg-primary/10 text-primary"
: hasComments
? "text-primary hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground hover:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
@ -537,7 +534,7 @@ export const AssistantMessage: FC = () => {
) : (
<span>Add comment</span>
)}
</button>
</Button>
</div>
{/* Desktop floating comment panel — overlays on top of chat content */}
@ -588,7 +585,7 @@ const AssistantActionBar: FC = () => {
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy to clipboard">
<TooltipIconButton tooltip="Copy">
<AuiIf condition={({ message }) => message.isCopied}>
<CheckIcon />
</AuiIf>
@ -620,10 +617,7 @@ const AssistantActionBar: FC = () => {
<ClipboardPaste />
</TooltipIconButton>
)}
<MessageInfoDropdown />
<div className="ml-auto">
<RevertTurnButton chatTurnId={chatTurnId} />
</div>
<MessageInfoDropdown chatTurnId={chatTurnId} />
</ActionBarPrimitive.Root>
);
};

View file

@ -9,8 +9,7 @@ const ChatScrollToBottom: FC = () => (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full border-0 bg-muted p-4 text-foreground hover:bg-accent hover:text-accent-foreground disabled:invisible"
>
<ArrowDownIcon />
</TooltipIconButton>

View file

@ -1,7 +1,8 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue } from "jotai";
import { AlertTriangle, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
@ -10,7 +11,6 @@ import {
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@ -44,8 +44,8 @@ interface ConnectorIndicatorProps {
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
(_props, ref) => {
const router = useRouter();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -218,7 +218,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onPointerDownOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button>svg]:size-5 select-none"
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
@ -380,34 +380,32 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
{/* LLM Configuration Warning */}
{!llmConfigLoading && !hasDocumentSummaryLLM && (
<Alert
variant="destructive"
className="mb-6 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
{isAutoMode && !hasGlobalConfigs
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
handleOpenChange(false);
setSearchSpaceSettingsDialog({
open: true,
initialTab: "models",
});
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
<div className="mb-6">
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription>
<p>
{isAutoMode && !hasGlobalConfigs
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
handleOpenChange(false);
router.push(
`/dashboard/${searchSpaceId}/search-space-settings/models`
);
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
</div>
)}
<TabsContent value="all" className="m-0">
@ -446,7 +444,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
</div>
</Tabs>
)}

View file

@ -81,8 +81,8 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
className={cn(
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
status.status === "warning"
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground"
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground"
)}
>
<div
@ -145,9 +145,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
"relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center",
"relative h-8 text-[11px] px-3 shrink-0 font-medium items-center justify-center",
isConnected &&
"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",
"bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground",
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}

View file

@ -2,6 +2,7 @@
import { Search, X } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
@ -25,7 +26,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<div
className={cn(
"flex-shrink-0 px-4 sm:px-12 pt-5 sm:pt-10 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-xl bg-muted/50 backdrop-blur-md"
isScrolled && "bg-popover shadow-xl"
)}
>
<DialogHeader>
@ -37,7 +38,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 sm:mt-8 border-b border-border/80 dark:border-white/5">
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 sm:mt-8 border-b border-popover-border">
<TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start">
<TabsTrigger
value="all"
@ -63,27 +64,29 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<div className="w-full sm:w-72 sm:pb-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<input
type="text"
autoComplete="off"
placeholder="Search"
className={cn(
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",
searchQuery ? "pr-9" : "pr-4"
)}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchQuery && (
<button
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
className="absolute right-1.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
aria-label="Clear search"
>
<X className="size-4" />
</button>
<X data-icon="inline-start" />
</Button>
)}
</div>
</div>

View file

@ -3,6 +3,7 @@
import { AlertTriangle, X } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ConnectorWarningBannerProps {
@ -42,14 +43,16 @@ export const ConnectorWarningBanner: FC<ConnectorWarningBannerProps> = ({
)}
</div>
{onDismiss && (
<button
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleDismiss}
className="shrink-0 p-0.5 rounded hover:bg-yellow-500/20 transition-colors"
className="size-6 shrink-0 rounded p-0 transition-colors hover:bg-yellow-500/20"
aria-label="Dismiss warning"
>
<X className="size-3.5 text-yellow-700 dark:text-yellow-300" />
</button>
<X data-icon="inline-start" className="text-yellow-700 dark:text-yellow-300" />
</Button>
)}
</div>
);

View file

@ -136,7 +136,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleClearDates}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Clear Dates
</Button>
@ -145,7 +145,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleLast30Days}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Last 30 Days
</Button>
@ -155,7 +155,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleNext30Days}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Next 30 Days
</Button>
@ -165,7 +165,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleLastYear}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Last Year
</Button>

View file

@ -70,20 +70,22 @@ export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSu
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
up at{" "}
<a
href="https://qianfan.cloud.baidu.com/"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
qianfan.cloud.baidu.com
</a>
<Alert>
<Info />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
<p>
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
up at{" "}
<a
href="https://qianfan.cloud.baidu.com/"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
qianfan.cloud.baidu.com
</a>
</p>
</AlertDescription>
</Alert>

View file

@ -96,10 +96,10 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
<Alert>
<Info />
<AlertTitle>API Token Required</AlertTitle>
<AlertDescription>
You'll need a BookStack API Token to use this connector. You can create one from your
BookStack instance settings.
</AlertDescription>

View file

@ -172,10 +172,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
<Alert>
<Info />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
</AlertDescription>
</Alert>
@ -428,10 +428,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
</div>
)}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Index Selection Tips</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px] mt-2">
<Alert>
<Info />
<AlertTitle>Index Selection Tips</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-4 space-y-1">
<li>Use wildcards like "logs-*" to match multiple indices</li>
<li>Separate multiple indices with commas</li>
@ -643,231 +643,6 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Elasticsearch connector allows you to search and retrieve documents from your
Elasticsearch cluster. Configure connection details, select specific indices, and
set search parameters to make your existing data searchable within SurfSense.
</p>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Connection Setup</h3>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get your Elasticsearch endpoint
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
You'll need the endpoint URL for your Elasticsearch cluster. This typically
looks like:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Cloud:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
https://your-cluster.es.region.aws.com:443
</code>
</li>
<li>
Self-hosted:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
https://elasticsearch.example.com:9200
</code>
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Configure authentication
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Elasticsearch requires authentication. You can use either:
</p>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
<strong>API Key:</strong> A base64-encoded API key. You can create one in
Elasticsearch by running:
<pre className="bg-muted p-2 rounded mt-1 text-[9px] overflow-x-auto">
<code>POST /_security/api_key</code>
</pre>
</li>
<li>
<strong>Username & Password:</strong> Basic authentication using your
Elasticsearch username and password.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Select indices
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Specify which indices to search. You can:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
Use wildcards: <code className="bg-muted px-1 py-0.5 rounded">logs-*</code>{" "}
to match multiple indices
</li>
<li>
List specific indices:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
logs-2024, documents-2024
</code>
</li>
<li>
Leave empty to search all accessible indices (not recommended for
performance)
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Advanced Configuration</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Query</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
The default query used for searches. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded">*</code> to match all
documents, or specify a more complex Elasticsearch query.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
Limit searches to specific fields for better performance. Common fields
include:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
<code className="bg-muted px-1 py-0.5 rounded">title</code> - Document
titles
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">content</code> - Main content
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">description</code> -
Descriptions
</li>
</ul>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Leave empty to search all fields in your documents.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Maximum Documents</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Set a limit on the number of documents retrieved per search (1-10,000). This
helps control response times and resource usage. Leave empty to use
Elasticsearch's default limit.
</p>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Troubleshooting</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Connection Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid URL:</strong> Ensure your endpoint URL includes the protocol
(https://) and port number if required.
</li>
<li>
<strong>SSL/TLS Errors:</strong> Verify that your cluster uses HTTPS and the
certificate is valid. Self-signed certificates may require additional
configuration.
</li>
<li>
<strong>Connection Timeout:</strong> Check your network connectivity and
firewall settings. Ensure the Elasticsearch cluster is accessible from
SurfSense servers.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Authentication Issues
</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid Credentials:</strong> Double-check your username/password or
API key. API keys must be base64-encoded.
</li>
<li>
<strong>Permission Denied:</strong> Ensure your API key or user account has
read permissions for the indices you want to search.
</li>
<li>
<strong>API Key Format:</strong> Elasticsearch API keys are typically
base64-encoded strings. Make sure you're using the full key value.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>No Results:</strong> Verify that your index selection matches
existing indices. Use wildcards carefully.
</li>
<li>
<strong>Slow Searches:</strong> Limit the number of indices or use specific
index names instead of wildcards. Reduce the maximum documents limit.
</li>
<li>
<strong>Field Not Found:</strong> Ensure the search fields you specify
actually exist in your Elasticsearch documents.
</li>
</ul>
</div>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Need More Help?</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
If you continue to experience issues, check your Elasticsearch cluster logs
and ensure your cluster version is compatible. For Elasticsearch Cloud
deployments, verify your access policies and IP allowlists.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -105,20 +105,23 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
A GitHub PAT is only required for private repositories. Public repos work without a token.{" "}
<a
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
>
Get your token
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
</a>{" "}
<Alert>
<Info />
<AlertTitle>Personal Access Token (Optional)</AlertTitle>
<AlertDescription>
<p>
A GitHub PAT is only required for private repositories. Public repos work without a
token.{" "}
<a
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
>
Get your token
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
</a>
</p>
</AlertDescription>
</Alert>

View file

@ -70,19 +70,21 @@ export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
<a
href="https://linkup.so"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
linkup.so
</a>
<Alert>
<Info />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
<p>
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
<a
href="https://linkup.so"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
linkup.so
</a>
</p>
</AlertDescription>
</Alert>

View file

@ -88,19 +88,21 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
You'll need a Luma API Key to use this connector. You can create one from{" "}
<a
href="https://lu.ma/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Luma API Settings
</a>
<Alert>
<Info />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
<p>
You'll need a Luma API Key to use this connector. You can create one from{" "}
<a
href="https://lu.ma/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Luma API Settings
</a>
</p>
</AlertDescription>
</Alert>

View file

@ -155,7 +155,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
className="h-6 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
onClick={() => handleConfigChange(DEFAULT_STDIO_CONFIG)}
>
Local Example
@ -164,7 +164,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
className="h-6 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
onClick={() => handleConfigChange(DEFAULT_HTTP_CONFIG)}
>
Remote Example
@ -210,7 +210,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
onClick={handleTestConnection}
disabled={isTesting}
variant="secondary"
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"
className="w-full h-8 text-[13px] px-3 font-medium bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground"
>
{isTesting ? (
<>

View file

@ -1,12 +1,11 @@
"use client";
import { Check, Copy, Info } from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react";
import type { FC } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
import { BACKEND_URL } from "@/lib/env-config";
@ -30,16 +29,6 @@ const PLUGIN_RELEASES_URL =
*/
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUrl, setCopiedUrl] = useState(false);
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const copyServerUrl = useCallback(async () => {
const ok = await copyToClipboardUtil(BACKEND_URL);
if (!ok) return;
setCopiedUrl(true);
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
}, []);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@ -52,10 +41,10 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
that just closes the dialog (see component-level docstring). */}
<form id="obsidian-connect-form" onSubmit={handleSubmit} />
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0 text-purple-500" />
<AlertTitle className="text-xs sm:text-sm">Plugin-based sync</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
<Alert>
<Info />
<AlertTitle>Plugin-based sync</AlertTitle>
<AlertDescription>
SurfSense now syncs Obsidian via an official plugin that runs inside Obsidian itself.
Works on desktop and mobile, in cloud and self-hosted deployments.
</AlertDescription>
@ -123,7 +112,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
variant="ghost"
size="icon"
onClick={copyToClipboard}
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
className="size-7 shrink-0 text-muted-foreground hover:text-accent-foreground"
aria-label={copied ? "Copied" : "Copy API key"}
>
{copied ? (

View file

@ -123,20 +123,22 @@ export const SearxngConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
You need access to a running SearxNG instance. Refer to the{" "}
<a
href="https://docs.searxng.org/admin/installation-docker.html"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
SearxNG installation guide
</a>{" "}
for setup instructions. If your instance requires an API key, include it below.
<Alert>
<Info />
<AlertTitle>SearxNG Instance Required</AlertTitle>
<AlertDescription>
<p>
You need access to a running SearxNG instance. Refer to the{" "}
<a
href="https://docs.searxng.org/admin/installation-docker.html"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
SearxNG installation guide
</a>{" "}
for setup instructions. If your instance requires an API key, include it below.
</p>
</AlertDescription>
</Alert>

View file

@ -70,19 +70,21 @@ export const TavilyApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
<a
href="https://tavily.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
tavily.com
</a>
<Alert>
<Info />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
<p>
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
<a
href="https://tavily.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
tavily.com
</a>
</p>
</AlertDescription>
</Alert>

View file

@ -166,10 +166,10 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
)}
{webhookInfo && (
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs mt-1">
<Alert>
<Info />
<AlertTitle>Configuration Instructions</AlertTitle>
<AlertDescription>
Configure this URL in Circleback Settings Automations Create automation Send
webhook request. The webhook will automatically send meeting notes, transcripts, and
action items to this search space.

View file

@ -3,6 +3,7 @@
import { Info, KeyRound } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@ -47,21 +48,17 @@ export const ClickUpConfig: FC<ClickUpConfigProps> = ({
return (
<div className="space-y-6">
{/* OAuth Info */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected via OAuth</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
<Alert>
<Info />
<AlertTitle>Connected via OAuth</AlertTitle>
<AlertDescription>
<p>
Workspace:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-inherit">{workspaceName}</code>
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
To update your connection, reconnect this connector.
</p>
</div>
</div>
<p>To update your connection, reconnect this connector.</p>
</AlertDescription>
</Alert>
</div>
);
}

View file

@ -14,6 +14,7 @@ import {
import type { FC } from "react";
import { useCallback, useState } from "react";
import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
@ -196,14 +197,16 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
>
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{folder.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFolder(folder.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${folder.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
{selectedFiles.map((file) => (
@ -214,14 +217,16 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
>
{getFileIconFromName(file.name)}
<span className="flex-1 truncate">{file.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFile(file.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
</div>
@ -237,10 +242,11 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
{isEditMode ? (
<div className="space-y-2">
<button
<Button
type="button"
variant="ghost"
onClick={() => setIsFolderTreeOpen((prev) => !prev)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
className="h-auto w-fit gap-2 px-0 py-0 text-xs font-normal text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
Change Selection
{isFolderTreeOpen ? (
@ -248,7 +254,7 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
) : (
<ChevronRight className="size-4" />
)}
</button>
</Button>
{isFolderTreeOpen && (
<DriveFolderTree
fetchItems={fetchItems}

View file

@ -3,6 +3,7 @@
import { Info, KeyRound } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@ -72,23 +73,17 @@ export const ConfluenceConfig: FC<ConfluenceConfigProps> = ({
return (
<div className="space-y-6">
{/* OAuth Info */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected via OAuth</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
This connector is authenticated using OAuth 2.0. Your Confluence instance is:
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
<Alert>
<Info />
<AlertTitle>Connected via OAuth</AlertTitle>
<AlertDescription>
<p>This connector is authenticated using OAuth 2.0. Your Confluence instance is:</p>
<p>
<code className="bg-muted px-1 py-0.5 rounded text-inherit">{siteUrl}</code>
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
To update your connection, reconnect this connector.
</p>
</div>
</div>
<p>To update your connection, reconnect this connector.</p>
</AlertDescription>
</Alert>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { AlertCircle, CheckCircle2, Hash, Info, Megaphone, RefreshCw } from "lucide-react";
import { type FC, useCallback, useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { connectorsApiService, type DiscordChannel } from "@/lib/apis/connectors-api.service";
@ -73,17 +74,14 @@ export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
return (
<div className="space-y-6">
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
The bot needs &quot;Read Message History&quot; permission to access channels. Ask a
server admin to grant this permission for channels shown below.
</p>
</div>
</div>
<Alert>
<Info />
<AlertTitle>Grant Channel Permissions</AlertTitle>
<AlertDescription>
The bot needs &quot;Read Message History&quot; permission to access channels. Ask a server
admin to grant this permission for channels shown below.
</AlertDescription>
</Alert>
{/* Channels Section */}
<div className="space-y-3">
@ -100,7 +98,7 @@ export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
size="sm"
onClick={fetchChannels}
disabled={isLoading}
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20"
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-accent hover:text-accent-foreground border-slate-400/20 dark:border-white/20"
>
<RefreshCw className={cn("mr-1.5 size-3", isLoading && "animate-spin")} />
Refresh
@ -175,7 +173,7 @@ interface ChannelPillProps {
const ChannelPill: FC<ChannelPillProps> = ({ channel }) => {
return (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 transition-colors">
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-accent hover:text-accent-foreground transition-colors">
{channel.type === "announcement" ? (
<Megaphone className="size-2.5 text-muted-foreground" />
) : (

View file

@ -14,6 +14,7 @@ import {
import type { FC } from "react";
import { useCallback, useEffect, useState } from "react";
import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
@ -177,14 +178,16 @@ export const DropboxConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCha
>
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{folder.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFolder(folder.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${folder.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
{selectedFiles.map((file) => (
@ -195,14 +198,16 @@ export const DropboxConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCha
>
{getFileIconFromName(file.name)}
<span className="flex-1 truncate">{file.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFile(file.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
</div>
@ -217,10 +222,11 @@ export const DropboxConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCha
{isEditMode ? (
<div className="space-y-2">
<button
<Button
type="button"
variant="ghost"
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
className="h-auto w-fit gap-2 px-0 py-0 text-xs font-normal text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
Change Selection
{isFolderTreeOpen ? (
@ -228,7 +234,7 @@ export const DropboxConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCha
) : (
<ChevronRight className="size-4" />
)}
</button>
</Button>
{isFolderTreeOpen && (
<DriveFolderTree
fetchItems={fetchItems}

View file

@ -92,20 +92,19 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
const [selectedFiles, setSelectedFiles] = useState<SelectedItem[]>(existingFiles);
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
const updateConfig = (
folders: SelectedItem[],
files: SelectedItem[],
options: IndexingOptions
) => {
if (onConfigChange) {
onConfigChange({
...connector.config,
selected_folders: folders,
selected_files: files,
indexing_options: options,
});
}
};
const updateConfig = useCallback(
(folders: SelectedItem[], files: SelectedItem[], options: IndexingOptions) => {
if (onConfigChange) {
onConfigChange({
...connector.config,
selected_folders: folders,
selected_files: files,
indexing_options: options,
});
}
},
[connector.config, onConfigChange]
);
const handlePicked = useCallback(
(result: PickerResult) => {
@ -115,8 +114,7 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
setSelectedFiles(files);
updateConfig(folders, files, indexingOptions);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[indexingOptions, connector.config]
[indexingOptions, updateConfig]
);
const {
@ -188,14 +186,16 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
>
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{folder.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFolder(folder.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${folder.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
{selectedFiles.map((file) => (
@ -206,14 +206,16 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
>
{getFileIconFromName(file.name)}
<span className="flex-1 truncate">{file.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFile(file.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
</div>
@ -225,7 +227,7 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
variant="outline"
onClick={openPicker}
disabled={pickerLoading || isAuthExpired}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground text-xs sm:text-sm h-8 sm:h-9"
>
{pickerLoading && <Spinner size="xs" className="mr-1.5" />}
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}

View file

@ -3,6 +3,7 @@
import { Info, KeyRound } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
@ -65,23 +66,17 @@ export const JiraConfig: FC<JiraConfigProps> = ({ connector, onConfigChange, onN
return (
<div className="space-y-6">
{/* OAuth Info */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected via OAuth</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
This connector is authenticated using OAuth 2.0. Your Jira instance is:
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
<Alert>
<Info />
<AlertTitle>Connected via OAuth</AlertTitle>
<AlertDescription>
<p>This connector is authenticated using OAuth 2.0. Your Jira instance is:</p>
<p>
<code className="bg-muted px-1 py-0.5 rounded text-inherit">{baseUrl}</code>
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
To update your connection, reconnect this connector.
</p>
</div>
</div>
<p>To update your connection, reconnect this connector.</p>
</AlertDescription>
</Alert>
</div>
);
}

View file

@ -215,7 +215,7 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
onClick={handleTestConnection}
disabled={isTesting}
variant="secondary"
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"
className="w-full h-8 text-[13px] px-3 font-medium bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground"
>
{isTesting ? (
<>

View file

@ -47,12 +47,10 @@ export const ObsidianConfig: FC<ConnectorConfigProps> = ({ connector }) => {
const LegacyBanner: FC = () => {
return (
<div className="space-y-6">
<Alert className="border-amber-500/40 bg-amber-500/10">
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
<AlertTitle className="text-xs sm:text-sm">
Sync stopped, install the plugin to migrate
</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
<Alert variant="warning">
<AlertTriangle />
<AlertTitle>Sync stopped, install the plugin to migrate</AlertTitle>
<AlertDescription>
This Obsidian connector used the legacy server-path scanner, which has been removed. The
notes already indexed remain searchable, but they no longer reflect changes made in your
vault.
@ -124,10 +122,10 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
return (
<div className="space-y-4">
<Alert className="border-emerald-500/30 bg-emerald-500/10">
<Info className="size-4 shrink-0 text-emerald-500" />
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
<Alert>
<Info />
<AlertTitle>Plugin connected</AlertTitle>
<AlertDescription>
Your notes stay synced automatically. To stop syncing, disable or uninstall the plugin in
Obsidian, or delete this connector.
</AlertDescription>
@ -152,11 +150,11 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
const UnknownConnectorState: FC = () => (
<Alert>
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
This connector has neither plugin metadata nor a legacy marker. It may predate migration you
can safely delete it and re-install the SurfSense Obsidian plugin to resume syncing.
<Info />
<AlertTitle>Unrecognized config</AlertTitle>
<AlertDescription>
This connector is missing plugin metadata and may predate the Obsidian plugin migration. You
can safely delete it and reinstall the SurfSense Obsidian plugin to resume syncing.
</AlertDescription>
</Alert>
);

View file

@ -14,6 +14,7 @@ import {
import type { FC } from "react";
import { useCallback, useEffect, useState } from "react";
import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
@ -178,14 +179,16 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
>
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{folder.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFolder(folder.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${folder.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
{selectedFiles.map((file) => (
@ -196,14 +199,16 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
>
{getFileIconFromName(file.name)}
<span className="flex-1 truncate">{file.name}</span>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFile(file.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
className="size-5 shrink-0 rounded p-0 hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</Button>
</div>
))}
</div>
@ -218,10 +223,11 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
{isEditMode ? (
<div className="space-y-2">
<button
<Button
type="button"
variant="ghost"
onClick={() => setIsFolderTreeOpen((prev) => !prev)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
className="h-auto w-fit gap-2 px-0 py-0 text-xs font-normal text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
Change Selection
{isFolderTreeOpen ? (
@ -229,7 +235,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
) : (
<ChevronRight className="size-4" />
)}
</button>
</Button>
{isFolderTreeOpen && (
<DriveFolderTree
fetchItems={fetchItems}

View file

@ -2,6 +2,7 @@
import { AlertCircle, CheckCircle2, Hash, Info, Lock, RefreshCw } from "lucide-react";
import { type FC, useCallback, useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { connectorsApiService, type SlackChannel } from "@/lib/apis/connectors-api.service";
@ -74,20 +75,20 @@ export const SlackConfig: FC<SlackConfigProps> = ({ connector }) => {
return (
<div className="space-y-6">
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Add Bot to Channels</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
<Alert>
<Info />
<AlertTitle>Add Bot to Channels</AlertTitle>
<AlertDescription>
<p>
Before indexing, add the SurfSense bot to each channel you want to index. The bot can
only access messages from channels it's been added to. Type{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[9px]">/invite @SurfSense</code> in
any channel to add it.
<code className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground">
/invite @SurfSense
</code>{" "}
in any channel to add it.
</p>
</div>
</div>
</AlertDescription>
</Alert>
{/* Channels Section */}
<div className="space-y-3">
@ -104,7 +105,7 @@ export const SlackConfig: FC<SlackConfigProps> = ({ connector }) => {
size="sm"
onClick={fetchChannels}
disabled={isLoading}
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20"
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-accent hover:text-accent-foreground border-slate-400/20 dark:border-white/20"
>
<RefreshCw className={cn("mr-1.5 size-3", isLoading && "animate-spin")} />
Refresh
@ -178,7 +179,7 @@ interface ChannelPillProps {
const ChannelPill: FC<ChannelPillProps> = ({ channel }) => {
return (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 transition-colors">
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-accent hover:text-accent-foreground transition-colors">
{channel.is_private ? (
<Lock className="size-2.5 text-muted-foreground" />
) : (

View file

@ -2,6 +2,7 @@
import { Info } from "lucide-react";
import type { FC } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { ConnectorConfigProps } from "../index";
export interface TeamsConfigProps extends ConnectorConfigProps {
@ -11,19 +12,17 @@ export interface TeamsConfigProps extends ConnectorConfigProps {
export const TeamsConfig: FC<TeamsConfigProps> = () => {
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<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">
<Alert>
<Info />
<AlertTitle>Microsoft Teams Access</AlertTitle>
<AlertDescription>
<p>
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>
</AlertDescription>
</Alert>
</div>
);
};

View file

@ -52,13 +52,13 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
</div>
{/* Chat tip */}
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-3 text-xs sm:text-sm">
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
<p className="text-muted-foreground">
<Alert>
<Info />
<AlertDescription>
Want a quick answer from a webpage without indexing it? Just paste the URL directly into
the chat instead.
</p>
</div>
</AlertDescription>
</Alert>
{/* API Key Field */}
<div className="space-y-2">
@ -79,7 +79,7 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
variant="ghost"
size="sm"
onClick={() => setShowApiKey((prev) => !prev)}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
>
{showApiKey ? "Hide" : "Show"}
</Button>
@ -116,9 +116,9 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
</div>
{/* Info Alert */}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
<Alert>
<Info />
<AlertDescription>
Configuration is saved when you start indexing. You can update these settings anytime from
the connector management page.
</AlertDescription>

View file

@ -90,14 +90,15 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<button
<Button
variant="ghost"
type="button"
onClick={onBack}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
className="mb-6 h-auto w-fit justify-start gap-2 px-0 py-0 text-xs text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<ArrowLeft className="size-4" />
<ArrowLeft data-icon="inline-start" />
Back to connectors
</button>
</Button>
<div className="flex items-center gap-4 mb-6">
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-slate-400/30">
@ -133,7 +134,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
<Button
variant="ghost"
onClick={onBack}

View file

@ -5,6 +5,7 @@ import { ArrowLeft, Info, RefreshCw } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
@ -206,14 +207,15 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
)}
>
{/* Back button */}
<button
<Button
variant="ghost"
type="button"
onClick={onBack}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
className="mb-6 h-auto w-fit justify-start gap-2 px-0 py-0 text-xs text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<ArrowLeft className="size-4" />
<ArrowLeft data-icon="inline-start" />
Back to connectors
</button>
</Button>
{/* Connector header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
@ -239,7 +241,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
size="sm"
onClick={handleQuickIndex}
disabled={isQuickIndexing || isIndexing || isSaving || isDisconnecting}
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-accent hover:text-accent-foreground border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
>
{isQuickIndexing || isIndexing ? (
<>
@ -349,41 +351,33 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Info box - hidden for live connectors */}
{connector.is_indexable && !isLive && (
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">
Re-indexing runs in the background
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
You can continue using SurfSense while we sync your data. Check inbox for
updates.
</p>
</div>
</div>
<Alert>
<Info />
<AlertDescription>
You can continue using SurfSense while we sync your data. Check inbox for updates.
</AlertDescription>
</Alert>
)}
</div>
</div>
{/* Top fade shadow - appears when scrolled */}
{isScrolled && (
<div className="absolute top-0 left-0 right-0 h-6 bg-gradient-to-b from-muted/50 to-transparent pointer-events-none z-10" />
<div className="absolute top-0 left-0 right-0 h-6 bg-gradient-to-b from-popover to-transparent pointer-events-none z-10" />
)}
{/* Bottom fade shadow - appears when there's more content */}
{hasMoreContent && (
<div className="absolute bottom-0 left-0 right-0 h-3 bg-gradient-to-t from-muted/50 to-transparent pointer-events-none z-10" />
<div className="absolute bottom-0 left-0 right-0 h-3 bg-gradient-to-t from-popover to-transparent pointer-events-none z-10" />
)}
</div>
{/* 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">
<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-popover">
{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."
: "This will remove all indexed data."}
? "Your agent will lose access to this service"
: "This will remove all indexed data"}
</span>
<div className="flex items-center gap-2 sm:gap-3">
<Button

View file

@ -2,6 +2,7 @@
import { ArrowLeft, Check, Info } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
@ -128,14 +129,15 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
>
{/* Back button - only show if not from OAuth */}
{!isFromOAuth && (
<button
<Button
variant="ghost"
type="button"
onClick={onSkip}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
className="mb-6 h-auto w-fit justify-start gap-2 px-0 py-0 text-xs text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<ArrowLeft className="size-4" />
<ArrowLeft data-icon="inline-start" />
Back to connectors
</button>
</Button>
)}
{/* Success header */}
@ -229,33 +231,27 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Info box - hidden for live connectors */}
{connector?.is_indexable && !isLive && (
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
You can continue using SurfSense while we sync your data. Check inbox for
updates.
</p>
</div>
</div>
<Alert>
<Info />
<AlertDescription>
You can continue using SurfSense while we sync your data. Check inbox for updates.
</AlertDescription>
</Alert>
)}
</div>
</div>
{/* Top fade shadow - appears when scrolled */}
{isScrolled && (
<div className="absolute top-0 left-0 right-0 h-6 bg-gradient-to-b from-muted/50 to-transparent pointer-events-none z-10" />
<div className="absolute top-0 left-0 right-0 h-6 bg-gradient-to-b from-popover to-transparent pointer-events-none z-10" />
)}
{/* Bottom fade shadow - appears when there's more content */}
{hasMoreContent && (
<div className="absolute bottom-0 left-0 right-0 h-3 bg-gradient-to-t from-muted/50 to-transparent pointer-events-none z-10" />
<div className="absolute bottom-0 left-0 right-0 h-3 bg-gradient-to-t from-popover to-transparent pointer-events-none z-10" />
)}
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-muted">
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-popover">
{isLive ? (
<Button onClick={onSkip} className="text-xs sm:text-sm">
Done

View file

@ -185,7 +185,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
"relative flex items-center gap-4 p-4 rounded-xl transition-all",
isAnyIndexing
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground border border-border"
)}
>
<div
@ -222,7 +222,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<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"
className="h-8 text-[11px] px-3 font-medium bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground shrink-0"
onClick={handleManageClick}
>
Manage
@ -247,7 +247,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
"flex items-center gap-4 p-4 rounded-xl transition-all",
isIndexing
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground border border-border"
)}
>
<div
@ -280,7 +280,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<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"
className="h-8 text-[11px] px-3 font-medium bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground shrink-0"
onClick={onManage ? () => onManage(connector) : undefined}
>
Manage
@ -302,7 +302,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{standaloneDocuments.map((doc) => (
<div
key={doc.type}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 transition-all"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground transition-all"
>
<div className="flex items-center justify-center">
{getConnectorIcon(doc.type, "size-3.5")}

View file

@ -24,6 +24,7 @@ interface ConnectorAccountsListViewProps {
indexingConnectorIds: Set<number>;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onDisconnect?: (connector: SearchSourceConnector) => Promise<void> | void;
onAddAccount: () => void;
isConnecting?: boolean;
addButtonText?: string;
@ -36,12 +37,15 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
indexingConnectorIds,
onBack,
onManage,
onDisconnect,
onAddAccount,
isConnecting = false,
addButtonText,
}) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const [reauthingId, setReauthingId] = useState<number | null>(null);
const [confirmDisconnectId, setConfirmDisconnectId] = useState<number | null>(null);
const [disconnectingId, setDisconnectingId] = useState<number | null>(null);
// Get connector status
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
@ -104,16 +108,17 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 border-b border-border/50 bg-muted">
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 bg-popover">
{/* Back button */}
<button
<Button
type="button"
variant="ghost"
onClick={onBack}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
className="mb-6 h-auto w-fit gap-2 px-0 py-0 text-xs font-normal text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<ArrowLeft className="size-4" />
Back to connectors
</button>
</Button>
{/* Connector header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
@ -131,15 +136,16 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
</div>
{/* Add Account Button with dashed border */}
<button
<Button
type="button"
variant="ghost"
onClick={onAddAccount}
disabled={isConnecting || !isEnabled}
className={cn(
"flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border-2 border-dashed text-xs sm:text-sm transition-all duration-200 shrink-0 w-full sm:w-auto",
"h-8 w-full shrink-0 gap-1.5 rounded-md border-2 border-dashed px-3 text-xs transition-all duration-200 sm:w-auto sm:text-sm",
!isEnabled
? "border-border/30 opacity-50 cursor-not-allowed"
: "border-slate-400/20 dark:border-white/20 hover:bg-primary/5",
: "border-slate-400/20 dark:border-white/20 hover:bg-accent hover:text-accent-foreground",
isConnecting && "opacity-50 cursor-not-allowed"
)}
>
@ -151,7 +157,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
)}
</div>
<span className="text-xs sm:text-sm font-medium">{buttonText}</span>
</button>
</Button>
</div>
</div>
@ -194,7 +200,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
"flex items-center gap-4 p-4 rounded-xl transition-all",
isIndexing
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground border border-border"
)}
>
<div
@ -227,7 +233,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
{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"
className="h-8 text-[11px] px-3 font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector)}
disabled={reauthingId === connector.id}
>
@ -236,11 +242,55 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
/>
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 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"
onClick={() => setConfirmDisconnectId(null)}
disabled={disconnectingId === connector.id}
>
Cancel
</Button>
</div>
) : (
<Button
variant="destructive"
size="sm"
className="h-8 text-[11px] px-3 font-medium shrink-0"
onClick={() => setConfirmDisconnectId(connector.id)}
>
Disconnect
</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"
className="h-8 text-[11px] px-3 font-medium bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground shrink-0"
onClick={() => onManage(connector)}
>
Manage

View file

@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { type FC, useCallback, useState } from "react";
import { toast } from "sonner";
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
@ -216,14 +217,15 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<button
<Button
variant="ghost"
type="button"
onClick={onBack}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
className="mb-6 h-auto w-fit justify-start gap-2 px-0 py-0 text-xs text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<ArrowLeft className="size-4" />
<ArrowLeft data-icon="inline-start" />
Back to connectors
</button>
</Button>
<div className="flex items-center gap-4 mb-6">
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-slate-400/30">
@ -259,7 +261,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
tag: {
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
closeButton:
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-accent-foreground",
},
}}
activeTagIndex={activeTagIndex}
@ -278,10 +280,10 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm">
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
<p className="text-muted-foreground">{t("chat_tip")}</p>
</div>
<Alert>
<Info />
<AlertDescription>{t("chat_tip")}</AlertDescription>
</Alert>
<div className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">{t("tips_title")}</h4>
@ -323,7 +325,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
</div>
{/* Fixed Footer - Action buttons */}
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
<Button
variant="ghost"
onClick={onBack}

View file

@ -1,7 +1,8 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue } from "jotai";
import { AlertTriangle, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import {
createContext,
type FC,
@ -16,7 +17,6 @@ import {
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@ -98,8 +98,8 @@ const DocumentUploadPopupContent: FC<{
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ isOpen, onOpenChange }) => {
const router = useRouter();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -133,10 +133,10 @@ const DocumentUploadPopupContent: FC<{
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-5 sm:[&>button]:top-8 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button]:z-[100] [&>button>svg]:size-4 sm:[&>button>svg]:size-5"
className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden ring-0 [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-5 sm:[&>button]:top-8 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button]:z-[100] [&>button>svg]:size-4 sm:[&>button>svg]:size-5"
>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
<DialogHeader className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-6 sm:pt-8 pb-10">
<DialogHeader className="sticky top-0 z-20 bg-popover px-4 sm:px-6 pt-6 sm:pt-8 pb-10">
<DialogTitle className="text-xl sm:text-3xl font-semibold tracking-tight pr-8 sm:pr-0">
Upload Documents
</DialogTitle>
@ -147,34 +147,30 @@ const DocumentUploadPopupContent: FC<{
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
{!isLoading && !hasDocumentSummaryLLM ? (
<Alert
variant="destructive"
className="mb-4 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
{isAutoMode && !hasGlobalConfigs
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
onOpenChange(false);
setSearchSpaceSettingsDialog({
open: true,
initialTab: "models",
});
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
<div className="mb-4">
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription>
<p>
{isAutoMode && !hasGlobalConfigs
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
onOpenChange(false);
router.push(`/dashboard/${searchSpaceId}/search-space-settings/models`);
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
</div>
) : (
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
)}

View file

@ -4,8 +4,9 @@ import type { ImageMessagePartComponent } from "@assistant-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import { ImageIcon, ImageOffIcon } from "lucide-react";
import NextImage from "next/image";
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { memo, type PropsWithChildren, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", {
@ -44,8 +45,14 @@ function ImageRoot({ className, variant, size, children, ...props }: ImageRootPr
);
}
type ImagePreviewProps = Omit<React.ComponentProps<"img">, "children"> & {
type ImagePreviewProps = Omit<
React.ComponentProps<"img">,
"children" | "height" | "onError" | "onLoad" | "src" | "width"
> & {
containerClassName?: string;
onError?: React.ReactEventHandler<HTMLImageElement>;
onLoad?: React.ReactEventHandler<HTMLImageElement>;
src?: string;
};
function ImagePreview({
@ -57,18 +64,17 @@ function ImagePreview({
src,
...props
}: ImagePreviewProps) {
const imgRef = useRef<HTMLImageElement>(null);
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
const [errorSrc, setErrorSrc] = useState<string | undefined>(undefined);
const imageSrc = src ?? "";
const loaded = loadedSrc === src;
const error = errorSrc === src;
const loaded = imageSrc !== "" && loadedSrc === imageSrc;
const error = imageSrc === "" || errorSrc === imageSrc;
useEffect(() => {
if (typeof src === "string" && imgRef.current?.complete && imgRef.current.naturalWidth > 0) {
setLoadedSrc(src);
}
}, [src]);
setLoadedSrc((current) => (current === imageSrc ? current : undefined));
setErrorSrc((current) => (current === imageSrc ? current : undefined));
}, [imageSrc]);
return (
<div data-slot="image-preview" className={cn("relative min-h-32", containerClassName)}>
@ -87,55 +93,22 @@ function ImagePreview({
>
<ImageOffIcon className="size-8 text-muted-foreground" />
</div>
) : isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
ref={imgRef}
src={src}
alt={alt}
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
onLoad={(e) => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.(e);
}}
onError={(e) => {
if (typeof src === "string") setErrorSrc(src);
onError?.(e);
}}
{...props}
/>
) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
// <img
// ref={imgRef}
// src={src}
// alt={alt}
// className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
// onLoad={(e) => {
// if (typeof src === "string") setLoadedSrc(src);
// onLoad?.(e);
// }}
// onError={(e) => {
// if (typeof src === "string") setErrorSrc(src);
// onError?.(e);
// }}
// {...props}
// />
<NextImage
fill
src={src || ""}
src={imageSrc}
alt={alt}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
className={cn("block object-contain", !loaded && "invisible", className)}
onLoad={() => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.();
onLoad={(event) => {
setLoadedSrc(imageSrc);
onLoad?.(event);
}}
onError={() => {
if (typeof src === "string") setErrorSrc(src);
onError?.();
onError={(event) => {
setErrorSrc(imageSrc);
onError?.(event);
}}
unoptimized={false}
unoptimized={isDataOrBlobUrl(imageSrc)}
{...props}
/>
)}
@ -196,59 +169,40 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
return (
<>
<button
<Button
type="button"
variant="ghost"
onClick={handleOpen}
className="aui-image-zoom-trigger cursor-zoom-in border-0 bg-transparent p-0 text-left"
className="aui-image-zoom-trigger h-auto cursor-zoom-in border-0 bg-transparent p-0 text-left hover:bg-transparent"
aria-label="Click to zoom image"
>
{children}
</button>
</Button>
{isMounted &&
isOpen &&
createPortal(
<button
<Button
type="button"
variant="ghost"
data-slot="image-zoom-overlay"
className="aui-image-zoom-overlay fade-in fixed inset-0 z-50 flex animate-in cursor-zoom-out items-center justify-center border-0 bg-black/80 p-0 duration-200"
className="aui-image-zoom-overlay fade-in fixed inset-0 z-50 h-auto w-auto animate-in cursor-zoom-out items-center justify-center rounded-none border-0 bg-black/80 p-0 duration-200 hover:bg-black/80 focus-visible:ring-0"
onClick={handleClose}
aria-label="Close zoomed image"
>
{/** biome-ignore lint/performance/noImgElement: <explanation> */}
{isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
data-slot="image-zoom-content"
src={src}
alt={alt}
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
handleClose();
}
}}
/>
) : (
<NextImage
data-slot="image-zoom-content"
fill
src={src}
alt={alt}
sizes="90vw"
className="aui-image-zoom-content fade-in zoom-in-95 object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
unoptimized={false}
/>
)}
</button>,
<NextImage
data-slot="image-zoom-content"
fill
src={src}
alt={alt}
sizes="90vw"
className="aui-image-zoom-content fade-in zoom-in-95 object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
unoptimized={isDataOrBlobUrl(src)}
/>
</Button>,
document.body
)}
</>

View file

@ -5,13 +5,23 @@ import { useSetAtom } from "jotai";
import { ExternalLink, FileText } from "lucide-react";
import dynamic from "next/dynamic";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useState } from "react";
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { CitationPanelContent } from "@/components/citation-panel/citation-panel";
import { Citation } from "@/components/tool-ui/citation";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -30,8 +40,6 @@ interface InlineCitationProps {
isDocsChunk?: boolean;
}
const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
/**
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
@ -53,7 +61,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
<Tooltip>
<TooltipTrigger asChild>
<span
className="ml-0.5 inline-flex h-5 min-w-5 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"
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
role="note"
>
<FileText className="size-3" />
@ -73,134 +81,175 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
};
const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
const openCitationPanel = useSetAtom(openCitationPanelAtom);
const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
const handleClick = () => {
if (isTouchLike) {
setMobilePreviewOpen(true);
return;
}
openCitationPanel({ chunkId });
};
return (
<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}`}
>
{chunkId}
</button>
<>
<Button
type="button"
variant="ghost"
onClick={handleClick}
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
title={`View source chunk #${chunkId}`}
aria-label={`View cited chunk ${chunkId}`}
>
{chunkId}
</Button>
<Drawer
open={mobilePreviewOpen}
onOpenChange={setMobilePreviewOpen}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[85vh] max-h-[85vh] z-80 overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerHeader className="pb-0">
<DrawerTitle>Citation</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<CitationPanelContent chunkId={chunkId} showHeader={false} />
</div>
</DrawerContent>
</Drawer>
</>
);
};
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
const docQuery = useSurfsenseDocPreviewQuery(chunkId, mobilePreviewOpen);
const cancelClose = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const handleMobileClick = () => {
setMobilePreviewOpen(true);
};
const scheduleClose = useCallback(() => {
cancelClose();
closeTimerRef.current = setTimeout(() => {
setOpen(false);
closeTimerRef.current = null;
}, POPOVER_HOVER_CLOSE_DELAY_MS);
}, [cancelClose]);
return (
<>
<CitationHoverPopover
id={`doc-${chunkId}`}
contentClassName="w-96 max-w-[calc(100vw-2rem)] p-0"
align="start"
trigger={(hoverProps) => (
<Button
type="button"
variant="ghost"
size={null}
onClick={isTouchLike ? handleMobileClick : undefined}
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
title="Surfsense documentation"
{...hoverProps}
>
<FileText className="size-3" />
doc
</Button>
)}
>
<SurfsenseDocPreview chunkId={chunkId} />
</CitationHoverPopover>
<Drawer
open={mobilePreviewOpen}
onOpenChange={setMobilePreviewOpen}
shouldScaleBackground={false}
>
<DrawerContent className="max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="pb-0">
<DrawerTitle>Surfsense documentation</DrawerTitle>
</DrawerHeader>
<SurfsenseDocPreviewContent
chunkId={chunkId}
query={docQuery}
contentClassName="max-h-[60vh]"
/>
</DrawerContent>
</Drawer>
</>
);
};
useEffect(() => () => cancelClose(), [cancelClose]);
const { data, isLoading, error } = useQuery({
function useSurfsenseDocPreviewQuery(chunkId: number, enabled = true) {
return useQuery({
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
enabled: open,
staleTime: 5 * 60 * 1000,
enabled,
});
}
type SurfsenseDocPreviewQuery = ReturnType<typeof useSurfsenseDocPreviewQuery>;
const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
const query = useSurfsenseDocPreviewQuery(chunkId);
return <SurfsenseDocPreviewContent chunkId={chunkId} query={query} />;
};
const SurfsenseDocPreviewContent: FC<{
chunkId: number;
query: SurfsenseDocPreviewQuery;
contentClassName?: string;
}> = ({ chunkId, query, contentClassName = "max-h-72" }) => {
const { data, isLoading, error } = query;
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()}
>
<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 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?.public_url && (
<a
href={data.public_url}
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={`${contentClassName} 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>
{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} enableCitations />
)}
{!isLoading && !error && !citedChunk?.content && (
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
)}
</div>
</PopoverContent>
</Popover>
)}
{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} enableCitations />
)}
{!isLoading && !error && !citedChunk?.content && (
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
)}
</div>
</>
);
};
function extractDomain(url: string): string {
try {
const hostname = new URL(url).hostname;
return hostname.replace(/^www\./, "");
} catch {
return url;
}
}
import { tryGetHostname } from "@/lib/url";
interface UrlCitationProps {
url: string;
@ -212,7 +261,7 @@ interface UrlCitationProps {
* page title and snippet (extracted deterministically from web_search tool results).
*/
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
const domain = extractDomain(url);
const domain = tryGetHostname(url) ?? url;
const meta = useCitationMetadata(url);
return (

View file

@ -1,6 +1,7 @@
"use client";
import { Folder as FolderIcon } from "lucide-react";
import { Folder as FolderIcon, X as XIcon } from "lucide-react";
import type { NodeEntry, TElement } from "platejs";
import type { PlateElementProps } from "platejs/react";
import {
createPlatePlugin,
@ -9,7 +10,16 @@ import {
PlateContent,
usePlateEditor,
} from "platejs/react";
import { type FC, forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from "react";
import {
createContext,
type FC,
forwardRef,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
@ -26,13 +36,9 @@ export interface MentionedDocument {
}
/**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``
* when omitted so legacy callers don't have to thread the
* discriminator. Folder callers pass ``kind: "folder"`` and the
* folder ``id`` and ``title``; ``document_type`` defaults to
* ``FOLDER_MENTION_DOCUMENT_TYPE`` inside ``insertMentionChip`` so the
* dedup key (`kind:document_type:id`) never collides with a doc chip
* that happens to share an id.
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``.
* Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE``
* so the dedup key never collides with a doc chip sharing the same id.
*/
export type MentionChipInput = {
id: number;
@ -87,12 +93,7 @@ type MentionElementNode = {
id: number;
title: string;
document_type?: string;
/**
* Discriminator added so a folder chip and a doc chip with the
* same id round-trip cleanly through ``getMentionedDocuments``
* and the persisted ``mentioned-documents`` content part.
* Defaults to ``"doc"`` for nodes that predate this field.
*/
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */
kind?: MentionKind;
statusLabel?: string | null;
statusKind?: MentionStatusKind;
@ -104,13 +105,22 @@ type ComposerValue = ComposerParagraph[];
const MENTION_TYPE = "mention";
const MENTION_CHIP_CLASSNAME =
"inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none";
"group inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none";
const MENTION_CHIP_ICON_CLASSNAME = "flex items-center text-muted-foreground leading-none";
const MENTION_CHIP_TITLE_CLASSNAME = "max-w-[120px] truncate leading-none";
const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6";
const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
/**
* Lets ``MentionElement`` reach the editor's chip-removal helper so
* the X button and Backspace go through the same call site.
*/
type MentionEditorContextValue = {
removeChip: (docId: number, docType: string | undefined) => void;
};
const MentionEditorContext = createContext<MentionEditorContextValue | null>(null);
const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
attributes,
children,
@ -124,16 +134,36 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
: "text-amber-700";
const isFolder = element.kind === "folder";
const ctx = useContext(MentionEditorContext);
return (
<span {...attributes} className="inline-flex align-middle">
<span contentEditable={false} className={`${MENTION_CHIP_CLASSNAME} cursor-default`}>
<span className={MENTION_CHIP_ICON_CLASSNAME}>
{isFolder ? (
<FolderIcon className="h-3 w-3" />
) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
<span className="relative flex h-3 w-3 items-center justify-center">
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
{isFolder ? (
<FolderIcon className="h-3 w-3" />
) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
</span>
{ctx ? (
<button
type="button"
aria-label={`Remove mention ${element.title}`}
title={`Remove ${element.title}`}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
ctx.removeChip(element.id, element.document_type);
}}
className="absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100"
>
<XIcon className="h-3 w-3" />
</button>
) : null}
</span>
</span>
<span className={MENTION_CHIP_TITLE_CLASSNAME} title={element.title}>
{element.title}
@ -294,17 +324,16 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
value: initialText ? toValueFromText(initialText) : EMPTY_VALUE,
});
// Move the caret to end-of-doc and focus the editor. Falls back
// to DOM focus if Plate's API throws (transient unmount race).
const focusAtEnd = useCallback(() => {
const el = editableRef.current;
if (!el) return;
el.focus();
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
}, []);
try {
editor.tf.select(editor.api.end([]));
editor.tf.focus();
} catch {
editableRef.current?.focus();
}
}, [editor]);
const getCurrentValue = useCallback(
() => (editor.children as ComposerValue) ?? EMPTY_VALUE,
@ -352,13 +381,18 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, emitState]
);
// Insert chip + trailing space as a single ``insertNodes`` call.
// The chip is a void inline; ``select: true`` on it alone would
// land the caret inside its empty children (an unrenderable
// point). With the space as the last inserted node, the caret
// resolves to that text node and stays visible. The
// ``withoutNormalizing`` wrapper batches the optional trigger
// delete + insert into a single undo step.
const insertMentionChip = useCallback(
(mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => {
if (typeof mention.id !== "number" || typeof mention.title !== "string") return;
const removeTriggerText = options?.removeTriggerText ?? true;
const current = getCurrentValue();
const selection = editor.selection;
const kind: MentionKind = mention.kind ?? "doc";
const document_type =
mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
@ -371,65 +405,48 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
children: [{ text: "" }],
};
const cursorCtx = getCursorTextContext(current, selection);
if (!cursorCtx) {
const lastBlock = current[current.length - 1] ?? { type: "p", children: [{ text: "" }] };
const appended: ComposerValue = [
...current.slice(0, -1),
{
...lastBlock,
children: [...lastBlock.children, mentionNode, { text: " " }],
},
];
setValue(appended);
requestAnimationFrame(focusAtEnd);
return;
}
editor.tf.withoutNormalizing(() => {
const selection = editor.selection;
const block = current[cursorCtx.blockIndex];
const currentChild = getTextNode(block.children[cursorCtx.childIndex]);
if (!currentChild) {
const children = [...block.children];
children.splice(cursorCtx.childIndex + 1, 0, mentionNode, { text: " " });
const next = [...current];
next[cursorCtx.blockIndex] = { ...block, children };
setValue(next as ComposerValue);
requestAnimationFrame(focusAtEnd);
return;
}
const text = currentChild.text;
let removeStart = cursorCtx.cursor;
if (removeTriggerText) {
for (let i = cursorCtx.cursor - 1; i >= 0; i--) {
if (text[i] === "@") {
removeStart = i;
break;
// No active selection (focus moved to a picker) — snap
// to end-of-doc so the chip appends cleanly.
if (!selection) {
editor.tf.select(editor.api.end([]));
} else if (removeTriggerText) {
// Delete the in-progress "@query" so the chip stands in for it.
const cursorCtx = getCursorTextContext(getCurrentValue(), selection);
if (cursorCtx) {
const text = cursorCtx.text;
let triggerIndex = -1;
for (let i = cursorCtx.cursor - 1; i >= 0; i--) {
if (text[i] === "@") {
triggerIndex = i;
break;
}
if (text[i] === " " || text[i] === "\n") break;
}
if (triggerIndex >= 0 && triggerIndex < cursorCtx.cursor) {
const path = [cursorCtx.blockIndex, cursorCtx.childIndex];
editor.tf.delete({
at: {
anchor: { path, offset: triggerIndex },
focus: { path, offset: cursorCtx.cursor },
},
});
}
}
if (text[i] === " " || text[i] === "\n") break;
}
}
const before = text.slice(0, removeStart);
const after = text.slice(cursorCtx.cursor);
const replacement: ComposerNode[] = [];
if (before.length > 0) replacement.push({ text: before });
replacement.push(mentionNode);
replacement.push({ text: ` ${after}` });
const children = [...block.children];
children.splice(cursorCtx.childIndex, 1, ...replacement);
const next = [...current];
next[cursorCtx.blockIndex] = { ...block, children };
setValue(next as ComposerValue);
requestAnimationFrame(focusAtEnd);
editor.tf.insertNodes([mentionNode, { text: " " }] as unknown as TElement[], {
select: true,
});
});
editor.tf.focus();
},
[editor.selection, focusAtEnd, getCurrentValue, setValue]
[editor, getCurrentValue]
);
// Backwards-compatible shim — pre-folder callers pass a doc-only
// payload; we route them through ``insertMentionChip`` with
// ``kind: "doc"``.
// Doc-only shim that routes through ``insertMentionChip``.
const insertDocumentChip = useCallback(
(
doc: Pick<Document, "id" | "title" | "document_type">,
@ -440,26 +457,43 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[insertMentionChip]
);
// Remove chip(s) matching (id, document_type). Iterates in
// descending path order so removing one entry can't invalidate
// later paths. Chips are deduped today, so this typically runs
// at most once.
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
const current = getCurrentValue();
let changed = false;
const next = current.map((block) => {
const children = block.children.filter((node) => {
if (!isMentionNode(node)) return true;
const match =
node.id === docId && (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
if (match) changed = true;
return !match;
});
return { ...block, children: children.length ? children : [{ text: "" }] };
const match = (n: unknown) => {
if (!n || typeof n !== "object" || !("type" in n)) return false;
const node = n as MentionElementNode;
if (node.type !== MENTION_TYPE) return false;
if (node.id !== docId) return false;
return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
};
const entries = Array.from(editor.api.nodes({ at: [], match })) as NodeEntry[];
if (entries.length === 0) return;
editor.tf.withoutNormalizing(() => {
for (const [, path] of entries.reverse()) {
editor.tf.removeNodes({ at: path });
}
});
if (!changed) return;
setValue(next as ComposerValue);
},
[getCurrentValue, setValue]
[editor]
);
// Single removal call site for Backspace and the X button so the
// two can never diverge (e.g. one forgetting to notify the parent).
const removeChip = useCallback(
(docId: number, docType: string | undefined) => {
removeDocumentChip(docId, docType);
onDocumentRemove?.(docId, docType);
},
[onDocumentRemove, removeDocumentChip]
);
// Update chip status in place via ``tf.setNodes`` so the user's
// selection survives backend status events arriving mid-typing.
const setDocumentChipStatus = useCallback(
(
docId: number,
@ -467,31 +501,31 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
statusLabel: string | null,
statusKind: MentionStatusKind = "pending"
) => {
const current = getCurrentValue();
let changed = false;
const next = current.map((block) => ({
...block,
children: block.children.map((node) => {
if (!isMentionNode(node)) return node;
const sameType = (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
if (node.id !== docId || !sameType) return node;
changed = true;
return {
...node,
statusLabel,
statusKind: statusLabel ? statusKind : undefined,
};
}),
}));
if (!changed) return;
setValue(next as ComposerValue);
const match = (n: unknown) => {
if (!n || typeof n !== "object" || !("type" in n)) return false;
const node = n as MentionElementNode;
if (node.type !== MENTION_TYPE) return false;
if (node.id !== docId) return false;
return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
};
editor.tf.setNodes(
{
statusLabel,
statusKind: statusLabel ? statusKind : undefined,
} as Partial<TElement>,
{ at: [], match }
);
},
[getCurrentValue, setValue]
[editor]
);
const clear = useCallback(() => {
setValue(EMPTY_VALUE);
}, [setValue]);
// ``tf.setValue`` wipes the selection — refocus so the caret
// returns after Enter-to-submit.
requestAnimationFrame(focusAtEnd);
}, [focusAtEnd, setValue]);
const setText = useCallback(
(text: string) => {
@ -510,7 +544,18 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
useImperativeHandle(
ref,
() => ({
focus: () => editableRef.current?.focus(),
// Preserve existing selection if any; otherwise seed one
// at end-of-doc so the contentEditable shows a caret.
focus: () => {
try {
if (!editor.selection) {
editor.tf.select(editor.api.end([]));
}
editor.tf.focus();
} catch {
editableRef.current?.focus();
}
},
clear,
setText,
getText,
@ -522,6 +567,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}),
[
clear,
editor,
getMentionedDocs,
getText,
insertMentionChip,
@ -564,10 +610,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (!isMentionNode(prev)) return;
e.preventDefault();
removeDocumentChip(prev.id, prev.document_type);
onDocumentRemove?.(prev.id, prev.document_type);
removeChip(prev.id, prev.document_type);
},
[editor.selection, getCurrentValue, onDocumentRemove, onKeyDown, onSubmit, removeDocumentChip]
[editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip]
);
const editableProps = useMemo(
@ -584,26 +629,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, handleKeyDown, placeholder]
);
const mentionEditorContextValue = useMemo<MentionEditorContextValue>(
() => ({ removeChip }),
[removeChip]
);
return (
<div className="relative w-full">
<Plate
editor={editor}
onChange={({ value }) => {
emitState(value as ComposerValue);
}}
>
<PlateContent
ref={editableRef}
readOnly={disabled}
{...editableProps}
className={cn(
"min-h-[24px] max-h-32 overflow-y-auto outline-none whitespace-pre-wrap wrap-break-word",
COMPOSER_TEXT_METRICS_CLASSNAME,
disabled && "opacity-50 cursor-not-allowed",
className
)}
/>
</Plate>
<MentionEditorContext.Provider value={mentionEditorContextValue}>
<Plate
editor={editor}
onChange={({ value }) => {
emitState(value as ComposerValue);
}}
>
<PlateContent
ref={editableRef}
readOnly={disabled}
{...editableProps}
className={cn(
"min-h-[24px] max-h-32 overflow-y-auto outline-none whitespace-pre-wrap wrap-break-word",
COMPOSER_TEXT_METRICS_CLASSNAME,
disabled && "opacity-50 cursor-not-allowed",
className
)}
/>
</Plate>
</MentionEditorContext.Provider>
</div>
);
}

View file

@ -7,7 +7,6 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn, copyToClipboard } from "@/lib/utils";
type MarkdownCodeBlockProps = {
@ -49,7 +48,7 @@ function MarkdownCodeBlockComponent({
}, [hasCopied]);
return (
<div className="mt-4 overflow-hidden rounded-2xl" style={{ background: "var(--syntax-bg)" }}>
<div className="mt-4 overflow-hidden rounded-md bg-accent">
<div className="flex items-center justify-between gap-4 px-4 py-2 font-semibold text-muted-foreground text-sm">
<span className="lowercase text-xs">{language}</span>
<Button
@ -82,23 +81,3 @@ function MarkdownCodeBlockComponent({
}
export const MarkdownCodeBlock = memo(MarkdownCodeBlockComponent);
export function MarkdownCodeBlockSkeleton() {
return (
<div
className="mt-4 overflow-hidden rounded-2xl border"
style={{ background: "var(--syntax-bg)" }}
>
<div className="flex items-center justify-between gap-4 border-b px-4 py-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
<div className="space-y-2 p-4">
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-8/12" />
<Skeleton className="h-4 w-9/12" />
</div>
</div>
);
}

View file

@ -22,7 +22,6 @@ import { MentionChip } from "@/components/assistant-ui/mention-chip";
import "katex/dist/katex.min.css";
import { toast } from "sonner";
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
@ -35,32 +34,17 @@ import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getVirtualPathDisplay } from "@/lib/chat/virtual-path-display";
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
import { tryGetHostname } from "@/lib/url";
import { cn } from "@/lib/utils";
function MarkdownCodeBlockSkeleton() {
return (
<div
className="mt-4 overflow-hidden rounded-2xl border"
style={{ background: "var(--syntax-bg)" }}
>
<div className="flex items-center justify-between gap-4 border-b px-4 py-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
<div className="space-y-2 p-4">
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-8/12" />
<Skeleton className="h-4 w-9/12" />
</div>
</div>
);
function MarkdownCodeBlockLoading() {
return <div className="mt-4 h-32 overflow-hidden rounded-md bg-accent" />;
}
const LazyMarkdownCodeBlock = dynamic(
() => import("./markdown-code-block").then((mod) => mod.MarkdownCodeBlock),
{
loading: () => <MarkdownCodeBlockSkeleton />,
loading: () => <MarkdownCodeBlockLoading />,
}
);
@ -139,15 +123,6 @@ const MarkdownTextImpl = () => {
export const MarkdownText = memo(MarkdownTextImpl);
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, "");
} catch {
return "";
}
}
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
@ -288,7 +263,7 @@ function FilePathLink({ path, className }: { path: string; className?: string })
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
if (!src) return null;
const domain = extractDomain(src);
const domain = tryGetHostname(src) ?? "";
return (
<div className="my-4 w-fit max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
@ -450,7 +425,7 @@ const defaultComponents = memoizeMarkdownComponents({
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
),
table: ({ className, ...props }) => (
<div className="aui-md-table-wrapper my-5 overflow-hidden rounded-2xl border">
<div className="aui-md-table-wrapper my-5 overflow-hidden rounded-md border">
<Table className={cn("aui-md-table", className)} {...props} />
</div>
),
@ -527,7 +502,7 @@ const defaultComponents = memoizeMarkdownComponents({
return (
<code
className={cn(
"aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal",
"aui-md-inline-code rounded-md bg-primary/10 px-1.5 py-0.5 font-mono text-[0.9em] font-normal text-primary/80",
className
)}
{...props}
@ -540,7 +515,7 @@ const defaultComponents = memoizeMarkdownComponents({
return (
<code
className={cn(
"aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal",
"aui-md-inline-code rounded-md bg-primary/10 px-1.5 py-0.5 font-mono text-[0.9em] font-normal text-primary/80",
className
)}
{...props}

View file

@ -66,23 +66,21 @@ export function MentionChip({
disabled={disabled}
aria-label={ariaLabel ?? label}
className={cn(
"inline-flex max-w-[220px] items-center gap-1.5 rounded-md border bg-background px-2 py-0.5 align-middle text-xs font-medium text-foreground leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isInteractive
? "cursor-pointer hover:bg-accent hover:text-accent-foreground"
: "cursor-default",
"inline-flex h-5 items-center gap-1 rounded bg-primary/10 px-1 align-middle text-xs font-bold text-primary/60 leading-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isInteractive ? "cursor-pointer" : "cursor-default",
disabled && "opacity-60",
className
)}
>
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
<span className="truncate">{label}</span>
<span className="max-w-[120px] truncate leading-none">{label}</span>
</button>
);
if (!tooltip) return chip;
return (
<Tooltip>
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs break-all">
{tooltip}

View file

@ -4,6 +4,7 @@ import type { ReasoningMessagePartComponent } from "@assistant-ui/react";
import { ChevronRightIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/**
@ -11,9 +12,9 @@ import { cn } from "@/lib/utils";
* (typed reasoning deltas from the chat model).
*
* Behaviour mirrors the existing `ThinkingStepsDisplay`:
* - collapsed by default;
* - auto-expanded while the part is still `running`;
* - auto-collapsed once status flips to `complete`.
* - collapsed by default;
* - auto-expanded while the part is still `running`;
* - auto-collapsed once status flips to `complete`.
*
* The component is registered via the `Reasoning` slot on
* `MessagePrimitive.Parts` in `assistant-message.tsx` so it lives at the
@ -45,12 +46,13 @@ export const ReasoningMessagePart: ReasoningMessagePartComponent = ({ text, stat
return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<div className="rounded-lg">
<button
<Button
variant="ghost"
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"
"h-auto w-full justify-start gap-1.5 p-0 text-left text-sm font-normal transition-colors hover:bg-transparent",
"text-muted-foreground hover:text-accent-foreground"
)}
>
{isRunning ? (
@ -59,9 +61,10 @@ export const ReasoningMessagePart: ReasoningMessagePartComponent = ({ text, stat
<span>{headerLabel}</span>
)}
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
data-icon="inline-end"
className={cn("transition-transform duration-200", isOpen && "rotate-90")}
/>
</button>
</Button>
<div
className={cn(

View file

@ -5,7 +5,7 @@
* assistant turn that has at least one reversible action.
*
* The button reads from the unified ``useAgentActionsQuery`` cache
* (the SAME react-query cache the agent-actions sheet and the inline
* (the SAME react-query cache the agent-actions dialog and the inline
* Revert button consume) filtered by ``chat_turn_id``. It shows a
* confirmation dialog summarising "N reversible / M total" and, on
* confirm, calls ``POST /threads/{id}/revert-turn/{chat_turn_id}``.
@ -15,6 +15,7 @@
* with their messages.
*/
import { ActionBarMorePrimitive } from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { CheckIcon, RotateCcw, XCircleIcon } from "lucide-react";
@ -47,9 +48,10 @@ import { cn } from "@/lib/utils";
interface RevertTurnButtonProps {
chatTurnId: string | null | undefined;
variant?: "button" | "menu-item";
}
export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
export function RevertTurnButton({ chatTurnId, variant = "button" }: RevertTurnButtonProps) {
const session = useAtomValue(chatSessionStateAtom);
const threadId = session?.threadId ?? null;
const queryClient = useQueryClient();
@ -125,23 +127,39 @@ export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
return (
<>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground gap-1.5"
onClick={(e) => {
e.stopPropagation();
{variant === "menu-item" ? (
<ActionBarMorePrimitive.Item
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onSelect={(e) => {
e.preventDefault();
setConfirmOpen(true);
}}
>
<RotateCcw className="size-3.5" />
<span>Revert turn</span>
<span className="text-xs tabular-nums opacity-70">
<span className="ml-auto text-xs tabular-nums opacity-70">
{reversibleCount}/{totalCount}
</span>
</Button>
</AlertDialogTrigger>
</ActionBarMorePrimitive.Item>
) : (
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-accent-foreground gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
>
<RotateCcw className="size-3.5" />
<span>Revert turn</span>
<span className="text-xs tabular-nums opacity-70">
{reversibleCount}/{totalCount}
</span>
</Button>
</AlertDialogTrigger>
)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this turn?</AlertDialogTitle>

View file

@ -1,298 +0,0 @@
"use client";
import {
ArchiveIcon,
MessageSquareIcon,
MoreVerticalIcon,
PlusIcon,
RotateCcwIcon,
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
createThreadListManager,
type ThreadListItem,
type ThreadListState,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
interface ThreadListProps {
searchSpaceId: number;
currentThreadId?: number;
className?: string;
}
export function ThreadList({ searchSpaceId, currentThreadId, className }: ThreadListProps) {
const router = useRouter();
const [state, setState] = useState<ThreadListState>({
threads: [],
archivedThreads: [],
isLoading: true,
error: null,
});
const [showArchived, setShowArchived] = useState(false);
// Create the thread list manager
const manager = useCallback(
() =>
createThreadListManager({
searchSpaceId,
currentThreadId: currentThreadId ?? null,
onThreadSwitch: (threadId) => {
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
},
onNewThread: (threadId) => {
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
},
}),
[searchSpaceId, currentThreadId, router]
);
// Load threads on mount and when searchSpaceId changes
const loadThreads = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
const newState = await manager().loadThreads();
setState(newState);
}, [manager]);
useEffect(() => {
loadThreads();
}, [loadThreads]);
// Handle new thread creation
const handleNewThread = async () => {
await manager().createNewThread();
await loadThreads();
};
// Handle thread actions
const handleArchive = async (threadId: number) => {
const success = await manager().archiveThread(threadId);
if (success) await loadThreads();
};
const handleUnarchive = async (threadId: number) => {
const success = await manager().unarchiveThread(threadId);
if (success) await loadThreads();
};
const handleDelete = async (threadId: number) => {
const success = await manager().deleteThread(threadId);
if (success) {
await loadThreads();
// If we deleted the current thread, redirect to new chat
if (threadId === currentThreadId) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}
};
const handleSwitchToThread = (threadId: number) => {
manager().switchToThread(threadId);
};
const displayedThreads = showArchived ? state.archivedThreads : state.threads;
if (state.isLoading) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="flex items-center justify-center p-4">
<span className="text-muted-foreground text-sm">Loading threads...</span>
</div>
</div>
);
}
if (state.error) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="p-4 text-center">
<span className="text-destructive text-sm">{state.error}</span>
<Button variant="ghost" size="sm" className="mt-2" onClick={loadThreads}>
Retry
</Button>
</div>
</div>
);
}
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Header with New Chat button */}
<div className="flex items-center justify-between border-b p-3">
<h2 className="font-semibold text-sm">Conversations</h2>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={handleNewThread}
title="New Chat"
>
<PlusIcon className="size-4" />
</Button>
</div>
{/* Tab toggle for active/archived */}
<div className="flex border-b">
<button
type="button"
onClick={() => setShowArchived(false)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
!showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Active ({state.threads.length})
</button>
<button
type="button"
onClick={() => setShowArchived(true)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Archived ({state.archivedThreads.length})
</button>
</div>
{/* Thread list */}
<div className="flex-1 overflow-y-auto">
{displayedThreads.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 text-center">
<MessageSquareIcon className="mb-2 size-8 text-muted-foreground/50" />
<p className="text-muted-foreground text-sm">
{showArchived ? "No archived conversations" : "No conversations yet"}
</p>
{!showArchived && (
<Button variant="outline" size="sm" className="mt-3" onClick={handleNewThread}>
<PlusIcon className="mr-1 size-3" />
Start a conversation
</Button>
)}
</div>
) : (
<div className="space-y-1 p-2">
{displayedThreads.map((thread) => (
<ThreadListItemComponent
key={thread.id}
thread={thread}
isActive={thread.id === currentThreadId}
isArchived={showArchived}
onClick={() => handleSwitchToThread(thread.id)}
onArchive={() => handleArchive(thread.id)}
onUnarchive={() => handleUnarchive(thread.id)}
onDelete={() => handleDelete(thread.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
interface ThreadListItemComponentProps {
thread: ThreadListItem;
isActive: boolean;
isArchived: boolean;
onClick: () => void;
onArchive: () => void;
onUnarchive: () => void;
onDelete: () => void;
}
const ThreadListItemComponent = memo(function ThreadListItemComponent({
thread,
isActive,
isArchived,
onClick,
onArchive,
onUnarchive,
onDelete,
}: ThreadListItemComponentProps) {
const relativeTime = useMemo(
() => formatRelativeTime(new Date(thread.updatedAt)),
[thread.updatedAt]
);
return (
<button
type="button"
className={cn(
"group flex w-full items-center gap-2 rounded-lg px-3 py-2 transition-colors cursor-pointer text-left",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
)}
onClick={onClick}
>
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{thread.title || "New Chat"}</p>
<p className="truncate text-xs text-muted-foreground">{relativeTime}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isArchived ? (
<DropdownMenuItem onClick={onUnarchive}>
<RotateCcwIcon className="mr-2 size-4" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={onArchive}>
<ArchiveIcon className="mr-2 size-4" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete}>
<TrashIcon className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</button>
);
});
/**
* Format a date as relative time (e.g., "2 hours ago", "Yesterday")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "Just now";
if (diffMins < 60) return `${diffMins} min${diffMins === 1 ? "" : "s"} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
}

File diff suppressed because it is too large Load diff

View file

@ -53,7 +53,7 @@ const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
}
return (
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary select-none">
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground select-none">
{initials}
</div>
);
@ -136,7 +136,7 @@ export const UserMessage: FC = () => {
<div className="col-start-2 min-w-0">
<div className="aui-user-message-content-wrapper flex items-end gap-2">
<div className="relative flex-1 min-w-0">
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<div className="aui-user-message-content wrap-break-word rounded-xl bg-muted px-4 py-2.5 text-foreground">
<MessagePrimitive.Parts components={userMessageParts} />
</div>
<div className="absolute right-0 top-full mt-1 z-10 opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:transition-opacity md:duration-200 md:delay-300 md:group-hover/user-msg:opacity-100 md:group-hover/user-msg:delay-0 md:group-hover/user-msg:pointer-events-auto">

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "motion/react";
import Link from "next/link";
import { useState } from "react";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
@ -46,8 +46,11 @@ interface SignInButtonProps {
export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
trackLoginAttempt("google");
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
@ -55,35 +58,34 @@ export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
const getClassName = () => {
if (variant === "desktop") {
return isGoogleAuth
? "hidden rounded-full bg-white px-5 py-2 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg md:flex dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
? "hidden rounded-full border border-white bg-white px-5 py-2 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] md:flex dark:border-white"
: "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black";
}
if (variant === "compact") {
return isGoogleAuth
? "rounded-full bg-white px-4 py-1.5 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
? "rounded-full border border-white bg-white px-4 py-1.5 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white"
: "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black";
}
// mobile
return isGoogleAuth
? "w-full rounded-lg bg-white px-8 py-2.5 text-neutral-700 shadow-md ring-1 ring-neutral-200/50 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 touch-manipulation"
? "w-full rounded-lg border border-white bg-white px-8 py-2.5 font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white touch-manipulation"
: "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation";
};
if (isGoogleAuth) {
return (
<motion.button
<button
type="button"
onClick={handleGoogleLogin}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={isRedirecting}
className={cn(
"flex items-center justify-center gap-2 font-semibold transition-all duration-200",
"flex items-center justify-center gap-2 transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50",
getClassName()
)}
>
<GoogleLogo className="h-4 w-4" />
<span>Sign In</span>
</motion.button>
</button>
);
}

View file

@ -213,7 +213,7 @@ export function CommentItem({
<Button
variant="ghost"
size="sm"
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-accent-foreground"
onClick={() => onReply(comment.id)}
>
<MessageCircleReply className="mr-1 size-3" />

View file

@ -1,5 +1,6 @@
"use client";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread";
@ -85,7 +86,9 @@ export function CommentPanel({
</div>
)}
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
{hasThreads && !isMobile ? <Separator className="bg-sidebar-border" /> : null}
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-sidebar")}>
<CommentComposer
members={members}
membersLoading={membersLoading}

View file

@ -1,6 +1,5 @@
"use client";
import { MessageCircleReply } from "lucide-react";
import {
Drawer,
DrawerContent,
@ -29,8 +28,7 @@ export function CommentSheet({
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<MessageCircleReply className="size-5" />
<DrawerTitle className="flex items-center justify-center gap-2 text-base font-semibold">
Comments
{commentCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
@ -56,7 +54,6 @@ export function CommentSheet({
>
<SheetHeader className="flex-shrink-0 px-4 py-4">
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
<MessageCircleReply className="size-5" />
Comments
{commentCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">

View file

@ -92,7 +92,7 @@ export function CommentThread({
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
className="h-6 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
onClick={() => setIsRepliesExpanded((prev) => !prev)}
>
{isRepliesExpanded ? (

View file

@ -1,6 +1,7 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { MemberMentionItemProps } from "./types";
@ -25,11 +26,14 @@ export function MemberMentionItem({
const displayName = member.displayName || member.email.split("@")[0];
return (
<button
<Button
variant="ghost"
type="button"
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
isHighlighted ? "bg-primary/15 text-accent-foreground" : "hover:bg-accent/50"
"h-auto w-full justify-start gap-3 rounded-none px-3 py-2 text-left transition-colors",
isHighlighted
? "bg-primary/15 text-accent-foreground"
: "hover:bg-accent hover:text-accent-foreground"
)}
onClick={() => onSelect(member)}
onMouseEnter={onMouseEnter}
@ -44,6 +48,6 @@ export function MemberMentionItem({
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">{member.email}</span>
</div>
</button>
</Button>
);
}

View file

@ -2,9 +2,9 @@
import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { ChevronDown, ChevronUp, ExternalLink, XIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef } from "react";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
@ -12,29 +12,27 @@ 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;
showHeader?: boolean;
}
/**
* 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.
* The user can jump to the full document via the editor panel.
*/
export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, onClose }) => {
export const CitationPanelContent: FC<CitationPanelContentProps> = ({
chunkId,
onClose,
showHeader = true,
}) => {
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setExpanded(false);
}, []);
const chunkWindow = expanded ? EXPANDED_CHUNK_WINDOW : DEFAULT_CHUNK_WINDOW;
const chunkWindow = DEFAULT_CHUNK_WINDOW;
const { data, isLoading, error } = useQuery({
queryKey: ["citation-panel", chunkId, chunkWindow] as const,
@ -50,14 +48,6 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
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;
@ -94,43 +84,60 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
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 className="shrink-0">
{showHeader && (
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
<h2 className="select-none text-lg font-semibold">Citation</h2>
<div className="flex items-center gap-1 shrink-0">
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close citation panel</span>
</Button>
)}
</div>
</div>
</div>
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
)}
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b 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 className="flex items-center gap-3 shrink-0 text-[11px] text-muted-foreground">
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
{!isLoading && !error && data && (
<Button
variant="default"
size="sm"
className="h-6 px-1.5 text-[11px]"
onClick={handleOpenFullDocument}
>
Open
</Button>
)}
</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 className="flex min-h-full items-center justify-center text-muted-foreground">
<Spinner size="md" />
</div>
)}
{error && (
<p className="py-8 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load citation"}
</p>
<div className="flex min-h-full items-center justify-center text-center">
<p className="text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load citation"}
</p>
</div>
)}
{!isLoading && !error && data && (
@ -150,22 +157,22 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
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"
? "rounded-md border-2 border-primary bg-accent px-4 py-3 shadow-sm"
: "rounded-md bg-accent 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] text-muted-foreground"
: "text-[11px] font-medium text-muted-foreground"
}
>
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`}
Chunk #{chunk.id}
</span>
{isCited && (
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span>
<span className="text-[11px] font-semibold text-primary">Cited chunk</span>
)}
</div>
<div className="text-sm">
@ -184,47 +191,6 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
</>
)}
</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

@ -13,6 +13,7 @@ import {
Presentation,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
@ -261,15 +262,17 @@ export function DriveFolderTree({
<div
className={cn(
"flex items-center group gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
isFolder && "hover:bg-accent cursor-pointer",
isFolder && "hover:bg-accent hover:text-accent-foreground cursor-pointer",
!isFolder && "cursor-default opacity-60",
isSelected && "bg-accent/50"
isSelected && "bg-accent text-accent-foreground"
)}
>
{isFolder ? (
<button
<Button
type="button"
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer"
variant="ghost"
size="icon"
className="h-3 w-3 shrink-0 cursor-pointer bg-transparent p-0 hover:bg-transparent sm:h-4 sm:w-4"
onClick={(e) => {
e.stopPropagation();
toggleFolder(item);
@ -283,7 +286,7 @@ export function DriveFolderTree({
) : (
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</button>
</Button>
) : (
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
)}
@ -314,13 +317,14 @@ export function DriveFolderTree({
</div>
{isFolder ? (
<button
<Button
type="button"
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer"
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start truncate bg-transparent p-0 text-left text-xs font-normal hover:bg-transparent hover:text-inherit sm:text-sm"
onClick={() => toggleFolder(item)}
>
{item.name}
</button>
</Button>
) : (
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
{item.name}
@ -356,13 +360,14 @@ export function DriveFolderTree({
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
/>
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-muted-foreground shrink-0" />
<button
<Button
type="button"
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
variant="ghost"
className="h-auto min-w-0 justify-start truncate bg-transparent p-0 text-left text-xs font-semibold hover:bg-transparent hover:text-inherit sm:text-sm"
onClick={() => toggleFolderSelection("root", rootLabel)}
>
{rootLabel}
</button>
</Button>
</div>
</div>

View file

@ -13,6 +13,7 @@ import {
Presentation,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
@ -237,15 +238,17 @@ export function GoogleDriveFolderTree({
<div
className={cn(
"flex items-center group gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
isFolder && "hover:bg-accent cursor-pointer",
isFolder && "hover:bg-accent hover:text-accent-foreground cursor-pointer",
!isFolder && "cursor-default opacity-60",
isSelected && "bg-accent/50"
isSelected && "bg-accent text-accent-foreground"
)}
>
{isFolder ? (
<button
<Button
type="button"
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer"
variant="ghost"
size="icon"
className="h-3 w-3 shrink-0 cursor-pointer bg-transparent p-0 hover:bg-transparent sm:h-4 sm:w-4"
onClick={(e) => {
e.stopPropagation();
toggleFolder(item);
@ -259,7 +262,7 @@ export function GoogleDriveFolderTree({
) : (
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</button>
</Button>
) : (
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
)}
@ -290,13 +293,14 @@ export function GoogleDriveFolderTree({
</div>
{isFolder ? (
<button
<Button
type="button"
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer"
variant="ghost"
className="h-auto min-w-0 flex-1 cursor-pointer justify-start truncate bg-transparent p-0 text-left text-xs hover:bg-transparent sm:text-sm"
onClick={() => toggleFolder(item)}
>
{item.name}
</button>
</Button>
) : (
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
{item.name}
@ -332,13 +336,14 @@ export function GoogleDriveFolderTree({
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
/>
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-muted-foreground shrink-0" />
<button
<Button
type="button"
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
variant="ghost"
className="h-auto cursor-pointer truncate bg-transparent p-0 text-left text-xs font-semibold hover:bg-transparent sm:text-sm"
onClick={() => toggleFolderSelection("root", "My Drive")}
>
My Drive
</button>
</Button>
</div>
</div>

View file

@ -48,9 +48,9 @@ export const DEFAULT_SHORTCUTS = {
export function Kbd({ keys, className }: { keys: string[]; className?: string }) {
return (
<span className={cn("inline-flex items-center gap-0.5", className)}>
{keys.map((key, i) => (
{keys.map((key) => (
<kbd
key={`${key}-${i}`}
key={key}
className={cn(
"inline-flex h-6 min-w-6 items-center justify-center rounded border bg-muted px-1 font-mono text-[11px] font-medium text-muted-foreground",
key.length > 3 && "px-1.5"
@ -136,14 +136,15 @@ export function ShortcutRecorder({
<RotateCcw className="size-3" />
</Button>
)}
<button
<Button
variant="ghost"
ref={inputRef}
type="button"
onClick={() => setRecording(true)}
onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)}
className={cn(
"flex h-7 items-center gap-0.5 rounded-md border px-2 transition-all focus:outline-none",
"h-7 justify-start gap-0.5 rounded-md border px-2 transition-all",
recording
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-input bg-muted/40 hover:bg-muted"
@ -156,7 +157,7 @@ export function ShortcutRecorder({
) : (
<Kbd keys={displayKeys} />
)}
</button>
</Button>
</div>
</div>
);

View file

@ -138,24 +138,14 @@ export const DocumentNode = React.memo(function DocumentNode({
return (
<ContextMenu onOpenChange={onContextMenuOpenChange}>
<ContextMenuTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: contains nested interactive children (Checkbox) that render as <button>, making a semantic <button> wrapper invalid */}
<div
role="button"
tabIndex={0}
ref={attachRef}
className={cn(
"group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none text-left",
isMentioned && "bg-accent/30",
"group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer select-none text-left",
isMentioned && "bg-accent text-accent-foreground",
isDragging && "opacity-40"
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={handleCheckChange}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCheckChange();
}
}}
>
{(() => {
if (statusState === "pending") {
@ -212,9 +202,17 @@ export const DocumentNode = React.memo(function DocumentNode({
onOpenChange={handleTitleTooltipOpenChange}
>
<TooltipTrigger asChild>
<span ref={titleRef} className="flex-1 min-w-0 truncate">
{doc.title}
</span>
<Button
type="button"
variant="ghost"
aria-disabled={!isSelectable}
onClick={handleCheckChange}
className="h-full min-w-0 flex-1 justify-start bg-transparent px-0 py-0 text-left font-normal text-inherit hover:bg-transparent hover:text-inherit"
>
<span ref={titleRef} className="min-w-0 flex-1 truncate">
{doc.title}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs break-words">
{doc.title}

View file

@ -78,19 +78,19 @@ export function DocumentsFilters({
<div className="flex select-none">
<div className="flex items-center gap-2 w-full">
{/* New Folder + AI Sort + Filter Toggle Group */}
<ToggleGroup type="multiple" variant="outline" value={[]} className="overflow-visible">
<ToggleGroup type="multiple" value={[]} className="overflow-visible">
{onCreateFolder && (
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="folder"
className="h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
className="h-8 w-8 shrink-0 border-0 bg-muted text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.preventDefault();
onCreateFolder();
}}
>
<FolderPlus size={14} />
<FolderPlus size={13} />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>New folder</TooltipContent>
@ -104,11 +104,12 @@ export function DocumentsFilters({
value="ai-sort"
disabled={aiSortBusy}
className={cn(
"h-9 w-9 shrink-0 border bg-muted/50 transition-colors",
"h-8 w-8 shrink-0 border-0 bg-muted transition-colors",
"relative before:absolute before:left-0 before:top-1/2 before:h-4 before:w-px before:-translate-y-1/2 before:bg-border/60 before:content-[''] dark:before:bg-white/10",
"disabled:pointer-events-none disabled:opacity-50",
aiSortEnabled
? "bg-accent text-accent-foreground hover:bg-accent"
: "text-muted-foreground hover:bg-muted/80 hover:text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
onClick={(e) => {
e.preventDefault();
@ -120,9 +121,9 @@ export function DocumentsFilters({
{aiSortBusy ? (
<Spinner size="xs" />
) : aiSortEnabled ? (
<IconBinaryTreeFilled size={16} />
<IconBinaryTreeFilled size={14} />
) : (
<IconBinaryTree size={16} />
<IconBinaryTree size={14} />
)}
</ToggleGroupItem>
</TooltipTrigger>
@ -142,9 +143,9 @@ export function DocumentsFilters({
<PopoverTrigger asChild>
<ToggleGroupItem
value="filter"
className="relative h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground overflow-visible"
className="relative h-8 w-8 shrink-0 overflow-visible border-0 bg-muted text-muted-foreground transition-colors before:absolute before:left-0 before:top-1/2 before:h-4 before:w-px before:-translate-y-1/2 before:bg-border/60 before:content-[''] hover:bg-accent hover:text-accent-foreground dark:before:bg-white/10"
>
<ListFilter size={14} />
<ListFilter size={13} />
{activeTypes.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-neutral-300 text-[9px] font-medium text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200">
{activeTypes.length}
@ -188,7 +189,7 @@ export function DocumentsFilters({
aria-selected={activeTypes.includes(value)}
tabIndex={0}
key={value}
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors cursor-pointer text-left"
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer text-left"
onClick={() => onToggleType(value, !activeTypes.includes(value))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@ -226,13 +227,13 @@ export function DocumentsFilters({
{/* Search Input */}
<div className="relative flex-1 min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={14} aria-hidden="true" />
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<Search size={13} aria-hidden="true" />
</div>
<Input
id={`${id}-input`}
ref={inputRef}
className="h-9 w-full pl-9 pr-8 text-sm select-none focus:select-text"
className="h-8 w-full select-none border-0 bg-muted pl-8 pr-7 text-sm shadow-none focus:select-text"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search docs"
@ -240,9 +241,11 @@ export function DocumentsFilters({
aria-label={t("filter_placeholder")}
/>
{Boolean(searchValue) && (
<button
<Button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Clear filter"
onClick={() => {
onSearch("");
@ -250,7 +253,7 @@ export function DocumentsFilters({
}}
>
<X size={14} strokeWidth={2} aria-hidden="true" />
</button>
</Button>
)}
</div>
@ -260,9 +263,9 @@ export function DocumentsFilters({
onClick={handleUpload}
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 border-0 shadow-none bg-white text-gray-700 hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
className="h-8 shrink-0 gap-1.5 border-0 bg-white text-gray-700 shadow-none hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-gray-800"
>
<Upload size={14} />
<Upload size={13} />
<span>Upload</span>
</Button>
</div>

View file

@ -260,7 +260,7 @@ export const FolderNode = React.memo(function FolderNode({
role="button"
tabIndex={0}
className={cn(
"group relative flex h-8 items-center gap-1 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
"group relative flex h-8 items-center gap-1 rounded-md px-1 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer select-none",
isExpanded && "font-medium",
isDragging && "opacity-40",
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",

View file

@ -85,42 +85,47 @@ export function FolderPickerDialog({
const FolderIcon = isExpanded ? FolderOpen : Folder;
return [
<button
key={f.id}
type="button"
disabled={isDisabled}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
isSelected && "bg-accent text-accent-foreground",
!isSelected && !isDisabled && "hover:bg-accent/50",
isDisabled && "cursor-not-allowed opacity-40"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
if (!isDisabled) setSelectedId(f.id);
}}
>
{hasChildren ? (
<button
<div key={f.id} className="relative w-full">
{hasChildren && (
<Button
type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center"
variant="ghost"
size="icon"
disabled={isDisabled}
className="absolute top-1/2 z-10 size-4 -translate-y-1/2 p-0"
style={{ left: `${depth * 16 + 8}px` }}
aria-label={isExpanded ? `Collapse ${f.name}` : `Expand ${f.name}`}
onClick={(e) => {
e.stopPropagation();
toggleExpand(f.id);
}}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
<ChevronDown data-icon="inline-start" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
<ChevronRight data-icon="inline-start" />
)}
</button>
) : (
<span className="h-4 w-4 shrink-0" />
</Button>
)}
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{f.name}</span>
</button>,
<Button
type="button"
variant="ghost"
disabled={isDisabled}
className={cn(
"h-auto w-full justify-start gap-1.5 px-2 py-1.5 text-sm font-normal",
isSelected && "bg-accent text-accent-foreground",
isDisabled && "cursor-not-allowed opacity-40"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
if (!isDisabled) setSelectedId(f.id);
}}
>
<span className="size-4 shrink-0" />
<FolderIcon data-icon="inline-start" className="text-muted-foreground" />
<span className="truncate">{f.name}</span>
</Button>
</div>,
...(isExpanded ? renderPickerLevel(f.id, depth + 1) : []),
];
});
@ -143,19 +148,19 @@ export function FolderPickerDialog({
</DialogHeader>
<div className="max-h-[300px] overflow-y-auto rounded-md border p-1">
<button
<Button
type="button"
variant="ghost"
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
selectedId === null && "bg-accent text-accent-foreground",
selectedId !== null && "hover:bg-accent/50"
"h-auto w-full justify-start gap-1.5 px-2 py-1.5 text-sm font-normal",
selectedId === null && "bg-accent text-accent-foreground"
)}
onClick={() => setSelectedId(null)}
>
<span className="h-4 w-4 shrink-0" />
<Home className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="size-4 shrink-0" />
<Home data-icon="inline-start" className="text-muted-foreground" />
<span>Root</span>
</button>
</Button>
{renderPickerLevel(null, 1)}
</div>

View file

@ -29,7 +29,7 @@ export function isVersionableType(documentType: string) {
}
const DIALOG_CLASSES =
"select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]";
"select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--popover)]";
export function VersionHistoryButton({ documentId, documentType }: VersionHistoryProps) {
if (!isVersionableType(documentType)) return null;
@ -177,15 +177,16 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
<div className="flex-1 overflow-y-auto p-2">
<div className="flex flex-col gap-0.5">
{versions.map((v) => (
<button
<Button
key={v.version_number}
type="button"
variant="ghost"
onClick={() => handleSelectVersion(v.version_number)}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:outline-none w-full",
"h-auto w-full justify-start gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:outline-none",
selectedVersion === v.version_number
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<div className="flex-1 min-w-0 space-y-0.5">
@ -197,7 +198,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
{v.title && <p className="text-xs text-muted-foreground truncate">{v.title}</p>}
</div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
</Button>
))}
</div>
</div>

View file

@ -448,17 +448,22 @@ export function EditorPanelContent({
return (
<>
{showDesktopHeader ? (
<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">File</h2>
<div className="shrink-0">
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
<h2 className="select-none text-lg font-semibold">File</h2>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close editor panel</span>
</Button>
</div>
</div>
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
<div className="min-w-0 flex flex-1 items-center gap-2">
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
</div>
@ -667,7 +672,7 @@ export function EditorPanelContent({
placeholder="Start writing..."
editorVariant="default"
allowModeToggle={false}
reserveToolbarSpace
reserveToolbarSpace={isEditing}
defaultEditing={isEditing}
className="**:[[role=toolbar]]:bg-sidebar!"
// Render `[citation:N]` badges in view mode only.

View file

@ -11,20 +11,16 @@ import {
} from "@/lib/citations/citation-parser";
/**
* Plate inline-void node modeling a single `[citation:...]` reference.
*
* Modeled after the existing `MentionPlugin` pattern in
* `inline-mention-editor.tsx` the only confirmed pattern in this repo
* for non-text inline UI. Inline-void elements satisfy Slate's invariant
* that the editor renders both atomic widgets and surrounding text
* cleanly without breaking selection / caret semantics.
* Plate inline-void node for one `[citation:...]` reference.
* Inline voids keep the citation chip atomic while preserving caret behavior
* around the surrounding text.
*/
export type CitationElementNode = {
type: "citation";
kind: "chunk" | "doc" | "url";
chunkId?: number;
url?: string;
/** Original `[citation:...]` substring for traceability/debugging. */
/** Original literal token that produced this citation node. */
rawText: string;
children: [{ text: "" }];
};
@ -62,17 +58,14 @@ const CitationPlugin = createPlatePlugin({
},
});
/** Plugin kit shape used elsewhere in the editor. */
export const CitationKit = [CitationPlugin];
// ---------------------------------------------------------------------------
// Slate value transform — runs after MarkdownPlugin.deserialize
// Slate value transform
// ---------------------------------------------------------------------------
// Structural shapes used by the value transform. We cannot use Plate's
// generic Element / Text type predicates directly because `Descendant` is a
// constrained union and our predicates would over-narrow. Casting through
// these row types keeps the walker readable without fighting the types.
// Local structural shapes keep the recursive walker readable without forcing
// Plate's broad Descendant union into narrower generic predicates.
type SlateText = { text: string } & Record<string, unknown>;
type SlateElement = { type?: string; children: Descendant[] } & Record<string, unknown>;
@ -89,19 +82,15 @@ function asElement(node: Descendant): SlateElement {
}
/**
* Element types whose subtrees we MUST NOT inject citation void elements
* into. Each rationale documented in the citation plan:
* - `KEYS.codeBlock` / `code_line` Plate's schema rejects inline elements
* inside code containers; the user expects literal text inside code.
* - `KEYS.link` `<button>` inside `<a>` is invalid HTML and the link
* swallows the citation click. Mirrors the `<a>` skip in
* `MarkdownViewer`.
* Subtrees that should keep citation tokens as text:
* - Code nodes preserve source text and reject inline void children.
* - Link nodes already render as anchors; citation chips are interactive
* shadcn Button-based controls, so injecting them would nest interactions.
*/
const SKIP_SUBTREE_TYPES = new Set<string>([KEYS.codeBlock, "code_line", KEYS.link]);
/**
* Build the marks portion of a Slate text node so we can preserve formatting
* (bold/italic/etc.) on the surrounding text fragments after we split.
* Preserve text marks such as bold and italic when splitting around citations.
*/
function copyMarks(textNode: SlateText): Record<string, unknown> {
const { text: _text, ...marks } = textNode;
@ -131,9 +120,7 @@ function makeCitationElement(
}
/**
* Re-extract the raw `[citation:...]` substrings that produced each parsed
* segment, in source order. Lets us preserve the original literal for
* `rawText` on the inline-void element.
* Keep each original citation token on the generated node for diagnostics.
*/
function extractRawCitationMatches(text: string): string[] {
const matches: string[] = [];
@ -159,9 +146,7 @@ function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[
let pendingText: string | null = null;
const flushText = () => {
// Slate inline-void adjacency: emit an empty text node (with copied
// marks) when the citation appears at the very start/end of the text
// node so neighbours of the void always have a text sibling.
// Inline voids need text siblings, even at text boundaries.
out.push({ ...marks, text: pendingText ?? "" } as unknown as Descendant);
pendingText = null;
};
@ -174,8 +159,7 @@ function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[
const raw = rawMatches[citationIdx] ?? "";
out.push(makeCitationElement(raw, segment) as unknown as Descendant);
citationIdx += 1;
// Always reset pendingText so the next loop iteration emits a
// trailing empty text node if no further plain text follows.
// Ensure a trailing text sibling if the citation ends the node.
pendingText = "";
}
}
@ -206,12 +190,9 @@ function transformChildren(children: Descendant[], urlMap: CitationUrlMap): Desc
}
/**
* Walk a deserialized Slate value and replace every `[citation:...]`
* substring with a `citation` inline-void element. URL placeholders
* created by `preprocessCitationMarkdown` are resolved through `urlMap`.
*
* Subtrees of `code_block`, `code_line`, and `link` are returned as-is
* see `SKIP_SUBTREE_TYPES` above.
* Replace citation tokens in a deserialized Slate tree with citation inline
* void nodes. URL placeholders from `preprocessCitationMarkdown` are resolved
* through `urlMap`; skipped subtrees are returned unchanged.
*/
export function injectCitationNodes(value: Descendant[], urlMap: CitationUrlMap): Descendant[] {
return transformChildren(value, urlMap);

View file

@ -2,6 +2,7 @@
import { ArrowUp, Loader2, Square } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state";
@ -28,14 +29,16 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
const abortRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modelSlug = model.seo_slug ?? model.model_name;
useEffect(() => {
anonymousChatApiService.getQuota().then(setQuota).catch(console.error);
}, []);
useEffect(() => {
if (messages.length === 0) return;
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
}, [messages.length]);
const autoResizeTextarea = useCallback(() => {
const textarea = textareaRef.current;
@ -63,7 +66,7 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
}
trackAnonymousChatMessageSent({
modelSlug: model.seo_slug,
modelSlug,
messageLength: trimmed.length,
surface: "free_model_page",
});
@ -84,7 +87,7 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
model_slug: model.seo_slug,
model_slug: modelSlug,
messages: chatHistory,
}),
signal: controller.signal,
@ -100,6 +103,8 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
remaining: 0,
status: "exceeded",
warning_threshold: quota?.warning_threshold ?? 800000,
captcha_required:
errorData.detail?.captcha_required ?? quota?.captcha_required ?? false,
});
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
return;
@ -148,7 +153,7 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
abortRef.current = null;
anonymousChatApiService.getQuota().then(setQuota).catch(console.error);
}
}, [input, isStreaming, messages, model.seo_slug, quota]);
}, [input, isStreaming, messages, modelSlug, quota]);
const handleCancel = useCallback(() => {
abortRef.current?.abort();
@ -258,22 +263,26 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
)}
/>
{isStreaming ? (
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleCancel}
className="absolute right-2 bottom-2 flex h-8 w-8 items-center justify-center rounded-lg bg-foreground text-background transition-colors hover:opacity-80"
className="absolute right-2 bottom-2 size-8 bg-foreground text-background hover:bg-foreground hover:text-background hover:opacity-80"
>
<Square className="h-3.5 w-3.5" fill="currentColor" />
</button>
</Button>
) : (
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleSubmit}
disabled={!input.trim() || isExceeded}
className="absolute right-2 bottom-2 flex h-8 w-8 items-center justify-center rounded-lg bg-foreground text-background transition-colors hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed"
className="absolute right-2 bottom-2 size-8 bg-foreground text-background hover:bg-foreground hover:text-background hover:opacity-80 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
</Button>
)}
</div>

View file

@ -5,6 +5,7 @@ import { ArrowUpIcon, Globe, Paperclip, SquareIcon } from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
@ -67,7 +68,6 @@ const ACCEPT_EXTENSIONS = Array.from(ANON_ALLOWED_EXTENSIONS).join(",");
export const FreeComposer: FC = () => {
const aui = useAui();
const isRunning = useAuiState(({ thread }) => thread.isRunning);
const isEmpty = useAuiState(({ thread }) => thread.isEmpty);
const { gate } = useLoginGate();
const anonMode = useAnonymousMode();
const [text, setText] = useState("");
@ -186,18 +186,19 @@ export const FreeComposer: FC = () => {
/>
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
onClick={handleUploadClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors",
"text-muted-foreground hover:text-foreground hover:bg-accent/50",
"h-auto gap-1.5 rounded-md px-2 py-1 text-xs transition-colors",
"text-muted-foreground hover:text-accent-foreground hover:bg-accent",
hasUploadedDoc && "text-primary"
)}
>
<Paperclip className="size-3.5" />
{hasUploadedDoc ? "1/1" : "Upload"}
</button>
</Button>
</TooltipTrigger>
<TooltipContent>
{hasUploadedDoc
@ -212,7 +213,7 @@ export const FreeComposer: FC = () => {
<TooltipTrigger asChild>
<label
htmlFor="free-web-search-toggle"
className="flex items-center gap-1.5 cursor-pointer select-none rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
className="flex items-center gap-1.5 cursor-pointer select-none rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-accent-foreground hover:bg-accent transition-colors"
>
<Globe className="size-3.5" />
<span className="hidden sm:inline">Web</span>

View file

@ -103,7 +103,7 @@ export function FreeModelSelector({ className }: { className?: string }) {
role="combobox"
aria-expanded={open}
className={cn(
"h-8 gap-2 px-3 text-sm bg-main-panel hover:bg-accent/50 dark:hover:bg-white/6 border border-border/40 select-none",
"h-8 gap-2 px-3 text-sm bg-muted hover:bg-muted/80 border-0 select-none",
className
)}
>
@ -122,7 +122,7 @@ export function FreeModelSelector({ className }: { className?: string }) {
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[320px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
className="w-[320px] p-0 rounded-lg shadow-lg overflow-hidden select-none"
align="start"
sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()}
@ -164,10 +164,10 @@ export function FreeModelSelector({ className }: { className?: string }) {
onMouseEnter={() => setFocusedIndex(index)}
className={cn(
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
"transition-all duration-150 mx-2",
"hover:bg-accent/40",
isSelected && "bg-primary/6 dark:bg-primary/8",
isFocused && "bg-accent/50"
"transition-colors duration-150 mx-2",
"hover:bg-accent hover:text-accent-foreground",
isFocused && "bg-accent text-accent-foreground",
isSelected && "bg-accent text-accent-foreground"
)}
>
<div className="shrink-0">

View file

@ -3,6 +3,7 @@
import { OctagonAlert, Orbit, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface QuotaWarningBannerProps {
@ -71,13 +72,15 @@ export function QuotaWarningBanner({
</Link>{" "}
for $5 of premium credit.
</p>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setDismissed(true)}
className="text-amber-400 hover:text-amber-600 dark:hover:text-amber-200"
className="size-6 text-amber-400 hover:bg-transparent hover:text-amber-600 dark:hover:text-amber-200"
>
<X className="h-4 w-4" />
</button>
</Button>
</div>
</div>
);

View file

@ -2,6 +2,7 @@
import { IconMessageCircleQuestion } from "@tabler/icons-react";
import Link from "next/link";
import type React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function CTAHomepage() {
@ -22,15 +23,16 @@ export function CTAHomepage() {
</p>
<div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4">
<Link href="/contact">
<button
type="button"
className="mt-8 flex space-x-2 items-center group text-base px-4 py-2 rounded-lg text-black dark:text-white border border-neutral-200 dark:border-neutral-800 shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset]"
>
<Button
asChild
variant="ghost"
className="mt-8 h-auto gap-2 rounded-lg border border-neutral-200 px-4 py-2 text-base text-black shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset] dark:border-neutral-800 dark:text-white"
>
<Link href="/contact" className="group">
<span>Talk to us</span>
<IconMessageCircleQuestion className="text-black dark:text-white group-hover:translate-x-1 stroke-[1px] h-3 w-3 mt-0.5 transition-transform duration-200" />
</button>
</Link>
</Link>
</Button>
</div>
</div>
{/* <div className="border-t md:border-t-0 md:border-l border-dashed p-8 md:p-14">

View file

@ -38,7 +38,7 @@ export function FooterNew() {
href: "/contact",
},
{
title: "Announcements",
title: "What's New",
href: "/announcements",
},
];

View file

@ -4,6 +4,7 @@ import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@ -176,32 +177,38 @@ export function HeroSection() {
function GetStartedButton() {
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
trackLoginAttempt("google");
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
if (isGoogleAuth) {
return (
<button
<Button
type="button"
variant="ghost"
onClick={handleGoogleLogin}
className="flex h-14 w-full cursor-pointer items-center justify-center gap-3 rounded-lg bg-white text-center text-base font-medium text-neutral-700 shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 dark:hover:bg-neutral-800"
disabled={isRedirecting}
className="h-14 w-full cursor-pointer gap-3 rounded-lg border border-white bg-white text-center text-base font-medium text-[#1f1f1f] shadow-sm transition duration-150 hover:bg-zinc-100 hover:text-[#1f1f1f] sm:w-56 dark:border-white"
>
<GoogleLogo className="h-5 w-5" />
<span>Continue with Google</span>
</button>
</Button>
);
}
return (
<Link
href="/login"
className="flex h-14 w-full items-center justify-center rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 sm:w-52 dark:bg-white dark:text-black"
<Button
asChild
variant="ghost"
className="h-14 w-full rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-black sm:w-52 dark:bg-white dark:text-black dark:hover:bg-white"
>
Get Started
</Link>
<Link href="/login">Get Started</Link>
</Button>
);
}
@ -212,35 +219,40 @@ function DownloadButton() {
if (!primary) {
return (
<a
href={fallbackUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-14 w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-auto sm:px-6 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
<Button
asChild
variant="ghost"
className="h-14 w-full gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-auto sm:px-6 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<Download className="size-4" />
Download for {os}
</a>
<a href={fallbackUrl} target="_blank" rel="noopener noreferrer">
<Download className="size-4" />
Download for {os}
</a>
</Button>
);
}
return (
<div className="flex h-14 w-full items-stretch sm:w-auto">
<a
href={primary.url}
className="flex flex-1 items-center justify-center gap-2 rounded-l-lg border border-r-0 border-neutral-200 bg-white px-5 text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-[0.99] hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
<Button
asChild
variant="ghost"
className="h-auto flex-1 gap-2 rounded-l-lg rounded-r-none border border-r-0 border-neutral-200 bg-white px-5 text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-[0.99] hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<Download className="size-4 shrink-0" />
Download for {os}
</a>
<a href={primary.url}>
<Download className="size-4 shrink-0" />
Download for {os}
</a>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<Button
type="button"
className="flex items-center justify-center rounded-r-lg border border-neutral-200 bg-white px-2.5 text-neutral-500 shadow-sm transition duration-150 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800"
variant="ghost"
className="h-auto rounded-l-none rounded-r-lg border border-neutral-200 bg-white px-2.5 text-neutral-500 shadow-sm transition duration-150 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800"
>
<ChevronDown className="size-4" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{alternatives.map((asset) => (
@ -284,11 +296,12 @@ const BrowserWindow = () => {
<div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4">
{TAB_ITEMS.map((item, index) => (
<React.Fragment key={item.title}>
<button
<Button
type="button"
variant="ghost"
onClick={() => setSelectedIndex(index)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
"h-auto shrink-0 gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
selectedIndex === index &&
!item.featured &&
"bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900",
@ -311,7 +324,7 @@ const BrowserWindow = () => {
<TooltipContent side="bottom">Desktop app only</TooltipContent>
</Tooltip>
)}
</button>
</Button>
{index !== TAB_ITEMS.length - 1 && (
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
)}
@ -354,13 +367,14 @@ const BrowserWindow = () => {
</p>
</div>
</div>
<button
<Button
type="button"
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 w-full"
variant="ghost"
className="h-auto w-full cursor-pointer rounded-none bg-neutral-50 p-2 hover:bg-neutral-50 sm:p-3 dark:bg-neutral-950 dark:hover:bg-neutral-950"
onClick={open}
>
<TabVideo src={selectedItem.src} />
</button>
<TabVideo key={selectedItem.src} src={selectedItem.src} />
</Button>
</motion.div>
</AnimatePresence>
</div>
@ -385,7 +399,7 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
if (!video) return;
video.currentTime = 0;
video.play().catch(() => {});
}, [src]);
}, []);
const handleCanPlay = useCallback(() => {
setHasLoaded(true);

View file

@ -7,6 +7,7 @@ import { SignInButton } from "@/components/auth/sign-in-button";
import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface NavItem {
@ -99,7 +100,7 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavPro
onMouseEnter={() => setHovered(idx)}
onMouseLeave={() => setHovered(null)}
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
key={`link=${idx}`}
key={navItem.link}
href={navItem.link}
>
{hovered === idx && (
@ -179,10 +180,12 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
<Logo className="h-8 w-8 rounded-md" disableLink />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</Link>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setOpen((prev) => !prev)}
className="relative z-50 flex items-center justify-center p-2 -mr-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
className="relative z-50 -mr-2 rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 touch-manipulation"
aria-label={open ? "Close menu" : "Open menu"}
>
{open ? (
@ -190,7 +193,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
) : (
<IconMenu2 className="h-6 w-6 text-black dark:text-white" />
)}
</button>
</Button>
</div>
<AnimatePresence>
@ -202,9 +205,9 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
transition={{ duration: 0.2, ease: "easeOut" }}
className="absolute inset-x-0 top-full mt-1 z-20 flex w-full flex-col items-start justify-start gap-4 rounded-xl bg-white/90 backdrop-blur-xl border border-white/20 shadow-2xl px-4 py-6 dark:bg-neutral-950/90 dark:border-neutral-800/50"
>
{navItems.map((navItem: NavItem, idx: number) => (
{navItems.map((navItem: NavItem) => (
<Link
key={`link=${idx}`}
key={navItem.link}
href={navItem.link}
className="relative text-neutral-600 dark:text-neutral-300"
>

View file

@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
<SelectTrigger id="param-key" className="w-full">
<SelectValue placeholder="Select parameter" />
</SelectTrigger>
<SelectContent className="bg-muted dark:border-neutral-700">
<SelectContent>
{PARAM_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{key}
@ -128,7 +128,10 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
</thead>
<tbody className="divide-y divide-gray-200 bg-black dark:bg-black">
{Object.entries(params).map(([key, val]) => (
<tr key={key} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<tr
key={key}
className="hover:bg-accent hover:text-accent-foreground transition-colors"
>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{key}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{val.toString()}</td>
<td className="px-4 py-3">

View file

@ -6,7 +6,6 @@ interface SidebarContextValue {
isCollapsed: boolean;
setIsCollapsed: (collapsed: boolean) => void;
toggleCollapsed: () => void;
sidebarWidth: number;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);

View file

@ -10,18 +10,36 @@ export const SIDEBAR_MAX_WIDTH = 480;
interface UseSidebarResizeReturn {
sidebarWidth: number;
handleMouseDown: (e: React.MouseEvent) => void;
handlePointerDown: (e: React.PointerEvent<HTMLElement>) => void;
isDragging: boolean;
}
function setGlobalDragCursor(active: boolean) {
const html = document.documentElement;
const body = document.body;
if (active) {
html.style.cursor = "col-resize";
body.style.cursor = "col-resize";
html.style.userSelect = "none";
body.style.userSelect = "none";
} else {
html.style.cursor = "";
body.style.cursor = "";
html.style.userSelect = "";
body.style.userSelect = "";
}
}
export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef(0);
const startWidthRef = useRef(defaultWidth);
const widthRef = useRef(defaultWidth);
const pointerIdRef = useRef<number | null>(null);
const captureTargetRef = useRef<HTMLElement | null>(null);
// Initialize from cookie on mount
useEffect(() => {
try {
const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
@ -29,73 +47,91 @@ export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarRe
const parsed = Number(match[1]);
if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
setSidebarWidth(parsed);
widthRef.current = parsed;
}
}
} catch {
// Ignore cookie read errors
}
} catch {}
}, []);
// Persist width to cookie
const persistWidth = useCallback((width: number) => {
try {
// biome-ignore lint/suspicious/noDocumentCookie: SSR-readable preference, not security-sensitive
document.cookie = `${SIDEBAR_WIDTH_COOKIE_NAME}=${width}; path=/; max-age=${SIDEBAR_WIDTH_COOKIE_MAX_AGE}`;
} catch {
// Ignore cookie write errors
}
}, []);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
startXRef.current = e.clientX;
startWidthRef.current = sidebarWidth;
setIsDragging(true);
const releaseCapture = useCallback(() => {
const target = captureTargetRef.current;
const pointerId = pointerIdRef.current;
if (target && pointerId !== null) {
try {
if (target.hasPointerCapture(pointerId)) {
target.releasePointerCapture(pointerId);
}
} catch {}
}
captureTargetRef.current = null;
pointerIdRef.current = null;
}, []);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[sidebarWidth]
);
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
const target = e.currentTarget;
try {
target.setPointerCapture(e.pointerId);
} catch {}
captureTargetRef.current = target;
pointerIdRef.current = e.pointerId;
startXRef.current = e.clientX;
startWidthRef.current = widthRef.current;
setIsDragging(true);
setGlobalDragCursor(true);
}, []);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const handlePointerMove = (e: PointerEvent) => {
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
const delta = e.clientX - startXRef.current;
const newWidth = Math.min(
SIDEBAR_MAX_WIDTH,
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
);
setSidebarWidth(newWidth);
if (newWidth !== widthRef.current) {
widthRef.current = newWidth;
setSidebarWidth(newWidth);
}
};
const handleMouseUp = () => {
const stop = (e: PointerEvent) => {
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
releaseCapture();
setIsDragging(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
// Persist the final width
setSidebarWidth((currentWidth) => {
persistWidth(currentWidth);
return currentWidth;
});
setGlobalDragCursor(false);
persistWidth(widthRef.current);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
setGlobalDragCursor(false);
releaseCapture();
};
}, [isDragging, persistWidth]);
}, [isDragging, persistWidth, releaseCapture]);
return {
sidebarWidth,
handleMouseDown,
handlePointerDown,
isDragging,
};
}

View file

@ -1,11 +1,12 @@
"use client";
import { Inbox, Megaphone, SquareLibrary } from "lucide-react";
import { Inbox, LibraryBig } from "lucide-react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useIsMobile } from "@/hooks/use-mobile";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import type { ChatItem, NavItem, PageUsage, SearchSpace } from "../types/layout.types";
@ -28,6 +29,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
const { gate } = useLoginGate();
const anonMode = useAnonymousMode();
const isMobile = useIsMobile();
const { unreadCount: announcementUnreadCount } = useAnnouncements();
const [quota, setQuota] = useState<{ used: number; limit: number } | null>(null);
const [isDocsSidebarOpen, setIsDocsSidebarOpen] = useState(false);
@ -55,28 +57,24 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
const navItems: NavItem[] = useMemo(
() =>
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: false,
},
isMobile
? {
title: "Documents",
url: "#documents",
icon: SquareLibrary,
isActive: false,
}
: null,
{
title: "Announcements",
url: "#announcements",
icon: Megaphone,
isActive: false,
},
].filter((item): item is NavItem => item !== null),
(
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: false,
},
isMobile
? {
title: "Documents",
url: "#documents",
icon: LibraryBig,
isActive: false,
}
: null,
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[isMobile]
);
@ -90,11 +88,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
(item: NavItem) => {
if (item.title === "Inbox") gate("use the inbox");
else if (item.title === "Documents") setIsDocsSidebarOpen((v) => !v);
else if (item.title === "Announcements") gate("view announcements");
},
[gate]
);
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
const handleSearchSpaceSelect = useCallback(
(_id: number) => gate("switch search spaces"),
[gate]
@ -127,6 +126,8 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
onSettings={gatedAction("search space settings")}
onManageMembers={gatedAction("team management")}
onUserSettings={gatedAction("account settings")}
onAnnouncements={handleAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={() => router.push("/register")}
pageUsage={pageUsage}
isChatPage

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
import { AlertTriangle, Inbox, LibraryBig } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -11,25 +11,14 @@ import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
searchSpaceSettingsDialogAtom,
teamDialogAtom,
userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms";
import {
removeChatTabAtom,
resetTabsAtom,
syncChatTabAtom,
type Tab,
} from "@/atoms/tabs/tabs.atom";
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
import {
AlertDialog,
AlertDialogAction,
@ -105,7 +94,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
const syncChatTab = useSetAtom(syncChatTabAtom);
const resetTabs = useSetAtom(resetTabsAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
// Key used to force-remount the page component (e.g. after deleting the active chat
@ -132,16 +120,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
});
// Unified slide-out panel state (only one can be open at a time)
type SlideoutPanel = "inbox" | "shared" | "private" | "announcements" | null;
type SlideoutPanel = "inbox" | "shared" | "private" | null;
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
const isAnnouncementsSidebarOpen = activeSlideoutPanel === "announcements";
// Documents sidebar state (shared atom so Composer can toggle it)
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
const [isDocumentsDocked, setIsDocumentsDocked] = useState(true);
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useAtom(rightPanelCollapsedAtom);
const setIsRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
// Open documents sidebar by default on desktop (docked mode)
const documentsInitialized = useRef(false);
@ -252,16 +239,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
// Reset transient slide-out panels and tabs when switching search spaces.
// Use a ref to skip the initial mount — only reset when the space actually changes.
// Reset transient slide-out panels when switching search spaces.
// Tabs intentionally persist across spaces — opening tabs from multiple
// search spaces is a supported flow (browser-tab semantics).
const prevSearchSpaceIdRef = useRef(searchSpaceId);
useEffect(() => {
if (prevSearchSpaceIdRef.current !== searchSpaceId) {
prevSearchSpaceIdRef.current = searchSpaceId;
setActiveSlideoutPanel(null);
resetTabs();
}
}, [searchSpaceId, resetTabs]);
}, [searchSpaceId]);
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
@ -313,6 +300,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
// Avoid overwriting live SSE-updated tab titles with fallback values.
title: chatId ? (thread?.title ?? undefined) : "New Chat",
chatUrl,
searchSpaceId: Number(searchSpaceId),
});
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
@ -347,6 +335,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}, [threadsData, searchSpaceId]);
// Navigation items
// Inbox is rendered explicitly below "New chat" in the sidebar (it is also
// surfaced in the icon rail's collapsed mode via this list). Announcements
// has been moved to the avatar dropdown and is no longer a nav item.
const navItems: NavItem[] = useMemo(
() =>
(
@ -362,28 +353,13 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
? {
title: "Documents",
url: "#documents",
icon: SquareLibrary,
icon: LibraryBig,
isActive: isDocumentsSidebarOpen,
}
: null,
{
title: "Announcements",
url: "#announcements",
icon: Megaphone,
isActive: isAnnouncementsSidebarOpen,
badge:
announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[
isMobile,
isInboxSidebarOpen,
isDocumentsSidebarOpen,
totalUnreadCount,
isAnnouncementsSidebarOpen,
announcementUnreadCount,
]
[isMobile, isInboxSidebarOpen, isDocumentsSidebarOpen, totalUnreadCount]
);
// Handlers
@ -398,19 +374,21 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsCreateSearchSpaceDialogOpen(true);
}, []);
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const setTeamDialogOpen = useSetAtom(teamDialogAtom);
const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom);
const handleUserSettings = useCallback(() => {
setUserSettingsDialog({ open: true, initialTab: "profile" });
}, [setUserSettingsDialog]);
router.push(`/dashboard/${searchSpaceId}/user-settings`);
}, [router, searchSpaceId]);
const handleAnnouncements = useCallback(() => {
setAnnouncementsDialog(true);
}, [setAnnouncementsDialog]);
const handleSearchSpaceSettings = useCallback(
(_space: SearchSpace) => {
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
(space: SearchSpace) => {
router.push(`/dashboard/${space.id}/search-space-settings`);
},
[setSearchSpaceSettingsDialog]
[router]
);
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
@ -519,10 +497,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}
return;
}
if (item.url === "#announcements") {
setActiveSlideoutPanel((prev) => (prev === "announcements" ? null : "announcements"));
return;
}
router.push(item.url);
},
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
@ -586,12 +560,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
);
const handleSettings = useCallback(() => {
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
}, [setSearchSpaceSettingsDialog]);
router.push(`/dashboard/${searchSpaceId}/search-space-settings`);
}, [router, searchSpaceId]);
const handleManageMembers = useCallback(() => {
setTeamDialogOpen(true);
}, [setTeamDialogOpen]);
router.push(`/dashboard/${searchSpaceId}/team`);
}, [router, searchSpaceId]);
const handleLogout = useCallback(async () => {
try {
@ -683,6 +657,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
const isChatPage = pathname?.includes("/new-chat") ?? false;
const isUserSettingsPage = pathname?.includes("/user-settings") === true;
const isSearchSpaceSettingsPage = pathname?.includes("/search-space-settings") === true;
const isTeamPage = pathname?.endsWith("/team") === true;
const useWorkspacePanel =
pathname?.endsWith("/buy-more") === true ||
pathname?.endsWith("/more-pages") === true ||
isUserSettingsPage ||
isSearchSpaceSettingsPage ||
isTeamPage;
return (
<>
@ -714,10 +697,21 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
onSettings={handleSettings}
onManageMembers={handleManageMembers}
onUserSettings={handleUserSettings}
onAnnouncements={handleAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={handleLogout}
theme={theme}
setTheme={setTheme}
isChatPage={isChatPage}
useWorkspacePanel={useWorkspacePanel}
workspacePanelViewportClassName={
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
? "items-start justify-center px-6 py-8 md:px-10 md:py-10"
: undefined
}
workspacePanelContentClassName={
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage ? "max-w-5xl" : undefined
}
isLoadingChats={isLoadingThreads}
activeSlideoutPanel={activeSlideoutPanel}
onSlideoutPanelChange={setActiveSlideoutPanel}
@ -897,13 +891,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
{/* Settings Dialogs */}
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
<UserSettingsDialog />
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
<AnnouncementsDialog />
{/* Agent action log + revert sheet */}
<ActionLogSheet />
{/* Agent action log + revert dialog */}
<ActionLogDialog />
</>
);
}

View file

@ -4,13 +4,11 @@ import { useAtomValue } from "jotai";
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, tabsAtom } from "@/atoms/tabs/tabs.atom";
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 { useIsMobile } from "@/hooks/use-mobile";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
import { RightPanelExpandButton } from "../right-panel/RightPanel";
interface HeaderProps {
mobileMenuTrigger?: React.ReactNode;
@ -19,14 +17,11 @@ interface HeaderProps {
export function Header({ mobileMenuTrigger }: HeaderProps) {
const pathname = usePathname();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const isMobile = useIsMobile();
const activeTab = useAtomValue(activeTabAtom);
const tabs = useAtomValue(tabsAtom);
const isFreePage = pathname?.startsWith("/free") ?? false;
const isChatPage = pathname?.includes("/new-chat") ?? false;
const isDocumentTab = activeTab?.type === "document";
const hasTabBar = tabs.length > 1;
const currentThreadState = useAtomValue(currentThreadAtom);
@ -74,7 +69,6 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
{hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)}
{!isMobile && !hasTabBar && <RightPanelExpandButton />}
</div>
</header>
);

View file

@ -1,11 +1,12 @@
"use client";
import { Plus } from "lucide-react";
import { Plus, SquarePen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { SearchSpace } from "../../types/layout.types";
import type { NavItem, SearchSpace, User } from "../../types/layout.types";
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
import { SearchSpaceAvatar } from "./SearchSpaceAvatar";
interface IconRailProps {
@ -15,6 +16,17 @@ interface IconRailProps {
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void;
isSingleRailMode?: boolean;
onNewChat?: () => void;
navItems?: NavItem[];
onNavItemClick?: (item: NavItem) => void;
user: User;
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
onLogout?: () => void;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
className?: string;
}
@ -25,11 +37,45 @@ export function IconRail({
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace,
isSingleRailMode = false,
onNewChat,
navItems = [],
onNavItemClick,
user,
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
onLogout,
theme,
setTheme,
className,
}: IconRailProps) {
const actionItems = isSingleRailMode
? [
...(onNewChat
? [
{
key: "new-chat",
label: "New chat",
onClick: onNewChat,
icon: SquarePen,
isActive: false,
},
]
: []),
...navItems.map((item) => ({
key: item.url,
label: item.title,
onClick: () => onNavItemClick?.(item),
icon: item.icon,
isActive: !!item.isActive,
})),
]
: [];
return (
<div className={cn("flex h-full w-14 flex-col items-center", className)}>
<ScrollArea className="w-full">
<div className={cn("flex h-full w-14 min-h-0 flex-col items-center", className)}>
<ScrollArea className="w-full min-h-0 flex-1">
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
{searchSpaces.map((searchSpace) => (
<SearchSpaceAvatar
@ -63,8 +109,45 @@ export function IconRail({
Add search space
</TooltipContent>
</Tooltip>
{actionItems.length > 0 && (
<>
<div className="my-1 h-px w-8 bg-border/60" />
{actionItems.map(({ key, label, onClick, icon: Icon, isActive }) => (
<Tooltip key={key}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onClick}
className={cn(
"h-10 w-10 rounded-lg",
isActive && "bg-accent text-accent-foreground"
)}
>
<Icon className="h-4 w-4" />
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
))}
</>
)}
</div>
</ScrollArea>
<SidebarUserProfile
user={user}
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={onLogout}
isCollapsed
theme={theme}
setTheme={setTheme}
/>
</div>
);
}

View file

@ -3,18 +3,17 @@
import { Settings, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -122,14 +121,16 @@ export function SearchSpaceAvatar({
);
const avatarButton = (
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClick}
className={cn(
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all select-none",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"relative rounded-lg font-semibold text-white transition-all select-none",
"hover:text-white hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-rail"
)}
style={{ backgroundColor: bgColor }}
>
@ -138,15 +139,15 @@ export function SearchSpaceAvatar({
{isShared && (
<span
className={cn(
"absolute -top-1 -right-1 flex items-center justify-center rounded-full bg-blue-500 text-white shadow-sm",
"absolute -top-1 -right-1 flex items-center justify-center rounded-full bg-gray-800 text-white shadow-sm",
size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"
)}
title={tCommon("shared")}
>
<Users className={cn(size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5")} />
<Users className={cn(size === "sm" ? "size-2" : "size-2.5")} />
</span>
)}
</button>
</Button>
);
const menuItems = (
@ -157,7 +158,6 @@ export function SearchSpaceAvatar({
{tCommon("settings")}
</DropdownMenuItem>
)}
{onSettings && onDelete && <DropdownMenuSeparator />}
{onDelete && isOwner && (
<DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
@ -190,7 +190,7 @@ export function SearchSpaceAvatar({
{avatarButton}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">{menuItems}</DropdownMenuContent>
<DropdownMenuContent>{menuItems}</DropdownMenuContent>
</DropdownMenu>
);
}
@ -208,14 +208,13 @@ export function SearchSpaceAvatar({
{tooltipContent}
</TooltipContent>
</Tooltip>
<ContextMenuContent className="w-48">
<ContextMenuContent>
{onSettings && (
<ContextMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</ContextMenuItem>
)}
{onSettings && onDelete && <ContextMenuSeparator />}
{onDelete && isOwner && (
<ContextMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />

View file

@ -3,7 +3,7 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { PanelRight } from "lucide-react";
import dynamic from "next/dynamic";
import { startTransition, useEffect } from "react";
import { type MouseEvent, startTransition, useEffect } from "react";
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";
@ -12,6 +12,7 @@ import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
import { cn } from "@/lib/utils";
import { DocumentsSidebar } from "../sidebar";
const EditorPanelContent = dynamic(
@ -51,13 +52,28 @@ interface RightPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
};
showCollapseButton?: boolean;
showTopBorder?: boolean;
}
function isKeyboardClick(event: MouseEvent) {
return event.detail === 0;
}
function CollapseButton({ onClick }: { onClick: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClick} className="h-8 w-8 shrink-0">
<Button
variant="ghost"
size="icon"
tabIndex={-1}
onClick={(event) => {
if (isKeyboardClick(event)) return;
onClick();
}}
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<PanelRight className="h-4 w-4" />
<span className="sr-only">Collapse panel</span>
</Button>
@ -67,13 +83,66 @@ function CollapseButton({ onClick }: { onClick: () => void }) {
);
}
interface RightPanelToggleButtonProps {
className?: string;
iconClassName?: string;
disabled?: boolean;
}
export function RightPanelToggleButton({
className,
iconClassName,
disabled = false,
}: RightPanelToggleButtonProps) {
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
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);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
const label = collapsed ? "Expand panel" : "Collapse panel";
if (!hasContent) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={disabled}
onClick={() => {
if (disabled) return;
startTransition(() => setCollapsed((value) => !value));
}}
className={cn(
"h-8 w-8 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
className
)}
>
<PanelRight className={cn("h-4 w-4", iconClassName)} />
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
/**
* Absolutely positioned expand button renders at top-right of the main
* container so it occupies the same screen position as the collapse button
* inside the Documents header.
*/
export function RightPanelExpandButton() {
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const [collapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
@ -91,20 +160,7 @@ export function RightPanelExpandButton() {
return (
<div className="flex shrink-0 items-center px-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => startTransition(() => setCollapsed(false))}
className="h-8 w-8 shrink-0 -m-0.5"
>
<PanelRight className="h-4 w-4" />
<span className="sr-only">Expand panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Expand panel</TooltipContent>
</Tooltip>
<RightPanelToggleButton className="-m-0.5" />
</div>
);
}
@ -117,7 +173,11 @@ const PANEL_WIDTHS = {
citation: 560,
} as const;
export function RightPanel({ documentsPanel }: RightPanelProps) {
export function RightPanel({
documentsPanel,
showCollapseButton = true,
showTopBorder = false,
}: RightPanelProps) {
const [activeTab] = useAtom(rightPanelTabAtom);
const reportState = useAtomValue(reportPanelAtom);
const closeReport = useSetAtom(closeReportPanelAtom);
@ -191,14 +251,19 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
}
const targetWidth = PANEL_WIDTHS[effectiveTab];
const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />;
const collapseButton = showCollapseButton ? (
<CollapseButton onClick={() => setCollapsed(true)} />
) : null;
if (!isVisible) return null;
return (
<aside
style={{ width: targetWidth }}
className="flex h-full shrink-0 flex-col rounded-xl border bg-sidebar text-sidebar-foreground overflow-hidden transition-[width] duration-200 ease-out"
className={cn(
"flex h-full shrink-0 flex-col border-l bg-panel text-sidebar-foreground overflow-hidden transition-[width] duration-200 ease-out",
showTopBorder && "border-t"
)}
>
<div className="relative flex-1 min-h-0 overflow-hidden">
{effectiveTab === "sources" && documentsOpen && documentsPanel && (

View file

@ -5,29 +5,40 @@ import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useCallback, useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
import { SidebarProvider, useSidebarState } from "../../hooks";
import { useSidebarResize } from "../../hooks/useSidebarResize";
import {
SIDEBAR_MAX_WIDTH,
SIDEBAR_MIN_WIDTH,
useSidebarResize,
} from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { Header } from "../header";
import { IconRail } from "../icon-rail";
import { RightPanel, RightPanelExpandButton } from "../right-panel/RightPanel";
import {
RightPanel,
RightPanelExpandButton,
RightPanelToggleButton,
} from "../right-panel/RightPanel";
import {
AllPrivateChatsSidebarContent,
AllSharedChatsSidebarContent,
AnnouncementsSidebarContent,
DocumentsSidebar,
InboxSidebarContent,
MobileSidebar,
MobileSidebarTrigger,
Sidebar,
SidebarCollapseButton,
} from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import { TabBar } from "../tabs/TabBar";
import { WorkspacePanel } from "./WorkspacePanel";
const DocumentTabContent = dynamic(
() => import("../tabs/DocumentTabContent").then((m) => ({ default: m.DocumentTabContent })),
@ -41,6 +52,36 @@ const DocumentTabContent = dynamic(
}
);
function MacDesktopTitleBar({
isSidebarCollapsed,
onToggleSidebar,
disableRightPanelToggle = false,
}: {
isSidebarCollapsed: boolean;
onToggleSidebar: () => void;
disableRightPanelToggle?: boolean;
}) {
return (
<div className="flex h-9 shrink-0 items-center bg-rail px-2 [app-region:drag] [-webkit-app-region:drag]">
<div className="ml-[72px] flex h-full items-center [app-region:no-drag] [-webkit-app-region:no-drag]">
<SidebarCollapseButton
isCollapsed={isSidebarCollapsed}
onToggle={onToggleSidebar}
className="h-6 w-6 rounded-md"
iconClassName="h-3.5 w-3.5"
/>
</div>
<div className="ml-auto flex h-full items-center [app-region:no-drag] [-webkit-app-region:no-drag]">
<RightPanelToggleButton
disabled={disableRightPanelToggle}
className="h-6 w-6 rounded-md"
iconClassName="h-3.5 w-3.5"
/>
</div>
</div>
);
}
// Per-tab data source
interface TabDataSource {
items: InboxItem[];
@ -53,7 +94,7 @@ interface TabDataSource {
markAllAsRead: () => Promise<boolean>;
}
export type ActiveSlideoutPanel = "inbox" | "shared" | "private" | "announcements" | null;
export type ActiveSlideoutPanel = "inbox" | "shared" | "private" | null;
// Inbox-related props — per-tab data sources with independent loading/pagination
interface InboxProps {
@ -87,12 +128,17 @@ interface LayoutShellProps {
onSettings?: () => void;
onManageMembers?: () => void;
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
defaultCollapsed?: boolean;
isChatPage?: boolean;
useWorkspacePanel?: boolean;
workspacePanelViewportClassName?: string;
workspacePanelContentClassName?: string;
children: React.ReactNode;
className?: string;
// Unified slide-out panel state
@ -121,25 +167,31 @@ function MainContentPanel({
isChatPage,
onTabSwitch,
onNewChat,
showRightPanelExpandButton = true,
showTopBorder = false,
children,
}: {
isChatPage: boolean;
onTabSwitch?: (tab: Tab) => void;
onNewChat?: () => void;
showRightPanelExpandButton?: boolean;
showTopBorder?: boolean;
children: React.ReactNode;
}) {
const activeTab = useAtomValue(activeTabAtom);
const isDocumentTab = activeTab?.type === "document";
return (
<div className="relative isolate flex flex-1 flex-col min-w-0">
<div
className={cn("relative isolate flex flex-1 flex-col min-w-0", showTopBorder && "border-t")}
>
<TabBar
onTabSwitch={onTabSwitch}
onNewChat={onNewChat}
rightActions={<RightPanelExpandButton />}
rightActions={showRightPanelExpandButton ? <RightPanelExpandButton /> : null}
className="min-w-0"
/>
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
<div className="relative flex flex-1 flex-col bg-panel overflow-hidden min-w-0">
<Header />
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (
@ -161,6 +213,10 @@ function MainContentPanel({
);
}
function DesktopWorkspaceRegion({ children }: { children: React.ReactNode }) {
return <div className="flex h-full min-w-0 flex-1 -mr-2">{children}</div>;
}
export function LayoutShell({
searchSpaces,
activeSearchSpaceId,
@ -185,12 +241,17 @@ export function LayoutShell({
onSettings,
onManageMembers,
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
onLogout,
pageUsage,
theme,
setTheme,
defaultCollapsed = false,
isChatPage = false,
useWorkspacePanel = false,
workspacePanelViewportClassName,
workspacePanelContentClassName,
children,
className,
activeSlideoutPanel = null,
@ -203,18 +264,20 @@ export function LayoutShell({
onTabSwitch,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const electronAPI = useElectronAPI();
const isMacDesktop = electronAPI?.versions.platform === "darwin";
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
const {
sidebarWidth,
handleMouseDown: onResizeMouseDown,
handlePointerDown: onResizePointerDown,
isDragging: isResizing,
} = useSidebarResize();
// Memoize context value to prevent unnecessary re-renders
const sidebarContextValue = useMemo(
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth }),
[isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth]
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
const closeSlideout = useCallback(
@ -233,16 +296,14 @@ export function LayoutShell({
? "Shared Chats"
: activeSlideoutPanel === "private"
? "Private Chats"
: activeSlideoutPanel === "announcements"
? "Announcements"
: "Panel";
: "Panel";
// Mobile layout
if (isMobile) {
return (
<SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full flex-col bg-main-panel", className)}>
<div className={cn("flex h-screen w-full flex-col bg-panel", className)}>
<Header
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
/>
@ -273,6 +334,8 @@ export function LayoutShell({
onSettings={onSettings}
onManageMembers={onManageMembers}
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
@ -280,9 +343,18 @@ export function LayoutShell({
isLoadingChats={isLoadingChats}
/>
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</main>
{useWorkspacePanel ? (
<WorkspacePanel
viewportClassName={workspacePanelViewportClassName}
contentClassName={workspacePanelContentClassName}
>
{children}
</WorkspacePanel>
) : (
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</main>
)}
{/* Mobile unified slide-out panel */}
<SidebarSlideOutPanel
@ -309,21 +381,6 @@ export function LayoutShell({
/>
</motion.div>
)}
{activeSlideoutPanel === "announcements" && (
<motion.div
key="announcements"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AnnouncementsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
{activeSlideoutPanel === "shared" && allSharedChatsPanel && (
<motion.div
key="shared"
@ -376,161 +433,200 @@ export function LayoutShell({
return (
<SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}>
<div
className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}
>
<div className="hidden md:flex overflow-hidden">
<IconRail
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace}
<div className={cn("flex h-screen w-full flex-col overflow-hidden bg-rail", className)}>
{isMacDesktop ? (
<MacDesktopTitleBar
isSidebarCollapsed={isCollapsed}
onToggleSidebar={toggleCollapsed}
disableRightPanelToggle={useWorkspacePanel}
/>
</div>
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content */}
<div
className={cn(
"relative hidden md:flex shrink-0 border bg-sidebar z-20 transition-[border-radius,border-color] duration-200",
anySlideOutOpen ? "rounded-l-xl border-r-0 delay-0" : "rounded-xl delay-150"
)}
>
<Sidebar
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onToggleCollapse={toggleCollapsed}
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
className={cn(
"flex shrink-0 transition-[border-radius] duration-200",
anySlideOutOpen ? "rounded-l-xl delay-0" : "rounded-xl delay-150"
)}
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
isResizing={isResizing}
/>
{/* Unified slide-out panel — shell stays open, content cross-fades */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
/>
</motion.div>
)}
{activeSlideoutPanel === "announcements" && (
<motion.div
key="announcements"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AnnouncementsSidebarContent onOpenChange={(open) => closeSlideout(open)} />
</motion.div>
)}
{activeSlideoutPanel === "shared" && allSharedChatsPanel && (
<motion.div
key="shared"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllSharedChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allSharedChatsPanel.searchSpaceId}
/>
</motion.div>
)}
{activeSlideoutPanel === "private" && allPrivateChatsPanel && (
<motion.div
key="private"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllPrivateChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
{/* Resize handle — negative margins eat the flex gap so spacing stays unchanged */}
{!isCollapsed && (
) : null}
<div className="flex min-h-0 flex-1 w-full gap-2 px-2 py-0 overflow-hidden">
<div
role="slider"
aria-label="Resize sidebar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
tabIndex={0}
onMouseDown={onResizeMouseDown}
className="hidden md:block h-full cursor-col-resize z-30 focus:outline-none"
style={{ width: 8, marginLeft: -8, marginRight: -8 }}
/>
)}
className={cn(
"hidden md:flex overflow-hidden -mr-2 pr-2 bg-rail",
!isMacDesktop && "border-r"
)}
>
<IconRail
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace}
isSingleRailMode={false}
user={user}
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={onLogout}
theme={theme}
setTheme={setTheme}
/>
</div>
{/* Main content panel */}
<MainContentPanel isChatPage={isChatPage} onTabSwitch={onTabSwitch} onNewChat={onNewChat}>
{children}
</MainContentPanel>
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
<div
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
)}
>
<Sidebar
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onToggleCollapse={toggleCollapsed}
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
renderUserProfile={false}
renderCollapseButton={!isMacDesktop}
collapsedHeaderContent={
isMacDesktop ? (
<Logo disableLink priority className="h-7 w-7 rounded-md" />
) : undefined
}
className={cn("flex shrink-0", isMacDesktop && "rounded-tl-xl")}
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
isResizing={isResizing}
/>
{/* Right panel — tabbed Sources/Report (desktop only) */}
{documentsPanel && (
<RightPanel
documentsPanel={{
open: documentsPanel.open,
onOpenChange: documentsPanel.onOpenChange,
}}
/>
)}
{!isCollapsed && (
<hr
aria-orientation="vertical"
aria-label="Resize sidebar"
aria-valuemin={SIDEBAR_MIN_WIDTH}
aria-valuemax={SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
tabIndex={0}
onPointerDown={onResizePointerDown}
style={{ touchAction: "none" }}
className={cn(
"absolute top-0 right-0 h-full w-4 translate-x-1/2 z-50 m-0 border-0 bg-transparent p-0 select-none cursor-col-resize",
"after:content-[''] after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-border/80 after:transition-colors",
isResizing && "after:bg-border"
)}
/>
)}
{/* Unified slide-out panel — shell stays open, content cross-fades */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
/>
</motion.div>
)}
{activeSlideoutPanel === "shared" && allSharedChatsPanel && (
<motion.div
key="shared"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllSharedChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allSharedChatsPanel.searchSpaceId}
/>
</motion.div>
)}
{activeSlideoutPanel === "private" && allPrivateChatsPanel && (
<motion.div
key="private"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<AllPrivateChatsSidebarContent
onOpenChange={(open) => closeSlideout(open)}
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
<DesktopWorkspaceRegion>
{useWorkspacePanel ? (
<WorkspacePanel
className={isMacDesktop ? "border-t" : undefined}
viewportClassName={workspacePanelViewportClassName}
contentClassName={workspacePanelContentClassName}
>
{children}
</WorkspacePanel>
) : (
<>
{/* Main content panel */}
<MainContentPanel
isChatPage={isChatPage}
onTabSwitch={onTabSwitch}
onNewChat={onNewChat}
showRightPanelExpandButton={!isMacDesktop}
showTopBorder={isMacDesktop}
>
{children}
</MainContentPanel>
{/* Right panel — tabbed Sources/Report (desktop only) */}
{documentsPanel ? (
<RightPanel
documentsPanel={{
open: documentsPanel.open,
onOpenChange: documentsPanel.onOpenChange,
}}
showCollapseButton={!isMacDesktop}
showTopBorder={isMacDesktop}
/>
) : null}
</>
)}
</DesktopWorkspaceRegion>
</div>
</div>
</TooltipProvider>
</SidebarProvider>

View file

@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface WorkspacePanelProps {
children: ReactNode;
className?: string;
viewportClassName?: string;
contentClassName?: string;
}
/**
* Full workspace area to the right of the left rail/sidebar.
* Use this when a route should own the whole workspace instead of rendering
* inside the normal TabBar/Header/main/right-panel chrome.
*/
export function WorkspacePanel({
children,
className,
viewportClassName,
contentClassName,
}: WorkspacePanelProps) {
return (
<main
className={cn(
"relative isolate flex min-w-0 flex-1 flex-col overflow-hidden bg-panel",
className
)}
>
<div
className={cn(
"flex min-h-0 flex-1 items-center justify-center overflow-auto px-4 py-8",
viewportClassName
)}
>
<div className={cn("w-full max-w-md", contentClassName)}>{children}</div>
</div>
</main>
);
}

View file

@ -1 +1,2 @@
export { LayoutShell } from "./LayoutShell";
export { WorkspacePanel } from "./WorkspacePanel";

View file

@ -252,37 +252,36 @@ export function AllPrivateChatsSidebarContent({
return (
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="shrink-0 p-3 pb-1.5 space-y-2">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
@ -296,23 +295,23 @@ export function AllPrivateChatsSidebarContent({
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4 mt-2"
className="shrink-0 mx-3 mt-1.5"
>
<TabsList stretch showBottomBorder size="sm">
<TabsTrigger value="active">
<span className="inline-flex items-center gap-1.5">
<MessageCircleMore className="h-4 w-4" />
<MessageCircleMore className="h-3.5 w-3.5" />
<span>Active</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger value="archived">
<span className="inline-flex items-center gap-1.5">
<ArchiveIcon className="h-4 w-4" />
<ArchiveIcon className="h-3.5 w-3.5" />
<span>Archived</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{archivedCount}
</span>
</span>
@ -321,7 +320,7 @@ export function AllPrivateChatsSidebarContent({
</Tabs>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1.5">
{isLoading ? (
<div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
@ -347,19 +346,11 @@ export function AllPrivateChatsSidebarContent({
const isActive = currentChatId === thread.id;
return (
<div
key={thread.id}
className={cn(
"sidebar-item-lazy group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<div key={thread.id} className="group/item relative w-full">
{isMobile ? (
<button
<Button
type="button"
variant="ghost"
onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
@ -371,21 +362,34 @@ export function AllPrivateChatsSidebarContent({
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
className={cn(
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</Button>
) : (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
className={cn(
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
@ -395,89 +399,97 @@ export function AllPrivateChatsSidebarContent({
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
<div
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center rounded-r-md pl-6 pr-1",
isActive
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0"
: openDropdownId === thread.id
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0 hover:bg-transparent",
isMobile
? "opacity-0 pointer-events-none absolute"
: openDropdownId === thread.id
? "opacity-100"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
openDropdownId === thread.id && "bg-accent hover:bg-accent",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && (
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"pointer-events-auto h-6 w-6 hover:bg-transparent",
openDropdownId === thread.id && "bg-accent hover:bg-accent"
)}
disabled={isBusy}
>
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && (
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<User className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
<User className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_chats") || "No private chats"}
</p>
{!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1">
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
</p>
)}

View file

@ -251,37 +251,36 @@ export function AllSharedChatsSidebarContent({
return (
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="shrink-0 p-3 pb-1.5 space-y-2">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
<Users className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
@ -295,23 +294,23 @@ export function AllSharedChatsSidebarContent({
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4 mt-2"
className="shrink-0 mx-3 mt-1.5"
>
<TabsList stretch showBottomBorder size="sm">
<TabsTrigger value="active">
<span className="inline-flex items-center gap-1.5">
<MessageCircleMore className="h-4 w-4" />
<MessageCircleMore className="h-3.5 w-3.5" />
<span>Active</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger value="archived">
<span className="inline-flex items-center gap-1.5">
<ArchiveIcon className="h-4 w-4" />
<ArchiveIcon className="h-3.5 w-3.5" />
<span>Archived</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{archivedCount}
</span>
</span>
@ -320,7 +319,7 @@ export function AllSharedChatsSidebarContent({
</Tabs>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1.5">
{isLoading ? (
<div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
@ -346,19 +345,11 @@ export function AllSharedChatsSidebarContent({
const isActive = currentChatId === thread.id;
return (
<div
key={thread.id}
className={cn(
"sidebar-item-lazy group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<div key={thread.id} className="group/item relative w-full">
{isMobile ? (
<button
<Button
type="button"
variant="ghost"
onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
@ -370,21 +361,34 @@ export function AllSharedChatsSidebarContent({
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
className={cn(
"h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</Button>
) : (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
className={cn(
"h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
@ -394,89 +398,97 @@ export function AllSharedChatsSidebarContent({
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
<div
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center rounded-r-md pl-6 pr-1",
isActive
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0"
: openDropdownId === thread.id
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0 hover:bg-transparent",
isMobile
? "opacity-0 pointer-events-none absolute"
: openDropdownId === thread.id
? "opacity-100"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
openDropdownId === thread.id && "bg-accent hover:bg-accent",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && (
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"pointer-events-auto h-6 w-6 hover:bg-transparent",
openDropdownId === thread.id && "bg-accent hover:bg-accent"
)}
disabled={isBusy}
>
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && (
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
<Users className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_shared_chats") || "No shared chats"}
</p>
{!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1">
<p className="mt-1 text-[11px] text-muted-foreground/70">
Share a chat to collaborate with your team
</p>
)}

View file

@ -1,84 +0,0 @@
"use client";
import { ChevronLeft } from "lucide-react";
import { useEffect } from "react";
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { Button } from "@/components/ui/button";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
export interface AnnouncementsSidebarContentProps {
onOpenChange: (open: boolean) => void;
onCloseMobileSidebar?: () => void;
}
interface AnnouncementsSidebarProps extends AnnouncementsSidebarContentProps {
open: boolean;
}
export function AnnouncementsSidebarContent({
onOpenChange,
onCloseMobileSidebar,
}: AnnouncementsSidebarContentProps) {
const isMobile = !useMediaQuery("(min-width: 640px)");
const { announcements, markAllRead } = useAnnouncements();
useEffect(() => {
markAllRead();
}, [markAllRead]);
return (
<div className="h-full flex flex-col">
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
onOpenChange(false);
onCloseMobileSidebar?.();
}}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">Close</span>
</Button>
)}
<h2 className="text-lg font-semibold">Announcements</h2>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{announcements.length === 0 ? (
<AnnouncementsEmptyState />
) : (
<div className="flex flex-col gap-4">
{announcements.map((announcement) => (
<AnnouncementCard key={announcement.id} announcement={announcement} />
))}
</div>
)}
</div>
</div>
);
}
export function AnnouncementsSidebar({
open,
onOpenChange,
onCloseMobileSidebar,
}: AnnouncementsSidebarProps) {
return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel="Announcements">
<AnnouncementsSidebarContent
onOpenChange={onOpenChange}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
</SidebarSlideOutPanel>
);
}

View file

@ -56,19 +56,20 @@ export function ChatListItem({
return (
<div className="group/item relative w-full">
<button
<Button
type="button"
variant="ghost"
onClick={handleClick}
{...(isMobile ? longPressHandlers : {})}
className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left",
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground"
)}
>
<span className="truncate">{animatedName}</span>
</button>
</Button>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div

View file

@ -4,12 +4,12 @@ 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 { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@ -65,29 +65,30 @@ export function DesktopLocalTabContent({
return (
<div className="flex min-h-0 flex-1 flex-col select-none">
<div className="mx-4 mt-4 mb-3">
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
<div className="flex h-7 w-full items-stretch rounded-lg border-0 bg-muted text-[11px] text-muted-foreground">
{localRootPaths.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<Button
type="button"
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
variant="ghost"
size="sm"
className="min-w-0 flex-1 h-full justify-start gap-1 px-2 text-left text-[11px] text-muted-foreground"
title={localRootPaths.join("\n")}
aria-label="Manage selected folders"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<Folder className="size-3 shrink-0" />
<span className="truncate">
{localRootPaths.length === 1
? "1 folder selected"
: `${localRootPaths.length} folders selected`}
</span>
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Selected folders
</DropdownMenuLabel>
<DropdownMenuSeparator className="mx-1 my-0.5" />
{localRootPaths.map((rootPath) => (
<DropdownMenuItem
key={rootPath}
@ -98,9 +99,11 @@ export function DesktopLocalTabContent({
<span className="min-w-0 flex-1 truncate">
{getFolderDisplayName(rootPath)}
</span>
<button
<Button
type="button"
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-accent-foreground"
onClick={(event) => {
event.stopPropagation();
void onRemoveFilesystemRoot(rootPath);
@ -108,10 +111,9 @@ export function DesktopLocalTabContent({
aria-label={`Remove ${getFolderDisplayName(rootPath)}`}
>
<X className="size-3" />
</button>
</Button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="mx-1 my-0.5" />
<DropdownMenuItem
variant="destructive"
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
@ -125,24 +127,26 @@ export function DesktopLocalTabContent({
</DropdownMenu>
) : (
<div
className="min-w-0 flex-1 flex items-center gap-1 px-2"
className="min-w-0 flex-1 flex items-center gap-1 px-2 transition-colors hover:bg-accent hover:text-accent-foreground"
title="No local folders selected"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<Folder className="size-3 shrink-0" />
<span className="truncate">No local folders selected</span>
</div>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-3 self-center bg-border"
className="data-[orientation=vertical]:h-3 self-center bg-border/60 dark:bg-white/10"
/>
{electronAvailable ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
<Button
type="button"
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
variant="ghost"
size="icon"
className="h-full w-8 text-muted-foreground hover:text-accent-foreground"
onClick={() => {
void onPickFilesystemRoot();
}}
@ -150,7 +154,7 @@ export function DesktopLocalTabContent({
aria-label="Add folder"
>
<FolderPlus className="size-3.5" />
</button>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
@ -169,7 +173,7 @@ export function DesktopLocalTabContent({
</div>
<Input
ref={localSearchInputRef}
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
className="peer h-8 w-full border-0 bg-muted pl-8 pr-8 text-sm shadow-none select-none focus:select-text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search local files"
@ -177,9 +181,11 @@ export function DesktopLocalTabContent({
aria-label="Search local files"
/>
{Boolean(localSearch) && (
<button
<Button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 h-full w-8 text-muted-foreground hover:text-accent-foreground"
aria-label="Clear local search"
onClick={() => {
setLocalSearch("");
@ -187,7 +193,7 @@ export function DesktopLocalTabContent({
}}
>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
</Button>
)}
</div>
</div>

View file

@ -35,7 +35,6 @@ import {
folderWatchDialogOpenAtom,
folderWatchInitialFolderAtom,
} from "@/atoms/folder-sync/folder-sync.atoms";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
@ -196,7 +195,6 @@ function AuthenticatedDocumentsSidebarBase({
const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null;
const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const { data: agentFlags } = useAtomValue(agentFlagsAtom);
const { data: connectors } = useAtomValue(connectorsAtom);
@ -1021,17 +1019,13 @@ function AuthenticatedDocumentsSidebarBase({
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
if (isMobile) {
onOpenChange(false);
} else {
setRightPanelCollapsed(true);
}
if (e.key === "Escape" && open && isMobile) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
}, [open, onOpenChange, isMobile]);
const showFilesystemTabs =
!isMobile && !!electronAPI && !!filesystemSettings && localFilesystemEnabled;
@ -1046,86 +1040,88 @@ function AuthenticatedDocumentsSidebarBase({
const cloudContent = (
<>
{/* Connected tools strip */}
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button
type="button"
onClick={() => setConnectorDialogOpen(true)}
className="flex items-center gap-2 min-w-0 flex-1 text-left px-3 py-2"
>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-muted-foreground">
{connectorCount > 0 ? "Manage connectors" : "Connect your connectors"}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConnectorDialogOpen(true)}
className="shrink-0 mx-4 mt-6 mb-2.5 h-auto select-none justify-start gap-2 bg-muted px-3 py-1.5 text-xs text-muted-foreground"
>
<Unplug className="size-4 shrink-0" />
<span className="truncate">
{connectorCount > 0 ? "Manage connectors" : "Connect your connectors"}
</span>
{connectorCount > 0 && (
<span className="shrink-0 rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{connectorCount}
</span>
{connectorCount > 0 && (
<span className="shrink-0 rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{connectorCount}
</span>
)}
<AvatarGroup className="ml-auto shrink-0">
{connectorCount > 0 && connectors
? connectors.slice(0, isMobile ? 5 : 9).map((connector, i) => {
)}
<AvatarGroup className="ml-auto shrink-0">
{connectorCount > 0 && connectors
? connectors.slice(0, isMobile ? 5 : 9).map((connector, i) => {
const avatar = (
<Avatar
key={connector.id}
className="size-6"
style={{ zIndex: Math.max(9 - i, 1) }}
>
<AvatarFallback className="bg-muted text-[10px] text-muted-foreground">
{getConnectorIcon(connector.connector_type, "size-3.5")}
</AvatarFallback>
</Avatar>
);
if (isMobile) return avatar;
return (
<Tooltip key={connector.id}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{connector.name}
</TooltipContent>
</Tooltip>
);
})
: (isMobile ? SHOWCASE_CONNECTORS.slice(0, 5) : SHOWCASE_CONNECTORS).map(
({ type, label }, i) => {
const avatar = (
<Avatar
key={connector.id}
key={type}
className="size-6"
style={{ zIndex: Math.max(9 - i, 1) }}
style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(connector.connector_type, "size-3.5")}
<AvatarFallback className="bg-muted text-[10px] text-muted-foreground">
{getConnectorIcon(type, "size-3.5")}
</AvatarFallback>
</Avatar>
);
if (isMobile) return avatar;
return (
<Tooltip key={connector.id}>
<Tooltip key={type}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{connector.name}
{label}
</TooltipContent>
</Tooltip>
);
})
: (isMobile ? SHOWCASE_CONNECTORS.slice(0, 5) : SHOWCASE_CONNECTORS).map(
({ type, label }, i) => {
const avatar = (
<Avatar
key={type}
className="size-6"
style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(type, "size-3.5")}
</AvatarFallback>
</Avatar>
);
if (isMobile) return avatar;
return (
<Tooltip key={type}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
)}
</AvatarGroup>
</button>
</div>
}
)}
</AvatarGroup>
</Button>
{isElectron && (
<button
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleWatchLocalFolder}
className="shrink-0 mx-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 transition-colors hover:bg-muted/80"
className="shrink-0 mx-4 mb-2.5 h-auto select-none justify-start gap-2 bg-muted px-3 py-1.5 text-xs text-muted-foreground"
>
<FolderClock className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-muted-foreground">Watch local folder</span>
</button>
<FolderClock className="size-4 shrink-0" />
<span className="truncate">Watch local folder</span>
</Button>
)}
<div className="flex-1 min-h-0 pt-0 flex flex-col">
<div className="px-4 pb-2">
<div className="px-4 pb-1.5">
<DocumentsFilters
typeCounts={typeCounts}
onSearch={setSearch}
@ -1142,15 +1138,17 @@ function AuthenticatedDocumentsSidebarBase({
<div className="relative flex-1 min-h-0 overflow-auto">
{deletableSelectedIds.length > 0 && (
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150 pointer-events-none">
<button
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-lg text-xs font-medium hover:bg-destructive/90 transition-colors"
className="pointer-events-auto h-auto gap-1.5 px-3 py-1 text-xs shadow-lg"
>
<Trash2 size={12} />
Delete {deletableSelectedIds.length}{" "}
{deletableSelectedIds.length === 1 ? "item" : "items"}
</button>
</Button>
</div>
)}
@ -1225,17 +1223,17 @@ function AuthenticatedDocumentsSidebarBase({
const documentsContent = (
<>
<div className="shrink-0 flex h-14 items-center px-4">
<div className="shrink-0 flex h-12 items-center px-3 border-b">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
@ -1275,7 +1273,7 @@ function AuthenticatedDocumentsSidebarBase({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => {
if (isDocked) {
onDockedChange(false);
@ -1286,9 +1284,9 @@ function AuthenticatedDocumentsSidebarBase({
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<ChevronRight className="h-4 w-4" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
@ -1582,7 +1580,6 @@ function AnonymousDocumentsSidebar({
const t = useTranslations("documents");
const tSidebar = useTranslations("sidebar");
const isMobile = !useMediaQuery("(min-width: 640px)");
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
const anonMode = useAnonymousMode();
const { gate } = useLoginGate();
@ -1694,17 +1691,13 @@ function AnonymousDocumentsSidebar({
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
if (isMobile) {
onOpenChange(false);
} else {
setRightPanelCollapsed(true);
}
if (e.key === "Escape" && open && isMobile) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
}, [open, onOpenChange, isMobile]);
const documentsContent = (
<>
@ -1718,20 +1711,20 @@ function AnonymousDocumentsSidebar({
/>
{/* Header */}
<div className="shrink-0 flex h-14 items-center px-4">
<div className="shrink-0 flex h-12 items-center px-3 border-b">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
<h2 className="select-none text-base font-semibold">{t("title") || "Documents"}</h2>
</div>
<div className="flex items-center gap-1">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4 text-muted-foreground" />
<X className="h-4 w-4" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
@ -1741,7 +1734,7 @@ function AnonymousDocumentsSidebar({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => {
if (isDocked) {
onDockedChange(false);
@ -1752,9 +1745,9 @@ function AnonymousDocumentsSidebar({
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<ChevronRight className="h-4 w-4" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
@ -1770,46 +1763,46 @@ function AnonymousDocumentsSidebar({
</div>
{/* Connectors strip (gated) */}
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button
type="button"
onClick={() => gate("connect your data sources")}
className="flex items-center gap-2 min-w-0 flex-1 text-left px-3 py-2"
>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-muted-foreground">Connect your connectors</span>
<AvatarGroup className="ml-auto shrink-0">
{(isMobile ? SHOWCASE_CONNECTORS.slice(0, 5) : SHOWCASE_CONNECTORS).map(
({ type, label }, i) => {
const avatar = (
<Avatar
key={type}
className="size-6"
style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(type, "size-3.5")}
</AvatarFallback>
</Avatar>
);
if (isMobile) return avatar;
return (
<Tooltip key={type}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
)}
</AvatarGroup>
</button>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => gate("connect your data sources")}
className="shrink-0 mx-4 mt-6 mb-2.5 h-auto select-none justify-start gap-2 border bg-muted/50 px-3 py-1.5 text-xs text-muted-foreground"
>
<Unplug className="size-4 shrink-0" />
<span className="truncate">Connect your connectors</span>
<AvatarGroup className="ml-auto shrink-0">
{(isMobile ? SHOWCASE_CONNECTORS.slice(0, 5) : SHOWCASE_CONNECTORS).map(
({ type, label }, i) => {
const avatar = (
<Avatar
key={type}
className="size-6"
style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-muted text-[10px] text-muted-foreground">
{getConnectorIcon(type, "size-3.5")}
</AvatarFallback>
</Avatar>
);
if (isMobile) return avatar;
return (
<Tooltip key={type}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
)}
</AvatarGroup>
</Button>
{/* Filters & upload */}
<div className="flex-1 min-h-0 pt-0 flex flex-col">
<div className="px-4 pb-2">
<div className="px-4 pb-1.5">
<DocumentsFilters
typeCounts={hasDoc ? { FILE: 1 } : {}}
onSearch={setSearch}
@ -1857,18 +1850,19 @@ function AnonymousDocumentsSidebar({
{!hasDoc && (
<div className="px-4 py-8 text-center">
<button
<Button
type="button"
variant="ghost"
onClick={handleAnonUploadClick}
disabled={isUploading}
className="relative flex w-full items-center justify-center rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
className="relative h-auto w-full border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary hover:border-primary/60 hover:bg-primary/5 hover:text-primary cursor-pointer"
>
<span className={`flex items-center gap-2 ${isUploading ? "opacity-0" : ""}`}>
<Upload className="size-4" />
Upload a document
</span>
{isUploading && <Spinner size="sm" className="absolute" />}
</button>
</Button>
<p className="mt-2 text-[11px] text-muted-foreground leading-relaxed">
Text, code, CSV, and HTML files only. Create an account for PDFs, images, and 30+
connectors.

View file

@ -178,7 +178,7 @@ export function InboxSidebarContent({
const [mounted, setMounted] = useState(false);
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
const connectorRafRef = useRef<number>();
const connectorRafRef = useRef<number | null>(null);
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
if (connectorRafRef.current) return;
@ -186,7 +186,7 @@ export function InboxSidebarContent({
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
connectorRafRef.current = undefined;
connectorRafRef.current = null;
});
}, []);
useEffect(
@ -528,17 +528,17 @@ export function InboxSidebarContent({
return (
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="shrink-0 p-3 pb-1.5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
@ -561,7 +561,10 @@ export function InboxSidebarContent({
onOpenChange={setFilterDrawerOpen}
shouldScaleBackground={false}
>
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
<DrawerContent
className="max-h-[70vh] z-80 bg-popover text-popover-foreground"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
@ -575,14 +578,15 @@ export function InboxSidebarContent({
{t("filter") || "Filter"}
</p>
<div className="space-y-1">
<button
<Button
type="button"
variant="ghost"
onClick={() => {
setActiveFilter("all");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "all"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -593,15 +597,16 @@ export function InboxSidebarContent({
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</button>
<button
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setActiveFilter("unread");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "unread"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -612,16 +617,17 @@ export function InboxSidebarContent({
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</button>
</Button>
{activeTab === "status" && (
<button
<Button
type="button"
variant="ghost"
onClick={() => {
setActiveFilter("errors");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "errors"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -632,7 +638,7 @@ export function InboxSidebarContent({
<span>{t("errors_only") || "Errors only"}</span>
</span>
{activeFilter === "errors" && <Check className="h-4 w-4" />}
</button>
</Button>
)}
</div>
</div>
@ -642,14 +648,15 @@ export function InboxSidebarContent({
{t("sources") || "Sources"}
</p>
<div className="space-y-1">
<button
<Button
type="button"
variant="ghost"
onClick={() => {
setSelectedSource(null);
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedSource === null
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -660,17 +667,18 @@ export function InboxSidebarContent({
<span>{t("all_sources") || "All sources"}</span>
</span>
{selectedSource === null && <Check className="h-4 w-4" />}
</button>
</Button>
{statusSourceOptions.map((source) => (
<button
<Button
key={source.key}
type="button"
variant="ghost"
onClick={() => {
setSelectedSource(source.key);
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
"h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedSource === source.key
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -681,7 +689,7 @@ export function InboxSidebarContent({
<span>{source.displayName}</span>
</span>
{selectedSource === source.key && <Check className="h-4 w-4" />}
</button>
</Button>
))}
</div>
</div>
@ -811,19 +819,19 @@ export function InboxSidebarContent({
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_inbox") || "Search inbox"}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
@ -842,23 +850,23 @@ export function InboxSidebarContent({
setActiveFilter("all");
}
}}
className="shrink-0 mx-4 mt-2"
className="shrink-0 mx-3 mt-1.5"
>
<TabsList stretch showBottomBorder size="sm">
<TabsTrigger value="comments">
<span className="inline-flex items-center gap-1.5">
<MessageCircleReply className="h-4 w-4" />
<MessageCircleReply className="h-3.5 w-3.5" />
<span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{formatInboxCount(comments.unreadCount)}
</span>
</span>
</TabsTrigger>
<TabsTrigger value="status">
<span className="inline-flex items-center gap-1.5">
<History className="h-4 w-4" />
<History className="h-3.5 w-3.5" />
<span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
{formatInboxCount(status.unreadCount)}
</span>
</span>
@ -922,11 +930,12 @@ export function InboxSidebarContent({
{activeTab === "status" ? (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
@ -942,7 +951,7 @@ export function InboxSidebarContent({
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p>
@ -952,11 +961,12 @@ export function InboxSidebarContent({
</TooltipContent>
</Tooltip>
) : (
<button
<Button
type="button"
variant="ghost"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
@ -972,7 +982,7 @@ export function InboxSidebarContent({
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</Button>
)}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
@ -1021,23 +1031,25 @@ export function InboxSidebarContent({
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{t("no_results_found") || "No results found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
{activeTab === "comments" ? (
<MessageCircleReply className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<MessageCircleReply className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<History className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
)}
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{getEmptyStateMessage().hint}</p>
<p className="text-xs text-muted-foreground">{getEmptyStateMessage().title}</p>
<p className="mt-1 text-[11px] text-muted-foreground/70">
{getEmptyStateMessage().hint}
</p>
</div>
)}
</div>

View file

@ -3,9 +3,11 @@
import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
interface LocalFilesystemBrowserProps {
rootPaths: string[];
@ -409,10 +411,11 @@ export function LocalFilesystemBrowser({
const files = [...folder.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
return (
<div key={folder.key} className="select-none">
<button
<Button
type="button"
variant="ghost"
onClick={() => toggleFolder(folder.key)}
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
className="h-8 w-full justify-start gap-1.5 px-2 text-left text-sm font-normal hover:bg-accent hover:text-accent-foreground"
style={{ paddingInlineStart: `${depth * 12 + 8}px` }}
draggable={false}
>
@ -423,7 +426,7 @@ export function LocalFilesystemBrowser({
)}
<FolderIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{folder.name}</span>
</button>
</Button>
{isExpanded && (
<>
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
@ -431,17 +434,21 @@ export function LocalFilesystemBrowser({
const extension = getNormalizedExtension(file.relativePath);
const isOpenable = openableExtensions.has(extension);
return (
<button
<Button
key={file.fullPath}
type="button"
variant="ghost"
onClick={
isOpenable
? () => onOpenFile(toMountedVirtualPath(mount, file.relativePath))
: 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"
}`}
className={cn(
"h-8 w-full justify-start gap-1.5 px-2 text-left text-sm font-normal",
isOpenable
? "hover:bg-accent hover:text-accent-foreground"
: "cursor-not-allowed opacity-60"
)}
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={
isOpenable
@ -453,7 +460,7 @@ export function LocalFilesystemBrowser({
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getFileName(file.relativePath)}</span>
</button>
</Button>
);
})}
</>

View file

@ -1,6 +1,6 @@
"use client";
import { PanelRightClose, Plus } from "lucide-react";
import { PanelLeft, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@ -34,6 +34,8 @@ interface MobileSidebarProps {
onSettings?: () => void;
onManageMembers?: () => void;
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
@ -43,8 +45,13 @@ interface MobileSidebarProps {
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
return (
<Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}>
<PanelRightClose className="h-5 w-5" />
<Button
variant="ghost"
size="icon"
onClick={onClick}
className="md:hidden h-8 w-8 shrink-0 text-muted-foreground hover:bg-transparent hover:text-muted-foreground"
>
<PanelLeft className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
);
@ -77,6 +84,8 @@ export function MobileSidebar({
onSettings,
onManageMembers,
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
onLogout,
pageUsage,
theme,
@ -99,11 +108,14 @@ export function MobileSidebar({
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-[340px] p-0 flex flex-row gap-0 [&>button]:hidden">
<SheetContent
side="left"
className="w-[340px] p-0 flex flex-row gap-0 bg-panel [&>button]:hidden"
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
{/* Vertical Search Spaces Rail - left side */}
<div className="flex h-full w-14 shrink-0 flex-col items-center bg-muted/40 border-r">
<div className="flex h-full w-14 shrink-0 flex-col items-center border-r bg-rail">
<ScrollArea className="w-full flex-1">
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
{searchSpaces.map((space) => (
@ -193,6 +205,16 @@ export function MobileSidebar({
}
: undefined
}
onAnnouncements={
onAnnouncements
? () => {
onOpenChange(false);
onAnnouncements();
}
: undefined
}
onNavigate={() => onOpenChange(false)}
announcementUnreadCount={announcementUnreadCount}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}

Some files were not shown because too many files have changed in this diff Show more