From 320568a388131da2d0e5c24780570fe3376bbb59 Mon Sep 17 00:00:00 2001 From: ved015 Date: Thu, 28 May 2026 16:48:28 +0530 Subject: [PATCH 01/12] add edit feature and thinking selection in nova chat --- apps/web/app/(app)/page.tsx | 15 +- .../components/chat/home-chat-composer.tsx | 39 ++++- apps/web/components/chat/index.tsx | 102 +++++++++++- .../chat/message/message-actions.tsx | 2 +- .../components/chat/message/user-message.tsx | 148 ++++++++++++++++-- .../components/chat/reasoning-selector.tsx | 118 ++++++++++++++ apps/web/lib/models.tsx | 22 +++ 7 files changed, 422 insertions(+), 24 deletions(-) create mode 100644 apps/web/components/chat/reasoning-selector.tsx diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index c177ec1e8..6c70deb26 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -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(null) const [queuedChatModel, setQueuedChatModel] = useState(null) + const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] = + useState(null) const [queuedChatProject, setQueuedChatProject] = useState( 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} /> diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx index 2f7cda2db..5e78fbb8e 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -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("gemini-2.5-pro") + const [reasoningEffort, setReasoningEffort] = useState( + getDefaultReasoningEffort("gemini-2.5-pro"), + ) const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ 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({ <> + 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( initialSelectedModel ?? "claude-sonnet-4.6", ) + const [reasoningEffort, setReasoningEffort] = useState( + 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(null) const [hoveredMessageId, setHoveredMessageId] = useState(null) const [messageFeedback, setMessageFeedback] = useState< @@ -147,6 +161,13 @@ export function ChatSidebar({ const isScrolledToBottomRef = useRef(true) const userJustSentRef = useRef(false) const sentQueuedMessageRef = useRef(null) + const truncateFromMessageIdRef = useRef(null) + const pendingRegenerationRef = useRef<{ + text: string + } | null>(null) + const [regenerationBaseLength, setRegenerationBaseLength] = useState< + number | null + >(null) const pendingHighlightReplyRef = useRef(null) const awaitingHighlightInjectionRef = useRef(false) const pendingHighlightMessageRef = useRef(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} /> + ) : ( + 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(selectedModel) + const [editReasoningEffort, setEditReasoningEffort] = + useState(reasoningEffort) + const textareaRef = useRef(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 (
-
-

{text}

+ {isEditing ? ( +
+