mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(mothership): message queueing for home chat (#3576)
* improvement(mothership): message queueing for home chat * fix(mothership): address PR review — move FileAttachmentForApi to types, defer onEditValueConsumed to effect, await sendMessage in sendNow * fix(mothership): replace updater side-effect with useEffect ref sync, move sendMessageRef to useLayoutEffect * fix(mothership): clear message queue on chat switch while sending * fix(mothership): remove stale isSending from handleKeyDown deps * fix(mothership): guard sendNow against double-click duplicate sends * fix(mothership): simplify queue callbacks — drop redundant deps and guard ref - Remove `setMessageQueue` from useCallback deps (stable setter, never changes) - Replace `sendNowProcessingRef` double-click guard with eager `messageQueueRef` update - Simplify `editQueuedMessage` with same eager-ref pattern for consistency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mothership): clear edit value on nav, stop queue drain on send failure - Reset editingInputValue when chatId changes so stale edit text doesn't leak into the next chat - Pass error flag to finalize so queue is cleared (not drained) when sendMessage fails — prevents cascading failures on auth expiry or rate limiting from silently consuming every queued message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mothership): eagerly update messageQueueRef in removeFromQueue Match the pattern used by sendNow and editQueuedMessage — update the ref synchronously so finalize's microtask cannot read a stale queue and drain a message the user just removed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mothership): mark onSendNow as explicit fire-and-forget Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export { MessageContent } from './message-content'
|
||||
export { MothershipView } from './mothership-view'
|
||||
export { QueuedMessages } from './queued-messages'
|
||||
export { TemplatePrompts } from './template-prompts'
|
||||
export { UserInput } from './user-input'
|
||||
export { UserMessageContent } from './user-message-content'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { QueuedMessages } from './queued-messages'
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
interface QueuedMessagesProps {
|
||||
messageQueue: QueuedMessage[]
|
||||
onRemove: (id: string) => void
|
||||
onSendNow: (id: string) => Promise<void>
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: QueuedMessagesProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
if (messageQueue.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='-mb-[12px] mx-[14px] overflow-hidden rounded-t-[16px] border border-[var(--border-1)] border-b-0 bg-[var(--surface-2)] pb-[12px] dark:bg-[var(--surface-3)]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className='flex w-full items-center gap-[6px] px-[14px] py-[8px] transition-colors hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
{messageQueue.length} Queued
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{messageQueue.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className='flex items-center gap-[8px] px-[14px] py-[6px] transition-colors hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] shrink-0 items-center justify-center'>
|
||||
<div className='h-[10px] w-[10px] rounded-full border-[1.5px] border-[var(--text-tertiary)]/40' />
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-[13px] text-[var(--text-primary)]'>{msg.content}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-[2px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit(msg.id)
|
||||
}}
|
||||
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
|
||||
>
|
||||
<Pencil className='h-[13px] w-[13px]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' sideOffset={4}>
|
||||
Edit queued message
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void onSendNow(msg.id)
|
||||
}}
|
||||
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
|
||||
>
|
||||
<ArrowUp className='h-[13px] w-[13px]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' sideOffset={4}>
|
||||
Send now
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(msg.id)
|
||||
}}
|
||||
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
|
||||
>
|
||||
<Trash2 className='h-[13px] w-[13px]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' sideOffset={4}>
|
||||
Remove from queue
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,10 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
|
||||
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type {
|
||||
FileAttachmentForApi,
|
||||
MothershipResource,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import {
|
||||
useContextManagement,
|
||||
useFileAttachments,
|
||||
@@ -125,9 +128,17 @@ function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>, maxHeight:
|
||||
function mapResourceToContext(resource: MothershipResource): ChatContext {
|
||||
switch (resource.type) {
|
||||
case 'workflow':
|
||||
return { kind: 'workflow', workflowId: resource.id, label: resource.title }
|
||||
return {
|
||||
kind: 'workflow',
|
||||
workflowId: resource.id,
|
||||
label: resource.title,
|
||||
}
|
||||
case 'knowledgebase':
|
||||
return { kind: 'knowledge', knowledgeId: resource.id, label: resource.title }
|
||||
return {
|
||||
kind: 'knowledge',
|
||||
knowledgeId: resource.id,
|
||||
label: resource.title,
|
||||
}
|
||||
case 'table':
|
||||
return { kind: 'table', tableId: resource.id, label: resource.title }
|
||||
case 'file':
|
||||
@@ -137,16 +148,12 @@ function mapResourceToContext(resource: MothershipResource): ChatContext {
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileAttachmentForApi {
|
||||
id: string
|
||||
key: string
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
}
|
||||
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
interface UserInputProps {
|
||||
defaultValue?: string
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
onSubmit: (
|
||||
text: string,
|
||||
fileAttachments?: FileAttachmentForApi[],
|
||||
@@ -161,6 +168,8 @@ interface UserInputProps {
|
||||
|
||||
export function UserInput({
|
||||
defaultValue = '',
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
onSubmit,
|
||||
isSending,
|
||||
onStopGeneration,
|
||||
@@ -176,9 +185,27 @@ export function UserInput({
|
||||
const [plusMenuActiveIndex, setPlusMenuActiveIndex] = useState(0)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue)
|
||||
if (defaultValue && defaultValue !== prevDefaultValue) {
|
||||
setPrevDefaultValue(defaultValue)
|
||||
setValue(defaultValue)
|
||||
} else if (!defaultValue && prevDefaultValue) {
|
||||
setPrevDefaultValue(defaultValue)
|
||||
}
|
||||
|
||||
const [prevEditValue, setPrevEditValue] = useState(editValue)
|
||||
if (editValue && editValue !== prevEditValue) {
|
||||
setPrevEditValue(editValue)
|
||||
setValue(editValue)
|
||||
} else if (!editValue && prevEditValue) {
|
||||
setPrevEditValue(editValue)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue) setValue(defaultValue)
|
||||
}, [defaultValue])
|
||||
if (editValue) {
|
||||
onEditValueConsumed?.()
|
||||
}
|
||||
}, [editValue, onEditValueConsumed])
|
||||
|
||||
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
|
||||
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
|
||||
@@ -393,9 +420,7 @@ export function UserInput({
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
if (!isSending) {
|
||||
handleSubmit()
|
||||
}
|
||||
handleSubmit()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -461,7 +486,7 @@ export function UserInput({
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSending, mentionTokensWithContext, value, textareaRef]
|
||||
[handleSubmit, mentionTokensWithContext, value, textareaRef]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -637,7 +662,9 @@ export function UserInput({
|
||||
<span
|
||||
key={`mention-${i}-${range.start}-${range.end}`}
|
||||
className='rounded-[5px] bg-[var(--surface-5)] py-[2px]'
|
||||
style={{ boxShadow: '-2px 0 0 var(--surface-5), 2px 0 0 var(--surface-5)' }}
|
||||
style={{
|
||||
boxShadow: '-2px 0 0 var(--surface-5), 2px 0 0 var(--surface-5)',
|
||||
}}
|
||||
>
|
||||
<span className='relative'>
|
||||
<span className='invisible'>{range.token.charAt(0)}</span>
|
||||
@@ -662,7 +689,7 @@ export function UserInput({
|
||||
<div
|
||||
onClick={handleContainerClick}
|
||||
className={cn(
|
||||
'relative mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
|
||||
'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
|
||||
isInitialView && 'shadow-sm'
|
||||
)}
|
||||
onDragEnter={files.handleDragEnter}
|
||||
@@ -818,7 +845,11 @@ export function UserInput({
|
||||
)}
|
||||
onMouseEnter={() => setPlusMenuActiveIndex(index)}
|
||||
onClick={() => {
|
||||
handleResourceSelect({ type, id: item.id, title: item.name })
|
||||
handleResourceSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
setPlusMenuOpen(false)
|
||||
setPlusMenuSearch('')
|
||||
setPlusMenuActiveIndex(0)
|
||||
|
||||
@@ -19,14 +19,14 @@ import type { ChatContext } from '@/stores/panel'
|
||||
import {
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
QueuedMessages,
|
||||
TemplatePrompts,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from './components'
|
||||
import { PendingTagIndicator } from './components/message-content/components/special-tags'
|
||||
import type { FileAttachmentForApi } from './components/user-input/user-input'
|
||||
import { useAutoScroll, useChat } from './hooks'
|
||||
import type { MothershipResource, MothershipResourceType } from './types'
|
||||
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
|
||||
@@ -183,8 +183,29 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
addResource,
|
||||
removeResource,
|
||||
reorderResources,
|
||||
messageQueue,
|
||||
removeFromQueue,
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const clearEditingValue = useCallback(() => setEditingInputValue(''), [])
|
||||
|
||||
const handleEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const msg = editQueuedMessage(id)
|
||||
if (msg) {
|
||||
setEditingInputValue(msg.content)
|
||||
}
|
||||
},
|
||||
[editQueuedMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setEditingInputValue('')
|
||||
}, [chatId])
|
||||
|
||||
useEffect(() => {
|
||||
wasSendingRef.current = false
|
||||
if (resolvedChatId) markRead(resolvedChatId)
|
||||
@@ -419,6 +440,12 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={removeFromQueue}
|
||||
onSendNow={sendNow}
|
||||
onEdit={handleEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
@@ -426,6 +453,8 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePathname } from 'next/navigation'
|
||||
@@ -28,14 +28,15 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { FileAttachmentForApi } from '../components/user-input/user-input'
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ContentBlock,
|
||||
ContentBlockType,
|
||||
FileAttachmentForApi,
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
QueuedMessage,
|
||||
SSEPayload,
|
||||
SSEPayloadData,
|
||||
ToolCallStatus,
|
||||
@@ -58,6 +59,10 @@ export interface UseChatReturn {
|
||||
addResource: (resource: MothershipResource) => boolean
|
||||
removeResource: (resourceType: MothershipResourceType, resourceId: string) => void
|
||||
reorderResources: (resources: MothershipResource[]) => void
|
||||
messageQueue: QueuedMessage[]
|
||||
removeFromQueue: (id: string) => void
|
||||
sendNow: (id: string) => Promise<void>
|
||||
editQueuedMessage: (id: string) => QueuedMessage | undefined
|
||||
}
|
||||
|
||||
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
|
||||
@@ -101,7 +106,11 @@ function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock {
|
||||
displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined,
|
||||
result:
|
||||
tc.result != null
|
||||
? { success: tc.status === 'success', output: tc.result, error: tc.error }
|
||||
? {
|
||||
success: tc.status === 'success',
|
||||
output: tc.result,
|
||||
error: tc.error,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
@@ -252,6 +261,14 @@ export function useChat(
|
||||
const activeResourceIdRef = useRef(activeResourceId)
|
||||
activeResourceIdRef.current = activeResourceId
|
||||
|
||||
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
|
||||
const messageQueueRef = useRef<QueuedMessage[]>([])
|
||||
useEffect(() => {
|
||||
messageQueueRef.current = messageQueue
|
||||
}, [messageQueue])
|
||||
|
||||
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const chatIdRef = useRef<string | undefined>(initialChatId)
|
||||
const appliedChatIdRef = useRef<string | undefined>(undefined)
|
||||
@@ -303,6 +320,7 @@ export function useChat(
|
||||
if (sendingRef.current) {
|
||||
chatIdRef.current = initialChatId
|
||||
setResolvedChatId(initialChatId)
|
||||
setMessageQueue([])
|
||||
return
|
||||
}
|
||||
chatIdRef.current = initialChatId
|
||||
@@ -313,6 +331,7 @@ export function useChat(
|
||||
setIsSending(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setMessageQueue([])
|
||||
}, [initialChatId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -329,6 +348,7 @@ export function useChat(
|
||||
setIsSending(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setMessageQueue([])
|
||||
}, [isHomePage])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -419,7 +439,9 @@ export function useChat(
|
||||
const isNewChat = !chatIdRef.current
|
||||
chatIdRef.current = parsed.chatId
|
||||
setResolvedChatId(parsed.chatId)
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.list(workspaceId),
|
||||
})
|
||||
if (isNewChat) {
|
||||
const userMsg = pendingUserMsgRef.current
|
||||
const activeStreamId = streamIdRef.current
|
||||
@@ -427,7 +449,13 @@ export function useChat(
|
||||
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(parsed.chatId), {
|
||||
id: parsed.chatId,
|
||||
title: null,
|
||||
messages: [{ id: userMsg.id, role: 'user', content: userMsg.content }],
|
||||
messages: [
|
||||
{
|
||||
id: userMsg.id,
|
||||
role: 'user',
|
||||
content: userMsg.content,
|
||||
},
|
||||
],
|
||||
activeStreamId,
|
||||
resources: [],
|
||||
})
|
||||
@@ -619,7 +647,9 @@ export function useChat(
|
||||
break
|
||||
}
|
||||
case 'title_updated': {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.list(workspaceId),
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
@@ -689,17 +719,37 @@ export function useChat(
|
||||
const invalidateChatQueries = useCallback(() => {
|
||||
const activeChatId = chatIdRef.current
|
||||
if (activeChatId) {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.detail(activeChatId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.detail(activeChatId),
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
}, [workspaceId, queryClient])
|
||||
|
||||
const finalize = useCallback(() => {
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
abortControllerRef.current = null
|
||||
invalidateChatQueries()
|
||||
}, [invalidateChatQueries])
|
||||
const finalize = useCallback(
|
||||
(options?: { error?: boolean }) => {
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
abortControllerRef.current = null
|
||||
invalidateChatQueries()
|
||||
|
||||
if (options?.error) {
|
||||
setMessageQueue([])
|
||||
return
|
||||
}
|
||||
|
||||
const next = messageQueueRef.current[0]
|
||||
if (next) {
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== next.id))
|
||||
const gen = streamGenRef.current
|
||||
queueMicrotask(() => {
|
||||
if (streamGenRef.current !== gen) return
|
||||
sendMessageRef.current(next.content, next.fileAttachments, next.contexts)
|
||||
})
|
||||
}
|
||||
},
|
||||
[invalidateChatQueries]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const activeStreamId = chatHistory?.activeStreamId
|
||||
@@ -714,7 +764,12 @@ export function useChat(
|
||||
const assistantId = crypto.randomUUID()
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: assistantId, role: 'assistant' as const, content: '', contentBlocks: [] },
|
||||
{
|
||||
id: assistantId,
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
contentBlocks: [],
|
||||
},
|
||||
])
|
||||
|
||||
const reconnect = async () => {
|
||||
@@ -745,9 +800,15 @@ export function useChat(
|
||||
if (!message.trim() || !workspaceId) return
|
||||
|
||||
if (sendingRef.current) {
|
||||
await persistPartialResponse()
|
||||
const queued: QueuedMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
content: message,
|
||||
fileAttachments,
|
||||
contexts,
|
||||
}
|
||||
setMessageQueue((prev) => [...prev, queued])
|
||||
return
|
||||
}
|
||||
abortControllerRef.current?.abort()
|
||||
|
||||
const gen = ++streamGenRef.current
|
||||
|
||||
@@ -854,14 +915,20 @@ export function useChat(
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message')
|
||||
} finally {
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize()
|
||||
finalize({ error: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize()
|
||||
}
|
||||
},
|
||||
[workspaceId, queryClient, processSSEStream, finalize, persistPartialResponse]
|
||||
[workspaceId, queryClient, processSSEStream, finalize]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
sendMessageRef.current = sendMessage
|
||||
})
|
||||
|
||||
const stopGeneration = useCallback(async () => {
|
||||
if (sendingRef.current) {
|
||||
@@ -943,6 +1010,32 @@ export function useChat(
|
||||
}
|
||||
}, [invalidateChatQueries, persistPartialResponse, executionStream])
|
||||
|
||||
const removeFromQueue = useCallback((id: string) => {
|
||||
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
|
||||
}, [])
|
||||
|
||||
const sendNow = useCallback(
|
||||
async (id: string) => {
|
||||
const msg = messageQueueRef.current.find((m) => m.id === id)
|
||||
if (!msg) return
|
||||
// Eagerly update ref so a rapid second click finds the message already gone
|
||||
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
|
||||
await stopGeneration()
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
|
||||
await sendMessage(msg.content, msg.fileAttachments, msg.contexts)
|
||||
},
|
||||
[stopGeneration, sendMessage]
|
||||
)
|
||||
|
||||
const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => {
|
||||
const msg = messageQueueRef.current.find((m) => m.id === id)
|
||||
if (!msg) return undefined
|
||||
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
|
||||
return msg
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamGenRef.current++
|
||||
@@ -968,5 +1061,9 @@ export function useChat(
|
||||
addResource,
|
||||
removeResource,
|
||||
reorderResources,
|
||||
messageQueue,
|
||||
removeFromQueue,
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import type { MothershipResourceType } from '@/lib/copilot/resource-types'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
export type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resource-types'
|
||||
export type {
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
} from '@/lib/copilot/resource-types'
|
||||
|
||||
export interface FileAttachmentForApi {
|
||||
id: string
|
||||
key: string
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string
|
||||
content: string
|
||||
fileAttachments?: FileAttachmentForApi[]
|
||||
contexts?: ChatContext[]
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE event types emitted by the Go orchestrator backend.
|
||||
@@ -203,38 +222,122 @@ export interface ToolUIMetadata {
|
||||
* fallback metadata for tools that arrive via `tool_generating` without `ui`.
|
||||
*/
|
||||
export const TOOL_UI_METADATA: Partial<Record<MothershipToolName, ToolUIMetadata>> = {
|
||||
glob: { title: 'Searching files', phaseLabel: 'Workspace', phase: 'workspace' },
|
||||
grep: { title: 'Searching code', phaseLabel: 'Workspace', phase: 'workspace' },
|
||||
glob: {
|
||||
title: 'Searching files',
|
||||
phaseLabel: 'Workspace',
|
||||
phase: 'workspace',
|
||||
},
|
||||
grep: {
|
||||
title: 'Searching code',
|
||||
phaseLabel: 'Workspace',
|
||||
phase: 'workspace',
|
||||
},
|
||||
read: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' },
|
||||
search_online: { title: 'Searching online', phaseLabel: 'Search', phase: 'search' },
|
||||
scrape_page: { title: 'Scraping page', phaseLabel: 'Search', phase: 'search' },
|
||||
get_page_contents: { title: 'Getting page contents', phaseLabel: 'Search', phase: 'search' },
|
||||
search_library_docs: { title: 'Searching library docs', phaseLabel: 'Search', phase: 'search' },
|
||||
manage_mcp_tool: { title: 'Managing MCP tool', phaseLabel: 'Management', phase: 'management' },
|
||||
manage_skill: { title: 'Managing skill', phaseLabel: 'Management', phase: 'management' },
|
||||
user_memory: { title: 'Accessing memory', phaseLabel: 'Management', phase: 'management' },
|
||||
function_execute: { title: 'Running code', phaseLabel: 'Code', phase: 'execution' },
|
||||
superagent: { title: 'Executing action', phaseLabel: 'Action', phase: 'execution' },
|
||||
user_table: { title: 'Managing table', phaseLabel: 'Resource', phase: 'resource' },
|
||||
workspace_file: { title: 'Managing file', phaseLabel: 'Resource', phase: 'resource' },
|
||||
create_workflow: { title: 'Creating workflow', phaseLabel: 'Resource', phase: 'resource' },
|
||||
edit_workflow: { title: 'Editing workflow', phaseLabel: 'Resource', phase: 'resource' },
|
||||
search_online: {
|
||||
title: 'Searching online',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
scrape_page: {
|
||||
title: 'Scraping page',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
get_page_contents: {
|
||||
title: 'Getting page contents',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
search_library_docs: {
|
||||
title: 'Searching library docs',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
manage_mcp_tool: {
|
||||
title: 'Managing MCP tool',
|
||||
phaseLabel: 'Management',
|
||||
phase: 'management',
|
||||
},
|
||||
manage_skill: {
|
||||
title: 'Managing skill',
|
||||
phaseLabel: 'Management',
|
||||
phase: 'management',
|
||||
},
|
||||
user_memory: {
|
||||
title: 'Accessing memory',
|
||||
phaseLabel: 'Management',
|
||||
phase: 'management',
|
||||
},
|
||||
function_execute: {
|
||||
title: 'Running code',
|
||||
phaseLabel: 'Code',
|
||||
phase: 'execution',
|
||||
},
|
||||
superagent: {
|
||||
title: 'Executing action',
|
||||
phaseLabel: 'Action',
|
||||
phase: 'execution',
|
||||
},
|
||||
user_table: {
|
||||
title: 'Managing table',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
workspace_file: {
|
||||
title: 'Managing file',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
create_workflow: {
|
||||
title: 'Creating workflow',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
edit_workflow: {
|
||||
title: 'Editing workflow',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
build: { title: 'Building', phaseLabel: 'Build', phase: 'subagent' },
|
||||
run: { title: 'Running', phaseLabel: 'Run', phase: 'subagent' },
|
||||
deploy: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' },
|
||||
auth: { title: 'Connecting credentials', phaseLabel: 'Auth', phase: 'subagent' },
|
||||
knowledge: { title: 'Managing knowledge', phaseLabel: 'Knowledge', phase: 'subagent' },
|
||||
knowledge_base: { title: 'Managing knowledge base', phaseLabel: 'Resource', phase: 'resource' },
|
||||
auth: {
|
||||
title: 'Connecting credentials',
|
||||
phaseLabel: 'Auth',
|
||||
phase: 'subagent',
|
||||
},
|
||||
knowledge: {
|
||||
title: 'Managing knowledge',
|
||||
phaseLabel: 'Knowledge',
|
||||
phase: 'subagent',
|
||||
},
|
||||
knowledge_base: {
|
||||
title: 'Managing knowledge base',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
table: { title: 'Managing tables', phaseLabel: 'Table', phase: 'subagent' },
|
||||
job: { title: 'Managing jobs', phaseLabel: 'Job', phase: 'subagent' },
|
||||
agent: { title: 'Agent action', phaseLabel: 'Agent', phase: 'subagent' },
|
||||
custom_tool: { title: 'Creating tool', phaseLabel: 'Tool', phase: 'subagent' },
|
||||
custom_tool: {
|
||||
title: 'Creating tool',
|
||||
phaseLabel: 'Tool',
|
||||
phase: 'subagent',
|
||||
},
|
||||
research: { title: 'Researching', phaseLabel: 'Research', phase: 'subagent' },
|
||||
plan: { title: 'Planning', phaseLabel: 'Plan', phase: 'subagent' },
|
||||
debug: { title: 'Debugging', phaseLabel: 'Debug', phase: 'subagent' },
|
||||
edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' },
|
||||
fast_edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' },
|
||||
open_resource: { title: 'Opening resource', phaseLabel: 'Resource', phase: 'resource' },
|
||||
fast_edit: {
|
||||
title: 'Editing workflow',
|
||||
phaseLabel: 'Edit',
|
||||
phase: 'subagent',
|
||||
},
|
||||
open_resource: {
|
||||
title: 'Opening resource',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
}
|
||||
|
||||
export interface SSEPayloadUI {
|
||||
|
||||
Reference in New Issue
Block a user