add edit feature and thinking selection in nova chat

This commit is contained in:
ved015 2026-05-28 16:48:28 +05:30
parent 9028b49362
commit 320568a388
7 changed files with 422 additions and 24 deletions

View file

@ -41,7 +41,7 @@ import {
useQuickNoteDraft,
} from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import type { ModelId } from "@/lib/models"
import type { ModelId, ReasoningEffort } from "@/lib/models"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
@ -161,6 +161,8 @@ export default function NewPage() {
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] =
useState<ReasoningEffort | null>(null)
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
null,
)
@ -490,6 +492,7 @@ export default function NewPage() {
setQueuedHighlightContent(highlightContent)
setQueuedChatSeed(userReply)
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
@ -498,10 +501,16 @@ export default function NewPage() {
)
const handleHomeChatStart = useCallback(
(message: string, model: ModelId, projectId: string) => {
(
message: string,
model: ModelId,
projectId: string,
reasoningEffort: ReasoningEffort,
) => {
setQueuedHighlightContent(null)
setQueuedChatSeed(message)
setQueuedChatModel(model)
setQueuedChatReasoningEffort(reasoningEffort)
setQueuedChatProject(projectId)
setQueuedMessageSource("home")
void setViewMode("chat")
@ -512,6 +521,7 @@ export default function NewPage() {
const consumeQueuedChat = useCallback(() => {
setQueuedChatSeed(null)
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedHighlightContent(null)
setQueuedMessageSource("highlight")
@ -633,6 +643,7 @@ export default function NewPage() {
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
initialSelectedModel={queuedChatModel}
initialReasoningEffort={queuedChatReasoningEffort}
initialChatProject={queuedChatProject}
/>
</div>

View file

@ -8,27 +8,54 @@ import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { ReasoningSelector } from "./reasoning-selector"
import { getDefaultReasoningEffort, type ReasoningEffort } from "@/lib/models"
export function HomeChatComposer({
onStartChat,
className,
}: {
onStartChat: (message: string, model: ModelId, projectId: string) => void
onStartChat: (
message: string,
model: ModelId,
projectId: string,
reasoningEffort: ReasoningEffort,
) => void
className?: string
}) {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
getDefaultReasoningEffort("gemini-2.5-pro"),
)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
AUTO_CHAT_SPACE_ID,
])
const handleModelChange = useCallback((model: ModelId) => {
setSelectedModel(model)
setReasoningEffort(getDefaultReasoningEffort(model))
}, [])
const send = useCallback(() => {
const t = input.trim()
if (!t) return
onStartChat(t, selectedModel, chatSpaceProjects[0] ?? selectedProject)
onStartChat(
t,
selectedModel,
chatSpaceProjects[0] ?? selectedProject,
reasoningEffort,
)
setInput("")
}, [chatSpaceProjects, input, onStartChat, selectedModel, selectedProject])
}, [
chatSpaceProjects,
input,
onStartChat,
reasoningEffort,
selectedModel,
selectedProject,
])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@ -52,9 +79,13 @@ export function HomeChatComposer({
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
onModelChange={handleModelChange}
minimal
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}

View file

@ -37,7 +37,12 @@ import { getNovaChatErrorCopy } from "@/lib/chat-stream-error"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { modelNames, type ModelId } from "@/lib/models"
import {
getDefaultReasoningEffort,
modelNames,
type ModelId,
type ReasoningEffort,
} from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { SuperLoader } from "../superloader"
import { UserMessage } from "./message/user-message"
@ -52,6 +57,7 @@ import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { ChatEmptyStatePlaceholder } from "./chat-empty-state"
import { ReasoningSelector } from "./reasoning-selector"
export function ChatLaunchFab({
onOpen,
@ -103,6 +109,7 @@ export function ChatSidebar({
onConsumeQueuedMessage,
queuedMessageSource = "highlight",
initialSelectedModel = null,
initialReasoningEffort = null,
initialChatProject = null,
emptyStateSuggestions,
layout = "sidebar",
@ -114,6 +121,7 @@ export function ChatSidebar({
onConsumeQueuedMessage?: () => void
queuedMessageSource?: "highlight" | "home"
initialSelectedModel?: ModelId | null
initialReasoningEffort?: ReasoningEffort | null
initialChatProject?: string | null
emptyStateSuggestions?: string[]
layout?: "sidebar" | "page"
@ -124,8 +132,14 @@ export function ChatSidebar({
const [selectedModel, setSelectedModel] = useState<ModelId>(
initialSelectedModel ?? "claude-sonnet-4.6",
)
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
initialReasoningEffort ??
getDefaultReasoningEffort(initialSelectedModel ?? "claude-sonnet-4.6"),
)
const selectedModelRef = useRef(selectedModel)
selectedModelRef.current = selectedModel
const reasoningEffortRef = useRef(reasoningEffort)
reasoningEffortRef.current = reasoningEffort
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
const [messageFeedback, setMessageFeedback] = useState<
@ -147,6 +161,13 @@ export function ChatSidebar({
const isScrolledToBottomRef = useRef(true)
const userJustSentRef = useRef(false)
const sentQueuedMessageRef = useRef<string | null>(null)
const truncateFromMessageIdRef = useRef<string | null>(null)
const pendingRegenerationRef = useRef<{
text: string
} | null>(null)
const [regenerationBaseLength, setRegenerationBaseLength] = useState<
number | null
>(null)
const pendingHighlightReplyRef = useRef<string | null>(null)
const awaitingHighlightInjectionRef = useRef(false)
const pendingHighlightMessageRef = useRef<UIMessage[] | null>(null)
@ -238,6 +259,8 @@ export function ChatSidebar({
enableSpaceDiscovery:
selectedProjectRef.current === AUTO_CHAT_SPACE_ID,
model: selectedModelRef.current,
reasoningEffort: reasoningEffortRef.current,
truncateFromMessageId: truncateFromMessageIdRef.current,
},
},
}),
@ -295,6 +318,7 @@ export function ChatSidebar({
const handleModelChange = useCallback(
(modelId: ModelId) => {
setSelectedModel(modelId)
setReasoningEffort(getDefaultReasoningEffort(modelId))
clearError()
},
[clearError],
@ -336,6 +360,7 @@ export function ChatSidebar({
const handleSend = () => {
if (!input.trim() || status === "submitted" || status === "streaming")
return
truncateFromMessageIdRef.current = null
if (!threadId) setThreadId(fallbackChatId)
analytics.chatMessageSent({ source: "typed" })
sendMessage({ text: input })
@ -347,6 +372,7 @@ export function ChatSidebar({
const handleSuggestedQuestion = useCallback(
(suggestion: string) => {
if (status === "submitted" || status === "streaming") return
truncateFromMessageIdRef.current = null
if (!threadId) setThreadId(fallbackChatId)
analytics.chatSuggestedQuestionClicked()
analytics.chatMessageSent({ source: "suggested" })
@ -364,6 +390,57 @@ export function ChatSidebar({
],
)
const handleRegenerateFromUserMessage = useCallback(
(
messageId: string,
text: string,
model: ModelId,
nextReasoningEffort: ReasoningEffort,
) => {
const trimmed = text.trim()
if (!trimmed || status === "submitted" || status === "streaming") return
const messageIndex = messages.findIndex(
(message) => message.id === messageId,
)
if (messageIndex === -1) return
truncateFromMessageIdRef.current = messageId
selectedModelRef.current = model
reasoningEffortRef.current = nextReasoningEffort
setSelectedModel(model)
setReasoningEffort(nextReasoningEffort)
clearError()
pendingRegenerationRef.current = {
text: trimmed,
}
setRegenerationBaseLength(messageIndex)
setMessages(messages.slice(0, messageIndex))
userJustSentRef.current = true
scrollToBottom()
},
[clearError, messages, scrollToBottom, setMessages, status],
)
useEffect(() => {
const pending = pendingRegenerationRef.current
if (
!pending ||
regenerationBaseLength === null ||
messages.length !== regenerationBaseLength ||
status !== "ready"
) {
return
}
pendingRegenerationRef.current = null
setRegenerationBaseLength(null)
analytics.chatMessageSent({ source: "typed" })
sendMessage({ text: pending.text })
window.setTimeout(() => {
truncateFromMessageIdRef.current = null
}, 0)
}, [messages.length, regenerationBaseLength, sendMessage, status])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@ -569,10 +646,18 @@ export function ChatSidebar({
setSelectedModel(initialSelectedModel)
return
}
if (
initialReasoningEffort &&
reasoningEffort !== initialReasoningEffort
) {
setReasoningEffort(initialReasoningEffort)
return
}
sentQueuedMessageRef.current = queuedMessage
analytics.chatMessageSent({ source: queuedMessageSource })
if (queuedHighlightContent) {
truncateFromMessageIdRef.current = null
// Start a fresh thread for highlight-based chats to avoid overwriting existing conversations
const newChatId = generateId()
chatIdRef.current = newChatId
@ -603,6 +688,7 @@ export function ChatSidebar({
},
]
} else {
truncateFromMessageIdRef.current = null
if (!threadId) setThreadId(fallbackChatId)
sendMessage({ text: queuedMessage })
}
@ -614,7 +700,9 @@ export function ChatSidebar({
queuedHighlightContent,
queuedMessageSource,
initialSelectedModel,
initialReasoningEffort,
selectedModel,
reasoningEffort,
status,
sendMessage,
onConsumeQueuedMessage,
@ -654,6 +742,7 @@ export function ChatSidebar({
awaitingHighlightInjectionRef.current = false
const reply = pendingHighlightReplyRef.current
pendingHighlightReplyRef.current = null
truncateFromMessageIdRef.current = null
sendMessage({ text: reply })
}
}, [messages, sendMessage, status])
@ -975,6 +1064,10 @@ export function ChatSidebar({
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
@ -1044,7 +1137,10 @@ export function ChatSidebar({
<UserMessage
message={message}
copiedMessageId={copiedMessageId}
selectedModel={selectedModel}
reasoningEffort={reasoningEffort}
onCopy={handleCopyMessage}
onRegenerate={handleRegenerateFromUserMessage}
/>
) : (
<AgentMessage
@ -1172,6 +1268,10 @@ export function ChatSidebar({
onModelChange={handleModelChange}
minimal
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}

View file

@ -1,4 +1,4 @@
import { Copy, Check, ThumbsUp, ThumbsDown } from "lucide-react"
import { Check, Copy, ThumbsDown, ThumbsUp } from "lucide-react"
import { cn } from "@lib/utils"
interface MessageActionsProps {

View file

@ -1,42 +1,158 @@
"use client"
import { memo } from "react"
import { Copy, Check } from "lucide-react"
import { memo, useEffect, useRef, useState } from "react"
import { Copy, Check, PencilIcon, PencilOffIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
import ChatModelSelector from "../model-selector"
import { ReasoningSelector } from "../reasoning-selector"
import {
getDefaultReasoningEffort,
type ModelId,
type ReasoningEffort,
} from "@/lib/models"
interface UserMessageProps {
message: UIMessage
copiedMessageId: string | null
selectedModel: ModelId
reasoningEffort: ReasoningEffort
onCopy: (messageId: string, text: string) => void
onRegenerate: (
messageId: string,
text: string,
model: ModelId,
reasoningEffort: ReasoningEffort,
) => void
}
export const UserMessage = memo(function UserMessage({
message,
copiedMessageId,
selectedModel,
reasoningEffort,
onCopy,
onRegenerate,
}: UserMessageProps) {
const [isEditing, setIsEditing] = useState(false)
const [draft, setDraft] = useState("")
const [editModel, setEditModel] = useState<ModelId>(selectedModel)
const [editReasoningEffort, setEditReasoningEffort] =
useState<ReasoningEffort>(reasoningEffort)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const text = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const startEditing = () => {
setDraft(text)
setEditModel(selectedModel)
setEditReasoningEffort(reasoningEffort)
setIsEditing(true)
}
const submitEdit = () => {
const nextText = draft.trim()
if (!nextText) return
setIsEditing(false)
onRegenerate(message.id, nextText, editModel, editReasoningEffort)
}
const handleEditModelChange = (model: ModelId) => {
setEditModel(model)
setEditReasoningEffort(getDefaultReasoningEffort(model))
}
useEffect(() => {
if (!isEditing) return
textareaRef.current?.focus()
}, [isEditing])
return (
<div className="flex flex-col items-end w-full">
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
{isEditing ? (
<div className="w-full max-w-[88%] rounded-[14px] border border-[#293952]/70 bg-[#0D121A] p-2 shadow-[0_16px_36px_rgba(0,0,0,0.28)]">
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
submitEdit()
}
}}
className="min-h-20 w-full resize-none bg-transparent p-2 text-sm text-white outline-none placeholder:text-white/30"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ChatModelSelector
selectedModel={editModel}
onModelChange={handleEditModelChange}
minimal
/>
<ReasoningSelector
value={editReasoningEffort}
onChange={setEditReasoningEffort}
dropdownDirection="down"
/>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={submitEdit}
disabled={!draft.trim()}
className="rounded-md bg-[#E052A0] p-2 transition-colors hover:bg-[#EF6FB4] disabled:cursor-not-allowed disabled:opacity-50"
title="Send edited message"
>
<svg
width="14"
height="14"
viewBox="0 0 12 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Send</title>
<path
d="M12 6L10.55 7.4L7 3.85L7 16L5 16L5 3.85L1.45 7.4L-4.37e-07 6L6 -2.62e-07L12 6Z"
fill="#FAFAFA"
/>
</svg>
</button>
</div>
</div>
</div>
) : (
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
</div>
)}
<div className="mt-1 flex max-w-full items-center justify-end gap-1">
<button
type="button"
onClick={() => onCopy(message.id, text)}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors"
title="Copy message"
>
{copiedMessageId === message.id ? (
<Check className="size-3.5 text-green-400" />
) : (
<Copy className="size-3.5 text-white/50" />
)}
</button>
<button
type="button"
onClick={isEditing ? () => setIsEditing(false) : startEditing}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors"
title={isEditing ? "Cancel edit" : "Edit message"}
>
{isEditing ? (
<PencilOffIcon className="size-3.5 text-white/50" />
) : (
<PencilIcon className="size-3.5 text-white/50" />
)}
</button>
</div>
<button
type="button"
onClick={() => onCopy(message.id, text)}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors mt-1"
title="Copy message"
>
{copiedMessageId === message.id ? (
<Check className="size-3.5 text-green-400" />
) : (
<Copy className="size-3.5 text-white/50" />
)}
</button>
</div>
)
})

View file

@ -0,0 +1,118 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { BrainIcon, CheckIcon, MoreHorizontalIcon, ZapIcon } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { reasoningOptions, type ReasoningEffort } from "@/lib/models"
interface ReasoningSelectorProps {
value: ReasoningEffort
onChange: (value: ReasoningEffort) => void
variant?: "pill" | "icon"
disabled?: boolean
dropdownDirection?: "up" | "down"
}
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
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)
}
return (
<div
ref={containerRef}
className="relative z-10 flex shrink-0 items-center"
>
<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 items-center gap-1 rounded-full bg-fg-primary/5 px-2.5 py-1 text-[13px] hover:bg-fg-primary/10",
dmSansClassName(),
)}
title="Reasoning"
aria-label="Reasoning"
>
{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>
</>
)}
</button>
{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",
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">
{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 rounded-md px-2.5 py-1.5 text-left transition-colors",
isSelected ? "bg-[#293952]/60" : "hover:bg-[#293952]/40",
)}
>
<Icon className="size-3 shrink-0 text-white/60" />
<span className="flex-1 text-[13px] font-medium text-white">
{option.label}
</span>
{isSelected && (
<CheckIcon className="size-3 shrink-0 text-[#E052A0]" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View file

@ -17,6 +17,7 @@ export const models = [
] as const
export type ModelId = (typeof models)[number]["id"]
export type ReasoningEffort = "instant" | "thinking"
export const modelNames: Record<ModelId, { name: string; version: string }> = {
"gpt-5.1": { name: "GPT", version: "5.1" },
@ -24,6 +25,27 @@ export const modelNames: Record<ModelId, { name: string; version: string }> = {
"gemini-2.5-pro": { name: "Gemini", version: "3 Pro" },
}
export const reasoningOptions: Array<{
id: ReasoningEffort
label: string
description: string
}> = [
{
id: "instant",
label: "Instant",
description: "Faster answers for everyday prompts",
},
{
id: "thinking",
label: "Thinking",
description: "Deeper reasoning for harder questions",
},
]
export function getDefaultReasoningEffort(_model: ModelId): ReasoningEffort {
return "instant"
}
interface ModelIconProps {
width?: number
height?: number