mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-20 05:54:01 +00:00
Grok 4.3, web sources UX, and mobile polish (#1064)
This commit is contained in:
parent
70955480fd
commit
7ec3e57fe1
13 changed files with 600 additions and 356 deletions
|
|
@ -580,17 +580,17 @@ export default function NewPage() {
|
|||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
const showBottomNav = isMobile && !!session
|
||||
const showBottomNav = isMobile && !!session && !isChatView
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
isGraphMode && "h-dvh overflow-hidden",
|
||||
(isGraphMode || isChatView) && "h-dvh overflow-hidden",
|
||||
showBottomNav &&
|
||||
!isGraphMode &&
|
||||
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
|
||||
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
|
||||
)}
|
||||
>
|
||||
{showNovaBackdrop && (
|
||||
|
|
@ -770,7 +770,7 @@ export default function NewPage() {
|
|||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30",
|
||||
showBottomNav
|
||||
? "bottom-[4.25rem]"
|
||||
? "bottom-[calc(4rem+env(safe-area-inset-bottom))]"
|
||||
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
|||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-3 md:hidden",
|
||||
"fixed inset-x-0 bottom-0 z-40 border-t border-white/[0.08] bg-[#0A0E14]/85 shadow-[0_-8px_24px_rgba(0,0,0,0.35)] backdrop-blur-xl md:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-around rounded-full border border-[#161F2C] bg-muted/95 px-2.5 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.55)] backdrop-blur-xl">
|
||||
<div className="flex items-center justify-around px-1 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||
<NavTab
|
||||
label="Home"
|
||||
icon={Home}
|
||||
|
|
@ -76,10 +76,10 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
|||
type="button"
|
||||
aria-label="Open chat"
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className="group relative flex size-11 shrink-0 items-center justify-center self-center rounded-full outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/50"
|
||||
className="group relative flex size-11 shrink-0 items-center justify-center rounded-full shadow-[0_0_18px_rgba(75,160,250,0.35)] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60 active:scale-95"
|
||||
>
|
||||
<NovaOrb
|
||||
size={42}
|
||||
size={40}
|
||||
className="pointer-events-none blur-[1px]! transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -172,13 +172,13 @@ function NavTabButton({
|
|||
aria-current={active ? "page" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
|
||||
"flex flex-1 flex-col items-center gap-1 rounded-lg py-1 outline-none transition-colors active:scale-95",
|
||||
active ? "text-white" : "text-[#737373] hover:text-white",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||
<span className="text-[11px] font-medium leading-none">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ export function HomeChatComposer({
|
|||
const [attachmentDrafts, setAttachmentDrafts] = useState<
|
||||
ChatAttachmentDraft[]
|
||||
>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
getDefaultReasoningEffort("gemini-2.5-pro"),
|
||||
getDefaultReasoningEffort("grok-4.3"),
|
||||
)
|
||||
const { selectedProject } = useProject()
|
||||
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
|
||||
|
|
@ -126,7 +126,7 @@ export function HomeChatComposer({
|
|||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6">
|
||||
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-2 md:pb-6">
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
|
|
@ -141,25 +141,27 @@ export function HomeChatComposer({
|
|||
attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
|
||||
showStatusStrip={false}
|
||||
stackedToolbar={
|
||||
<>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
minimal
|
||||
/>
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
|
||||
/>
|
||||
</>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
minimal
|
||||
/>
|
||||
}
|
||||
toolbarTrailing={
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
}
|
||||
toolbarEnd={
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 shadow-none hover:bg-white/5"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useQueryState } from "nuqs"
|
|||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { motion } from "motion/react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
} from "@ui/components/sheet"
|
||||
import { ScrollArea } from "@ui/components/scroll-area"
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Check,
|
||||
ChevronDownIcon,
|
||||
HistoryIcon,
|
||||
|
|
@ -200,11 +202,11 @@ export function ChatSidebar({
|
|||
ChatAttachmentDraft[]
|
||||
>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(
|
||||
initialSelectedModel ?? "claude-sonnet-4.6",
|
||||
initialSelectedModel ?? "grok-4.3",
|
||||
)
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
initialReasoningEffort ??
|
||||
getDefaultReasoningEffort(initialSelectedModel ?? "claude-sonnet-4.6"),
|
||||
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.3"),
|
||||
)
|
||||
const selectedModelRef = useRef(selectedModel)
|
||||
selectedModelRef.current = selectedModel
|
||||
|
|
@ -351,9 +353,18 @@ export function ChatSidebar({
|
|||
const sendSettings = pendingSendSettingsRef.current
|
||||
pendingSendSettingsRef.current = null
|
||||
|
||||
// Tool parts (incl. Anthropic server-side web search) don't round-trip
|
||||
// through convertToModelMessages and cause tool_use/tool_result mismatches.
|
||||
const sanitizedMessages = messages.map((m) => ({
|
||||
...m,
|
||||
parts: (m.parts ?? []).filter(
|
||||
(p) => !p.type.startsWith("tool-") && p.type !== "dynamic-tool",
|
||||
),
|
||||
}))
|
||||
|
||||
return {
|
||||
body: {
|
||||
messages,
|
||||
messages: sanitizedMessages,
|
||||
metadata: {
|
||||
chatId: chatIdRef.current,
|
||||
projectId: selectedProjectRef.current,
|
||||
|
|
@ -1063,11 +1074,14 @@ export function ChatSidebar({
|
|||
}) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
// Strip tool parts — persisted format doesn't round-trip through
|
||||
// convertToModelMessages correctly and causes tool_use/tool_result
|
||||
// mismatch errors. Text history is sufficient for context.
|
||||
// Strip tool parts (they break convertToModelMessages with tool_use/tool_result
|
||||
// mismatches); keep text/reasoning + source parts so citations survive reload.
|
||||
parts: (m.parts || []).filter(
|
||||
(p) => p.type === "text" || p.type === "reasoning",
|
||||
(p) =>
|
||||
p.type === "text" ||
|
||||
p.type === "reasoning" ||
|
||||
p.type === "source-url" ||
|
||||
p.type === "source-document",
|
||||
),
|
||||
metadata: m.metadata,
|
||||
createdAt: new Date(m.createdAt),
|
||||
|
|
@ -1469,6 +1483,24 @@ export function ChatSidebar({
|
|||
const isStackedInput = layout === "page"
|
||||
const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
|
||||
const isResponding = status === "submitted" || status === "streaming"
|
||||
const isWebSearching =
|
||||
isResponding &&
|
||||
(() => {
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last || last.role !== "assistant") return false
|
||||
const parts = last.parts ?? []
|
||||
const searching = parts.some(
|
||||
(p) =>
|
||||
(p.type === "dynamic-tool" &&
|
||||
isWebSearchToolName((p as { toolName?: string }).toolName ?? "")) ||
|
||||
(p.type.startsWith("tool-") &&
|
||||
isWebSearchToolName(p.type.slice("tool-".length))),
|
||||
)
|
||||
const hasText = parts.some(
|
||||
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
|
||||
)
|
||||
return searching && !hasText
|
||||
})()
|
||||
const hasBusyAttachment = attachmentDrafts.some(
|
||||
(attachment) =>
|
||||
attachment.status === "uploading" || attachment.status === "queued",
|
||||
|
|
@ -1748,6 +1780,16 @@ export function ChatSidebar({
|
|||
)}
|
||||
>
|
||||
<div className="mr-2 flex min-w-0 flex-1 items-center gap-2">
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => _setIsChatOpen(false)}
|
||||
aria-label="Back"
|
||||
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-[#161F2C] bg-black text-[#FAFAFA] transition-colors hover:bg-[#161F2C]"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
{!isStackedInput && (
|
||||
<>
|
||||
<ChatModelSelector
|
||||
|
|
@ -1801,9 +1843,8 @@ export function ChatSidebar({
|
|||
className={
|
||||
messages.length > 0
|
||||
? cn(
|
||||
"flex flex-col space-y-3 min-h-full justify-end",
|
||||
"flex flex-col space-y-3 min-h-full justify-end pb-4",
|
||||
isPageDesktop || isMobile ? "pt-2" : "pt-14",
|
||||
isStackedInput && "px-4",
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
|
@ -1851,11 +1892,6 @@ export function ChatSidebar({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{(status === "submitted" || status === "streaming") && (
|
||||
<div className="flex gap-2">
|
||||
<SuperLoader label="Thinking…" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1878,8 +1914,7 @@ export function ChatSidebar({
|
|||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"mb-2 rounded-lg bg-amber-950/40 px-3 py-2 text-sm text-amber-50/95",
|
||||
isPageDesktop ? "mx-0" : "mx-4",
|
||||
"mx-4 mb-2 rounded-lg bg-amber-950/40 px-3 py-2 text-sm text-amber-50/95",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -1931,7 +1966,7 @@ export function ChatSidebar({
|
|||
"shrink-0",
|
||||
isStackedInput &&
|
||||
(isMobile
|
||||
? "px-4 pb-2"
|
||||
? "px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
||||
: "px-4 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6"),
|
||||
)}
|
||||
>
|
||||
|
|
@ -1953,11 +1988,13 @@ export function ChatSidebar({
|
|||
activeStatus={
|
||||
isResponding && isQueueFull
|
||||
? `Queue full (${CHAT_QUEUE_LIMIT} max)`
|
||||
: status === "submitted"
|
||||
? "Thinking…"
|
||||
: status === "streaming"
|
||||
? "Structuring response…"
|
||||
: "Waiting for input…"
|
||||
: isWebSearching
|
||||
? "Searching the web…"
|
||||
: status === "submitted"
|
||||
? "Thinking…"
|
||||
: status === "streaming"
|
||||
? "Thinking…"
|
||||
: "Waiting for input…"
|
||||
}
|
||||
queuedMessages={messageQueue}
|
||||
showStatusStrip={showInputStatusStrip}
|
||||
|
|
@ -1967,25 +2004,31 @@ export function ChatSidebar({
|
|||
}
|
||||
stackedToolbar={
|
||||
isStackedInput ? (
|
||||
<>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
minimal
|
||||
/>
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
|
||||
/>
|
||||
</>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
minimal
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
toolbarTrailing={
|
||||
isStackedInput ? (
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
toolbarEnd={
|
||||
isStackedInput ? (
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 shadow-none hover:bg-white/5"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { SuperLoader } from "@/components/superloader"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
|
|
@ -46,6 +47,10 @@ interface ChatInputProps {
|
|||
onExpandedChange?: (expanded: boolean) => void
|
||||
/** Model + space controls on one row with send; textarea full-width above */
|
||||
stackedToolbar?: ReactNode
|
||||
/** Trailing controls rendered after the attach button (e.g. reasoning) */
|
||||
toolbarTrailing?: ReactNode
|
||||
/** Controls rendered just left of the send button (e.g. space selector) */
|
||||
toolbarEnd?: ReactNode
|
||||
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
|
||||
showStatusStrip?: boolean
|
||||
attachments?: ChatAttachmentDraft[]
|
||||
|
|
@ -70,6 +75,8 @@ export default function ChatInput({
|
|||
chainOfThoughtComponent,
|
||||
onExpandedChange,
|
||||
stackedToolbar,
|
||||
toolbarTrailing,
|
||||
toolbarEnd,
|
||||
showStatusStrip = true,
|
||||
attachments = [],
|
||||
onAddAttachmentFiles,
|
||||
|
|
@ -184,7 +191,7 @@ export default function ChatInput({
|
|||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isResponding}
|
||||
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-[#242832] bg-black text-[#A6B0BE] transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-white/45 transition-colors hover:bg-white/5 hover:text-white/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Attach files"
|
||||
title="Attach files"
|
||||
>
|
||||
|
|
@ -204,7 +211,7 @@ export default function ChatInput({
|
|||
className={cn("relative z-20!")}
|
||||
animate={{
|
||||
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
|
||||
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
|
||||
margin: "0",
|
||||
borderRadius: showStatusStrip
|
||||
? isExpanded
|
||||
? "0 0 12px 12px"
|
||||
|
|
@ -251,10 +258,16 @@ export default function ChatInput({
|
|||
disabled={!chainOfThoughtComponent}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
{isResponding ? (
|
||||
<SuperLoader label={activeStatus || "Thinking…"} />
|
||||
) : (
|
||||
<>
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{chainOfThoughtComponent && (
|
||||
<ChevronUpIcon
|
||||
|
|
@ -334,11 +347,13 @@ export default function ChatInput({
|
|||
rows={1}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{attachmentButton}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{stackedToolbar}
|
||||
{attachmentButton}
|
||||
{toolbarTrailing}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{toolbarEnd}
|
||||
{isResponding && <StopButton onClick={onStop} />}
|
||||
{(!isResponding || canSubmit) && (
|
||||
<SendButton
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { Streamdown } from "streamdown"
|
||||
import {
|
||||
|
|
@ -52,48 +52,205 @@ type SourceUrlPart = {
|
|||
title?: string
|
||||
}
|
||||
|
||||
function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
|
||||
function sourceHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "")
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function faviconUrl(host: string): string {
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
|
||||
}
|
||||
|
||||
function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return isWebSearchToolName(part.toolName ?? "")
|
||||
}
|
||||
if (part.type.startsWith("tool-")) {
|
||||
return isWebSearchToolName(part.type.slice("tool-".length))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function CitationLink({
|
||||
href,
|
||||
label,
|
||||
source,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
source?: SourceUrlPart
|
||||
}) {
|
||||
const url = source?.url ?? href
|
||||
const host = sourceHost(url)
|
||||
const rawTitle = source?.title?.trim()
|
||||
const hasTitle =
|
||||
!!rawTitle && rawTitle !== host && !/^https?:\/\//i.test(rawTitle)
|
||||
let path = ""
|
||||
try {
|
||||
path = new URL(url).pathname.replace(/\/$/, "")
|
||||
} catch {}
|
||||
return (
|
||||
<span className="group/cite relative inline">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 no-underline hover:text-blue-300"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden -translate-x-1/2 pb-1.5 group-hover/cite:block">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pointer-events-auto block w-64 overflow-hidden rounded-lg border border-white/10 bg-[#0B0F16]/95 p-2.5 no-underline shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<img
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded bg-white/10"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<span className="truncate text-xs font-medium text-white/85">
|
||||
{host}
|
||||
</span>
|
||||
</span>
|
||||
{hasTitle ? (
|
||||
<span className="mt-1.5 block line-clamp-2 text-xs leading-snug text-white/60">
|
||||
{rawTitle}
|
||||
</span>
|
||||
) : path ? (
|
||||
<span className="mt-1 block truncate text-[11px] text-white/35">
|
||||
{path}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function makeMarkdownComponents(sources: SourceUrlPart[]) {
|
||||
return {
|
||||
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
|
||||
const label =
|
||||
typeof children === "string"
|
||||
? children
|
||||
: Array.isArray(children)
|
||||
? children.join("")
|
||||
: ""
|
||||
const match = label.match(/^\[?(\d+)\]?$/)
|
||||
if (match && href) {
|
||||
const n = Number(match[1])
|
||||
const source = sources.find((s) => s.url === href) ?? sources[n - 1]
|
||||
return <CitationLink href={href} label={label} source={source} />
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setExpanded(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDown)
|
||||
return () => document.removeEventListener("mousedown", onDown)
|
||||
}, [expanded])
|
||||
|
||||
if (sources.length === 0) return null
|
||||
|
||||
const faviconHosts: string[] = []
|
||||
for (const s of sources) {
|
||||
const host = sourceHost(s.url)
|
||||
if (!faviconHosts.includes(host)) faviconHosts.push(host)
|
||||
if (faviconHosts.length >= 3) break
|
||||
}
|
||||
const count = sources.length
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
|
||||
expanded && "border-b border-[#1E2128]",
|
||||
)}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${count} web ${count === 1 ? "source" : "sources"}`}
|
||||
>
|
||||
<GlobeIcon className="size-3 shrink-0 text-emerald-400" />
|
||||
<span className="font-medium text-emerald-400">
|
||||
Web sources
|
||||
<span className="text-white/40 font-normal ml-1">
|
||||
({sources.length})
|
||||
</span>
|
||||
<span className="flex -space-x-1.5">
|
||||
{faviconHosts.length > 0 ? (
|
||||
faviconHosts.map((host) => (
|
||||
<img
|
||||
key={host}
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
className="size-4 rounded-full border border-[#0B0F16] bg-white/10 object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<GlobeIcon className="size-3.5 text-emerald-400" />
|
||||
)}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3 text-white/30 shrink-0 ml-auto" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3 text-white/30 shrink-0 ml-auto" />
|
||||
)}
|
||||
<span>{count === 1 ? "1 source" : `${count} sources`}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="px-3 py-2 space-y-1.5 list-none">
|
||||
{sources.map((s) => (
|
||||
<li key={s.sourceId}>
|
||||
<a
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline break-all"
|
||||
>
|
||||
{s.title?.trim() || s.url}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="absolute bottom-full left-0 z-[1000] mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl">
|
||||
<ul className="max-h-72 list-none space-y-0.5 overflow-y-auto">
|
||||
{sources.map((s) => {
|
||||
const host = sourceHost(s.url)
|
||||
return (
|
||||
<li key={s.sourceId}>
|
||||
<a
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
|
||||
>
|
||||
<img
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
className="mt-0.5 size-4 shrink-0 rounded-full bg-white/10"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-white/80">
|
||||
{s.title?.trim() || host}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-white/40">
|
||||
{host}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -210,6 +367,23 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
|
|||
if (toolName === "bash") {
|
||||
return <BashToolDisplay part={part} />
|
||||
}
|
||||
if (isWebSearchToolName(toolName)) {
|
||||
if (part.state === "output-available") return null
|
||||
if (part.state === "error" || part.state === "output-error") {
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 text-xs text-red-400/80">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span>Web search failed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 text-xs text-white/50">
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-emerald-400/80" />
|
||||
<span>Searching the web…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const meta =
|
||||
TOOL_META[toolName] ??
|
||||
(isWebSearchToolName(toolName)
|
||||
|
|
@ -342,6 +516,27 @@ export function AgentMessage({
|
|||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
const webSources = (() => {
|
||||
const seen = new Set<string>()
|
||||
const out: SourceUrlPart[] = []
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "source-url") continue
|
||||
const source = part as SourceUrlPart
|
||||
if (seen.has(source.url)) continue
|
||||
seen.add(source.url)
|
||||
out.push(source)
|
||||
}
|
||||
return out
|
||||
})()
|
||||
const hasAssistantText = message.parts.some(
|
||||
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
|
||||
)
|
||||
const sourceKey = webSources.map((s) => s.url).join("|")
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by stable source urls
|
||||
const markdownComponents = useMemo(
|
||||
() => makeMarkdownComponents(webSources),
|
||||
[sourceKey],
|
||||
)
|
||||
const responseModelLabel = responseModel
|
||||
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
|
||||
: null
|
||||
|
|
@ -358,24 +553,10 @@ export function AgentMessage({
|
|||
|
||||
{message.parts.map((part, partIndex) => {
|
||||
if (part.type === "source-url") {
|
||||
if (
|
||||
partIndex > 0 &&
|
||||
message.parts[partIndex - 1]?.type === "source-url"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const sources: SourceUrlPart[] = []
|
||||
for (let j = partIndex; j < message.parts.length; j++) {
|
||||
const p = message.parts[j]
|
||||
if (!p || p.type !== "source-url") break
|
||||
sources.push(p as SourceUrlPart)
|
||||
}
|
||||
return (
|
||||
<WebSourcesGroup
|
||||
key={`${message.id}-web-sources-${partIndex}`}
|
||||
sources={sources}
|
||||
/>
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (isWebSearchPart(part)) {
|
||||
return null
|
||||
}
|
||||
if (part.type === "source-document") {
|
||||
const doc = part as {
|
||||
|
|
@ -400,12 +581,30 @@ export function AgentMessage({
|
|||
)
|
||||
}
|
||||
if (part.type === "text") {
|
||||
// Skip fragments mid-run — source-url citations split one answer into
|
||||
// many text parts; rendering each separately tears markdown (lists etc.).
|
||||
let prev = partIndex - 1
|
||||
while (prev >= 0 && message.parts[prev]?.type === "source-url") {
|
||||
prev--
|
||||
}
|
||||
if (prev >= 0 && message.parts[prev]?.type === "text") {
|
||||
return null
|
||||
}
|
||||
let runText = ""
|
||||
for (let j = partIndex; j < message.parts.length; j++) {
|
||||
const p = message.parts[j]
|
||||
if (p?.type === "text") runText += p.text
|
||||
else if (p?.type === "source-url") continue
|
||||
else break
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${partIndex}`}
|
||||
className="text-sm text-white/90 chat-markdown-content"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
<Streamdown components={markdownComponents}>
|
||||
{runText}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -448,29 +647,41 @@ export function AgentMessage({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<MessageActions
|
||||
messageId={message.id}
|
||||
messageText={messageText}
|
||||
isLastMessage={isLastAgentMessage}
|
||||
isHovered={isHovered}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
onCopy={onCopy}
|
||||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
{responseModelLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
|
||||
isHovered ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{responseModelLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasAssistantText && (
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<MessageActions
|
||||
messageId={message.id}
|
||||
messageText={messageText}
|
||||
isLastMessage={isLastAgentMessage}
|
||||
isHovered={isHovered}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
onCopy={onCopy}
|
||||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
{webSources.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity duration-200",
|
||||
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<WebSourcesPill sources={webSources} />
|
||||
</div>
|
||||
)}
|
||||
{responseModelLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
|
||||
isHovered ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{responseModelLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -44,8 +45,7 @@ export default function ChatModelSelector({
|
|||
const selectedModel = selectedModelProp ?? internalModel
|
||||
const currentModelData = modelNames[selectedModel]
|
||||
const selectedModelLabel = `${currentModelData.name} ${currentModelData.version}`
|
||||
const selectedItemClass =
|
||||
"border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]"
|
||||
const selectedItemClass = "bg-white/[0.06] text-white"
|
||||
|
||||
const handleModelSelect = (modelId: ModelId) => {
|
||||
if (onModelChange) {
|
||||
|
|
@ -61,18 +61,23 @@ export default function ChatModelSelector({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full border border-white/15 bg-black px-3 py-1.5 text-[13px] text-white transition-colors hover:border-white/30 hover:bg-white/5",
|
||||
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-white/80 transition-colors hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`Model: ${selectedModelLabel}`}
|
||||
>
|
||||
<p className="min-w-0 truncate text-left text-white">
|
||||
<p className="min-w-0 truncate text-left text-white/80">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-white/55">{currentModelData.version}</span>
|
||||
<span className="text-white/40">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-white/55" />
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-white/40 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -89,7 +94,12 @@ export default function ChatModelSelector({
|
|||
{currentModelData.name}{" "}
|
||||
<span className="text-white/55">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-4 text-white/55" />
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 text-white/55 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
|
||||
|
|
@ -103,66 +113,82 @@ export default function ChatModelSelector({
|
|||
>
|
||||
{trigger}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"isolate absolute left-0 z-[1000] w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
|
||||
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
const isSelected = selectedModel === model.id
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/10",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{modelData.name}{" "}
|
||||
<span
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: dropdownDirection === "up" ? 6 : -6,
|
||||
}}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: dropdownDirection === "up" ? 6 : -6,
|
||||
}}
|
||||
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn(
|
||||
"isolate absolute left-0 z-[1000] w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl",
|
||||
dropdownDirection === "up"
|
||||
? "bottom-full mb-2 origin-bottom"
|
||||
: "top-full mt-2 origin-top",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
const isSelected = selectedModel === model.id
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/[0.06]",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/55",
|
||||
"truncate text-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{modelData.version}
|
||||
</span>
|
||||
{modelData.name}{" "}
|
||||
<span
|
||||
className={cn(
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/40",
|
||||
)}
|
||||
>
|
||||
{modelData.version}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-xs",
|
||||
isSelected ? "text-white/50" : "text-white/40",
|
||||
)}
|
||||
>
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-xs",
|
||||
isSelected ? "text-white/60" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
BrainIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
MoreHorizontalIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { BrainIcon, ZapIcon } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { reasoningOptions, type ReasoningEffort } from "@/lib/models"
|
||||
|
|
@ -20,124 +14,60 @@ interface ReasoningSelectorProps {
|
|||
dropdownDirection?: "up" | "down"
|
||||
}
|
||||
|
||||
const iconVariants = {
|
||||
initial: (toThinking: boolean) => ({
|
||||
y: toThinking ? "-110%" : "110%",
|
||||
opacity: 0,
|
||||
}),
|
||||
animate: { y: "0%", opacity: 1 },
|
||||
exit: (toThinking: boolean) => ({
|
||||
y: toThinking ? "110%" : "-110%",
|
||||
opacity: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
export function ReasoningSelector({
|
||||
value,
|
||||
onChange,
|
||||
variant = "pill",
|
||||
disabled = false,
|
||||
dropdownDirection = "up",
|
||||
}: ReasoningSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const selected = reasoningOptions.find((option) => option.id === value)
|
||||
const SelectedIcon = value === "thinking" ? BrainIcon : ZapIcon
|
||||
const selectedLabel = selected?.label ?? "Reasoning"
|
||||
const selectedItemClass =
|
||||
"border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]"
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (next: ReasoningEffort) => {
|
||||
onChange(next)
|
||||
setIsOpen(false)
|
||||
}
|
||||
const toThinking = value === "thinking"
|
||||
const Icon = toThinking ? BrainIcon : ZapIcon
|
||||
const selectedLabel =
|
||||
reasoningOptions.find((option) => option.id === value)?.label ?? "Reasoning"
|
||||
const nextValue: ReasoningEffort = toThinking ? "instant" : "thinking"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(nextValue)}
|
||||
className={cn(
|
||||
"relative flex shrink-0 items-center",
|
||||
isOpen ? "z-[1000]" : "z-10",
|
||||
"group flex shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 text-xs text-white/80 transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
title={`Reasoning: ${selectedLabel} — click to switch`}
|
||||
aria-label={`Reasoning: ${selectedLabel}. Click to switch.`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "icon"
|
||||
? "rounded p-1.5 hover:bg-white/10"
|
||||
: "flex size-9 items-center justify-center gap-1.5 rounded-full border border-white/15 bg-black px-0 py-1.5 text-xs text-white hover:border-white/30 hover:bg-white/5 sm:size-auto sm:justify-start sm:px-2.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
title={`Reasoning: ${selectedLabel}`}
|
||||
aria-label={`Reasoning: ${selectedLabel}`}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{variant === "icon" ? (
|
||||
<MoreHorizontalIcon className="size-3.5 text-white/50 hover:text-white/80" />
|
||||
) : (
|
||||
<>
|
||||
<SelectedIcon className="size-3.5 shrink-0 text-white/65" />
|
||||
<span className="hidden text-white sm:inline">
|
||||
{selected?.label}
|
||||
</span>
|
||||
<ChevronDownIcon className="hidden size-3.5 shrink-0 text-white/55 sm:block" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"isolate absolute left-0 z-[1000] w-[min(14rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
|
||||
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{reasoningOptions.map((option) => {
|
||||
const Icon = option.id === "thinking" ? BrainIcon : ZapIcon
|
||||
const isSelected = option.id === value
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.id)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/65",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="relative flex size-3.5 shrink-0 items-center justify-center overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout" custom={toThinking}>
|
||||
<motion.span
|
||||
key={value}
|
||||
custom={toThinking}
|
||||
variants={iconVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Icon className="size-3.5 text-white/45 transition-colors group-hover:text-white/70" />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
<span className="ml-0 max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:ml-1 group-hover:max-w-[88px] group-hover:opacity-100">
|
||||
{selectedLabel}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useMemo, useEffect, useCallback, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
|
@ -1128,14 +1128,14 @@ export function SelectSpacesModal({
|
|||
>
|
||||
<div className="flex items-start justify-between gap-4 px-4 pt-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<p
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Select Space
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
|
||||
{isBulkDeleteMode
|
||||
? "Choose spaces to permanently delete"
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function SuperLoader({
|
|||
<output
|
||||
aria-label={label}
|
||||
className={`inline-flex flex-row items-center gap-2 ${className}`}
|
||||
style={{ width: size + 10 }}
|
||||
style={{ minWidth: size + 10 }}
|
||||
>
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -87,7 +87,7 @@ export function SuperLoader({
|
|||
</motion.svg>
|
||||
|
||||
<span
|
||||
className="text-xs font-medium text-slate-500"
|
||||
className="whitespace-nowrap text-xs font-medium text-slate-500"
|
||||
style={{ fontSize: size * 0.25 }}
|
||||
>
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,16 @@
|
|||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Loose lists wrap item content in <p> whose top margin detaches the bullet */
|
||||
.chat-markdown-content li > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Model spams `---` between every section; headings already separate them */
|
||||
.chat-markdown-content hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shadow-inside-out {
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||
|
|
|
|||
|
|
@ -7,28 +7,25 @@ import {
|
|||
} from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
|
||||
function fetchErrorMessage(err: unknown, fallback: string): string {
|
||||
if (typeof err === "string") return err
|
||||
if (err && typeof err === "object") {
|
||||
const e = err as { error?: unknown; message?: unknown }
|
||||
if (typeof e.error === "string") return e.error
|
||||
if (typeof e.message === "string") return e.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface DocumentWithId {
|
||||
id?: string
|
||||
customId?: string | null
|
||||
}
|
||||
|
||||
interface DocumentsQueryData {
|
||||
documents: DocumentWithId[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
type InfiniteQueryData = {
|
||||
pages: DocumentsResponse[]
|
||||
pageParams: number[]
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsOptions {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
|
@ -566,7 +563,9 @@ export function useDocumentMutations({
|
|||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete document")
|
||||
throw new Error(
|
||||
fetchErrorMessage(response.error, "Failed to delete document"),
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
|
|
@ -606,7 +605,9 @@ export function useDocumentMutations({
|
|||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete documents")
|
||||
throw new Error(
|
||||
fetchErrorMessage(response.error, "Failed to delete documents"),
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
export const models = [
|
||||
{
|
||||
id: "grok-4.3",
|
||||
name: "Grok 4.3",
|
||||
description: "xAI's latest model",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1",
|
||||
name: "GPT 5.1",
|
||||
|
|
@ -20,6 +25,7 @@ export type ModelId = (typeof models)[number]["id"]
|
|||
export type ReasoningEffort = "instant" | "thinking"
|
||||
|
||||
export const modelNames: Record<ModelId, { name: string; version: string }> = {
|
||||
"grok-4.3": { name: "Grok", version: "4.3" },
|
||||
"gpt-5.1": { name: "GPT", version: "5.1" },
|
||||
"claude-sonnet-4.6": { name: "Claude", version: "4.6" },
|
||||
"gemini-2.5-pro": { name: "Gemini", version: "3 Pro" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue