mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
add edit feature , reasoning mode, message queue in nova chat (#1018)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Ishaan Gupta <ishaankone@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0e29de0af2
commit
73c320c83b
11 changed files with 837 additions and 99 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}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,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"
|
||||
|
|
@ -51,6 +56,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,
|
||||
|
|
@ -94,6 +100,31 @@ export function ChatLaunchFab({
|
|||
)
|
||||
}
|
||||
|
||||
type QueuedChatMessage = {
|
||||
id: string
|
||||
text: string
|
||||
model: ModelId
|
||||
reasoningEffort: ReasoningEffort
|
||||
}
|
||||
|
||||
const CHAT_QUEUE_LIMIT = 3
|
||||
|
||||
function normalizeModelId(value: unknown): ModelId | null {
|
||||
if (typeof value !== "string") return null
|
||||
return value in modelNames ? (value as ModelId) : null
|
||||
}
|
||||
|
||||
function getMessageResponseModel(message: UIMessage): ModelId | null {
|
||||
const metadata = (
|
||||
message as UIMessage & { metadata?: Record<string, unknown> }
|
||||
).metadata
|
||||
return (
|
||||
normalizeModelId(metadata?.model) ??
|
||||
normalizeModelId(metadata?.responseModel) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
isChatOpen,
|
||||
setIsChatOpen: _setIsChatOpen,
|
||||
|
|
@ -102,6 +133,7 @@ export function ChatSidebar({
|
|||
onConsumeQueuedMessage,
|
||||
queuedMessageSource = "highlight",
|
||||
initialSelectedModel = null,
|
||||
initialReasoningEffort = null,
|
||||
initialChatProject = null,
|
||||
emptyStateSuggestions,
|
||||
layout = "sidebar",
|
||||
|
|
@ -113,6 +145,7 @@ export function ChatSidebar({
|
|||
onConsumeQueuedMessage?: () => void
|
||||
queuedMessageSource?: "highlight" | "home"
|
||||
initialSelectedModel?: ModelId | null
|
||||
initialReasoningEffort?: ReasoningEffort | null
|
||||
initialChatProject?: string | null
|
||||
emptyStateSuggestions?: string[]
|
||||
layout?: "sidebar" | "page"
|
||||
|
|
@ -123,8 +156,17 @@ 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 [messageQueue, setMessageQueue] = useState<QueuedChatMessage[]>([])
|
||||
const queuedDispatchInFlightRef = useRef(false)
|
||||
const queuedDispatchSawResponseRef = useRef(false)
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
|
||||
const [messageFeedback, setMessageFeedback] = useState<
|
||||
|
|
@ -146,6 +188,22 @@ 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 pendingSendSettingsRef = useRef<{
|
||||
model: ModelId
|
||||
reasoningEffort: ReasoningEffort
|
||||
} | null>(null)
|
||||
const pendingResponseModelsRef = useRef<ModelId[]>([])
|
||||
const seenAssistantMessageIdsRef = useRef<Set<string>>(new Set())
|
||||
const [responseModelByMessageId, setResponseModelByMessageId] = useState<
|
||||
Record<string, ModelId>
|
||||
>({})
|
||||
const [regenerationBaseLength, setRegenerationBaseLength] = useState<
|
||||
number | null
|
||||
>(null)
|
||||
const pendingHighlightReplyRef = useRef<string | null>(null)
|
||||
const awaitingHighlightInjectionRef = useRef(false)
|
||||
const pendingHighlightMessageRef = useRef<UIMessage[] | null>(null)
|
||||
|
|
@ -224,22 +282,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,
|
||||
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],
|
||||
)
|
||||
|
|
@ -291,9 +357,38 @@ export function ChatSidebar({
|
|||
[error, selectedModel],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
pendingResponseModelsRef.current = []
|
||||
}
|
||||
}, [error])
|
||||
|
||||
useEffect(() => {
|
||||
const updates: Record<string, ModelId> = {}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue
|
||||
if (seenAssistantMessageIdsRef.current.has(message.id)) continue
|
||||
|
||||
seenAssistantMessageIdsRef.current.add(message.id)
|
||||
const responseModel =
|
||||
getMessageResponseModel(message) ??
|
||||
pendingResponseModelsRef.current.shift() ??
|
||||
null
|
||||
|
||||
if (responseModel) {
|
||||
updates[message.id] = responseModel
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return
|
||||
setResponseModelByMessageId((prev) => ({ ...prev, ...updates }))
|
||||
}, [messages])
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(modelId: ModelId) => {
|
||||
setSelectedModel(modelId)
|
||||
setReasoningEffort(getDefaultReasoningEffort(modelId))
|
||||
clearError()
|
||||
},
|
||||
[clearError],
|
||||
|
|
@ -333,11 +428,36 @@ export function ChatSidebar({
|
|||
}, [])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || status === "submitted" || status === "streaming")
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
|
||||
if (status === "submitted" || status === "streaming") {
|
||||
if (messageQueue.length >= CHAT_QUEUE_LIMIT) return
|
||||
|
||||
setMessageQueue((prev) => {
|
||||
if (prev.length >= CHAT_QUEUE_LIMIT) return prev
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
text,
|
||||
model: selectedModel,
|
||||
reasoningEffort,
|
||||
},
|
||||
]
|
||||
})
|
||||
setInput("")
|
||||
analytics.chatMessageSent({ source: "typed" })
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
truncateFromMessageIdRef.current = null
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
analytics.chatMessageSent({ source: "typed" })
|
||||
sendMessage({ text: input })
|
||||
pendingResponseModelsRef.current.push(selectedModel)
|
||||
sendMessage({ text })
|
||||
setInput("")
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
|
|
@ -346,9 +466,11 @@ 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" })
|
||||
pendingResponseModelsRef.current.push(selectedModel)
|
||||
sendMessage({ text: suggestion })
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
|
|
@ -360,9 +482,66 @@ export function ChatSidebar({
|
|||
status,
|
||||
threadId,
|
||||
scrollToBottom,
|
||||
selectedModel,
|
||||
],
|
||||
)
|
||||
|
||||
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
|
||||
pendingSendSettingsRef.current = {
|
||||
model,
|
||||
reasoningEffort: 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" })
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
pendingResponseModelsRef.current.push(
|
||||
pendingSendSettingsRef.current?.model ?? selectedModelRef.current,
|
||||
)
|
||||
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 && !isMobile) {
|
||||
e.preventDefault()
|
||||
|
|
@ -436,6 +615,12 @@ export function ChatSidebar({
|
|||
setThreadId(null)
|
||||
setFallbackChatId(newChatId)
|
||||
setInput("")
|
||||
setMessageQueue([])
|
||||
pendingResponseModelsRef.current = []
|
||||
seenAssistantMessageIdsRef.current = new Set()
|
||||
setResponseModelByMessageId({})
|
||||
queuedDispatchInFlightRef.current = false
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
}, [setThreadId, setMessages])
|
||||
|
||||
const fetchThreads = useCallback(async () => {
|
||||
|
|
@ -477,6 +662,7 @@ export function ChatSidebar({
|
|||
role: string
|
||||
parts: Array<{ type: string }>
|
||||
createdAt: string
|
||||
metadata?: Record<string, unknown>
|
||||
}) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
|
|
@ -486,11 +672,18 @@ export function ChatSidebar({
|
|||
parts: (m.parts || []).filter(
|
||||
(p) => p.type === "text" || p.type === "reasoning",
|
||||
),
|
||||
metadata: m.metadata,
|
||||
createdAt: new Date(m.createdAt),
|
||||
}),
|
||||
)
|
||||
pendingResponseModelsRef.current = []
|
||||
seenAssistantMessageIdsRef.current = new Set()
|
||||
setResponseModelByMessageId({})
|
||||
setThreadId(id)
|
||||
setPendingThreadLoad({ id, messages: uiMessages })
|
||||
setMessageQueue([])
|
||||
queuedDispatchInFlightRef.current = false
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
analytics.chatThreadLoaded({ thread_id: id })
|
||||
setIsHistoryOpen(false)
|
||||
setConfirmingDeleteId(null)
|
||||
|
|
@ -579,10 +772,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
|
||||
|
|
@ -613,7 +814,11 @@ export function ChatSidebar({
|
|||
},
|
||||
]
|
||||
} else {
|
||||
truncateFromMessageIdRef.current = null
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
pendingResponseModelsRef.current.push(selectedModel)
|
||||
sendMessage({ text: queuedMessage })
|
||||
}
|
||||
onConsumeQueuedMessage?.()
|
||||
|
|
@ -624,7 +829,9 @@ export function ChatSidebar({
|
|||
queuedHighlightContent,
|
||||
queuedMessageSource,
|
||||
initialSelectedModel,
|
||||
initialReasoningEffort,
|
||||
selectedModel,
|
||||
reasoningEffort,
|
||||
status,
|
||||
sendMessage,
|
||||
onConsumeQueuedMessage,
|
||||
|
|
@ -664,6 +871,10 @@ export function ChatSidebar({
|
|||
awaitingHighlightInjectionRef.current = false
|
||||
const reply = pendingHighlightReplyRef.current
|
||||
pendingHighlightReplyRef.current = null
|
||||
truncateFromMessageIdRef.current = null
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
pendingResponseModelsRef.current.push(selectedModelRef.current)
|
||||
sendMessage({ text: reply })
|
||||
}
|
||||
}, [messages, sendMessage, status])
|
||||
|
|
@ -675,6 +886,57 @@ export function ChatSidebar({
|
|||
}
|
||||
}, [queuedMessage])
|
||||
|
||||
useEffect(() => {
|
||||
const isRespondingNow = status === "submitted" || status === "streaming"
|
||||
if (isRespondingNow) {
|
||||
if (queuedDispatchInFlightRef.current) {
|
||||
queuedDispatchSawResponseRef.current = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (status !== "ready") {
|
||||
queuedDispatchInFlightRef.current = false
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (queuedDispatchInFlightRef.current) {
|
||||
if (!queuedDispatchSawResponseRef.current) return
|
||||
queuedDispatchInFlightRef.current = false
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
}
|
||||
|
||||
const nextMessage = messageQueue[0]
|
||||
if (!nextMessage) return
|
||||
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
truncateFromMessageIdRef.current = null
|
||||
pendingSendSettingsRef.current = {
|
||||
model: nextMessage.model,
|
||||
reasoningEffort: nextMessage.reasoningEffort,
|
||||
}
|
||||
pendingResponseModelsRef.current.push(nextMessage.model)
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
setMessageQueue((prev) =>
|
||||
prev[0]?.id === nextMessage.id
|
||||
? prev.slice(1)
|
||||
: prev.filter((item) => item.id !== nextMessage.id),
|
||||
)
|
||||
sendMessage({ text: nextMessage.text })
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
}, [
|
||||
fallbackChatId,
|
||||
messageQueue,
|
||||
scrollToBottom,
|
||||
sendMessage,
|
||||
setThreadId,
|
||||
status,
|
||||
threadId,
|
||||
])
|
||||
|
||||
// Scroll to bottom when a new user message is added or a thread is loaded
|
||||
useEffect(() => {
|
||||
const lastMessageId = messages[messages.length - 1]?.id ?? null
|
||||
|
|
@ -791,7 +1053,9 @@ export function ChatSidebar({
|
|||
const isStackedInput = layout === "page"
|
||||
const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
|
||||
const isResponding = status === "submitted" || status === "streaming"
|
||||
const showInputStatusStrip = !isStackedInput
|
||||
const showInputStatusStrip =
|
||||
!isStackedInput || isResponding || messages.length > 0
|
||||
const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT
|
||||
|
||||
const chatHistorySheet = (
|
||||
<Sheet
|
||||
|
|
@ -972,6 +1236,10 @@ export function ChatSidebar({
|
|||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
|
|
@ -1017,6 +1285,7 @@ export function ChatSidebar({
|
|||
? cn(
|
||||
"flex flex-col space-y-3 min-h-full justify-end",
|
||||
isPageDesktop || isMobile ? "pt-2" : "pt-14",
|
||||
isStackedInput && "px-4",
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
|
@ -1041,7 +1310,10 @@ export function ChatSidebar({
|
|||
<UserMessage
|
||||
message={message}
|
||||
copiedMessageId={copiedMessageId}
|
||||
selectedModel={selectedModel}
|
||||
reasoningEffort={reasoningEffort}
|
||||
onCopy={handleCopyMessage}
|
||||
onRegenerate={handleRegenerateFromUserMessage}
|
||||
/>
|
||||
) : (
|
||||
<AgentMessage
|
||||
|
|
@ -1052,6 +1324,7 @@ export function ChatSidebar({
|
|||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
expandedMemories={expandedMemories}
|
||||
responseModel={responseModelByMessageId[message.id] ?? null}
|
||||
onCopy={handleCopyMessage}
|
||||
onLike={handleLikeMessage}
|
||||
onDislike={handleDislikeMessage}
|
||||
|
|
@ -1151,13 +1424,18 @@ export function ChatSidebar({
|
|||
onStop={handleStop}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={isResponding}
|
||||
sendDisabled={isResponding && isQueueFull}
|
||||
sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`}
|
||||
activeStatus={
|
||||
status === "submitted"
|
||||
? "Thinking…"
|
||||
: status === "streaming"
|
||||
? "Structuring response…"
|
||||
: "Waiting for input…"
|
||||
isResponding && isQueueFull
|
||||
? `Queue full (${CHAT_QUEUE_LIMIT} max)`
|
||||
: status === "submitted"
|
||||
? "Thinking…"
|
||||
: status === "streaming"
|
||||
? "Structuring response…"
|
||||
: "Waiting for input…"
|
||||
}
|
||||
queuedMessages={messageQueue}
|
||||
showStatusStrip={showInputStatusStrip}
|
||||
onExpandedChange={setIsInputExpanded}
|
||||
chainOfThoughtComponent={
|
||||
|
|
@ -1171,6 +1449,10 @@ export function ChatSidebar({
|
|||
onModelChange={handleModelChange}
|
||||
minimal
|
||||
/>
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
|||
export function SendButton({
|
||||
onClick,
|
||||
disabled,
|
||||
disabledTooltip = "Type a message to send",
|
||||
}: {
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
disabledTooltip?: string
|
||||
}) {
|
||||
const button = (
|
||||
<button
|
||||
|
|
@ -31,7 +33,7 @@ export function SendButton({
|
|||
<title>Send Icon</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"
|
||||
fill="#9CA3AF"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -43,7 +45,7 @@ export function SendButton({
|
|||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Type a message to send</TooltipContent>
|
||||
<TooltipContent side="top">{disabledTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
"use client"
|
||||
|
||||
import { ChevronUpIcon } from "lucide-react"
|
||||
import { BrainIcon, ChevronUpIcon, ZapIcon } from "lucide-react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { SendButton, StopButton } from "./actions"
|
||||
import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models"
|
||||
|
||||
export interface QueuedChatMessagePreview {
|
||||
id: string
|
||||
text: string
|
||||
model: ModelId
|
||||
reasoningEffort: ReasoningEffort
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string
|
||||
|
|
@ -15,7 +23,10 @@ interface ChatInputProps {
|
|||
onStop: () => void
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void
|
||||
isResponding?: boolean
|
||||
sendDisabled?: boolean
|
||||
sendDisabledTooltip?: string
|
||||
activeStatus?: string
|
||||
queuedMessages?: QueuedChatMessagePreview[]
|
||||
chainOfThoughtComponent?: React.ReactNode
|
||||
onExpandedChange?: (expanded: boolean) => void
|
||||
/** Model + space controls on one row with send; textarea full-width above */
|
||||
|
|
@ -31,7 +42,10 @@ export default function ChatInput({
|
|||
onStop,
|
||||
onKeyDown,
|
||||
isResponding = false,
|
||||
sendDisabled = false,
|
||||
sendDisabledTooltip,
|
||||
activeStatus,
|
||||
queuedMessages = [],
|
||||
chainOfThoughtComponent,
|
||||
onExpandedChange,
|
||||
stackedToolbar,
|
||||
|
|
@ -40,6 +54,12 @@ export default function ChatInput({
|
|||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const isSendDisabled = !value.trim() || sendDisabled
|
||||
const hasQueuedPreview = queuedMessages.length > 0
|
||||
const resolvedSendDisabledTooltip =
|
||||
sendDisabled && value.trim()
|
||||
? sendDisabledTooltip
|
||||
: "Type a message to send"
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStatusStrip && isExpanded) {
|
||||
|
|
@ -101,7 +121,8 @@ export default function ChatInput({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
|
||||
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left transition-[padding] duration-200",
|
||||
hasQueuedPreview && "pb-1.5",
|
||||
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => {
|
||||
|
|
@ -126,10 +147,55 @@ export default function ChatInput({
|
|||
/>
|
||||
)}
|
||||
</button>
|
||||
{hasQueuedPreview && (
|
||||
<div className="flex flex-col gap-1 px-3 pr-4 pb-3">
|
||||
<AnimatePresence initial={false}>
|
||||
{queuedMessages.map((queued) => {
|
||||
const model = modelNames[queued.model]
|
||||
const ReasoningIcon =
|
||||
queued.reasoningEffort === "thinking" ? BrainIcon : ZapIcon
|
||||
return (
|
||||
<motion.div
|
||||
key={queued.id}
|
||||
layout
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 px-2.5 py-1">
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-xs text-white/35",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{queued.text}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 text-[10px] text-white/28",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{model.name} {model.version}
|
||||
</span>
|
||||
<span className="text-white/18">·</span>
|
||||
<ReasoningIcon className="size-3 shrink-0 text-white/30" />
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{stackedToolbar ? (
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
|
||||
<div className="relative z-30 flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
@ -139,17 +205,19 @@ export default function ChatInput({
|
|||
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{stackedToolbar}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{isResponding && <StopButton onClick={onStop} />}
|
||||
{(!isResponding || value.trim()) && (
|
||||
<SendButton
|
||||
onClick={onSend}
|
||||
disabled={isSendDisabled}
|
||||
disabledTooltip={resolvedSendDisabledTooltip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,13 +238,15 @@ export default function ChatInput({
|
|||
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="transition-all duration-200">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
<div className="flex items-center gap-1.5 transition-all duration-200">
|
||||
{isResponding && <StopButton onClick={onStop} />}
|
||||
{(!isResponding || value.trim()) && (
|
||||
<SendButton
|
||||
onClick={onSend}
|
||||
disabled={isSendDisabled}
|
||||
disabledTooltip={resolvedSendDisabledTooltip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
|
||||
import { modelNames, type ModelId } from "@/lib/models"
|
||||
import { RelatedMemories } from "./related-memories"
|
||||
import { MessageActions } from "./message-actions"
|
||||
|
||||
|
|
@ -313,6 +314,7 @@ interface AgentMessageProps {
|
|||
copiedMessageId: string | null
|
||||
messageFeedback: Record<string, "like" | "dislike" | null>
|
||||
expandedMemories: string | null
|
||||
responseModel: ModelId | null
|
||||
onCopy: (messageId: string, text: string) => void
|
||||
onLike: (messageId: string) => void
|
||||
onDislike: (messageId: string) => void
|
||||
|
|
@ -327,6 +329,7 @@ export function AgentMessage({
|
|||
copiedMessageId,
|
||||
messageFeedback,
|
||||
expandedMemories,
|
||||
responseModel,
|
||||
onCopy,
|
||||
onLike,
|
||||
onDislike,
|
||||
|
|
@ -339,6 +342,9 @@ export function AgentMessage({
|
|||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
const responseModelLabel = responseModel
|
||||
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
|
|
@ -442,17 +448,29 @@ export function AgentMessage({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<MessageActions
|
||||
messageId={message.id}
|
||||
messageText={messageText}
|
||||
isLastMessage={isLastAgentMessage}
|
||||
isHovered={isHovered}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
onCopy={onCopy}
|
||||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<MessageActions
|
||||
messageId={message.id}
|
||||
messageText={messageText}
|
||||
isLastMessage={isLastAgentMessage}
|
||||
isHovered={isHovered}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
onCopy={onCopy}
|
||||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
{responseModelLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
|
||||
isHovered ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{responseModelLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,165 @@
|
|||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { Copy, Check } from "lucide-react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/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 { SendButton } from "../input/actions"
|
||||
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>
|
||||
</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" />
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="edit"
|
||||
initial={{ opacity: 0, scaleX: 0.4, scaleY: 0.6 }}
|
||||
animate={{ opacity: 1, scaleX: 1, scaleY: 1 }}
|
||||
exit={{ opacity: 0, scaleX: 0.4, scaleY: 0.6 }}
|
||||
transition={{ duration: 0.55, ease: [0.22, 0.68, 0.18, 1] }}
|
||||
className="relative z-20 w-full max-w-[88%] origin-right rounded-xl bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md"
|
||||
>
|
||||
<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
|
||||
dropdownDirection="up"
|
||||
/>
|
||||
<ReasoningSelector
|
||||
value={editReasoningEffort}
|
||||
onChange={setEditReasoningEffort}
|
||||
dropdownDirection="up"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SendButton
|
||||
onClick={submitEdit}
|
||||
disabled={!draft.trim()}
|
||||
disabledTooltip="Type a message to send"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Copy className="size-3.5 text-white/50" />
|
||||
<motion.div
|
||||
key="view"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="max-w-[80%] origin-top-right rounded-[12px] bg-[#1B1F24] p-3 px-[14px]"
|
||||
>
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ layout: { duration: 0.28, ease: [0.22, 1, 0.36, 1] } }}
|
||||
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>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,9 @@ 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 handleModelSelect = (modelId: ModelId) => {
|
||||
if (onModelChange) {
|
||||
|
|
@ -56,71 +61,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-[13px] 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-[1000]" : "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(
|
||||
"isolate absolute left-0 z-[1000] 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 border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "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-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{modelData.name}{" "}
|
||||
<span
|
||||
className={cn(
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/55",
|
||||
)}
|
||||
>
|
||||
{modelData.version}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-xs",
|
||||
isSelected ? "text-white/60" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
143
apps/web/components/chat/reasoning-selector.tsx
Normal file
143
apps/web/components/chat/reasoning-selector.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "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"
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex shrink-0 items-center",
|
||||
isOpen ? "z-[1000]" : "z-10",
|
||||
)}
|
||||
>
|
||||
<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 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" ? (
|
||||
<MoreHorizontalIcon className="size-3.5 text-white/50 hover:text-white/80" />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"isolate absolute left-0 z-[1000] 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="space-y-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.5 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/65",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</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