mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-06 08:16:03 +00:00
add edit feature and thinking selection in nova chat
This commit is contained in:
parent
9028b49362
commit
320568a388
7 changed files with 422 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
118
apps/web/components/chat/reasoning-selector.tsx
Normal file
118
apps/web/components/chat/reasoning-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue