Merge upstream/dev - preserve local UI enhancements (Logs in menu, conditional search, hover edit buttons)

This commit is contained in:
Eric Lammertsma 2026-01-22 19:41:11 -05:00
commit 089beb8d8c
117 changed files with 12068 additions and 4857 deletions

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

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

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

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>