mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
fix ui and add queue feature
This commit is contained in:
parent
f93b1f906f
commit
120bb2c195
5 changed files with 212 additions and 31 deletions
|
|
@ -101,6 +101,15 @@ export function ChatLaunchFab({
|
|||
)
|
||||
}
|
||||
|
||||
type QueuedChatMessage = {
|
||||
id: string
|
||||
text: string
|
||||
model: ModelId
|
||||
reasoningEffort: ReasoningEffort
|
||||
}
|
||||
|
||||
const CHAT_QUEUE_LIMIT = 5
|
||||
|
||||
export function ChatSidebar({
|
||||
isChatOpen,
|
||||
setIsChatOpen,
|
||||
|
|
@ -140,6 +149,9 @@ export function ChatSidebar({
|
|||
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<
|
||||
|
|
@ -368,12 +380,35 @@ 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 })
|
||||
sendMessage({ text })
|
||||
setInput("")
|
||||
userJustSentRef.current = true
|
||||
scrollToBottom()
|
||||
|
|
@ -445,6 +480,8 @@ export function ChatSidebar({
|
|||
pendingRegenerationRef.current = null
|
||||
setRegenerationBaseLength(null)
|
||||
analytics.chatMessageSent({ source: "typed" })
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
sendMessage({ text: pending.text })
|
||||
window.setTimeout(() => {
|
||||
truncateFromMessageIdRef.current = null
|
||||
|
|
@ -513,6 +550,9 @@ export function ChatSidebar({
|
|||
setThreadId(null)
|
||||
setFallbackChatId(newChatId)
|
||||
setInput("")
|
||||
setMessageQueue([])
|
||||
queuedDispatchInFlightRef.current = false
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
}, [setThreadId, setMessages])
|
||||
|
||||
const fetchThreads = useCallback(async () => {
|
||||
|
|
@ -568,6 +608,9 @@ export function ChatSidebar({
|
|||
)
|
||||
setThreadId(id)
|
||||
setPendingThreadLoad({ id, messages: uiMessages })
|
||||
setMessageQueue([])
|
||||
queuedDispatchInFlightRef.current = false
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
analytics.chatThreadLoaded({ thread_id: id })
|
||||
setIsHistoryOpen(false)
|
||||
setConfirmingDeleteId(null)
|
||||
|
|
@ -700,6 +743,8 @@ export function ChatSidebar({
|
|||
} else {
|
||||
truncateFromMessageIdRef.current = null
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
sendMessage({ text: queuedMessage })
|
||||
}
|
||||
onConsumeQueuedMessage?.()
|
||||
|
|
@ -753,6 +798,8 @@ export function ChatSidebar({
|
|||
const reply = pendingHighlightReplyRef.current
|
||||
pendingHighlightReplyRef.current = null
|
||||
truncateFromMessageIdRef.current = null
|
||||
queuedDispatchInFlightRef.current = true
|
||||
queuedDispatchSawResponseRef.current = false
|
||||
sendMessage({ text: reply })
|
||||
}
|
||||
}, [messages, sendMessage, status])
|
||||
|
|
@ -764,6 +811,56 @@ 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,
|
||||
}
|
||||
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
|
||||
|
|
@ -882,6 +979,7 @@ export function ChatSidebar({
|
|||
const isResponding = status === "submitted" || status === "streaming"
|
||||
const showInputStatusStrip =
|
||||
!isStackedInput || isResponding || messages.length > 0
|
||||
const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT
|
||||
|
||||
const chatHistorySheet = (
|
||||
<Sheet
|
||||
|
|
@ -1244,6 +1342,66 @@ export function ChatSidebar({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{messageQueue.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 rounded-xl border border-[#263348]/80 bg-[#0B1119]/95 p-2 shadow-[0_14px_36px_rgba(0,0,0,0.34)]",
|
||||
isPageDesktop ? "mx-0" : "mx-4",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3 px-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-5 min-w-5 items-center justify-center rounded-full bg-[#267BF1]/15 px-1.5 text-[11px] font-medium text-[#8DBDFF]">
|
||||
{messageQueue.length}
|
||||
</div>
|
||||
<p className="truncate text-xs font-medium text-white/75">
|
||||
Queued messages
|
||||
</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-[11px] text-white/38">
|
||||
{messageQueue.length}/{CHAT_QUEUE_LIMIT}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex max-h-36 flex-col gap-1 overflow-y-auto pr-1">
|
||||
{messageQueue.map((queued, index) => {
|
||||
const model = modelNames[queued.model]
|
||||
const reasoningLabel =
|
||||
queued.reasoningEffort === "thinking" ? "Thinking" : "Instant"
|
||||
return (
|
||||
<div
|
||||
key={queued.id}
|
||||
className="group flex min-h-10 items-center gap-2 rounded-lg border border-transparent bg-white/[0.035] px-2 py-1.5 transition-colors hover:border-[#263348]/90 hover:bg-white/[0.055]"
|
||||
>
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-[#101822] text-[11px] text-white/45">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm leading-5 text-white/88">
|
||||
{queued.text}
|
||||
</p>
|
||||
<p className="truncate text-[11px] leading-4 text-white/38">
|
||||
{model.name} {model.version} - {reasoningLabel}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMessageQueue((prev) =>
|
||||
prev.filter((item) => item.id !== queued.id),
|
||||
)
|
||||
}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-white/35 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
aria-label="Remove queued message"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
|
|
@ -1258,12 +1416,16 @@ export function ChatSidebar({
|
|||
onStop={stop}
|
||||
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…"
|
||||
}
|
||||
showStatusStrip={showInputStatusStrip}
|
||||
onExpandedChange={setIsInputExpanded}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ interface ChatInputProps {
|
|||
onStop: () => void
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void
|
||||
isResponding?: boolean
|
||||
sendDisabled?: boolean
|
||||
sendDisabledTooltip?: string
|
||||
activeStatus?: string
|
||||
chainOfThoughtComponent?: React.ReactNode
|
||||
onExpandedChange?: (expanded: boolean) => void
|
||||
|
|
@ -31,6 +33,8 @@ export default function ChatInput({
|
|||
onStop,
|
||||
onKeyDown,
|
||||
isResponding = false,
|
||||
sendDisabled = false,
|
||||
sendDisabledTooltip,
|
||||
activeStatus,
|
||||
chainOfThoughtComponent,
|
||||
onExpandedChange,
|
||||
|
|
@ -40,6 +44,11 @@ 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 resolvedSendDisabledTooltip =
|
||||
sendDisabled && value.trim()
|
||||
? sendDisabledTooltip
|
||||
: "Type a message to send"
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStatusStrip && isExpanded) {
|
||||
|
|
@ -139,17 +148,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 +181,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>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ 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) {
|
||||
|
|
@ -117,9 +119,9 @@ export default function ChatModelSelector({
|
|||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-[#E6E6E6] text-[#101010]"
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/10",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
|
|
@ -131,13 +133,13 @@ export default function ChatModelSelector({
|
|||
<div
|
||||
className={cn(
|
||||
"truncate text-[15px] font-medium",
|
||||
isSelected ? "text-[#101010]" : "text-white",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{modelData.name}{" "}
|
||||
<span
|
||||
className={cn(
|
||||
isSelected ? "text-[#101010]/60" : "text-white/55",
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/55",
|
||||
)}
|
||||
>
|
||||
{modelData.version}
|
||||
|
|
@ -146,14 +148,14 @@ export default function ChatModelSelector({
|
|||
<div
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-xs",
|
||||
isSelected ? "text-[#101010]/60" : "text-white/45",
|
||||
isSelected ? "text-white/60" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#101010]/70" />
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export function ReasoningSelector({
|
|||
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
|
||||
|
|
@ -105,30 +107,30 @@ export function ReasoningSelector({
|
|||
type="button"
|
||||
onClick={() => handleSelect(option.id)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||
"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
|
||||
? "bg-[#E6E6E6] text-[#101010]"
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isSelected ? "text-[#101010]/70" : "text-white/65",
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/65",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-[15px] font-medium",
|
||||
isSelected ? "text-[#101010]" : "text-white",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#101010]/70" />
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue