@@ -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 ? (
- <>
-
-
-
- >
+
+ ) : undefined
+ }
+ toolbarTrailing={
+ isStackedInput ? (
+
+ ) : undefined
+ }
+ toolbarEnd={
+ isStackedInput ? (
+
) : undefined
}
/>
diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx
index a2293a785..d655d6572 100644
--- a/apps/web/components/chat/input/index.tsx
+++ b/apps/web/components/chat/input/index.tsx
@@ -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}
>
-
-
- {activeStatus || "Waiting for input..."}
-
+ {isResponding ? (
+
+ ) : (
+ <>
+
+
+ {activeStatus || "Waiting for input..."}
+
+ >
+ )}
{chainOfThoughtComponent && (
- {attachmentButton}
-
+
{stackedToolbar}
+ {attachmentButton}
+ {toolbarTrailing}
+ {toolbarEnd}
{isResponding &&
}
{(!isResponding || canSubmit) && (
+
+ {label}
+
+
+
+
+
+
+ {host}
+
+
+ {hasTitle ? (
+
+ {rawTitle}
+
+ ) : path ? (
+
+ {path}
+
+ ) : null}
+
+
+
+ )
+}
+
+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
+ }
+ return (
+
+ {children}
+
+ )
+ },
+ }
+}
+
+function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
const [expanded, setExpanded] = useState(false)
+ const ref = useRef(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 (
-
+
{expanded && (
-
+
)}
)
@@ -210,6 +367,23 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
if (toolName === "bash") {
return
}
+ if (isWebSearchToolName(toolName)) {
+ if (part.state === "output-available") return null
+ if (part.state === "error" || part.state === "output-error") {
+ return (
+
+
+ Web search failed
+
+ )
+ }
+ return (
+
+
+ Searching the web…
+
+ )
+ }
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
()
+ 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 (
-
- )
+ 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 (
- {part.text}
+
+ {runText}
+
)
}
@@ -448,29 +647,41 @@ export function AgentMessage({
})}
-
-
- {responseModelLabel && (
-
- {responseModelLabel}
-
- )}
-
+ {hasAssistantText && (
+
+
+ {webSources.length > 0 && (
+
+
+
+ )}
+ {responseModelLabel && (
+
+ {responseModelLabel}
+
+ )}
+
+ )}
)
}
diff --git a/apps/web/components/chat/model-selector.tsx b/apps/web/components/chat/model-selector.tsx
index 65208ca57..ca9935ee7 100644
--- a/apps/web/components/chat/model-selector.tsx
+++ b/apps/web/components/chat/model-selector.tsx
@@ -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({
) : (
)
@@ -103,66 +113,82 @@ export default function ChatModelSelector({
>
{trigger}
- {isOpen && (
-
-
- {models.map((model) => {
- const modelData = modelNames[model.id]
- const isSelected = selectedModel === model.id
- return (
-
+ )
+ })}
+
+
+ )}
+
)
}
diff --git a/apps/web/components/chat/reasoning-selector.tsx b/apps/web/components/chat/reasoning-selector.tsx
index a02fc4fa4..c0ceb2271 100644
--- a/apps/web/components/chat/reasoning-selector.tsx
+++ b/apps/web/components/chat/reasoning-selector.tsx
@@ -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
(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 (
- 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.`}
>
-
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" ? (
-
- ) : (
- <>
-
-
- {selected?.label}
-
-
- >
- )}
-
-
- {isOpen && (
-
-
- {reasoningOptions.map((option) => {
- const Icon = option.id === "thinking" ? BrainIcon : ZapIcon
- const isSelected = option.id === value
- return (
-
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",
- )}
- >
-
-
- {isSelected && (
-
- )}
-
- )
- })}
-
-
- )}
-
+
+
+
+
+
+
+
+
+ {selectedLabel}
+
+
)
}
diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx
index 528a2f862..7198d435d 100644
--- a/apps/web/components/select-spaces-modal.tsx
+++ b/apps/web/components/select-spaces-modal.tsx
@@ -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({
>
-
Select Space
-
+
{isBulkDeleteMode
? "Choose spaces to permanently delete"
diff --git a/apps/web/components/superloader.tsx b/apps/web/components/superloader.tsx
index 5cac100f5..2c40152f3 100644
--- a/apps/web/components/superloader.tsx
+++ b/apps/web/components/superloader.tsx
@@ -47,7 +47,7 @@ export function SuperLoader({