mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
Merge branch 'dev' into fix/env-config-connector-forms
This commit is contained in:
commit
81ce9e4071
291 changed files with 8271 additions and 7022 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "Read Message History" 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 "Read Message History" 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" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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're a member of the teams you want to interact
|
||||
with.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function FooterNew() {
|
|||
href: "/contact",
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
title: "What's New",
|
||||
href: "/announcements",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ interface SidebarContextValue {
|
|||
isCollapsed: boolean;
|
||||
setIsCollapsed: (collapsed: boolean) => void;
|
||||
toggleCollapsed: () => void;
|
||||
sidebarWidth: number;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
39
surfsense_web/components/layout/ui/shell/WorkspacePanel.tsx
Normal file
39
surfsense_web/components/layout/ui/shell/WorkspacePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { LayoutShell } from "./LayoutShell";
|
||||
export { WorkspacePanel } from "./WorkspacePanel";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue