mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-27 01:36:30 +02:00
Merge upstream/dev - preserve local UI enhancements (Logs in menu, conditional search, hover edit buttons)
This commit is contained in:
commit
089beb8d8c
117 changed files with 12068 additions and 4857 deletions
|
|
@ -191,7 +191,7 @@ export const AssistantMessage: FC = () => {
|
|||
|
||||
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
|
||||
{showCommentTrigger && !isDesktop && (
|
||||
<div className="mt-2 flex justify-start">
|
||||
<div className="ml-2 mt-1 flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCommentTriggerClick}
|
||||
|
|
@ -234,7 +234,7 @@ const AssistantActionBar: FC = () => {
|
|||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
autohideFloat="single-branch"
|
||||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
|
||||
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:border md:data-floating:bg-background md:data-floating:p-1 md:data-floating:shadow-sm [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<TooltipIconButton tooltip="Copy">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatSessionStatusProps {
|
||||
isAiResponding: boolean;
|
||||
respondingToUserId: string | null;
|
||||
currentUserId: string | null;
|
||||
members: Array<{
|
||||
user_id: string;
|
||||
user_display_name?: string | null;
|
||||
user_email?: string | null;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
|
||||
isAiResponding,
|
||||
respondingToUserId,
|
||||
currentUserId,
|
||||
members,
|
||||
className,
|
||||
}) => {
|
||||
if (!isAiResponding || !respondingToUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (respondingToUserId === currentUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const respondingUser = members.find((m) => m.user_id === respondingToUserId);
|
||||
const displayName = respondingUser?.user_display_name || respondingUser?.user_email || "another user";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground bg-muted/50 rounded-lg",
|
||||
"animate-in fade-in slide-in-from-bottom-2 duration-300 ease-out",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>Currently responding to {displayName}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@ import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog
|
|||
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||
import { ComposioToolkitView } from "./connector-popup/views/composio-toolkit-view";
|
||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||
|
||||
|
|
@ -87,6 +88,12 @@ export const ConnectorIndicator: FC = () => {
|
|||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
// Composio
|
||||
viewingComposio,
|
||||
connectingComposioToolkit,
|
||||
handleOpenComposio,
|
||||
handleBackFromComposio,
|
||||
handleConnectComposioToolkit,
|
||||
} = useConnectorDialog();
|
||||
|
||||
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
||||
|
|
@ -176,6 +183,20 @@ export const ConnectorIndicator: FC = () => {
|
|||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingComposio && searchSpaceId ? (
|
||||
<ComposioToolkitView
|
||||
searchSpaceId={searchSpaceId}
|
||||
connectedToolkits={
|
||||
(connectors || [])
|
||||
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
|
||||
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
|
||||
.filter(Boolean)
|
||||
}
|
||||
onBack={handleBackFromComposio}
|
||||
onConnectToolkit={handleConnectComposioToolkit}
|
||||
isConnecting={connectingComposioToolkit !== null}
|
||||
connectingToolkitId={connectingComposioToolkit}
|
||||
/>
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
|
|
@ -312,6 +333,7 @@ export const ConnectorIndicator: FC = () => {
|
|||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
onOpenComposio={handleOpenComposio}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioConnectorCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
connectorCount?: number;
|
||||
onConnect: () => void;
|
||||
}
|
||||
|
||||
export const ComposioConnectorCard: FC<ComposioConnectorCardProps> = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
connectorCount = 0,
|
||||
onConnect,
|
||||
}) => {
|
||||
const hasConnections = connectorCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
|
||||
"border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5",
|
||||
"hover:border-violet-500/40 hover:from-violet-500/10 hover:to-purple-500/10"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
|
||||
"bg-gradient-to-br from-violet-500/10 to-purple-500/10 border-violet-500/20"
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/connectors/composio.svg"
|
||||
alt="Composio"
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
|
||||
<Zap className="size-3.5 text-violet-500" />
|
||||
</div>
|
||||
{hasConnections ? (
|
||||
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||
<span>
|
||||
{connectorCount} {connectorCount === 1 ? "connection" : "connections"}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={hasConnections ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium shadow-xs",
|
||||
!hasConnections && "bg-violet-600 hover:bg-violet-700 text-white",
|
||||
hasConnections &&
|
||||
"bg-white text-slate-700 hover:bg-slate-50 border-0 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
||||
)}
|
||||
onClick={onConnect}
|
||||
>
|
||||
{hasConnections ? "Manage" : "Browse"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -24,11 +24,6 @@
|
|||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||
},
|
||||
"GITHUB_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "maintenance",
|
||||
"statusMessage": "Rework in progress."
|
||||
}
|
||||
},
|
||||
"globalSettings": {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
|
|
@ -34,8 +29,6 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
|
||||
const githubConnectorFormSchema = z.object({
|
||||
|
|
@ -44,10 +37,8 @@ const githubConnectorFormSchema = z.object({
|
|||
}),
|
||||
github_pat: z
|
||||
.string()
|
||||
.min(20, {
|
||||
message: "GitHub Personal Access Token seems too short.",
|
||||
})
|
||||
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
||||
.optional()
|
||||
.refine((pat) => !pat || pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||
}),
|
||||
repo_full_names: z.string().min(1, {
|
||||
|
|
@ -59,8 +50,6 @@ type GithubConnectorFormValues = z.infer<typeof githubConnectorFormSchema>;
|
|||
|
||||
export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<GithubConnectorFormValues>({
|
||||
|
|
@ -94,16 +83,18 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
name: values.name,
|
||||
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
|
||||
config: {
|
||||
GITHUB_PAT: values.github_pat,
|
||||
GITHUB_PAT: values.github_pat || null, // Optional - only for private repos
|
||||
repo_full_names: repoList,
|
||||
},
|
||||
is_indexable: true,
|
||||
is_active: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
// GitHub indexes full repo snapshots - no date range needed
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
|
|
@ -117,18 +108,19 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token Required</AlertTitle>
|
||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a GitHub Personal Access Token to use this connector. You can create one
|
||||
from{" "}
|
||||
A GitHub PAT is only required for private repositories. Public repos work without a
|
||||
token.{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
|
||||
>
|
||||
GitHub Settings
|
||||
</a>
|
||||
Get your token
|
||||
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</a>{" "}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
|
@ -167,7 +159,10 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
name="github_pat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">GitHub Personal Access Token</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm">
|
||||
GitHub Personal Access Token{" "}
|
||||
<span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
|
|
@ -178,8 +173,8 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your GitHub PAT will be encrypted and stored securely. It typically starts with
|
||||
"ghp_" or "github_pat_".
|
||||
Only required for private repositories. Leave empty if indexing public repos
|
||||
only.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -225,15 +220,9 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
<h3 className="text-sm sm:text-base font-medium">Sync Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
{/* Note: No date range for GitHub - it indexes full repo snapshots */}
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
|
|
@ -301,169 +290,18 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with GitHub integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</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 GitHub connector uses a Personal Access Token (PAT) to authenticate with the
|
||||
GitHub API. You provide a comma-separated list of repository full names (e.g.,
|
||||
"owner/repo1, owner/repo2") that you want to index. The connector indexes relevant
|
||||
files (code, markdown, text) from the selected repositories.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
The connector indexes files based on common code and documentation extensions.
|
||||
</li>
|
||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
||||
<li>Only specified repositories are indexed.</li>
|
||||
<li>
|
||||
Indexing runs periodically (check connector settings for frequency) to keep
|
||||
content up-to-date.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">
|
||||
Personal Access Token Required
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
|
||||
repositories. The PAT will be stored securely to enable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 1: Generate GitHub PAT
|
||||
</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
Go to your GitHub{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Developer settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Click on <strong>Personal access tokens</strong>, then choose{" "}
|
||||
<strong>Tokens (classic)</strong> or <strong>Fine-grained tokens</strong>{" "}
|
||||
(recommended if available).
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate new token</strong> (and choose the appropriate type).
|
||||
</li>
|
||||
<li>Give your token a descriptive name (e.g., "SurfSense Connector").</li>
|
||||
<li>Set an expiration date for the token (recommended for security).</li>
|
||||
<li>
|
||||
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
|
||||
<strong>Repository access</strong> (for fine-grained), grant the necessary
|
||||
permissions. At minimum, the <strong>`repo`</strong> scope (or equivalent
|
||||
read access to repositories for fine-grained tokens) is required to read
|
||||
repository content.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate token</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Important:</strong> Copy your new PAT immediately. You won't be able
|
||||
to see it again after leaving the page.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 2: Specify repositories
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Enter a comma-separated list of repository full names in the format
|
||||
"owner/repo1, owner/repo2". The connector will index files from only the
|
||||
specified repositories.
|
||||
</p>
|
||||
<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">Repository Access</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Make sure your PAT has access to all repositories you want to index. Private
|
||||
repositories require appropriate permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>GitHub</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>GitHub Personal Access Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Enter a comma-separated list of <strong>Repository Names</strong> (e.g.,
|
||||
"owner/repo1, owner/repo2").
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your GitHub repositories will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<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">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The GitHub connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Code files from selected repositories</li>
|
||||
<li>README files and Markdown documentation</li>
|
||||
<li>Common text-based file formats</li>
|
||||
<li>Repository metadata and structure</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{/* Documentation Link */}
|
||||
<div>
|
||||
<Link
|
||||
href="/docs/connectors/github"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs sm:text-sm font-medium underline underline-offset-4 hover:text-primary transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
View GitHub Connector Documentation
|
||||
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, Info, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
// Get toolkit display info
|
||||
const getToolkitInfo = (toolkitId: string): { name: string; icon: string; description: string } => {
|
||||
switch (toolkitId) {
|
||||
case "googledrive":
|
||||
return {
|
||||
name: "Google Drive",
|
||||
icon: "/connectors/google-drive.svg",
|
||||
description: "Files and documents from Google Drive",
|
||||
};
|
||||
case "gmail":
|
||||
return {
|
||||
name: "Gmail",
|
||||
icon: "/connectors/google-gmail.svg",
|
||||
description: "Emails from Gmail",
|
||||
};
|
||||
case "googlecalendar":
|
||||
return {
|
||||
name: "Google Calendar",
|
||||
icon: "/connectors/google-calendar.svg",
|
||||
description: "Events from Google Calendar",
|
||||
};
|
||||
case "slack":
|
||||
return {
|
||||
name: "Slack",
|
||||
icon: "/connectors/slack.svg",
|
||||
description: "Messages from Slack",
|
||||
};
|
||||
case "notion":
|
||||
return {
|
||||
name: "Notion",
|
||||
icon: "/connectors/notion.svg",
|
||||
description: "Pages from Notion",
|
||||
};
|
||||
case "github":
|
||||
return {
|
||||
name: "GitHub",
|
||||
icon: "/connectors/github.svg",
|
||||
description: "Repositories from GitHub",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: toolkitId,
|
||||
icon: "/connectors/composio.svg",
|
||||
description: "Connected via Composio",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ComposioConfig: FC<ComposioConfigProps> = ({ connector }) => {
|
||||
const toolkitId = connector.config?.toolkit_id as string;
|
||||
const toolkitName = connector.config?.toolkit_name as string;
|
||||
const isIndexable = connector.config?.is_indexable as boolean;
|
||||
const composioAccountId = connector.config?.composio_connected_account_id as string;
|
||||
|
||||
const toolkitInfo = getToolkitInfo(toolkitId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolkit Info Card */}
|
||||
<div className="rounded-xl border border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/10 to-purple-500/10 border border-violet-500/20 shrink-0">
|
||||
<Image
|
||||
src={toolkitInfo.icon}
|
||||
alt={toolkitInfo.name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-semibold">{toolkitName || toolkitInfo.name}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
>
|
||||
<Zap className="size-3 mr-0.5" />
|
||||
Composio
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{toolkitInfo.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Connection Details
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Toolkit</span>
|
||||
<span className="text-xs font-medium">{toolkitId}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Indexing Supported</span>
|
||||
<Badge
|
||||
variant={isIndexable ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0 h-5",
|
||||
isIndexable
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
)}
|
||||
>
|
||||
{isIndexable ? "Yes" : "Coming Soon"}
|
||||
</Badge>
|
||||
</div>
|
||||
{composioAccountId && (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Account ID</span>
|
||||
<span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]">
|
||||
{composioAccountId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Info className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This connection uses Composio's managed OAuth, which means you don't need to
|
||||
wait for app verification. Your data is securely accessed through Composio.
|
||||
</p>
|
||||
<a
|
||||
href="https://composio.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400 hover:underline"
|
||||
>
|
||||
Learn more about Composio
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -12,25 +12,29 @@ export interface GithubConfigProps extends ConnectorConfigProps {
|
|||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
// Helper functions moved outside component to avoid useEffect dependency issues
|
||||
const stringToArray = (arr: string[] | string | undefined): string[] => {
|
||||
if (Array.isArray(arr)) return arr;
|
||||
if (typeof arr === "string") {
|
||||
return arr
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const arrayToString = (arr: string[]): string => {
|
||||
return arr.join(", ");
|
||||
};
|
||||
|
||||
export const GithubConfig: FC<GithubConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const stringToArray = (arr: string[] | string | undefined): string[] => {
|
||||
if (Array.isArray(arr)) return arr;
|
||||
if (typeof arr === "string") {
|
||||
return arr
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const arrayToString = (arr: string[]): string => {
|
||||
return arr.join(", ");
|
||||
};
|
||||
// Track internal changes to prevent useEffect from overwriting user input
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
const [githubPat, setGithubPat] = useState<string>(
|
||||
(connector.config?.GITHUB_PAT as string) || ""
|
||||
|
|
@ -40,8 +44,13 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
|||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update values when connector changes
|
||||
// Update values when connector changes externally (not from our own input)
|
||||
useEffect(() => {
|
||||
// Skip if this is our own internal change
|
||||
if (isInternalChange.current) {
|
||||
isInternalChange.current = false;
|
||||
return;
|
||||
}
|
||||
const pat = (connector.config?.GITHUB_PAT as string) || "";
|
||||
const repos = arrayToString(stringToArray(connector.config?.repo_full_names));
|
||||
setGithubPat(pat);
|
||||
|
|
@ -50,6 +59,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
|||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleGithubPatChange = (value: string) => {
|
||||
isInternalChange.current = true;
|
||||
setGithubPat(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
|
|
@ -60,6 +70,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
|||
};
|
||||
|
||||
const handleRepoFullNamesChange = (value: string) => {
|
||||
isInternalChange.current = true;
|
||||
setRepoFullNames(value);
|
||||
const repoList = stringToArray(value);
|
||||
if (onConfigChange) {
|
||||
|
|
@ -71,6 +82,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
|||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
isInternalChange.current = true;
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
|
|
@ -105,7 +117,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
|||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
GitHub Personal Access Token
|
||||
GitHub Personal Access Token (optional)
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|||
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
||||
import { BookStackConfig } from "./components/bookstack-config";
|
||||
import { CirclebackConfig } from "./components/circleback-config";
|
||||
import { ComposioConfig } from "./components/composio-config";
|
||||
import { ClickUpConfig } from "./components/clickup-config";
|
||||
import { ConfluenceConfig } from "./components/confluence-config";
|
||||
import { DiscordConfig } from "./components/discord-config";
|
||||
|
|
@ -73,6 +74,8 @@ export function getConnectorConfigComponent(
|
|||
return CirclebackConfig;
|
||||
case "MCP_CONNECTOR":
|
||||
return MCPConfig;
|
||||
case "COMPOSIO_CONNECTOR":
|
||||
return ComposioConfig;
|
||||
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -206,9 +206,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||
{connector.is_indexable && (
|
||||
<>
|
||||
{/* Date range selector - not shown for Google Drive (uses folder selection) or Webcrawler (uses config) */}
|
||||
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" && (
|
||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
|
|
|
|||
|
|
@ -151,9 +151,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||
{connector?.is_indexable && (
|
||||
<>
|
||||
{/* Date range selector - not shown for Google Drive (uses folder selection) or Webcrawler (uses config) */}
|
||||
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" && (
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
|
|
|
|||
|
|
@ -168,5 +168,56 @@ export const OTHER_CONNECTORS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
// Composio Connector (Single entry that opens toolkit selector)
|
||||
export const COMPOSIO_CONNECTORS = [
|
||||
{
|
||||
id: "composio-connector",
|
||||
title: "Composio",
|
||||
description: "Connect 100+ apps via Composio (Google, Slack, Notion, etc.)",
|
||||
connectorType: EnumConnectorName.COMPOSIO_CONNECTOR,
|
||||
// No authEndpoint - handled via toolkit selector view
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Composio Toolkits (available integrations via Composio)
|
||||
export const COMPOSIO_TOOLKITS = [
|
||||
{
|
||||
id: "googledrive",
|
||||
name: "Google Drive",
|
||||
description: "Search your Drive files",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
description: "Search through your emails",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "googlecalendar",
|
||||
name: "Google Calendar",
|
||||
description: "Search through your events",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Search Slack messages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
description: "Search Notion pages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
description: "Search repositories",
|
||||
isIndexable: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Re-export IndexingConfigState from schemas for backward compatibility
|
||||
export type { IndexingConfigState } from "./connector-popup.schemas";
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export const useConnectorDialog = () => {
|
|||
// MCP list view state (for managing multiple MCP connectors)
|
||||
const [viewingMCPList, setViewingMCPList] = useState(false);
|
||||
|
||||
// Composio toolkit view state
|
||||
const [viewingComposio, setViewingComposio] = useState(false);
|
||||
const [connectingComposioToolkit, setConnectingComposioToolkit] = useState<string | null>(null);
|
||||
|
||||
// Track if we came from accounts list when entering edit mode
|
||||
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
||||
connectorType: string;
|
||||
|
|
@ -155,6 +159,17 @@ export const useConnectorDialog = () => {
|
|||
setViewingMCPList(true);
|
||||
}
|
||||
|
||||
// Clear Composio view if view is not "composio" anymore
|
||||
if (params.view !== "composio" && viewingComposio) {
|
||||
setViewingComposio(false);
|
||||
setConnectingComposioToolkit(null);
|
||||
}
|
||||
|
||||
// Handle Composio view
|
||||
if (params.view === "composio" && !viewingComposio) {
|
||||
setViewingComposio(true);
|
||||
}
|
||||
|
||||
// Handle connect view
|
||||
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
||||
setConnectingConnectorType(params.connectorType);
|
||||
|
|
@ -846,6 +861,63 @@ export const useConnectorDialog = () => {
|
|||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle opening Composio toolkit view
|
||||
const handleOpenComposio = useCallback(() => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setViewingComposio(true);
|
||||
|
||||
// Update URL to show Composio view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "composio");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Handle going back from Composio view
|
||||
const handleBackFromComposio = useCallback(() => {
|
||||
setViewingComposio(false);
|
||||
setConnectingComposioToolkit(null);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle connecting a Composio toolkit
|
||||
const handleConnectComposioToolkit = useCallback(
|
||||
async (toolkitId: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setConnectingComposioToolkit(toolkitId);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/composio/connector/add?space_id=${searchSpaceId}&toolkit_id=${toolkitId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to initiate Composio OAuth for ${toolkitId}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.auth_url) {
|
||||
// Redirect to Composio OAuth
|
||||
window.location.href = data.auth_url;
|
||||
} else {
|
||||
throw new Error("No authorization URL received from Composio");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error connecting Composio toolkit:", error);
|
||||
toast.error(`Failed to connect ${toolkitId}. Please try again.`);
|
||||
setConnectingComposioToolkit(null);
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
// Handle starting indexing
|
||||
const handleStartIndexing = useCallback(
|
||||
async (refreshConnectors: () => void) => {
|
||||
|
|
@ -1506,6 +1578,7 @@ export const useConnectorDialog = () => {
|
|||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
viewingComposio,
|
||||
|
||||
// Setters
|
||||
setSearchQuery,
|
||||
|
|
@ -1541,5 +1614,12 @@ export const useConnectorDialog = () => {
|
|||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
|
||||
// Composio
|
||||
viewingComposio,
|
||||
connectingComposioToolkit,
|
||||
handleOpenComposio,
|
||||
handleBackFromComposio,
|
||||
handleConnectComposioToolkit,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import type { FC } from "react";
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { ConnectorCard } from "../components/connector-card";
|
||||
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
||||
import { ComposioConnectorCard } from "../components/composio-connector-card";
|
||||
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS, COMPOSIO_CONNECTORS } from "../constants/connector-constants";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
|
||||
/**
|
||||
|
|
@ -34,6 +35,7 @@ interface AllConnectorsTabProps {
|
|||
onCreateYouTubeCrawler?: () => void;
|
||||
onManage?: (connector: SearchSourceConnector) => void;
|
||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||
onOpenComposio?: () => void;
|
||||
}
|
||||
|
||||
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||
|
|
@ -49,6 +51,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
onCreateYouTubeCrawler,
|
||||
onManage,
|
||||
onViewAccountsList,
|
||||
onOpenComposio,
|
||||
}) => {
|
||||
// Filter connectors based on search
|
||||
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
||||
|
|
@ -69,6 +72,20 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Filter Composio connectors
|
||||
const filteredComposio = COMPOSIO_CONNECTORS.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Count Composio connectors
|
||||
const composioConnectorCount = allConnectors
|
||||
? allConnectors.filter(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.COMPOSIO_CONNECTOR
|
||||
).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Quick Connect */}
|
||||
|
|
@ -137,6 +154,30 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
</section>
|
||||
)}
|
||||
|
||||
{/* Composio Integrations */}
|
||||
{filteredComposio.length > 0 && onOpenComposio && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</h3>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 font-medium">
|
||||
No verification needed
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredComposio.map((connector) => (
|
||||
<ComposioConnectorCard
|
||||
key={connector.id}
|
||||
id={connector.id}
|
||||
title={connector.title}
|
||||
description={connector.description}
|
||||
connectorCount={composioConnectorCount}
|
||||
onConnect={onOpenComposio}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* More Integrations */}
|
||||
{filteredOther.length > 0 && (
|
||||
<section>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
|||
// Special mappings (connector type differs from document type)
|
||||
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
||||
COMPOSIO_CONNECTOR: "COMPOSIO_CONNECTOR",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Loader2,
|
||||
Mail,
|
||||
HardDrive,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioToolkit {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isIndexable: boolean;
|
||||
}
|
||||
|
||||
interface ComposioToolkitViewProps {
|
||||
searchSpaceId: string;
|
||||
connectedToolkits: string[];
|
||||
onBack: () => void;
|
||||
onConnectToolkit: (toolkitId: string) => void;
|
||||
isConnecting: boolean;
|
||||
connectingToolkitId: string | null;
|
||||
}
|
||||
|
||||
// Available Composio toolkits
|
||||
const COMPOSIO_TOOLKITS: ComposioToolkit[] = [
|
||||
{
|
||||
id: "googledrive",
|
||||
name: "Google Drive",
|
||||
description: "Search your Drive files and documents",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
description: "Search through your emails",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "googlecalendar",
|
||||
name: "Google Calendar",
|
||||
description: "Search through your events",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Search Slack messages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
description: "Search Notion pages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
description: "Search repositories and code",
|
||||
isIndexable: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Get icon for toolkit
|
||||
const getToolkitIcon = (toolkitId: string, className?: string) => {
|
||||
const iconClass = className || "size-5";
|
||||
|
||||
switch (toolkitId) {
|
||||
case "googledrive":
|
||||
return <Image src="/connectors/google-drive.svg" alt="Google Drive" width={20} height={20} className={iconClass} />;
|
||||
case "gmail":
|
||||
return <Image src="/connectors/google-gmail.svg" alt="Gmail" width={20} height={20} className={iconClass} />;
|
||||
case "googlecalendar":
|
||||
return <Image src="/connectors/google-calendar.svg" alt="Google Calendar" width={20} height={20} className={iconClass} />;
|
||||
case "slack":
|
||||
return <Image src="/connectors/slack.svg" alt="Slack" width={20} height={20} className={iconClass} />;
|
||||
case "notion":
|
||||
return <Image src="/connectors/notion.svg" alt="Notion" width={20} height={20} className={iconClass} />;
|
||||
case "github":
|
||||
return <Image src="/connectors/github.svg" alt="GitHub" width={20} height={20} className={iconClass} />;
|
||||
default:
|
||||
return <Zap className={iconClass} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
||||
searchSpaceId,
|
||||
connectedToolkits,
|
||||
onBack,
|
||||
onConnectToolkit,
|
||||
isConnecting,
|
||||
connectingToolkitId,
|
||||
}) => {
|
||||
const [hoveredToolkit, setHoveredToolkit] = useState<string | null>(null);
|
||||
|
||||
// Separate indexable and non-indexable toolkits
|
||||
const indexableToolkits = COMPOSIO_TOOLKITS.filter((t) => t.isIndexable);
|
||||
const nonIndexableToolkits = COMPOSIO_TOOLKITS.filter((t) => !t.isIndexable);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 sm:pb-6 border-b border-border/50 bg-muted">
|
||||
{/* Back button */}
|
||||
<button
|
||||
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 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
{/* Header content */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex gap-4 flex-1 w-full sm:w-auto">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-500/20 border border-violet-500/30 shrink-0">
|
||||
<Image
|
||||
src="/connectors/composio.svg"
|
||||
alt="Composio"
|
||||
width={28}
|
||||
height={28}
|
||||
className="size-7"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
||||
Composio
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||
Connect 100+ apps with managed OAuth - no verification needed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://composio.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Powered by Composio</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
|
||||
{/* Indexable Toolkits (Google Services) */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
Indexable
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect Google services via Composio's verified OAuth app. Your data will be indexed and searchable.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{indexableToolkits.map((toolkit) => {
|
||||
const isConnected = connectedToolkits.includes(toolkit.id);
|
||||
const isThisConnecting = connectingToolkitId === toolkit.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toolkit.id}
|
||||
onMouseEnter={() => setHoveredToolkit(toolkit.id)}
|
||||
onMouseLeave={() => setHoveredToolkit(null)}
|
||||
className={cn(
|
||||
"group relative flex flex-col p-4 rounded-xl border transition-all duration-200",
|
||||
isConnected
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-border bg-card hover:border-violet-500/30 hover:bg-violet-500/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg border transition-colors",
|
||||
isConnected
|
||||
? "bg-emerald-500/10 border-emerald-500/20"
|
||||
: "bg-muted border-border group-hover:border-violet-500/20 group-hover:bg-violet-500/10"
|
||||
)}
|
||||
>
|
||||
{getToolkitIcon(toolkit.id, "size-5")}
|
||||
</div>
|
||||
{isConnected && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
<Check className="size-3 mr-0.5" />
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">
|
||||
{toolkit.description}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"w-full h-8 text-xs font-medium",
|
||||
!isConnected && "bg-violet-600 hover:bg-violet-700 text-white"
|
||||
)}
|
||||
onClick={() => onConnectToolkit(toolkit.id)}
|
||||
disabled={isConnecting || isConnected}
|
||||
>
|
||||
{isThisConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-3 mr-1.5 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : isConnected ? (
|
||||
"Connected"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Non-Indexable Toolkits (Coming Soon) */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
|
||||
Coming Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect these services for future indexing support. Currently available for connection only.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
|
||||
{nonIndexableToolkits.map((toolkit) => (
|
||||
<div
|
||||
key={toolkit.id}
|
||||
className="group relative flex flex-col p-4 rounded-xl border border-border bg-card/50"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border bg-muted border-border">
|
||||
{getToolkitIcon(toolkit.id, "size-5")}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
||||
Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">
|
||||
{toolkit.description}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-8 text-xs font-medium"
|
||||
disabled
|
||||
>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Info footer */}
|
||||
<div className="mt-8 p-4 rounded-xl bg-muted/50 border border-border/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 border border-violet-500/20 shrink-0">
|
||||
<Zap className="size-4 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Composio provides pre-verified OAuth apps, so you don't need to wait for Google app verification.
|
||||
Your data is securely processed through Composio's managed authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
|
|
@ -59,6 +61,8 @@ import {
|
|||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThreadProps {
|
||||
|
|
@ -86,6 +90,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
|||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
autoScroll
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||
showGutter && "lg:pr-30"
|
||||
|
|
@ -215,7 +220,7 @@ const Composer: FC = () => {
|
|||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const { search_space_id } = useParams();
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const composerRuntime = useComposerRuntime();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
|
|
@ -223,6 +228,23 @@ const Composer: FC = () => {
|
|||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
// Live collaboration state
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
const threadId = useMemo(() => {
|
||||
if (Array.isArray(chat_id) && chat_id.length > 0) {
|
||||
return Number.parseInt(chat_id[0], 10) || null;
|
||||
}
|
||||
return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
|
||||
}, [chat_id]);
|
||||
const sessionState = useAtomValue(chatSessionStateAtom);
|
||||
const isAiResponding = sessionState?.isAiResponding ?? false;
|
||||
const respondingToUserId = sessionState?.respondingToUserId ?? null;
|
||||
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
|
||||
|
||||
// Sync comments for the entire thread via Electric SQL (one subscription per thread)
|
||||
useCommentsElectric(threadId);
|
||||
|
||||
// Auto-focus editor on new chat page after mount
|
||||
useEffect(() => {
|
||||
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
||||
|
|
@ -298,9 +320,9 @@ const Composer: FC = () => {
|
|||
[showDocumentPopover]
|
||||
);
|
||||
|
||||
// Submit message (blocked during streaming or when document picker is open)
|
||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isThreadRunning) {
|
||||
if (isThreadRunning || isBlockedByOtherUser) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
|
|
@ -315,6 +337,7 @@ const Composer: FC = () => {
|
|||
}, [
|
||||
showDocumentPopover,
|
||||
isThreadRunning,
|
||||
isBlockedByOtherUser,
|
||||
composerRuntime,
|
||||
setMentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
|
|
@ -374,7 +397,13 @@ const Composer: FC = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||
<ChatSessionStatus
|
||||
isAiResponding={isAiResponding}
|
||||
respondingToUserId={respondingToUserId}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
members={members ?? []}
|
||||
/>
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||
<ComposerAttachments />
|
||||
{/* Inline editor with @mention support */}
|
||||
|
|
@ -417,13 +446,17 @@ const Composer: FC = () => {
|
|||
/>,
|
||||
document.body
|
||||
)}
|
||||
<ComposerAction />
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerAction: FC = () => {
|
||||
interface ComposerActionProps {
|
||||
isBlockedByOtherUser?: boolean;
|
||||
}
|
||||
|
||||
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||
// Check if any attachments are still being processed (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
||||
|
|
@ -458,7 +491,8 @@ const ComposerAction: FC = () => {
|
|||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
|
||||
const isSendDisabled =
|
||||
hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
|
|
@ -487,13 +521,15 @@ const ComposerAction: FC = () => {
|
|||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
!hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
isBlockedByOtherUser
|
||||
? "Wait for AI to finish responding"
|
||||
: !hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ function findMentionTrigger(
|
|||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
|
||||
const fullMatch = mentionMatch[0];
|
||||
const query = mentionMatch[1];
|
||||
const atIndex = cursorPos - query.length - 1;
|
||||
|
||||
|
|
@ -80,7 +79,7 @@ function findMentionTrigger(
|
|||
export function CommentComposer({
|
||||
members,
|
||||
membersLoading = false,
|
||||
placeholder = "Write a comment...",
|
||||
placeholder = "Comment or @mention",
|
||||
submitLabel = "Send",
|
||||
isSubmitting = false,
|
||||
onSubmit,
|
||||
|
|
@ -145,6 +144,13 @@ export function CommentComposer({
|
|||
const cursorPos = e.target.selectionStart;
|
||||
setDisplayContent(value);
|
||||
|
||||
// Auto-resize textarea on content change
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = e.target;
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
});
|
||||
|
||||
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
|
||||
|
||||
if (triggerResult.isActive) {
|
||||
|
|
@ -208,9 +214,9 @@ export function CommentComposer({
|
|||
|
||||
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
|
||||
const foundMentions: InsertedMention[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const matches = initialValue.matchAll(mentionPattern);
|
||||
|
||||
while ((match = mentionPattern.exec(initialValue)) !== null) {
|
||||
for (const match of matches) {
|
||||
const displayName = match[1];
|
||||
const member = members.find(
|
||||
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
|
||||
|
|
@ -237,6 +243,19 @@ export function CommentComposer({
|
|||
|
||||
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
|
||||
|
||||
// Auto-resize textarea
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [adjustTextareaHeight]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover
|
||||
|
|
@ -251,7 +270,8 @@ export function CommentComposer({
|
|||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[80px] resize-none"
|
||||
className="min-h-[40px] max-h-[200px] resize-none overflow-y-auto scrollbar-thin"
|
||||
rows={1}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="size-7 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -115,12 +113,8 @@ export function CommentItem({
|
|||
members = [],
|
||||
membersLoading = false,
|
||||
}: CommentItemProps) {
|
||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||
|
||||
const isCurrentUser = currentUser?.id === comment.author?.id;
|
||||
const displayName = isCurrentUser
|
||||
? "Me"
|
||||
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
||||
const displayName =
|
||||
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
||||
const email = comment.author?.email || "";
|
||||
|
||||
const handleEditSubmit = (content: string) => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentThread } from "../comment-thread/comment-thread";
|
||||
import type { CommentPanelProps } from "./types";
|
||||
|
||||
function getInitials(name: string | null | undefined, email: string): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
|
||||
export function CommentPanel({
|
||||
threads,
|
||||
members,
|
||||
|
|
@ -21,15 +33,10 @@ export function CommentPanel({
|
|||
maxHeight,
|
||||
variant = "desktop",
|
||||
}: CommentPanelProps) {
|
||||
const [isComposerOpen, setIsComposerOpen] = useState(false);
|
||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||
|
||||
const handleCommentSubmit = (content: string) => {
|
||||
onCreateComment(content);
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleComposerCancel = () => {
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
const isMobile = variant === "mobile";
|
||||
|
|
@ -51,7 +58,6 @@ export function CommentPanel({
|
|||
}
|
||||
|
||||
const hasThreads = threads.length > 0;
|
||||
const showEmptyState = !hasThreads && !isComposerOpen;
|
||||
|
||||
// Ensure minimum usable height for empty state + composer button
|
||||
const minHeight = 180;
|
||||
|
|
@ -63,7 +69,7 @@ export function CommentPanel({
|
|||
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
||||
>
|
||||
{hasThreads && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
|
||||
<div className="space-y-4 p-4">
|
||||
{threads.map((thread) => (
|
||||
<CommentThread
|
||||
|
|
@ -81,38 +87,35 @@ export function CommentPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No comments yet</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Start a conversation about this response
|
||||
</p>
|
||||
{!hasThreads && currentUser && (
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-1">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage
|
||||
src={currentUser.avatar_url ?? undefined}
|
||||
alt={currentUser.display_name ?? currentUser.email}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
|
||||
{getInitials(currentUser.display_name, currentUser.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{currentUser.display_name ?? currentUser.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn("p-3", showEmptyState && !isMobile && "border-t", isMobile && "border-t")}>
|
||||
{isComposerOpen ? (
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Write a comment..."
|
||||
submitLabel="Comment"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onCancel={handleComposerCancel}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsComposerOpen(true)}
|
||||
>
|
||||
<MessageSquarePlus className="mr-2 size-4" />
|
||||
Add a comment...
|
||||
</Button>
|
||||
)}
|
||||
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Comment or @mention"
|
||||
submitLabel="Comment"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleCommentSubmit}
|
||||
autoFocus={!hasThreads}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
|
||||
|
|
@ -15,22 +22,39 @@ export function CommentSheet({
|
|||
}: CommentSheetProps) {
|
||||
const isBottomSheet = side === "bottom";
|
||||
|
||||
// Use Drawer for mobile (bottom), Sheet for medium screens (right)
|
||||
if (isBottomSheet) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={onOpenChange} shouldScaleBackground={false}>
|
||||
<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">
|
||||
<MessageSquare className="size-5" />
|
||||
Comments
|
||||
{commentCount > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{commentCount}
|
||||
</span>
|
||||
)}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// Use Sheet for medium screens (right side)
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side={side}
|
||||
className={cn(
|
||||
"flex flex-col p-0",
|
||||
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
|
||||
)}
|
||||
className={cn("flex flex-col gap-0 overflow-hidden p-0 h-full w-full max-w-md")}
|
||||
>
|
||||
{/* Drag handle indicator - only for bottom sheet */}
|
||||
{isBottomSheet && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
<SheetHeader className={cn("flex-shrink-0 border-b px-4", isBottomSheet ? "pb-3" : "py-4")}>
|
||||
<SheetHeader className="flex-shrink-0 px-4 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
Comments
|
||||
|
|
@ -41,7 +65,7 @@ export function CommentSheet({
|
|||
)}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -128,23 +128,21 @@ export function CommentThread({
|
|||
{/* Reply composer or button */}
|
||||
|
||||
{isReplyComposerOpen ? (
|
||||
<>
|
||||
<div className="pt-3">
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Write a reply..."
|
||||
submitLabel="Reply"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleReplySubmit}
|
||||
onCancel={handleReplyCancel}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="pt-3">
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Reply or @mention"
|
||||
submitLabel="Reply"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleReplySubmit}
|
||||
onCancel={handleReplyCancel}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1.5 size-3" />
|
||||
<MessageSquare className="mr-1 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -156,7 +154,7 @@ export function CommentThread({
|
|||
{!hasReplies && !isReplyComposerOpen && (
|
||||
<div className="ml-7 mt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1.5 size-3" />
|
||||
<MessageSquare className="mr-1 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CommentTriggerProps } from "./types";
|
||||
|
|
@ -25,7 +25,7 @@ export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: Comm
|
|||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
||||
<MessageSquarePlus className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
||||
{hasComments && (
|
||||
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{commentCount > 9 ? "9+" : commentCount}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export function DashboardBreadcrumb() {
|
|||
const segments = path.split("/").filter(Boolean);
|
||||
const breadcrumbs: BreadcrumbItemInterface[] = [];
|
||||
|
||||
// Handle search space
|
||||
// Handle search space (start directly with search space, skip "Dashboard")
|
||||
if (segments[0] === "dashboard" && segments[1]) {
|
||||
// Use the actual search space name if available, otherwise fall back to the ID
|
||||
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { cleanupElectric } from "@/lib/electric/client";
|
||||
|
|
@ -29,19 +30,18 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
|||
import { LayoutShell } from "../ui/shell";
|
||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
|
||||
|
||||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
children: React.ReactNode;
|
||||
breadcrumb?: React.ReactNode;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({
|
||||
searchSpaceId,
|
||||
children,
|
||||
breadcrumb,
|
||||
languageSwitcher,
|
||||
}: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
|
|
@ -61,8 +61,8 @@ export function LayoutDataProvider({
|
|||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: null;
|
||||
|
||||
// Fetch current search space
|
||||
const { data: searchSpace } = useQuery({
|
||||
// Fetch current search space (for caching purposes)
|
||||
useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||
enabled: !!searchSpaceId,
|
||||
|
|
@ -79,9 +79,25 @@ export function LayoutDataProvider({
|
|||
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
||||
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
||||
|
||||
// Inbox sidebar state
|
||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||
|
||||
// Inbox hook
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
const {
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading: inboxLoading,
|
||||
loadingMore: inboxLoadingMore,
|
||||
hasMore: inboxHasMore,
|
||||
loadMore: inboxLoadMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||
|
|
@ -151,8 +167,15 @@ export function LayoutDataProvider({
|
|||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox", // Special URL to indicate this is handled differently
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname]
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -244,6 +267,11 @@ export function LayoutDataProvider({
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
// Handle inbox specially - open sidebar instead of navigating
|
||||
if (item.url === "#inbox") {
|
||||
setIsInboxSidebarOpen(true);
|
||||
return;
|
||||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router]
|
||||
|
|
@ -296,10 +324,6 @@ export function LayoutDataProvider({
|
|||
}
|
||||
}, [router]);
|
||||
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
|
||||
const handleViewAllSharedChats = useCallback(() => {
|
||||
setIsAllSharedChatsSidebarOpen(true);
|
||||
}, []);
|
||||
|
|
@ -369,9 +393,8 @@ export function LayoutDataProvider({
|
|||
onLogout={handleLogout}
|
||||
pageUsage={pageUsage}
|
||||
breadcrumb={breadcrumb}
|
||||
languageSwitcher={languageSwitcher}
|
||||
theme={theme}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
setTheme={setTheme}
|
||||
isChatPage={isChatPage}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -518,6 +541,20 @@ export function LayoutDataProvider({
|
|||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
|
||||
{/* Inbox Sidebar */}
|
||||
<InboxSidebar
|
||||
open={isInboxSidebarOpen}
|
||||
onOpenChange={setIsInboxSidebarOpen}
|
||||
inboxItems={inboxItems}
|
||||
unreadCount={unreadCount}
|
||||
loading={inboxLoading}
|
||||
loadingMore={inboxLoadingMore}
|
||||
hasMore={inboxHasMore}
|
||||
loadMore={inboxLoadMore}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
/>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
<CreateSearchSpaceDialog
|
||||
open={isCreateSearchSpaceDialogOpen}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { NotificationButton } from "@/components/notifications/NotificationButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface HeaderProps {
|
||||
breadcrumb?: React.ReactNode;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
theme?: string;
|
||||
onToggleTheme?: () => void;
|
||||
mobileMenuTrigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
breadcrumb,
|
||||
languageSwitcher,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
mobileMenuTrigger,
|
||||
}: HeaderProps) {
|
||||
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Check if we're on a chat page
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
|
||||
// Use Jotai atom for thread state (synced from chat page)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
||||
// Show button only when we have a thread id (thread exists and is synced to Jotai)
|
||||
const hasThread = isChatPage && currentThreadState.id !== null;
|
||||
|
||||
// Create minimal thread object for ChatShareButton (used for API calls)
|
||||
const threadForButton: ThreadRecord | null =
|
||||
hasThread && currentThreadState.id !== null
|
||||
? {
|
||||
id: currentThreadState.id,
|
||||
visibility: currentThreadState.visibility ?? "PRIVATE",
|
||||
// These fields are not used by ChatShareButton for display, only for checks
|
||||
created_by_id: null,
|
||||
search_space_id: 0,
|
||||
title: "",
|
||||
archived: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}
|
||||
: null;
|
||||
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {
|
||||
// Visibility change is handled by ChatShareButton internally via Jotai
|
||||
// This callback can be used for additional side effects if needed
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
||||
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
||||
|
|
@ -29,24 +53,11 @@ export function Header({
|
|||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Notifications */}
|
||||
<NotificationButton />
|
||||
|
||||
{/* Theme toggle */}
|
||||
{onToggleTheme && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" onClick={onToggleTheme} className="h-8 w-8">
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Share button - only show on chat pages when thread exists */}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
|
||||
{languageSwitcher}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,9 +35,8 @@ interface LayoutShellProps {
|
|||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
breadcrumb?: React.ReactNode;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
theme?: string;
|
||||
onToggleTheme?: () => void;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
defaultCollapsed?: boolean;
|
||||
isChatPage?: boolean;
|
||||
children: React.ReactNode;
|
||||
|
|
@ -69,9 +68,8 @@ export function LayoutShell({
|
|||
onLogout,
|
||||
pageUsage,
|
||||
breadcrumb,
|
||||
languageSwitcher,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
setTheme,
|
||||
defaultCollapsed = false,
|
||||
isChatPage = false,
|
||||
children,
|
||||
|
|
@ -88,9 +86,6 @@ export function LayoutShell({
|
|||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
languageSwitcher={languageSwitcher}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||
/>
|
||||
|
||||
|
|
@ -120,6 +115,8 @@ export function LayoutShell({
|
|||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
|
||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
|
|
@ -166,16 +163,13 @@ export function LayoutShell({
|
|||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
className="hidden md:flex border-r shrink-0"
|
||||
/>
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
languageSwitcher={languageSwitcher}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
/>
|
||||
<Header breadcrumb={breadcrumb} />
|
||||
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import { format } from "date-fns";
|
|||
import {
|
||||
ArchiveIcon,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
|
|
@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({
|
|||
aria-label={t("chats") || "Private Chats"}
|
||||
>
|
||||
<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">
|
||||
<Lock className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
|
|||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<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 ({activeCount})
|
||||
</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 ({archivedCount})
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="active"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<MessageCircleMore className="h-4 w-4" />
|
||||
<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">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<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">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
|
|
@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({
|
|||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
|
|
@ -419,7 +415,7 @@ export function AllPrivateChatsSidebar({
|
|||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Lock className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<User className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{showArchived
|
||||
? t("no_archived_chats") || "No archived chats"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
|
|
@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({
|
|||
aria-label={t("shared_chats") || "Shared Chats"}
|
||||
>
|
||||
<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">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
|
|||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<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 ({activeCount})
|
||||
</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 ({archivedCount})
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="active"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<MessageCircleMore className="h-4 w-4" />
|
||||
<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">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<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">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
|
|
@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({
|
|||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
|
|||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="sr-only">{t("more_options")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
|
|
@ -0,0 +1,854 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
AtSign,
|
||||
BellDot,
|
||||
Check,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import {
|
||||
type ConnectorIndexingMetadata,
|
||||
type NewMentionMetadata,
|
||||
isConnectorIndexingMetadata,
|
||||
isNewMentionMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Get initials from name or email for avatar fallback
|
||||
*/
|
||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
if (email) {
|
||||
const localPart = email.split("@")[0];
|
||||
return localPart.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return "U";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for connector type
|
||||
*/
|
||||
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||
LINEAR_CONNECTOR: "Linear",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||
DISCORD_CONNECTOR: "Discord",
|
||||
JIRA_CONNECTOR: "Jira",
|
||||
CONFLUENCE_CONNECTOR: "Confluence",
|
||||
BOOKSTACK_CONNECTOR: "BookStack",
|
||||
CLICKUP_CONNECTOR: "ClickUp",
|
||||
AIRTABLE_CONNECTOR: "Airtable",
|
||||
LUMA_CONNECTOR: "Luma",
|
||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||
WEBCRAWLER_CONNECTOR: "Web Crawler",
|
||||
YOUTUBE_CONNECTOR: "YouTube",
|
||||
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||
MCP_CONNECTOR: "MCP",
|
||||
TAVILY_API: "Tavily",
|
||||
SEARXNG_API: "SearXNG",
|
||||
LINKUP_API: "Linkup",
|
||||
BAIDU_SEARCH_API: "Baidu",
|
||||
};
|
||||
|
||||
return (
|
||||
displayNames[connectorType] ||
|
||||
connectorType
|
||||
.replace(/_/g, " ")
|
||||
.replace(/CONNECTOR|API/gi, "")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
type InboxTab = "mentions" | "status";
|
||||
type InboxFilter = "all" | "unread";
|
||||
|
||||
interface InboxSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inboxItems: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function InboxSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading,
|
||||
loadingMore = false,
|
||||
hasMore = false,
|
||||
loadMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onCloseMobileSidebar,
|
||||
}: InboxSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Dropdown state for filter menu (desktop only)
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
// Drawer state for filter menu (mobile only)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
||||
// Prefetch trigger ref - placed on item near the end
|
||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Reset connector filter when switching away from status tab
|
||||
useEffect(() => {
|
||||
if (activeTab !== "status") {
|
||||
setSelectedConnector(null);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Split items by type
|
||||
const mentionItems = useMemo(
|
||||
() => inboxItems.filter((item) => item.type === "new_mention"),
|
||||
[inboxItems]
|
||||
);
|
||||
|
||||
const statusItems = useMemo(
|
||||
() =>
|
||||
inboxItems.filter(
|
||||
(item) => item.type === "connector_indexing" || item.type === "document_processing"
|
||||
),
|
||||
[inboxItems]
|
||||
);
|
||||
|
||||
// Get unique connector types from status items for filtering
|
||||
const uniqueConnectorTypes = useMemo(() => {
|
||||
const connectorTypes = new Set<string>();
|
||||
|
||||
statusItems
|
||||
.filter((item) => item.type === "connector_indexing")
|
||||
.forEach((item) => {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
connectorTypes.add(item.metadata.connector_type);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(connectorTypes).map((type) => ({
|
||||
type,
|
||||
displayName: getConnectorTypeDisplayName(type),
|
||||
}));
|
||||
}, [statusItems]);
|
||||
|
||||
// Get items for current tab
|
||||
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
|
||||
|
||||
// Filter items based on filter type, connector filter, and search query
|
||||
const filteredItems = useMemo(() => {
|
||||
let items = currentTabItems;
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
items = items.filter((item) => !item.read);
|
||||
}
|
||||
|
||||
// Apply connector filter (only for status tab)
|
||||
if (activeTab === "status" && selectedConnector) {
|
||||
items = items.filter((item) => {
|
||||
if (item.type === "connector_indexing") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
return item.metadata.connector_type === selectedConnector;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false; // Hide document_processing when a specific connector is selected
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||
|
||||
// Intersection Observer for infinite scroll with prefetching
|
||||
// Only active when not searching (search results are client-side filtered)
|
||||
useEffect(() => {
|
||||
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// When trigger element is visible, load more
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null, // viewport
|
||||
rootMargin: "100px", // Start loading 100px before visible
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (prefetchTriggerRef.current) {
|
||||
observer.observe(prefetchTriggerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
|
||||
|
||||
// Count unread items per tab
|
||||
const unreadMentionsCount = useMemo(() => {
|
||||
return mentionItems.filter((item) => !item.read).length;
|
||||
}, [mentionItems]);
|
||||
|
||||
const unreadStatusCount = useMemo(() => {
|
||||
return statusItems.filter((item) => !item.read).length;
|
||||
}, [statusItems]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
if (!item.read) {
|
||||
setMarkingAsReadId(item.id);
|
||||
await markAsRead(item.id);
|
||||
setMarkingAsReadId(null);
|
||||
}
|
||||
|
||||
if (item.type === "new_mention") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isNewMentionMetadata(item.metadata)) {
|
||||
const searchSpaceId = item.search_space_id;
|
||||
const threadId = item.metadata.thread_id;
|
||||
const commentId = item.metadata.comment_id;
|
||||
|
||||
if (searchSpaceId && threadId) {
|
||||
const url = commentId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||
onOpenChange(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
await markAllAsRead();
|
||||
}, [markAllAsRead]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return "now";
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return `${Math.floor(diffDays / 7)}w`;
|
||||
} catch {
|
||||
return "now";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (item: InboxItem) => {
|
||||
// For mentions, show the author's avatar with initials fallback
|
||||
if (item.type === "new_mention") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isNewMentionMetadata(item.metadata)) {
|
||||
const authorName = item.metadata.author_name;
|
||||
const avatarUrl = item.metadata.author_avatar_url;
|
||||
const authorEmail = item.metadata.author_email;
|
||||
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(authorName, authorEmail)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
// Fallback for invalid metadata
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(null, null)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// For status items (connector/document), show status icons
|
||||
// Safely access status from metadata
|
||||
const metadata = item.metadata as Record<string, unknown>;
|
||||
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||
<Spinner size="sm" className="text-foreground" />
|
||||
</div>
|
||||
);
|
||||
case "completed":
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getEmptyStateMessage = () => {
|
||||
if (activeTab === "mentions") {
|
||||
return {
|
||||
title: t("no_mentions") || "No mentions",
|
||||
hint: t("no_mentions_hint") || "You'll see mentions from others here",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t("no_status_updates") || "No status updates",
|
||||
hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
|
||||
};
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-70 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("inbox") || "Inbox"}
|
||||
>
|
||||
<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">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mobile: Button that opens bottom drawer */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setFilterDrawerOpen(true)}
|
||||
>
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Drawer
|
||||
open={filterDrawerOpen}
|
||||
onOpenChange={setFilterDrawerOpen}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent className="max-h-[70vh] 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">
|
||||
<ListFilter className="size-5" />
|
||||
{t("filter") || "Filter"}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Filter section */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("filter") || "Filter"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
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",
|
||||
activeFilter === "all"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
<span>{t("all") || "All"}</span>
|
||||
</span>
|
||||
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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",
|
||||
activeFilter === "unread"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BellDot className="h-4 w-4" />
|
||||
<span>{t("unread") || "Unread"}</span>
|
||||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connectors section - only for status tab */}
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("connectors") || "Connectors"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(null);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === null
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<button
|
||||
key={connector.type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(connector.type);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === connector.type
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
/* Desktop: Dropdown menu */
|
||||
<DropdownMenu
|
||||
open={openDropdown === "filter"}
|
||||
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||
{t("filter") || "Filter"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActiveFilter("all")}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
<span>{t("all") || "All"}</span>
|
||||
</span>
|
||||
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActiveFilter("unread")}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BellDot className="h-4 w-4" />
|
||||
<span>{t("unread") || "Unread"}</span>
|
||||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||
{t("connectors") || "Connectors"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{t("mark_all_read") || "Mark all as read"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 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"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="mentions"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<AtSign className="h-4 w-4" />
|
||||
<span>{t("mentions") || "Mentions"}</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">
|
||||
{unreadMentionsCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="status"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<History className="h-4 w-4" />
|
||||
<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">
|
||||
{unreadStatusCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only if not searching)
|
||||
const isPrefetchTrigger =
|
||||
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={isMarkingAsRead}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
<div className="shrink-0">{getStatusIcon(item)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium line-clamp-2",
|
||||
!item.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Time and unread dot - fixed width to prevent content shift */}
|
||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(item.created_at)}
|
||||
</span>
|
||||
{!item.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
</div>
|
||||
) : searchQuery ? (
|
||||
<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">
|
||||
{t("no_results_found") || "No results found"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
{activeTab === "mentions" ? (
|
||||
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
) : (
|
||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{getEmptyStateMessage().hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -33,6 +33,8 @@ interface MobileSidebarProps {
|
|||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
}
|
||||
|
||||
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
||||
|
|
@ -70,6 +72,8 @@ export function MobileSidebar({
|
|||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
theme,
|
||||
setTheme,
|
||||
}: MobileSidebarProps) {
|
||||
const handleSearchSpaceSelect = (id: number) => {
|
||||
onSearchSpaceSelect(id);
|
||||
|
|
@ -145,6 +149,8 @@ export function MobileSidebar({
|
|||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
className="w-full border-none"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
type="button"
|
||||
onClick={() => onItemClick?.(item)}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
item.isActive && "bg-accent text-accent-foreground"
|
||||
|
|
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.badge && (
|
||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{item.title}</span>
|
||||
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
|
||||
{item.badge && (
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ interface SidebarProps {
|
|||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +60,8 @@ export function Sidebar({
|
|||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
theme,
|
||||
setTheme,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
|
@ -241,6 +245,8 @@ export function Sidebar({
|
|||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
isCollapsed={isCollapsed}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function SidebarSection({
|
|||
|
||||
{/* Action button - visible on hover (always visible on mobile) */}
|
||||
{action && (
|
||||
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||
<div className="shrink-0 opacity-100 md:opacity-0 md:group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronUp, LogOut, Settings } from "lucide-react";
|
||||
import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useLocaleContext } from "@/contexts/LocaleContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { User } from "../../types/layout.types";
|
||||
|
||||
// Supported languages configuration
|
||||
const LANGUAGES = [
|
||||
{ code: "en" as const, name: "English", flag: "🇺🇸" },
|
||||
{ code: "zh" as const, name: "简体中文", flag: "🇨🇳" },
|
||||
];
|
||||
|
||||
// Supported themes configuration
|
||||
const THEMES = [
|
||||
{ value: "light" as const, name: "Light", icon: Sun },
|
||||
{ value: "dark" as const, name: "Dark", icon: Moon },
|
||||
{ value: "system" as const, name: "System", icon: Laptop },
|
||||
];
|
||||
|
||||
interface SidebarUserProfileProps {
|
||||
user: User;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
isCollapsed?: boolean;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,12 +119,23 @@ export function SidebarUserProfile({
|
|||
onUserSettings,
|
||||
onLogout,
|
||||
isCollapsed = false,
|
||||
theme,
|
||||
setTheme,
|
||||
}: SidebarUserProfileProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { locale, setLocale } = useLocaleContext();
|
||||
const bgColor = stringToColor(user.email);
|
||||
const initials = getInitials(user.email);
|
||||
const displayName = user.name || user.email.split("@")[0];
|
||||
|
||||
const handleLanguageChange = (newLocale: "en" | "zh") => {
|
||||
setLocale(newLocale);
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: "light" | "dark" | "system") => {
|
||||
setTheme?.(newTheme);
|
||||
};
|
||||
|
||||
// Collapsed view - just show avatar with dropdown
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
|
|
@ -118,7 +149,8 @@ export function SidebarUserProfile({
|
|||
className={cn(
|
||||
"flex h-10 w-full items-center justify-center rounded-md",
|
||||
"hover:bg-accent transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
"focus:outline-none focus-visible:outline-none",
|
||||
"data-[state=open]:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
|
|
@ -129,7 +161,7 @@ export function SidebarUserProfile({
|
|||
<TooltipContent side="right">{displayName}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
|
||||
<DropdownMenuContent className="w-56" side="right" align="center" sideOffset={8}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
|
|
@ -147,6 +179,65 @@ export function SidebarUserProfile({
|
|||
{t("user_settings")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{setTheme && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
{t("theme")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{THEMES.map((themeOption) => {
|
||||
const Icon = themeOption.icon;
|
||||
const isSelected = theme === themeOption.value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={themeOption.value}
|
||||
onClick={() => handleThemeChange(themeOption.value)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Languages className="mr-2 h-4 w-4" />
|
||||
{t("language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{LANGUAGES.map((language) => {
|
||||
const isSelected = locale === language.code;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
|
|
@ -169,7 +260,8 @@ export function SidebarUserProfile({
|
|||
className={cn(
|
||||
"flex w-full items-center gap-2 px-2 py-3 text-left",
|
||||
"hover:bg-accent transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
"focus:outline-none focus-visible:outline-none",
|
||||
"data-[state=open]:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
|
|
@ -185,7 +277,7 @@ export function SidebarUserProfile({
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
|
||||
<DropdownMenuContent className="w-56" side="top" align="center" sideOffset={4}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
|
|
@ -203,6 +295,65 @@ export function SidebarUserProfile({
|
|||
{t("user_settings")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{setTheme && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
{t("theme")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{THEMES.map((themeOption) => {
|
||||
const Icon = themeOption.icon;
|
||||
const isSelected = theme === themeOption.value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={themeOption.value}
|
||||
onClick={() => handleThemeChange(themeOption.value)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Languages className="mr-2 h-4 w-4" />
|
||||
{t("language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{LANGUAGES.map((language) => {
|
||||
const isSelected = locale === language.code;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { InboxSidebar } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
|
|
|
|||
|
|
@ -5,18 +5,14 @@ import type {
|
|||
GlobalNewLLMConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
import { ChatShareButton } from "./chat-share-button";
|
||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
searchSpaceId: number;
|
||||
thread?: ThreadRecord | null;
|
||||
onThreadVisibilityChange?: (visibility: ChatVisibility) => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }: ChatHeaderProps) {
|
||||
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||
|
|
@ -52,7 +48,6 @@ export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }:
|
|||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||
<ChatShareButton thread={thread ?? null} onVisibilityChange={onThreadVisibilityChange} />
|
||||
<ModelConfigSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={handleSidebarClose}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Lock, Users } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { User, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type ChatVisibility,
|
||||
type ThreadRecord,
|
||||
|
|
@ -23,13 +26,13 @@ const visibilityOptions: {
|
|||
value: ChatVisibility;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Lock;
|
||||
icon: typeof User;
|
||||
}[] = [
|
||||
{
|
||||
value: "PRIVATE",
|
||||
label: "Private",
|
||||
description: "Only you can access this chat",
|
||||
icon: Lock,
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
value: "SEARCH_SPACE",
|
||||
|
|
@ -42,9 +45,13 @@ const visibilityOptions: {
|
|||
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const currentVisibility = thread?.visibility ?? "PRIVATE";
|
||||
// Use Jotai atom for visibility (single source of truth)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
|
||||
|
||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||
const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it
|
||||
|
||||
const handleVisibilityChange = useCallback(
|
||||
|
|
@ -54,11 +61,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
// Update Jotai atom immediately for instant UI feedback
|
||||
setThreadVisibility(newVisibility);
|
||||
|
||||
try {
|
||||
await updateThreadVisibility(thread.id, newVisibility);
|
||||
|
||||
// Refetch all thread queries to update sidebar immediately
|
||||
// Refetch threads list to update sidebar
|
||||
await queryClient.refetchQueries({
|
||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
||||
});
|
||||
|
|
@ -70,12 +79,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update visibility:", error);
|
||||
// Revert Jotai state on error
|
||||
setThreadVisibility(thread.visibility ?? "PRIVATE");
|
||||
toast.error("Failed to update sharing settings");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
[thread, currentVisibility, onVisibilityChange, queryClient]
|
||||
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
|
||||
);
|
||||
|
||||
// Don't show if no thread (new chat that hasn't been created yet)
|
||||
|
|
@ -83,45 +92,38 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
return null;
|
||||
}
|
||||
|
||||
const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users;
|
||||
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
|
||||
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
|
||||
"text-xs md:text-sm font-medium text-foreground",
|
||||
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CurrentIcon className="size-3.5 md:size-4 text-muted-foreground" />
|
||||
<span className="hidden md:inline">
|
||||
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CurrentIcon className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-sm">
|
||||
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Share settings</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[320px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60"
|
||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="p-1.5 space-y-1">
|
||||
{/* Updating overlay */}
|
||||
{isUpdating && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Updating</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibilityOptions.map((option) => {
|
||||
const isSelected = currentVisibility === option.value;
|
||||
const Icon = option.icon;
|
||||
|
|
@ -131,9 +133,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
type="button"
|
||||
key={option.value}
|
||||
onClick={() => handleVisibilityChange(option.value)}
|
||||
disabled={isUpdating}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||
"hover:bg-accent/50 cursor-pointer",
|
||||
"focus:outline-none",
|
||||
isSelected && "bg-accent/80"
|
||||
|
|
@ -141,13 +142,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 p-1.5 rounded-md shrink-0",
|
||||
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||
isSelected ? "bg-primary/10" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
"size-4 block",
|
||||
isSelected ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
|
|
@ -157,11 +158,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||
{option.description}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useAtomValue } from "jotai";
|
|||
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNewLLMConfigMutationAtom,
|
||||
|
|
@ -38,6 +39,12 @@ export function ModelConfigSidebar({
|
|||
mode,
|
||||
}: ModelConfigSidebarProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Handle SSR - only render portal on client
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Mutations - use mutateAsync from the atom value
|
||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||
|
|
@ -147,7 +154,9 @@ export function ModelConfigSidebar({
|
|||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
return (
|
||||
if (!mounted) return null;
|
||||
|
||||
const sidebarContent = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
|
|
@ -157,7 +166,7 @@ export function ModelConfigSidebar({
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
|
|
@ -172,7 +181,7 @@ export function ModelConfigSidebar({
|
|||
stiffness: 300,
|
||||
}}
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
|
||||
"fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
|
||||
"bg-background border-l border-border/50 shadow-2xl",
|
||||
"flex flex-col"
|
||||
)}
|
||||
|
|
@ -245,16 +254,16 @@ export function ModelConfigSidebar({
|
|||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Configuration Name
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -264,15 +273,15 @@ export function ModelConfigSidebar({
|
|||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -281,9 +290,9 @@ export function ModelConfigSidebar({
|
|||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Citations
|
||||
</label>
|
||||
</div>
|
||||
<Badge
|
||||
variant={config.citations_enabled ? "default" : "secondary"}
|
||||
className="w-fit"
|
||||
|
|
@ -297,9 +306,9 @@ export function ModelConfigSidebar({
|
|||
<>
|
||||
<div className="h-px bg-border/50" />
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
System Instructions
|
||||
</label>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
|
||||
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
|
||||
{config.system_instructions}
|
||||
|
|
@ -367,4 +376,6 @@ export function ModelConfigSidebar({
|
|||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ interface ModelSelectorProps {
|
|||
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
|
||||
// Fetch configs
|
||||
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
|
|
@ -142,7 +141,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
return;
|
||||
}
|
||||
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
|
|
@ -155,8 +153,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
} catch (error) {
|
||||
console.error("Failed to switch model:", error);
|
||||
toast.error("Failed to switch model");
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[currentConfig, searchSpaceId, updatePreferences]
|
||||
|
|
@ -175,74 +171,59 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
|
||||
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
|
||||
"text-xs md:text-sm font-medium text-foreground",
|
||||
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 md:size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Loading...</span>
|
||||
<span className="text-muted-foreground md:hidden">Load...</span>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Loading</span>
|
||||
</>
|
||||
) : currentConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentConfig.provider)}
|
||||
<span className="max-w-[80px] md:max-w-[150px] truncate">{currentConfig.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-0.5 md:ml-1 text-[9px] md:text-[10px] px-1 md:px-1.5 py-0 h-3.5 md:h-4 bg-muted/80"
|
||||
>
|
||||
<span className="max-w-[100px] md:max-w-[150px] truncate hidden md:inline">
|
||||
{currentConfig.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
|
||||
{currentConfig.model_name.split("/").pop()?.slice(0, 10) ||
|
||||
currentConfig.model_name.slice(0, 10)}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="size-3.5 md:size-4 text-muted-foreground" />
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
||||
<span className="text-muted-foreground md:hidden">Model</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown className="size-3 md:size-3.5 text-muted-foreground ml-1 shrink-0" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0 transition-transform duration-200",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60"
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-xl relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{/* Switching overlay */}
|
||||
{isSwitching && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Switching model...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalModels > 3 && (
|
||||
<div className="flex items-center gap-1 md:gap-2 border-b border-border/30 px-2 md:px-3 py-1.5 md:py-2">
|
||||
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||
disabled={isSwitching}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -250,7 +231,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Bot className="size-8 text-muted-foreground/40" />
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||
</div>
|
||||
|
|
@ -271,8 +252,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
value={`global-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group",
|
||||
"aria-selected:bg-accent/50",
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
|
|
@ -333,8 +314,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
value={`user-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group",
|
||||
"aria-selected:bg-accent/50",
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import {
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Hash,
|
||||
|
|
@ -387,7 +386,7 @@ export function SourceDetailPanel({
|
|||
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium">Loading document...</p>
|
||||
<p className="text-sm text-muted-foreground font-medium">Loading document</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -490,8 +489,8 @@ export function SourceDetailPanel({
|
|||
>
|
||||
{idx + 1}
|
||||
{isCited && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-background">
|
||||
<Sparkles className="h-2 w-2 text-primary-foreground absolute top-0.5 left-0.5" />
|
||||
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-4 h-4 bg-primary rounded-full border-2 border-background shadow-sm">
|
||||
<Sparkles className="h-2.5 w-2.5 text-primary-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useNotifications } from "@/hooks/use-notifications";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NotificationPopup } from "./NotificationPopup";
|
||||
|
||||
export function NotificationButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const params = useParams();
|
||||
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
|
||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||
|
||||
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
|
||||
userId,
|
||||
searchSpaceId
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 relative">
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
|
||||
unreadCount > 9 && "px-1"
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Notifications</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<NotificationPopup
|
||||
notifications={notifications}
|
||||
unreadCount={unreadCount}
|
||||
loading={loading}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { Notification } from "@/hooks/use-notifications";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NotificationPopupProps {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function NotificationPopup({
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onClose,
|
||||
}: NotificationPopupProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
await markAllAsRead();
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.read) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.type === "new_mention") {
|
||||
const metadata = notification.metadata as {
|
||||
thread_id?: number;
|
||||
comment_id?: number;
|
||||
};
|
||||
const searchSpaceId = notification.search_space_id;
|
||||
const threadId = metadata?.thread_id;
|
||||
const commentId = metadata?.comment_id;
|
||||
|
||||
if (searchSpaceId && threadId) {
|
||||
const url = commentId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||
onClose?.();
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
|
||||
} catch {
|
||||
return "Recently";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (notification: Notification) => {
|
||||
const status = notification.metadata?.status as string | undefined;
|
||||
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">Notifications</h3>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-0" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<ScrollArea className="h-[400px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-0 pb-2">
|
||||
{notifications.map((notification, index) => (
|
||||
<div key={notification.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
|
||||
!notification.read && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium break-all",
|
||||
!notification.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
|
||||
{convertRenderedToDisplay(notification.message)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(notification.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{index < notifications.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -551,7 +551,9 @@ export function LLMConfigForm({
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-xs sm:text-sm font-medium">Enable Citations</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm font-medium">
|
||||
Enable Citations
|
||||
</FormLabel>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Include [citation:id] references to source documents
|
||||
</FormDescription>
|
||||
|
|
|
|||
|
|
@ -77,4 +77,17 @@ export {
|
|||
ScrapeWebpageResultSchema,
|
||||
ScrapeWebpageToolUI,
|
||||
} from "./scrape-webpage";
|
||||
export {
|
||||
type MemoryItem,
|
||||
type RecallMemoryArgs,
|
||||
RecallMemoryArgsSchema,
|
||||
type RecallMemoryResult,
|
||||
RecallMemoryResultSchema,
|
||||
RecallMemoryToolUI,
|
||||
type SaveMemoryArgs,
|
||||
SaveMemoryArgsSchema,
|
||||
type SaveMemoryResult,
|
||||
SaveMemoryResultSchema,
|
||||
SaveMemoryToolUI,
|
||||
} from "./user-memory";
|
||||
export { type WriteTodosData, WriteTodosSchema, WriteTodosToolUI } from "./write-todos";
|
||||
|
|
|
|||
283
surfsense_web/components/tool-ui/user-memory.tsx
Normal file
283
surfsense_web/components/tool-ui/user-memory.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { BrainIcon, CheckIcon, Loader2Icon, SearchIcon, XIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Zod Schemas for save_memory tool
|
||||
// ============================================================================
|
||||
|
||||
const SaveMemoryArgsSchema = z.object({
|
||||
content: z.string(),
|
||||
category: z.string().default("fact"),
|
||||
});
|
||||
|
||||
const SaveMemoryResultSchema = z.object({
|
||||
status: z.enum(["saved", "error"]),
|
||||
memory_id: z.number().nullish(),
|
||||
memory_text: z.string().nullish(),
|
||||
category: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
type SaveMemoryArgs = z.infer<typeof SaveMemoryArgsSchema>;
|
||||
type SaveMemoryResult = z.infer<typeof SaveMemoryResultSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Zod Schemas for recall_memory tool
|
||||
// ============================================================================
|
||||
|
||||
const RecallMemoryArgsSchema = z.object({
|
||||
query: z.string().nullish(),
|
||||
category: z.string().nullish(),
|
||||
top_k: z.number().default(5),
|
||||
});
|
||||
|
||||
const MemoryItemSchema = z.object({
|
||||
id: z.number(),
|
||||
memory_text: z.string(),
|
||||
category: z.string(),
|
||||
updated_at: z.string().nullish(),
|
||||
});
|
||||
|
||||
const RecallMemoryResultSchema = z.object({
|
||||
status: z.enum(["success", "error"]),
|
||||
count: z.number().nullish(),
|
||||
memories: z.array(MemoryItemSchema).nullish(),
|
||||
formatted_context: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
type RecallMemoryArgs = z.infer<typeof RecallMemoryArgsSchema>;
|
||||
type RecallMemoryResult = z.infer<typeof RecallMemoryResultSchema>;
|
||||
type MemoryItem = z.infer<typeof MemoryItemSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Category badge colors
|
||||
// ============================================================================
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
preference: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
fact: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
instruction: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
|
||||
context: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
};
|
||||
|
||||
function CategoryBadge({ category }: { category: string }) {
|
||||
const colorClass = categoryColors[category] || "bg-gray-500/10 text-gray-600 dark:text-gray-400";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Save Memory Tool UI
|
||||
// ============================================================================
|
||||
|
||||
export const SaveMemoryToolUI = makeAssistantToolUI<SaveMemoryArgs, SaveMemoryResult>({
|
||||
toolName: "save_memory",
|
||||
render: function SaveMemoryUI({ args, result, status }) {
|
||||
const isRunning = status.type === "running" || status.type === "requires-action";
|
||||
const isComplete = status.type === "complete";
|
||||
const isError = result?.status === "error";
|
||||
|
||||
// Parse args safely
|
||||
const parsedArgs = SaveMemoryArgsSchema.safeParse(args);
|
||||
const content = parsedArgs.success ? parsedArgs.data.content : "";
|
||||
const category = parsedArgs.success ? parsedArgs.data.category : "fact";
|
||||
|
||||
// Loading state
|
||||
if (isRunning) {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2Icon className="size-4 animate-spin text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-muted-foreground">Saving to memory...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XIcon className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-destructive">Failed to save memory</span>
|
||||
{result?.error && <p className="mt-1 text-xs text-destructive/70">{result.error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (isComplete && result?.status === "saved") {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
|
||||
<BrainIcon className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckIcon className="size-3 text-green-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground">Memory saved</span>
|
||||
<CategoryBadge category={category} />
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">{content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default/incomplete state - show what's being saved
|
||||
if (content) {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||
<BrainIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Saving memory</span>
|
||||
<CategoryBadge category={category} />
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">{content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Recall Memory Tool UI
|
||||
// ============================================================================
|
||||
|
||||
export const RecallMemoryToolUI = makeAssistantToolUI<RecallMemoryArgs, RecallMemoryResult>({
|
||||
toolName: "recall_memory",
|
||||
render: function RecallMemoryUI({ args, result, status }) {
|
||||
const isRunning = status.type === "running" || status.type === "requires-action";
|
||||
const isComplete = status.type === "complete";
|
||||
const isError = result?.status === "error";
|
||||
|
||||
// Parse args safely
|
||||
const parsedArgs = RecallMemoryArgsSchema.safeParse(args);
|
||||
const query = parsedArgs.success ? parsedArgs.data.query : null;
|
||||
|
||||
// Loading state
|
||||
if (isRunning) {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2Icon className="size-4 animate-spin text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{query ? `Searching memories for "${query}"...` : "Recalling memories..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XIcon className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-destructive">Failed to recall memories</span>
|
||||
{result?.error && <p className="mt-1 text-xs text-destructive/70">{result.error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state with memories
|
||||
if (isComplete && result?.status === "success") {
|
||||
const memories = result.memories || [];
|
||||
const count = result.count || 0;
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">No memories found</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BrainIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Recalled {count} {count === 1 ? "memory" : "memories"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{memories.slice(0, 5).map((memory: MemoryItem) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
className="flex items-start gap-2 rounded-md bg-muted/50 px-3 py-2"
|
||||
>
|
||||
<CategoryBadge category={memory.category} />
|
||||
<span className="text-sm text-muted-foreground flex-1">{memory.memory_text}</span>
|
||||
</div>
|
||||
))}
|
||||
{memories.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground">...and {memories.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default/incomplete state
|
||||
if (query) {
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">Searching memories for "{query}"</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Exports
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
SaveMemoryArgsSchema,
|
||||
SaveMemoryResultSchema,
|
||||
RecallMemoryArgsSchema,
|
||||
RecallMemoryResultSchema,
|
||||
type SaveMemoryArgs,
|
||||
type SaveMemoryResult,
|
||||
type RecallMemoryArgs,
|
||||
type RecallMemoryResult,
|
||||
type MemoryItem,
|
||||
};
|
||||
115
surfsense_web/components/ui/drawer.tsx
Normal file
115
surfsense_web/components/ui/drawer.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
|
||||
}
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
|
||||
overlayClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay className={overlayClassName} />
|
||||
<DrawerPrimitive.Content
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />;
|
||||
}
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
|
||||
}
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
DrawerHandle.displayName = "DrawerHandle";
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerHandle,
|
||||
};
|
||||
|
|
@ -182,13 +182,13 @@ function DropdownMenuSubTrigger({
|
|||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -94,16 +94,11 @@ function SelectItem({
|
|||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent/50 focus:text-accent-foreground hover:bg-accent/50 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,13 +42,15 @@ function SheetContent({
|
|||
className,
|
||||
children,
|
||||
side = "right",
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
overlayClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetOverlay className={overlayClassName} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
|
|
|
|||
33
surfsense_web/components/ui/spinner.tsx
Normal file
33
surfsense_web/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
/** Whether to hide the track behind the spinner arc */
|
||||
hideTrack?: boolean;
|
||||
/** Additional classes to apply */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "h-3 w-3 border-[1.5px]",
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-8 w-8 border-[3px]",
|
||||
xl: "h-10 w-10 border-4",
|
||||
};
|
||||
|
||||
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
|
||||
return (
|
||||
<output
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
"block animate-spin rounded-full",
|
||||
hideTrack ? "border-transparent" : "border-current/20",
|
||||
"border-t-current",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue