improvement(mothership): chat stability

This commit is contained in:
Emir Karabeg
2026-03-09 15:40:43 -07:00
parent fe5f809e1a
commit 917af6d141
5 changed files with 58 additions and 46 deletions

View File

@@ -101,10 +101,10 @@ export function UserInput({
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
if (!isSending) handleSubmit()
}
},
[handleSubmit]
[handleSubmit, isSending]
)
const toggleListening = useCallback(() => {

View File

@@ -145,7 +145,7 @@ export function Home({ chatId }: HomeProps = {}) {
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='min-h-0 flex-1 overflow-y-auto px-[24px] py-[16px]'>
<div className='mx-auto max-w-[640px] space-y-[16px]'>
{messages.map((msg) => {
{messages.map((msg, index) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex justify-end'>
@@ -159,7 +159,8 @@ export function Home({ chatId }: HomeProps = {}) {
}
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
const isThisStreaming = isSending && msg === messages[messages.length - 1]
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasBlocks && !msg.content && isThisStreaming) {
return (

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import { usePathname } from 'next/navigation'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { tableKeys } from '@/hooks/queries/tables'
import {
@@ -116,8 +116,6 @@ function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
export function useChat(workspaceId: string, initialChatId?: string): UseChatReturn {
const pathname = usePathname()
const queryClient = useQueryClient()
const router = useRouter()
const routerRef = useRef(router)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -131,10 +129,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const streamIdRef = useRef<string | undefined>(undefined)
const sendingRef = useRef(false)
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
useEffect(() => {
routerRef.current = router
}, [router])
const streamGenRef = useRef(0)
const isHomePage = pathname.endsWith('/home')
@@ -157,6 +152,10 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
}, [])
useEffect(() => {
if (sendingRef.current) {
chatIdRef.current = initialChatId
return
}
chatIdRef.current = initialChatId
appliedChatIdRef.current = undefined
setMessages([])
@@ -168,10 +167,12 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
useEffect(() => {
if (!isHomePage || !chatIdRef.current) return
streamGenRef.current++
chatIdRef.current = undefined
appliedChatIdRef.current = undefined
abortControllerRef.current?.abort()
abortControllerRef.current = null
sendingRef.current = false
setMessages([])
setError(null)
setIsSending(false)
@@ -252,7 +253,11 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
activeStreamId,
})
}
routerRef.current.replace(`/workspace/${workspaceId}/task/${parsed.chatId}`)
window.history.replaceState(
null,
'',
`/workspace/${workspaceId}/task/${parsed.chatId}`
)
}
}
break
@@ -407,6 +412,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
)
const finalize = useCallback(() => {
sendingRef.current = false
setIsSending(false)
abortControllerRef.current = null
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -422,8 +428,10 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const activeStreamId = chatHistory?.activeStreamId
if (!activeStreamId || !appliedChatIdRef.current || sendingRef.current) return
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
sendingRef.current = true
setIsSending(true)
const assistantId = crypto.randomUUID()
@@ -442,7 +450,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
} finally {
finalize()
if (streamGenRef.current === gen) {
finalize()
}
}
}
reconnect()
@@ -468,6 +478,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
abortControllerRef.current?.abort()
const gen = ++streamGenRef.current
setError(null)
setIsSending(true)
sendingRef.current = true
@@ -530,16 +542,35 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to send message')
} finally {
sendingRef.current = false
finalize()
if (streamGenRef.current === gen) {
finalize()
}
}
},
[workspaceId, queryClient, processSSEStream, finalize]
)
const stopGeneration = useCallback(() => {
streamGenRef.current++
abortControllerRef.current?.abort()
abortControllerRef.current = null
sendingRef.current = false
setIsSending(false)
const activeChatId = chatIdRef.current
if (activeChatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(activeChatId) })
}
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
}, [workspaceId, queryClient])
useEffect(() => {
return () => {
streamGenRef.current++
abortControllerRef.current?.abort()
abortControllerRef.current = null
sendingRef.current = false
}
}, [])
return {

View File

@@ -166,6 +166,7 @@ export function Table({
const [renameValue, setRenameValue] = useState('')
const [deletingColumn, setDeletingColumn] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isDraggingRef = useRef(false)
const { tableData, isLoadingTable, rows, isLoadingRows } = useTableData({
@@ -362,12 +363,6 @@ export function Table({
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return
if (column.type === 'json') {
const row = rowsRef.current.find((r) => r.id === rowId)
if (row) setEditingRow(row)
return
}
if (column.type === 'boolean') {
const row = rowsRef.current.find((r) => r.id === rowId)
if (row) {
@@ -393,6 +388,8 @@ export function Table({
const anchor = selectionAnchorRef.current
if (!anchor || editingCellRef.current || editingEmptyCellRef.current) return
if (!containerRef.current?.contains(e.target as Node)) return
const cols = columnsRef.current
const dataRows = visibleRowsRef.current
const totalRows = dataRows.length + PLACEHOLDER_ROW_COUNT
@@ -558,7 +555,7 @@ export function Table({
if (selectionFocusRef.current !== null) return
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column || column.type === 'json' || column.type === 'boolean') return
if (!column || column.type === 'boolean') return
setEditingEmptyCell({ rowIndex, columnName })
}, [])
@@ -738,7 +735,7 @@ export function Table({
}
return (
<div className='flex h-full flex-col'>
<div ref={containerRef} className='flex h-full flex-col'>
{!embedded && (
<>
<ResourceHeader
@@ -1290,27 +1287,15 @@ function CellContent({
const isNull = value === null || value === undefined
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{isNull ? '' : boolValue ? 'true' : 'false'}
</span>
)
if (isNull) return null
return <span className='text-[var(--text-primary)]'>{value ? 'true' : 'false'}</span>
}
if (isNull) return null
if (column.type === 'json') {
return (
<span className='block truncate font-mono text-[11px] text-[var(--text-secondary)]'>
{JSON.stringify(value)}
</span>
)
}
if (column.type === 'number') {
return (
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
<span className='block truncate text-[var(--text-primary)]'>{JSON.stringify(value)}</span>
)
}
@@ -1324,7 +1309,7 @@ function CellContent({
hour: '2-digit',
minute: '2-digit',
})
return <span className='text-[12px] text-[var(--text-secondary)]'>{formatted}</span>
return <span className='text-[var(--text-primary)]'>{formatted}</span>
} catch {
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
@@ -1399,12 +1384,7 @@ function InlineEditor({
onKeyDown={handleKeyDown}
onBlur={handleSave}
className={cn(
'w-full min-w-0 select-text border-none bg-transparent p-0 outline-none',
column.type === 'number'
? 'font-mono text-[12px] text-[var(--text-secondary)]'
: column.type === 'date'
? 'text-[12px] text-[var(--text-secondary)]'
: 'text-[13px] text-[var(--text-primary)]'
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[13px] text-[var(--text-primary)] outline-none'
)}
/>
)

View File

@@ -49,7 +49,7 @@ export function cleanCellValue(value: unknown, column: ColumnDefinition): unknow
export function formatValueForInput(value: unknown, type: string): string {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return typeof value === 'string' ? value : JSON.stringify(value)
}
if (type === 'date' && value) {
try {