fix chat selector ui and edit state

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ved015 2026-05-29 21:10:11 +05:30
parent 320568a388
commit f93b1f906f
4 changed files with 134 additions and 66 deletions

View file

@ -165,6 +165,10 @@ export function ChatSidebar({
const pendingRegenerationRef = useRef<{
text: string
} | null>(null)
const pendingSendSettingsRef = useRef<{
model: ModelId
reasoningEffort: ReasoningEffort
} | null>(null)
const [regenerationBaseLength, setRegenerationBaseLength] = useState<
number | null
>(null)
@ -246,24 +250,30 @@ export function ChatSidebar({
new DefaultChatTransport({
api: `${chatApiBase}/chat`,
credentials: "include",
prepareSendMessagesRequest: ({ messages }) => ({
body: {
messages,
metadata: {
chatId: chatIdRef.current,
projectId: selectedProjectRef.current,
spaceMode:
selectedProjectRef.current === AUTO_CHAT_SPACE_ID
? "auto"
: "manual",
enableSpaceDiscovery:
selectedProjectRef.current === AUTO_CHAT_SPACE_ID,
model: selectedModelRef.current,
reasoningEffort: reasoningEffortRef.current,
truncateFromMessageId: truncateFromMessageIdRef.current,
prepareSendMessagesRequest: ({ messages }) => {
const sendSettings = pendingSendSettingsRef.current
pendingSendSettingsRef.current = null
return {
body: {
messages,
metadata: {
chatId: chatIdRef.current,
projectId: selectedProjectRef.current,
spaceMode:
selectedProjectRef.current === AUTO_CHAT_SPACE_ID
? "auto"
: "manual",
enableSpaceDiscovery:
selectedProjectRef.current === AUTO_CHAT_SPACE_ID,
model: sendSettings?.model ?? selectedModelRef.current,
reasoningEffort:
sendSettings?.reasoningEffort ?? reasoningEffortRef.current,
truncateFromMessageId: truncateFromMessageIdRef.current,
},
},
},
}),
}
},
}),
[chatApiBase],
)
@ -405,10 +415,10 @@ export function ChatSidebar({
if (messageIndex === -1) return
truncateFromMessageIdRef.current = messageId
selectedModelRef.current = model
reasoningEffortRef.current = nextReasoningEffort
setSelectedModel(model)
setReasoningEffort(nextReasoningEffort)
pendingSendSettingsRef.current = {
model,
reasoningEffort: nextReasoningEffort,
}
clearError()
pendingRegenerationRef.current = {
text: trimmed,

View file

@ -90,6 +90,7 @@ export const UserMessage = memo(function UserMessage({
selectedModel={editModel}
onModelChange={handleEditModelChange}
minimal
dropdownDirection="down"
/>
<ReasoningSelector
value={editReasoningEffort}

View file

@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/lib/fonts"
import { ChevronDownIcon } from "lucide-react"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
import { models, type ModelId, modelNames } from "@/lib/models"
import { analytics } from "@/lib/analytics"
@ -13,12 +13,14 @@ interface ChatModelSelectorProps {
onModelChange?: (model: ModelId) => void
/** Compact pill matching inline send control. */
minimal?: boolean
dropdownDirection?: "up" | "down"
}
export default function ChatModelSelector({
selectedModel: selectedModelProp,
onModelChange,
minimal = false,
dropdownDirection = "up",
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] =
useState<ModelId>("claude-sonnet-4.6")
@ -41,6 +43,7 @@ export default function ChatModelSelector({
const selectedModel = selectedModelProp ?? internalModel
const currentModelData = modelNames[selectedModel]
const selectedModelLabel = `${currentModelData.name} ${currentModelData.version}`
const handleModelSelect = (modelId: ModelId) => {
if (onModelChange) {
@ -56,71 +59,102 @@ 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 bg-fg-primary/5 px-3 py-1.5 text-sm transition-colors hover:bg-fg-primary/10",
"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-sm text-white transition-colors hover:border-white/30 hover:bg-white/5",
dmSansClassName(),
)}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-label={`Model: ${selectedModelLabel}`}
>
<p className="min-w-0 truncate text-left text-fg-primary">
<p className="min-w-0 truncate text-left text-white">
{currentModelData.name}{" "}
<span className="text-fg-subtle">{currentModelData.version}</span>
<span className="text-white/55">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-3.5 shrink-0 text-fg-subtle" />
<ChevronDownIcon className="size-3.5 shrink-0 text-white/55" />
</button>
) : (
<Button
variant="headers"
className={cn(
"h-10! max-w-[min(100%,220px)] shrink gap-1 rounded-full border-[#73737333] bg-surface-base text-base",
"h-10! max-w-[min(100%,220px)] shrink gap-1.5 rounded-full border-white/15 bg-black text-base text-white shadow-none transition-colors hover:border-white/30 hover:bg-white/5",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-label={`Model: ${selectedModelLabel}`}
>
<p className="truncate text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
<span className="text-white/55">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
<ChevronDownIcon className="size-4 text-white/55" />
</Button>
)
return (
<div
ref={containerRef}
className="relative z-10 flex min-w-0 shrink items-center gap-2"
className={cn(
"relative flex min-w-0 shrink items-center gap-2",
isOpen ? "z-[100]" : "z-10",
)}
>
{trigger}
{isOpen && (
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
<div
className={cn(
"absolute left-0 z-[100] 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 flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
selectedModel === model.id
? "bg-[#293952]/60"
: "hover:bg-[#293952]/40",
"flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
isSelected
? "bg-[#E6E6E6] text-[#101010]"
: "text-white hover:bg-white/10",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="text-sm font-medium text-white">
{modelData.name}{" "}
<span className="text-fg-subtle">{modelData.version}</span>
</div>
<div className="text-xs text-fg-muted truncate w-full">
{model.description}
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-[15px] font-medium",
isSelected ? "text-[#101010]" : "text-white",
)}
>
{modelData.name}{" "}
<span
className={cn(
isSelected ? "text-[#101010]/60" : "text-white/55",
)}
>
{modelData.version}
</span>
</div>
<div
className={cn(
"mt-0.5 truncate text-xs",
isSelected ? "text-[#101010]/60" : "text-white/45",
)}
>
{model.description}
</div>
</div>
{isSelected && (
<CheckIcon className="size-4 shrink-0 text-[#101010]/70" />
)}
</button>
)
})}

View file

@ -1,7 +1,13 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { BrainIcon, CheckIcon, MoreHorizontalIcon, ZapIcon } from "lucide-react"
import {
BrainIcon,
CheckIcon,
ChevronDownIcon,
MoreHorizontalIcon,
ZapIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { reasoningOptions, type ReasoningEffort } from "@/lib/models"
@ -25,6 +31,7 @@ export function ReasoningSelector({
const containerRef = useRef<HTMLDivElement>(null)
const selected = reasoningOptions.find((option) => option.id === value)
const SelectedIcon = value === "thinking" ? BrainIcon : ZapIcon
const selectedLabel = selected?.label ?? "Reasoning"
useEffect(() => {
if (!isOpen) return
@ -48,7 +55,10 @@ export function ReasoningSelector({
return (
<div
ref={containerRef}
className="relative z-10 flex shrink-0 items-center"
className={cn(
"relative flex shrink-0 items-center",
isOpen ? "z-[100]" : "z-10",
)}
>
<button
type="button"
@ -58,18 +68,22 @@ export function ReasoningSelector({
"cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50",
variant === "icon"
? "rounded p-1.5 hover:bg-white/10"
: "flex items-center gap-1 rounded-full bg-fg-primary/5 px-2.5 py-1 text-[13px] hover:bg-fg-primary/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-[13px] text-white hover:border-white/30 hover:bg-white/5 sm:size-auto sm:justify-start sm:px-2.5",
dmSansClassName(),
)}
title="Reasoning"
aria-label="Reasoning"
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 shrink-0 text-fg-subtle" />
<span className="text-fg-primary">{selected?.label}</span>
<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>
@ -77,16 +91,11 @@ export function ReasoningSelector({
{isOpen && (
<div
className={cn(
"absolute left-0 z-50 w-44 overflow-hidden rounded-lg border border-surface-border bg-surface-card shadow-xl backdrop-blur-xl",
"absolute left-0 z-[100] 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="border-b border-surface-border px-3 py-1.5">
<span className="text-[11px] font-medium text-fg-muted">
Reasoning effort
</span>
</div>
<div className="p-1">
<div className="space-y-1">
{reasoningOptions.map((option) => {
const Icon = option.id === "thinking" ? BrainIcon : ZapIcon
const isSelected = option.id === value
@ -96,16 +105,30 @@ export function ReasoningSelector({
type="button"
onClick={() => handleSelect(option.id)}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors",
isSelected ? "bg-[#293952]/60" : "hover:bg-[#293952]/40",
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors",
isSelected
? "bg-[#E6E6E6] text-[#101010]"
: "text-white hover:bg-white/10",
)}
>
<Icon className="size-3 shrink-0 text-white/60" />
<span className="flex-1 text-[13px] font-medium text-white">
{option.label}
</span>
<Icon
className={cn(
"size-4 shrink-0",
isSelected ? "text-[#101010]/70" : "text-white/65",
)}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-[15px] font-medium",
isSelected ? "text-[#101010]" : "text-white",
)}
>
{option.label}
</div>
</div>
{isSelected && (
<CheckIcon className="size-3 shrink-0 text-[#E052A0]" />
<CheckIcon className="size-4 shrink-0 text-[#101010]/70" />
)}
</button>
)