Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot

This commit is contained in:
Vikhyath Mondreti
2026-03-12 10:02:34 -07:00
3 changed files with 242 additions and 71 deletions

View File

@@ -167,12 +167,14 @@ export function Home({ chatId }: HomeProps = {}) {
sendMessage,
stopGeneration,
resources,
isResourceCleanupSettled,
activeResourceId,
setActiveResourceId,
} = useChat(workspaceId, chatId)
const [isResourceCollapsed, setIsResourceCollapsed] = useState(false)
const [showExpandButton, setShowExpandButton] = useState(false)
const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false)
useEffect(() => {
if (!isResourceCollapsed) {
@@ -186,16 +188,24 @@ export function Home({ chatId }: HomeProps = {}) {
const collapseResource = useCallback(() => setIsResourceCollapsed(true), [])
const expandResource = useCallback(() => setIsResourceCollapsed(false), [])
const prevResourceCountRef = useRef(resources.length)
const animateResourcePanel =
prevResourceCountRef.current === 0 && resources.length > 0 && isSending
const visibleResources = isResourceCleanupSettled ? resources : []
const prevResourceCountRef = useRef(visibleResources.length)
const shouldEnterResourcePanel =
isSending && prevResourceCountRef.current === 0 && visibleResources.length > 0
useEffect(() => {
if (animateResourcePanel) {
if (shouldEnterResourcePanel) {
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
if (!isCollapsed) toggleCollapsed()
setIsResourceAnimatingIn(true)
}
prevResourceCountRef.current = resources.length
})
prevResourceCountRef.current = visibleResources.length
}, [shouldEnterResourcePanel, visibleResources.length])
useEffect(() => {
if (!isResourceAnimatingIn) return
const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400)
return () => clearTimeout(timer)
}, [isResourceAnimatingIn])
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[]) => {
@@ -340,19 +350,19 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
</div>
{resources.length > 0 && (
{visibleResources.length > 0 && (
<MothershipView
workspaceId={workspaceId}
resources={resources}
resources={visibleResources}
activeResourceId={activeResourceId}
onSelectResource={setActiveResourceId}
onCollapse={collapseResource}
isCollapsed={isResourceCollapsed}
className={animateResourcePanel ? 'animate-slide-in-right' : undefined}
className={isResourceAnimatingIn ? 'animate-slide-in-right' : undefined}
/>
)}
{resources.length > 0 && showExpandButton && (
{visibleResources.length > 0 && showExpandButton && (
<div className='absolute top-[8.5px] right-[16px]'>
<button
type='button'

View File

@@ -6,16 +6,15 @@ const BOTTOM_THRESHOLD = 30
* Manages sticky auto-scroll for a streaming chat container.
*
* Stays pinned to the bottom while content streams in. Detaches when the user
* explicitly scrolls up (wheel, touch, or scrollbar drag). Re-attaches when
* the scroll position returns to within {@link BOTTOM_THRESHOLD} of the bottom.
* scrolls beyond {@link BOTTOM_THRESHOLD} from the bottom. Re-attaches when
* the scroll position returns within the threshold. Preserves bottom position
* across container resizes (e.g. sidebar collapse).
*/
export function useAutoScroll(isStreaming: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
const stickyRef = useRef(true)
const prevScrollTopRef = useRef(0)
const prevScrollHeightRef = useRef(0)
const touchStartYRef = useRef(0)
const atBottomRef = useRef(true)
const rafIdRef = useRef(0)
const teardownRef = useRef<(() => void) | null>(null)
const scrollToBottom = useCallback(() => {
const el = containerRef.current
@@ -24,8 +23,30 @@ export function useAutoScroll(isStreaming: boolean) {
}, [])
const callbackRef = useCallback((el: HTMLDivElement | null) => {
teardownRef.current?.()
teardownRef.current = null
containerRef.current = el
if (el) el.scrollTop = el.scrollHeight
if (!el) return
el.scrollTop = el.scrollHeight
atBottomRef.current = true
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
atBottomRef.current = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD
}
const ro = new ResizeObserver(() => {
if (atBottomRef.current) el.scrollTop = el.scrollHeight
})
el.addEventListener('scroll', onScroll, { passive: true })
ro.observe(el)
teardownRef.current = () => {
el.removeEventListener('scroll', onScroll)
ro.disconnect()
}
}, [])
useEffect(() => {
@@ -33,71 +54,26 @@ export function useAutoScroll(isStreaming: boolean) {
const el = containerRef.current
if (!el) return
stickyRef.current = true
prevScrollTopRef.current = el.scrollTop
prevScrollHeightRef.current = el.scrollHeight
atBottomRef.current = true
scrollToBottom()
const detach = () => {
stickyRef.current = false
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) detach()
}
const onTouchStart = (e: TouchEvent) => {
touchStartYRef.current = e.touches[0].clientY
}
const onTouchMove = (e: TouchEvent) => {
if (e.touches[0].clientY > touchStartYRef.current) detach()
}
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
if (distanceFromBottom <= BOTTOM_THRESHOLD) {
stickyRef.current = true
} else if (
scrollTop < prevScrollTopRef.current &&
scrollHeight <= prevScrollHeightRef.current
) {
stickyRef.current = false
}
prevScrollTopRef.current = scrollTop
prevScrollHeightRef.current = scrollHeight
}
const guardedScroll = () => {
if (stickyRef.current) scrollToBottom()
if (atBottomRef.current) scrollToBottom()
}
const onMutation = () => {
prevScrollHeightRef.current = el.scrollHeight
if (!stickyRef.current) return
if (!atBottomRef.current) return
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = requestAnimationFrame(guardedScroll)
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchmove', onTouchMove, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
const observer = new MutationObserver(onMutation)
observer.observe(el, { childList: true, subtree: true, characterData: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('scroll', onScroll)
observer.disconnect()
cancelAnimationFrame(rafIdRef.current)
if (stickyRef.current) scrollToBottom()
if (atBottomRef.current) scrollToBottom()
}
}, [isStreaming, scrollToBottom])

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
@@ -6,7 +6,7 @@ import { executeRunToolOnClient } from '@/lib/copilot/client-sse/run-tool-execut
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import { tableKeys, useTablesList } from '@/hooks/queries/tables'
import {
type TaskChatHistory,
type TaskStoredContentBlock,
@@ -16,7 +16,8 @@ import {
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflows, workflowKeys } from '@/hooks/queries/workflows'
import { useWorkspaceFiles, workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { FileAttachmentForApi } from '../components/user-input/user-input'
import type {
@@ -48,6 +49,7 @@ export interface UseChatReturn {
sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[]) => Promise<void>
stopGeneration: () => Promise<void>
resources: MothershipResource[]
isResourceCleanupSettled: boolean
activeResourceId: string | null
setActiveResourceId: (id: string | null) => void
}
@@ -57,6 +59,57 @@ const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
error: 'error',
} as const
function areResourcesEqual(left: MothershipResource[], right: MothershipResource[]): boolean {
if (left.length !== right.length) return false
return left.every(
(resource, index) =>
resource.id === right[index]?.id &&
resource.type === right[index]?.type &&
resource.title === right[index]?.title
)
}
function sanitizeResources(
resources: MothershipResource[],
existingFileIds: Set<string>,
existingTableIds: Set<string>,
existingWorkflowIds: Set<string>,
pendingFileIds: Set<string>,
pendingTableIds: Set<string>,
pendingWorkflowIds: Set<string>,
shouldFilterMissingFiles: boolean,
shouldFilterMissingTables: boolean,
shouldFilterMissingWorkflows: boolean
): MothershipResource[] {
return resources.filter((resource) => {
if (resource.type === 'file') {
if (pendingFileIds.has(resource.id)) {
return true
}
if (shouldFilterMissingFiles && !existingFileIds.has(resource.id)) {
return false
}
}
if (resource.type === 'table') {
if (pendingTableIds.has(resource.id)) {
return true
}
if (shouldFilterMissingTables && !existingTableIds.has(resource.id)) {
return false
}
}
if (resource.type === 'workflow') {
if (pendingWorkflowIds.has(resource.id)) {
return true
}
if (shouldFilterMissingWorkflows && !existingWorkflowIds.has(resource.id)) {
return false
}
}
return true
})
}
function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
const mapped: ContentBlock = {
type: block.type as ContentBlockType,
@@ -161,12 +214,55 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
const streamGenRef = useRef(0)
const streamingContentRef = useRef('')
const pendingFileResourceIdsRef = useRef<Set<string>>(new Set())
const pendingTableResourceIdsRef = useRef<Set<string>>(new Set())
const pendingWorkflowResourceIdsRef = useRef<Set<string>>(new Set())
const isHomePage = pathname.endsWith('/home')
const { data: chatHistory } = useChatHistory(initialChatId)
const {
data: workspaceFiles = [],
isLoading: isWorkspaceFilesLoading,
isError: isWorkspaceFilesError,
} = useWorkspaceFiles(workspaceId)
const {
data: workspaceTables = [],
isLoading: isWorkspaceTablesLoading,
isError: isWorkspaceTablesError,
} = useTablesList(workspaceId)
const {
data: workflows = [],
isLoading: isWorkflowsLoading,
isError: isWorkflowsError,
} = useWorkflows(workspaceId, { syncRegistry: false })
const existingWorkspaceFileIds = useMemo(
() => new Set(workspaceFiles.map((file) => file.id)),
[workspaceFiles]
)
const existingWorkspaceTableIds = useMemo(
() => new Set(workspaceTables.map((table) => table.id)),
[workspaceTables]
)
const existingWorkflowIds = useMemo(
() => new Set(workflows.map((workflow) => workflow.id)),
[workflows]
)
const isResourceCleanupSettled = useMemo(
() => !isWorkspaceFilesLoading && !isWorkspaceTablesLoading && !isWorkflowsLoading,
[isWorkspaceFilesLoading, isWorkspaceTablesLoading, isWorkflowsLoading]
)
const addResource = useCallback((resource: MothershipResource) => {
if (resource.type === 'file') {
pendingFileResourceIdsRef.current.add(resource.id)
} else if (resource.type === 'table') {
pendingTableResourceIdsRef.current.add(resource.id)
} else if (resource.type === 'workflow') {
pendingWorkflowResourceIdsRef.current.add(resource.id)
}
setResources((prev) => {
const existing = prev.find((r) => r.type === resource.type && r.id === resource.id)
if (existing) {
@@ -182,6 +278,30 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
setActiveResourceId(resource.id)
}, [])
useEffect(() => {
for (const id of pendingFileResourceIdsRef.current) {
if (existingWorkspaceFileIds.has(id)) {
pendingFileResourceIdsRef.current.delete(id)
}
}
}, [existingWorkspaceFileIds])
useEffect(() => {
for (const id of pendingTableResourceIdsRef.current) {
if (existingWorkspaceTableIds.has(id)) {
pendingTableResourceIdsRef.current.delete(id)
}
}
}, [existingWorkspaceTableIds])
useEffect(() => {
for (const id of pendingWorkflowResourceIdsRef.current) {
if (existingWorkflowIds.has(id)) {
pendingWorkflowResourceIdsRef.current.delete(id)
}
}
}, [existingWorkflowIds])
useEffect(() => {
if (sendingRef.current) {
chatIdRef.current = initialChatId
@@ -194,6 +314,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
setIsSending(false)
setResources([])
setActiveResourceId(null)
pendingFileResourceIdsRef.current.clear()
pendingTableResourceIdsRef.current.clear()
pendingWorkflowResourceIdsRef.current.clear()
}, [initialChatId])
useEffect(() => {
@@ -209,6 +332,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
setIsSending(false)
setResources([])
setActiveResourceId(null)
pendingFileResourceIdsRef.current.clear()
pendingTableResourceIdsRef.current.clear()
pendingWorkflowResourceIdsRef.current.clear()
}, [isHomePage])
useEffect(() => {
@@ -245,6 +371,51 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
}
}, [chatHistory, workspaceId])
useEffect(() => {
setResources((prev) => {
const shouldFilterMissingFiles = !isWorkspaceFilesLoading && !isWorkspaceFilesError
const shouldFilterMissingTables = !isWorkspaceTablesLoading && !isWorkspaceTablesError
const shouldFilterMissingWorkflows = !isWorkflowsLoading && !isWorkflowsError
const next = sanitizeResources(
prev,
existingWorkspaceFileIds,
existingWorkspaceTableIds,
existingWorkflowIds,
pendingFileResourceIdsRef.current,
pendingTableResourceIdsRef.current,
pendingWorkflowResourceIdsRef.current,
shouldFilterMissingFiles,
shouldFilterMissingTables,
shouldFilterMissingWorkflows
)
return areResourcesEqual(prev, next) ? prev : next
})
}, [
resources,
existingWorkspaceFileIds,
existingWorkspaceTableIds,
existingWorkflowIds,
isWorkspaceFilesError,
isWorkspaceFilesLoading,
isWorkspaceTablesError,
isWorkspaceTablesLoading,
isWorkflowsError,
isWorkflowsLoading,
])
useEffect(() => {
if (resources.length === 0) {
if (activeResourceId !== null) {
setActiveResourceId(null)
}
return
}
if (!activeResourceId || !resources.some((resource) => resource.id === activeResourceId)) {
setActiveResourceId(resources[resources.length - 1].id)
}
}, [activeResourceId, resources])
const processSSEStream = useCallback(
async (reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => {
const decoder = new TextDecoder()
@@ -256,6 +427,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
let lastTableId: string | null = null
let lastWorkflowId: string | null = null
let runningText = ''
let lastContentSource: 'main' | 'subagent' | null = null
streamingContentRef.current = ''
toolArgsMapRef.current.clear()
@@ -326,9 +498,17 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
case 'content': {
const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '')
if (chunk) {
const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main'
const needsBoundaryNewline =
lastContentSource !== null &&
lastContentSource !== contentSource &&
runningText.length > 0 &&
!runningText.endsWith('\n')
const tb = ensureTextBlock()
tb.content = (tb.content ?? '') + chunk
runningText += chunk
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
tb.content = (tb.content ?? '') + normalizedChunk
runningText += normalizedChunk
lastContentSource = contentSource
streamingContentRef.current = runningText
flush()
}
@@ -425,6 +605,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
resource = extractTableResource(parsed, storedArgs, lastTableId)
if (resource) {
lastTableId = resource.id
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
}
@@ -444,6 +625,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
if (resource) {
if (resource.type === 'table') {
lastTableId = resource.id
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
} else if (resource.type === 'file') {
@@ -459,6 +641,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
resource = extractFunctionExecuteResource(parsed, storedArgs)
if (resource?.type === 'table') {
lastTableId = resource.id
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
}
@@ -466,6 +649,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
resource = extractWorkflowResource(parsed, lastWorkflowId, storedArgs)
if (resource) {
lastWorkflowId = resource.id
queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
const registry = useWorkflowRegistry.getState()
if (!registry.workflows[resource.id]) {
useWorkflowRegistry.setState((state) => ({
@@ -761,6 +945,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
sendMessage,
stopGeneration,
resources,
isResourceCleanupSettled,
activeResourceId,
setActiveResourceId,
}