This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-01-21 22:58:48 -08:00
commit 12b825bff0
59 changed files with 2292 additions and 1022 deletions

View file

@ -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">

View file

@ -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": {

View file

@ -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>
);
};

View file

@ -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"

View file

@ -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}

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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) => {

View file

@ -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;
@ -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="p-3">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Comment or @mention"
submitLabel="Comment"
isSubmitting={isSubmitting}
onSubmit={handleCommentSubmit}
autoFocus={!hasThreads}
/>
</div>
</div>
);

View file

@ -20,7 +20,7 @@ export function CommentSheet({
<SheetContent
side={side}
className={cn(
"flex flex-col p-0",
"flex flex-col gap-0 overflow-hidden p-0",
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
)}
>
@ -41,7 +41,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>

View file

@ -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>

View file

@ -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}

View file

@ -76,10 +76,7 @@ export function DashboardBreadcrumb() {
const segments = path.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItemInterface[] = [];
// Always start with Dashboard
breadcrumbs.push({ label: t("dashboard"), href: "/dashboard" });
// 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]}`;

View file

@ -34,14 +34,12 @@ 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");
@ -302,10 +300,6 @@ export function LayoutDataProvider({
}
}, [router]);
const handleToggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const handleViewAllSharedChats = useCallback(() => {
setIsAllSharedChatsSidebarOpen(true);
}, []);
@ -375,9 +369,8 @@ export function LayoutDataProvider({
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={handleToggleTheme}
setTheme={setTheme}
isChatPage={isChatPage}
>
{children}

View file

@ -1,25 +1,50 @@
"use client";
import { Moon, Sun } from "lucide-react";
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 { NotificationButton } from "@/components/notifications/NotificationButton";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
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 +54,13 @@ export function Header({
</div>
{/* Right side - Actions */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-4">
{/* 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>
{/* Share button - only show on chat pages when thread exists */}
{hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)}
{languageSwitcher}
</div>
</header>
);

View file

@ -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}

View file

@ -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";
@ -239,7 +239,7 @@ export function AllPrivateChatsSidebar({
<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" />
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
<Button
@ -419,7 +419,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"

View file

@ -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>

View file

@ -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>

View file

@ -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}>

View file

@ -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}

View file

@ -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 { Loader2, 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",
@ -44,7 +47,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
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(
@ -55,10 +63,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
}
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 +81,14 @@ 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,38 +96,41 @@ 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="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Updating</span>
@ -133,7 +149,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
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 +157,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 +173,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}

View file

@ -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;
}

View file

@ -170,59 +170,55 @@ 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="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Switching model...</span>
@ -230,9 +226,9 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
</div>
)}
<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"
@ -243,7 +239,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>
@ -264,8 +260,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",
"aria-selected:bg-accent/50",
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
@ -326,8 +322,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",
"aria-selected:bg-accent/50",
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>

View file

@ -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>

View file

@ -3,27 +3,64 @@
import { useAtomValue } from "jotai";
import { Bell } from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useCallback, useEffect, 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 { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
import { NotificationPopup } from "./NotificationPopup";
const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
export function NotificationButton() {
const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
// Filter state - null means show all, otherwise filter by type
const [activeFilter, setActiveFilter] = useState<NotificationTypeEnum | null>(null);
// Load filter from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (
parsed === null ||
["new_mention", "connector_indexing", "document_processing"].includes(parsed)
) {
setActiveFilter(parsed);
}
}
} catch {
// Ignore localStorage errors
}
}, []);
// Handle filter toggle - clicking same pill again shows all
const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
setActiveFilter((current) => {
const newFilter = current === filter ? null : filter;
try {
localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
} catch {
// Ignore localStorage errors
}
return newFilter;
});
}, []);
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
searchSpaceId,
activeFilter
);
return (
@ -31,7 +68,7 @@ export function NotificationButton() {
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 relative">
<Button variant="outline" size="icon" className="h-8 w-8 relative border-0">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span
@ -57,6 +94,8 @@ export function NotificationButton() {
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)}
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
/>
</PopoverContent>
</Popover>

View file

@ -1,15 +1,53 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react";
import {
AlertCircle,
AtSign,
Bell,
Cable,
CheckCheck,
CheckCircle2,
FileText,
Loader2,
} from "lucide-react";
import { useRouter } from "next/navigation";
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 { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
/**
* Filter configuration for notification types
*/
const NOTIFICATION_FILTERS = {
new_mention: { label: "Mentions", icon: AtSign },
connector_indexing: { label: "Connectors", icon: Cable },
document_processing: { label: "Documents", icon: FileText },
} as const;
/**
* 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";
}
interface NotificationPopupProps {
notifications: Notification[];
unreadCount: number;
@ -17,6 +55,8 @@ interface NotificationPopupProps {
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onClose?: () => void;
activeFilter: NotificationTypeEnum | null;
onFilterChange: (filter: NotificationTypeEnum | null) => void;
}
export function NotificationPopup({
@ -26,6 +66,8 @@ export function NotificationPopup({
markAsRead,
markAllAsRead,
onClose,
activeFilter,
onFilterChange,
}: NotificationPopupProps) {
const router = useRouter();
@ -66,6 +108,28 @@ export function NotificationPopup({
};
const getStatusIcon = (notification: Notification) => {
// For mentions, show the author's avatar with initials fallback
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
author_name?: string;
author_avatar_url?: string | null;
author_email?: string;
};
const authorName = metadata?.author_name;
const avatarUrl = metadata?.author_avatar_url;
const authorEmail = metadata?.author_email;
return (
<Avatar className="h-6 w-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
</AvatarFallback>
</Avatar>
);
}
// For other notification types, show status icons
const status = notification.metadata?.status as string | undefined;
switch (status) {
@ -83,7 +147,7 @@ export function NotificationPopup({
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 justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Notifications</h3>
</div>
@ -95,6 +159,35 @@ export function NotificationPopup({
)}
</div>
{/* Filter Pills */}
<div className="flex items-center gap-1.5 px-4 py-2 overflow-x-auto">
{(
Object.entries(NOTIFICATION_FILTERS) as [
NotificationTypeEnum,
(typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
][]
).map(([key, { label, icon: Icon }]) => {
const isActive = activeFilter === key;
return (
<button
key={key}
type="button"
onClick={() => onFilterChange(key)}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
"border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
isActive
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
{/* Notifications List */}
<ScrollArea className="h-[400px]">
{loading ? (

View file

@ -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";

View 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,
};

View file

@ -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>
);
}

View file

@ -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>
);