Grok 4.3, web sources UX, and mobile polish (#1064)

This commit is contained in:
Mahesh Sanikommu 2026-06-08 00:04:15 -07:00 committed by GitHub
parent 70955480fd
commit 7ec3e57fe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 600 additions and 356 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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