Compare commits

..

8 Commits

Author SHA1 Message Date
Waleed
7b572f1f61 v0.6.10: tour fix, connectors reliability improvements, tooltip gif fixes 2026-03-24 21:38:19 -07:00
Vikhyath Mondreti
b497033795 Revert "improvement(mothership): show continue options on abort (#3746)" (#3756)
This reverts commit b9926df8e0.
2026-03-24 21:08:52 -07:00
Waleed
666dc67aa2 fix(db): use bigint for token counter columns in user_stats (#3755) 2026-03-24 21:08:07 -07:00
Waleed
7af7a225f2 fix(knowledge): route connector doc processing through queue instead of fire-and-forget (#3754)
* fix(knowledge): route connector doc processing through queue instead of fire-and-forget

* fix(knowledge): rename jobIds to batchIds in processDocumentsWithTrigger return type

* improvement(knowledge): add Trigger.dev tags for connector sync and document processing tasks

* fix(knowledge): move completeSyncLog after doc enqueue, handle NULL processingStartedAt in stuck doc query
2026-03-24 21:07:55 -07:00
Waleed
228578e282 fix(auth): remove captcha from login, fix signup captcha flow (#3753)
* fix(auth): remove captcha from login, fix signup captcha flow

* fix(auth): show Turnstile widget at normal size for Managed mode challenges
2026-03-24 20:36:49 -07:00
Waleed
be647469ac fix(ui): constrain tooltip width and remove question mark cursor (#3752)
- Add max-w-[260px] to Tooltip.Content so video previews don't blow out the tooltip size
- Replace cursor-help with cursor-default on info icons in settings
2026-03-24 19:01:45 -07:00
Waleed
96b171cf74 improvement(tour): fix tour auto-start logic and standardize selectors (#3751)
* improvement(tour): fix tour auto-start logic and standardize selectors

* fix(tour): address PR review comments

- Move autoStartAttempted.add() inside timer callback to prevent
  blocking auto-start when tour first mounts while disabled
- Memoize setJoyrideRef with useCallback to prevent ref churn
- Remove unused joyrideRef
2026-03-24 18:32:17 -07:00
Theodore Li
cdea2404e3 improvement(ui): Merge ui components for mothership chat (#3748)
* improvement(ui): Merge ui definitions for mothership chat

* Fix lint

* Restore copilot layout

* Fix subagent text not animating collapses

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-24 20:19:04 -04:00
27 changed files with 15077 additions and 496 deletions

View File

@@ -1,7 +1,6 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
@@ -88,8 +87,6 @@ export default function LoginPage({
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()
const callbackUrlParam = searchParams?.get('callbackUrl')
@@ -169,20 +166,6 @@ export default function LoginPage({
const safeCallbackUrl = callbackUrl
let errorHandled = false
// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
if (turnstileSiteKey && turnstileRef.current) {
try {
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
}
}
setFormError(null)
const result = await client.signIn.email(
{
@@ -191,11 +174,6 @@ export default function LoginPage({
callbackURL: safeCallbackUrl,
},
{
fetchOptions: {
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
},
onError: (ctx) => {
logger.error('Login error:', ctx.error)
@@ -464,16 +442,6 @@ export default function LoginPage({
</div>
</div>
{turnstileSiteKey && (
<div className='absolute'>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
</div>
)}
{resetSuccessMessage && (
<div className='text-[#4CAF50] text-xs'>
<p>{resetSuccessMessage}</p>

View File

@@ -93,6 +93,8 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()
@@ -249,17 +251,30 @@ function SignupFormContent({
const sanitizedName = trimmedName
// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
if (turnstileSiteKey && turnstileRef.current) {
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
widget.reset()
token = await Promise.race([
new Promise<string>((resolve, reject) => {
captchaResolveRef.current = resolve
captchaRejectRef.current = reject
widget.execute()
}),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
}),
])
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
} finally {
clearTimeout(timeoutId)
captchaResolveRef.current = null
captchaRejectRef.current = null
}
}
@@ -478,13 +493,14 @@ function SignupFormContent({
</div>
{turnstileSiteKey && (
<div className='absolute'>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
</div>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => captchaResolveRef.current?.(token)}
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
options={{ execution: 'execute' }}
/>
)}
{formError && (

View File

@@ -2,7 +2,7 @@ import type { Step } from 'react-joyride'
export const navTourSteps: Step[] = [
{
target: '[data-item-id="home"]',
target: '[data-tour="nav-home"]',
title: 'Home',
content:
'Your starting point. Describe what you want to build in plain language or pick a template to get started.',
@@ -11,7 +11,7 @@ export const navTourSteps: Step[] = [
spotlightPadding: 0,
},
{
target: '[data-item-id="search"]',
target: '[data-tour="nav-search"]',
title: 'Search',
content: 'Quickly find workflows, blocks, and tools. Use Cmd+K to open it from anywhere.',
placement: 'right',
@@ -19,7 +19,7 @@ export const navTourSteps: Step[] = [
spotlightPadding: 0,
},
{
target: '[data-item-id="tables"]',
target: '[data-tour="nav-tables"]',
title: 'Tables',
content:
'Store and query structured data. Your workflows can read and write to tables directly.',
@@ -27,14 +27,14 @@ export const navTourSteps: Step[] = [
disableBeacon: true,
},
{
target: '[data-item-id="files"]',
target: '[data-tour="nav-files"]',
title: 'Files',
content: 'Upload and manage files that your workflows can process, transform, or reference.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-item-id="knowledge-base"]',
target: '[data-tour="nav-knowledge-base"]',
title: 'Knowledge Base',
content:
'Build knowledge bases from your documents. Set up connectors to give your agents realtime access to your data sources from sources like Notion, Drive, Slack, Confluence, and more.',
@@ -42,7 +42,7 @@ export const navTourSteps: Step[] = [
disableBeacon: true,
},
{
target: '[data-item-id="scheduled-tasks"]',
target: '[data-tour="nav-scheduled-tasks"]',
title: 'Scheduled Tasks',
content:
'View and manage background tasks. Set up new tasks, or view the tasks the Mothership is monitoring for upcoming or past executions.',
@@ -50,7 +50,7 @@ export const navTourSteps: Step[] = [
disableBeacon: true,
},
{
target: '[data-item-id="logs"]',
target: '[data-tour="nav-logs"]',
title: 'Logs',
content:
'Monitor every workflow execution. See inputs, outputs, errors, and timing for each run. View analytics on performance and costs, filter previous runs, and view snapshots of the workflow at the time of execution.',
@@ -58,7 +58,7 @@ export const navTourSteps: Step[] = [
disableBeacon: true,
},
{
target: '.tasks-section',
target: '[data-tour="nav-tasks"]',
title: 'Tasks',
content:
'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
@@ -66,7 +66,7 @@ export const navTourSteps: Step[] = [
disableBeacon: true,
},
{
target: '.workflows-section',
target: '[data-tour="nav-workflows"]',
title: 'Workflows',
content:
'All your workflows live here. Create new ones with the + button and organize them into folders. Deploy your workflows as API, webhook, schedule, or chat widget. Then hit Run to test it out.',

View File

@@ -72,12 +72,22 @@ export function TourTooltipAdapter({
}
}, [step])
const refCallback = useCallback(
/**
* Forwards the Joyride tooltip ref safely, handling both
* callback refs and RefObject refs from the library.
* Memoized to prevent ref churn (null → node cycling) on re-renders.
*/
const setJoyrideRef = useCallback(
(node: HTMLDivElement | null) => {
if (tooltipProps.ref) {
;(tooltipProps.ref as React.RefCallback<HTMLDivElement>)(node)
const { ref } = tooltipProps
if (!ref) return
if (typeof ref === 'function') {
ref(node)
} else {
;(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[tooltipProps.ref]
)
@@ -86,7 +96,7 @@ export function TourTooltipAdapter({
return (
<>
<div
ref={refCallback}
ref={setJoyrideRef}
role={tooltipProps.role}
aria-modal={tooltipProps['aria-modal']}
style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 0, height: 0 }}

View File

@@ -65,6 +65,13 @@ function clearTourCompletion(storageKey: string): void {
}
}
/**
* Tracks which tours have already attempted auto-start in this page session.
* Module-level so it survives component remounts (e.g. navigating between
* workflows remounts WorkflowTour), while still resetting on full page reload.
*/
const autoStartAttempted = new Set<string>()
/**
* Shared hook for managing product tour state with smooth transitions.
*
@@ -87,16 +94,51 @@ export function useTour({
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
const [isEntrance, setIsEntrance] = useState(true)
const hasAutoStarted = useRef(false)
const disabledRef = useRef(disabled)
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)
useEffect(() => {
disabledRef.current = disabled
}, [disabled])
/**
* Schedules a two-frame rAF to reveal the tooltip after the browser
* finishes repositioning. Stores the outer frame ID in `rafRef` so
* it can be cancelled on unmount or when the tour is interrupted.
*/
const scheduleReveal = useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
setIsTooltipVisible(true)
})
})
}, [])
/** Cancels any pending transition timer and rAF reveal */
const cancelPendingTransitions = useCallback(() => {
if (transitionTimerRef.current) {
clearTimeout(transitionTimerRef.current)
transitionTimerRef.current = null
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}, [])
const stopTour = useCallback(() => {
cancelPendingTransitions()
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
markTourCompleted(storageKey)
}, [storageKey])
}, [storageKey, cancelPendingTransitions])
/** Transition to a new step with a coordinated fade-out/fade-in */
const transitionToStep = useCallback(
@@ -106,65 +148,48 @@ export function useTour({
return
}
/** Hide tooltip during transition */
setIsTooltipVisible(false)
if (transitionTimerRef.current) {
clearTimeout(transitionTimerRef.current)
}
cancelPendingTransitions()
transitionTimerRef.current = setTimeout(() => {
transitionTimerRef.current = null
setStepIndex(newIndex)
setIsEntrance(false)
/**
* Wait for the browser to process the Radix Popover repositioning
* before showing the tooltip at the new position.
*/
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsTooltipVisible(true)
})
})
scheduleReveal()
}, FADE_OUT_MS)
},
[steps.length, stopTour]
[steps.length, stopTour, cancelPendingTransitions, scheduleReveal]
)
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
useEffect(() => {
if (disabled && run) {
cancelPendingTransitions()
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
logger.info(`${tourName} paused — disabled became true`)
}
}, [disabled, run, tourName])
}, [disabled, run, tourName, cancelPendingTransitions])
/** Auto-start on first visit */
/** Auto-start on first visit (once per page session per tour) */
useEffect(() => {
if (disabled || hasAutoStarted.current) return
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
const timer = setTimeout(() => {
hasAutoStarted.current = true
if (!isTourCompleted(storageKey)) {
setStepIndex(0)
setIsEntrance(true)
setIsTooltipVisible(false)
setRun(true)
logger.info(`Auto-starting ${tourName}`)
if (disabledRef.current) return
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsTooltipVisible(true)
})
})
}
autoStartAttempted.add(storageKey)
setStepIndex(0)
setIsEntrance(true)
setIsTooltipVisible(false)
setRun(true)
logger.info(`Auto-starting ${tourName}`)
scheduleReveal()
}, autoStartDelay)
return () => clearTimeout(timer)
}, [storageKey, autoStartDelay, tourName, disabled])
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
/** Listen for manual trigger events */
useEffect(() => {
@@ -179,11 +204,6 @@ export function useTour({
clearTimeout(retriggerTimerRef.current)
}
/**
* Start with the tooltip hidden so Joyride can mount, find the
* target element, and position its overlay/spotlight before the
* tooltip card appears.
*/
retriggerTimerRef.current = setTimeout(() => {
retriggerTimerRef.current = null
setStepIndex(0)
@@ -191,12 +211,7 @@ export function useTour({
setIsTooltipVisible(false)
setRun(true)
logger.info(`${tourName} triggered via event`)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsTooltipVisible(true)
})
})
scheduleReveal()
}, 50)
}
@@ -207,15 +222,17 @@ export function useTour({
clearTimeout(retriggerTimerRef.current)
}
}
}, [triggerEvent, resettable, storageKey, tourName])
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
/** Clean up all pending async work on unmount */
useEffect(() => {
return () => {
if (transitionTimerRef.current) {
clearTimeout(transitionTimerRef.current)
cancelPendingTransitions()
if (retriggerTimerRef.current) {
clearTimeout(retriggerTimerRef.current)
}
}
}, [])
}, [cancelPendingTransitions])
const handleCallback = useCallback(
(data: CallBackProps) => {

View File

@@ -10,7 +10,7 @@ export const workflowTourSteps: Step[] = [
disableBeacon: true,
},
{
target: '[data-tab-button="copilot"]',
target: '[data-tour="tab-copilot"]',
title: 'AI Copilot',
content:
'Build and debug workflows using natural language. Describe what you want and Copilot creates the blocks for you.',
@@ -19,7 +19,7 @@ export const workflowTourSteps: Step[] = [
spotlightPadding: 0,
},
{
target: '[data-tab-button="toolbar"]',
target: '[data-tour="tab-toolbar"]',
title: 'Block Library',
content:
'Browse all available blocks and triggers. Drag them onto the canvas to build your workflow step by step.',
@@ -28,7 +28,7 @@ export const workflowTourSteps: Step[] = [
spotlightPadding: 0,
},
{
target: '[data-tab-button="editor"]',
target: '[data-tour="tab-editor"]',
title: 'Block Editor',
content:
'Click any block on the canvas to configure it here. Set inputs, credentials, and fine-tune behavior.',

View File

@@ -3,6 +3,7 @@ export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export { MothershipChat } from './mothership-chat/mothership-chat'
export { MothershipView } from './mothership-view'
export { QueuedMessages } from './queued-messages'
export { TemplatePrompts } from './template-prompts'

View File

@@ -44,6 +44,21 @@ export function AgentGroup({
const [expanded, setExpanded] = useState(defaultExpanded || !allDone)
const [mounted, setMounted] = useState(defaultExpanded || !allDone)
const didAutoCollapseRef = useRef(allDone)
const wasAutoExpandedRef = useRef(defaultExpanded)
useEffect(() => {
if (defaultExpanded) {
wasAutoExpandedRef.current = true
setMounted(true)
setExpanded(true)
return
}
if (wasAutoExpandedRef.current && allDone) {
wasAutoExpandedRef.current = false
setExpanded(false)
}
}, [defaultExpanded, allDone])
useEffect(() => {
if (!autoCollapse || didAutoCollapseRef.current) return
@@ -65,7 +80,10 @@ export function AgentGroup({
{hasItems ? (
<button
type='button'
onClick={() => setExpanded((prev) => !prev)}
onClick={() => {
wasAutoExpandedRef.current = false
setExpanded((prev) => !prev)
}}
className='flex cursor-pointer items-center gap-[8px]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>

View File

@@ -389,7 +389,7 @@ export function MessageContent({
return (
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
<AgentGroup
key={`${segment.id}-${segment.id === lastOpenSubagentGroupId ? 'expanded' : 'default'}`}
key={segment.id}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
items={segment.items}
@@ -413,7 +413,9 @@ export function MessageContent({
return (
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
<CircleStop className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='font-base text-[14px] text-[var(--text-body)]'>Stopped</span>
<span className='font-base text-[14px] text-[var(--text-body)]'>
Stopped by user
</span>
</div>
)
}

View File

@@ -0,0 +1,190 @@
'use client'
import { useLayoutEffect, useRef } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import {
assistantMessageHasRenderableContent,
MessageContent,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import { UserInput } from '@/app/workspace/[workspaceId]/home/components/user-input'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks'
import type {
ChatMessage,
FileAttachmentForApi,
QueuedMessage,
} from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
interface MothershipChatProps {
messages: ChatMessage[]
isSending: boolean
onSubmit: (
text: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[]
) => void
onStopGeneration: () => void
messageQueue: QueuedMessage[]
onRemoveQueuedMessage: (id: string) => void
onSendQueuedMessage: (id: string) => Promise<void>
onEditQueuedMessage: (id: string) => void
userId?: string
onContextAdd?: (context: ChatContext) => void
editValue?: string
onEditValueConsumed?: () => void
layout?: 'mothership-view' | 'copilot-view'
initialScrollBlocked?: boolean
animateInput?: boolean
onInputAnimationEnd?: () => void
className?: string
}
const LAYOUT_STYLES = {
'mothership-view': {
scrollContainer:
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
content: 'mx-auto max-w-[42rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg relative pb-5',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
footerInner: 'mx-auto max-w-[42rem]',
},
'copilot-view': {
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
content: 'space-y-4',
userRow: 'flex flex-col items-end gap-[6px] pt-2',
attachmentWidth: 'max-w-[85%]',
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
assistantRow: 'group/msg relative pb-3',
footer: 'flex-shrink-0 px-3 pb-3',
footerInner: '',
},
} as const
export function MothershipChat({
messages,
isSending,
onSubmit,
onStopGeneration,
messageQueue,
onRemoveQueuedMessage,
onSendQueuedMessage,
onEditQueuedMessage,
userId,
onContextAdd,
editValue,
onEditValueConsumed,
layout = 'mothership-view',
initialScrollBlocked = false,
animateInput = false,
onInputAnimationEnd,
className,
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
useLayoutEffect(() => {
if (!hasMessages) {
initialScrollDoneRef.current = false
return
}
if (initialScrollDoneRef.current || initialScrollBlocked) return
initialScrollDoneRef.current = true
scrollToBottom()
}, [hasMessages, initialScrollBlocked, scrollToBottom])
return (
<div className={cn('flex h-full min-h-0 flex-col', className)}>
<div ref={scrollContainerRef} className={styles.scrollContainer}>
<div className={styles.content}>
{messages.map((msg, index) => {
if (msg.role === 'user') {
const hasAttachments = Boolean(msg.attachments?.length)
return (
<div key={msg.id} className={styles.userRow}>
{hasAttachments && (
<ChatMessageAttachments
attachments={msg.attachments ?? []}
align='end'
className={styles.attachmentWidth}
/>
)}
<div className={styles.userBubble}>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className={styles.assistantRow}>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? onSubmit : undefined}
/>
</div>
)
})}
</div>
</div>
<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={onEditQueuedMessage}
/>
<UserInput
onSubmit={onSubmit}
isSending={isSending}
onStopGeneration={onStopGeneration}
isInitialView={false}
userId={userId}
onContextAdd={onContextAdd}
editValue={editValue}
onEditValueConsumed={onEditValueConsumed}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { PanelLeft } from '@/components/emcn/icons'
@@ -11,21 +11,10 @@ import {
LandingWorkflowSeedStorage,
} from '@/lib/core/utils/browser-storage'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import {
assistantMessageHasRenderableContent,
ChatMessageAttachments,
MessageContent,
MothershipView,
QueuedMessages,
TemplatePrompts,
UserInput,
UserMessageContent,
} from './components'
import { PendingTagIndicator } from './components/message-content/components/special-tags'
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
const logger = createLogger('Home')
@@ -173,7 +162,11 @@ export function Home({ chatId }: HomeProps = {}) {
sendNow,
editQueuedMessage,
streamingFile,
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
} = useChat(
workspaceId,
chatId,
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
)
const [editingInputValue, setEditingInputValue] = useState('')
const [prevChatId, setPrevChatId] = useState(chatId)
@@ -285,22 +278,7 @@ export function Home({ chatId }: HomeProps = {}) {
[addResource, handleResourceEvent]
)
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
useLayoutEffect(() => {
if (!hasMessages) {
initialScrollDoneRef.current = false
return
}
if (initialScrollDoneRef.current) return
if (resources.length > 0 && isResourceCollapsed) return
initialScrollDoneRef.current = true
scrollToBottom()
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
useEffect(() => {
if (hasMessages) return
@@ -354,90 +332,23 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div className='relative flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
>
<div className='mx-auto max-w-[42rem] space-y-6'>
{messages.map((msg, index) => {
if (msg.role === 'user') {
const hasAttachments = msg.attachments && msg.attachments.length > 0
return (
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
{hasAttachments && (
<ChatMessageAttachments
attachments={msg.attachments!}
align='end'
className='max-w-[70%]'
/>
)}
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className='group/msg relative pb-5'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? sendMessage : undefined}
/>
</div>
)
})}
</div>
</div>
<div
className={`flex-shrink-0 px-[24px] pb-[16px]${isInputEntering ? ' animate-slide-in-bottom' : ''}`}
onAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
>
<div className='mx-auto max-w-[42rem]'>
<QueuedMessages
messageQueue={messageQueue}
onRemove={removeFromQueue}
onSendNow={sendNow}
onEdit={handleEditQueuedMessage}
/>
<UserInput
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
/>
</div>
</div>
<MothershipChat
messages={messages}
isSending={isSending}
onSubmit={handleSubmit}
onStopGeneration={stopGeneration}
messageQueue={messageQueue}
onRemoveQueuedMessage={removeFromQueue}
onSendQueuedMessage={sendNow}
onEditQueuedMessage={handleEditQueuedMessage}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
animateInput={isInputEntering}
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
/>
</div>
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}

View File

@@ -1,6 +1,10 @@
export { useAnimatedPlaceholder } from './use-animated-placeholder'
export { useAutoScroll } from './use-auto-scroll'
export type { UseChatReturn } from './use-chat'
export { useChat } from './use-chat'
export {
getMothershipUseChatOptions,
getWorkflowCopilotUseChatOptions,
useChat,
} from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
export { useStreamingReveal } from './use-streaming-reveal'

View File

@@ -8,7 +8,7 @@ import {
markRunToolManuallyStopped,
reportManualRunToolStop,
} from '@/lib/copilot/client-sse/run-tool-execution'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { COPILOT_CHAT_API_PATH, MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import {
extractResourcesFromToolResult,
isResourceToolName,
@@ -85,8 +85,6 @@ const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
const DEPLOY_TOOL_NAMES = new Set(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy'])
const RECONNECT_TAIL_ERROR =
'Live reconnect failed before the stream finished. The latest response may be incomplete.'
const CONTINUE_OPTIONS_CONTENT =
'<options>{"continue":{"title":"Continue","description":"Pick up where we left off"}}</options>'
function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
const mapped: ContentBlock = {
@@ -265,6 +263,29 @@ export interface UseChatOptions {
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
}
export function getMothershipUseChatOptions(
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
): UseChatOptions {
return {
apiPath: MOTHERSHIP_CHAT_API_PATH,
stopPath: '/api/mothership/chat/stop',
...options,
}
}
export function getWorkflowCopilotUseChatOptions(
options: Pick<
UseChatOptions,
'workflowId' | 'onToolResult' | 'onTitleUpdate' | 'onStreamEnd'
> = {}
): UseChatOptions {
return {
apiPath: COPILOT_CHAT_API_PATH,
stopPath: '/api/mothership/chat/stop',
...options,
}
}
export function useChat(
workspaceId: string,
initialChatId?: string,
@@ -1192,14 +1213,8 @@ export function useChat(
if (storedBlocks.length > 0) {
storedBlocks.push({ type: 'stopped' })
storedBlocks.push({ type: 'text', content: CONTINUE_OPTIONS_CONTENT })
}
const persistedContent =
content && !content.includes('<options>')
? `${content}\n\n${CONTINUE_OPTIONS_CONTENT}`
: content
try {
const res = await fetch(stopPathRef.current, {
method: 'POST',
@@ -1207,7 +1222,7 @@ export function useChat(
body: JSON.stringify({
chatId,
streamId,
content: persistedContent,
content,
...(storedBlocks.length > 0 && { contentBlocks: storedBlocks }),
}),
})
@@ -1233,50 +1248,6 @@ export function useChat(
const messagesRef = useRef(messages)
messagesRef.current = messages
const resolveInterruptedToolCalls = useCallback(() => {
setMessages((prev) => {
const hasAnyExecuting = prev.some((m) =>
m.contentBlocks?.some((b) => b.toolCall?.status === 'executing')
)
if (!hasAnyExecuting) return prev
let lastAssistantIdx = -1
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === 'assistant') {
lastAssistantIdx = i
break
}
}
return prev.map((msg, idx) => {
const hasExecuting = msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')
const isLastAssistant = idx === lastAssistantIdx
if (!hasExecuting && !isLastAssistant) return msg
const blocks: ContentBlock[] = (msg.contentBlocks ?? []).map((block) => {
if (block.toolCall?.status !== 'executing') return block
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped',
},
}
})
if (isLastAssistant && !blocks.some((b) => b.type === 'stopped')) {
blocks.push({ type: 'stopped' as const })
}
if (
isLastAssistant &&
!blocks.some((b) => b.type === 'text' && b.content?.includes('<options>'))
) {
blocks.push({ type: 'text', content: CONTINUE_OPTIONS_CONTENT })
}
return { ...msg, contentBlocks: blocks.length > 0 ? blocks : msg.contentBlocks }
})
})
}, [])
const finalize = useCallback(
(options?: { error?: boolean }) => {
sendingRef.current = false
@@ -1291,8 +1262,6 @@ export function useChat(
}
}
resolveInterruptedToolCalls()
if (options?.error) {
setMessageQueue([])
return
@@ -1308,7 +1277,7 @@ export function useChat(
})
}
},
[invalidateChatQueries, resolveInterruptedToolCalls]
[invalidateChatQueries]
)
finalizeRef.current = finalize
@@ -1466,7 +1435,24 @@ export function useChat(
sendingRef.current = false
setIsSending(false)
resolveInterruptedToolCalls()
setMessages((prev) =>
prev.map((msg) => {
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
const updated = msg.contentBlocks!.map((block) => {
if (block.toolCall?.status !== 'executing') return block
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped by user',
},
}
})
updated.push({ type: 'stopped' as const })
return { ...msg, contentBlocks: updated }
})
)
if (sid) {
fetch('/api/copilot/chat/abort', {
@@ -1532,7 +1518,7 @@ export function useChat(
reportManualRunToolStop(workflowId, toolCallId).catch(() => {})
}
}, [invalidateChatQueries, persistPartialResponse, executionStream, resolveInterruptedToolCalls])
}, [invalidateChatQueries, persistPartialResponse, executionStream])
const removeFromQueue = useCallback((id: string) => {
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Camera, Check, Pencil } from 'lucide-react'
import { Camera, Check, Info, Pencil } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import {
@@ -376,20 +376,22 @@ export function General() {
</div>
<div className='flex items-center justify-between'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Label htmlFor='auto-connect' className='cursor-help'>
Auto-connect on drop
</Label>
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Automatically connect blocks when dropped near each other</p>
<Tooltip.Preview
src='/tooltips/auto-connect-on-drop.mp4'
alt='Auto-connect on drop example'
/>
</Tooltip.Content>
</Tooltip.Root>
<div className='flex items-center gap-[6px]'>
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Automatically connect blocks when dropped near each other</p>
<Tooltip.Preview
src='/tooltips/auto-connect-on-drop.mp4'
alt='Auto-connect on drop example'
loop={false}
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='auto-connect'
checked={settings?.autoConnect ?? true}
@@ -398,20 +400,21 @@ export function General() {
</div>
<div className='flex items-center justify-between'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Label htmlFor='error-notifications' className='cursor-help'>
Canvas error notifications
</Label>
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Show error popups on blocks when a workflow run fails</p>
<Tooltip.Preview
src='/tooltips/canvas-error-notification.mp4'
alt='Canvas error notification example'
/>
</Tooltip.Content>
</Tooltip.Root>
<div className='flex items-center gap-[6px]'>
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Show error popups on blocks when a workflow run fails</p>
<Tooltip.Preview
src='/tooltips/canvas-error-notification.mp4'
alt='Canvas error notification example'
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='error-notifications'
checked={settings?.errorNotificationsEnabled ?? true}

View File

@@ -34,16 +34,9 @@ import { Lock, Unlock, Upload } from '@/components/emcn/icons'
import { VariableIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
import { ConversationListItem, MessageActions } from '@/app/workspace/[workspaceId]/components'
import {
assistantMessageHasRenderableContent,
MessageContent,
QueuedMessages,
UserInput,
UserMessageContent,
} from '@/app/workspace/[workspaceId]/home/components'
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { useAutoScroll, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -332,13 +325,15 @@ export const Panel = memo(function Panel() {
removeFromQueue: copilotRemoveFromQueue,
sendNow: copilotSendNow,
editQueuedMessage: copilotEditQueuedMessage,
} = useChat(workspaceId, copilotChatId, {
apiPath: '/api/copilot/chat',
stopPath: '/api/mothership/chat/stop',
workflowId: activeWorkflowId || undefined,
onTitleUpdate: loadCopilotChats,
onToolResult: handleCopilotToolResult,
})
} = useChat(
workspaceId,
copilotChatId,
getWorkflowCopilotUseChatOptions({
workflowId: activeWorkflowId || undefined,
onTitleUpdate: loadCopilotChats,
onToolResult: handleCopilotToolResult,
})
)
const handleCopilotNewChat = useCallback(() => {
if (!activeWorkflowId || !workspaceId) return
@@ -403,9 +398,6 @@ export const Panel = memo(function Panel() {
[copilotSendMessage]
)
const { ref: copilotScrollRef, scrollToBottom: copilotScrollToBottom } =
useAutoScroll(copilotIsSending)
/**
* Mark hydration as complete on mount
* This allows React to take over visibility control from CSS
@@ -700,6 +692,7 @@ export const Panel = memo(function Panel() {
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
onClick={() => handleTabClick('copilot')}
data-tab-button='copilot'
data-tour='tab-copilot'
>
Copilot
</Button>
@@ -713,6 +706,7 @@ export const Panel = memo(function Panel() {
variant={_hasHydrated && activeTab === 'toolbar' ? 'active' : 'ghost'}
onClick={() => handleTabClick('toolbar')}
data-tab-button='toolbar'
data-tour='tab-toolbar'
>
Toolbar
</Button>
@@ -725,6 +719,7 @@ export const Panel = memo(function Panel() {
variant={_hasHydrated && activeTab === 'editor' ? 'active' : 'ghost'}
onClick={() => handleTabClick('editor')}
data-tab-button='editor'
data-tour='tab-editor'
>
Editor
</Button>
@@ -813,77 +808,21 @@ export const Panel = memo(function Panel() {
</div>
</div>
<div
ref={copilotScrollRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4'
>
<div className='space-y-4'>
{copilotMessages.map((msg, index) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-2'>
<div className='max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant =
msg.role === 'assistant' && index === copilotMessages.length - 1
const isThisStreaming = copilotIsSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === copilotMessages.length - 1
return (
<div key={msg.id} className='group/msg relative pb-3'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? copilotSendMessage : undefined}
/>
</div>
)
})}
</div>
</div>
<div className='flex-shrink-0 px-3 pb-3'>
<QueuedMessages
messageQueue={copilotMessageQueue}
onRemove={copilotRemoveFromQueue}
onSendNow={copilotSendNow}
onEdit={handleCopilotEditQueuedMessage}
/>
<UserInput
onSubmit={handleCopilotSubmit}
isSending={copilotIsSending}
onStopGeneration={copilotStopGeneration}
isInitialView={false}
userId={session?.user?.id}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
/>
</div>
<MothershipChat
className='min-h-0 flex-1'
messages={copilotMessages}
isSending={copilotIsSending}
onSubmit={handleCopilotSubmit}
onStopGeneration={copilotStopGeneration}
messageQueue={copilotMessageQueue}
onRemoveQueuedMessage={copilotRemoveFromQueue}
onSendQueuedMessage={copilotSendNow}
onEditQueuedMessage={handleCopilotEditQueuedMessage}
userId={session?.user?.id}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
layout='copilot-view'
/>
</div>
)}
<div

View File

@@ -215,6 +215,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
<Link
href={item.href}
data-item-id={item.id}
data-tour={`nav-${item.id}`}
className={`${baseClasses} ${activeClasses}`}
onClick={
item.onClick
@@ -233,6 +234,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
<button
type='button'
data-item-id={item.id}
data-tour={`nav-${item.id}`}
className={`${baseClasses} ${activeClasses}`}
onClick={item.onClick}
>
@@ -1139,7 +1141,7 @@ export const Sidebar = memo(function Sidebar() {
)}
>
{/* Tasks */}
<div className='tasks-section flex flex-shrink-0 flex-col'>
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
{!isCollapsed && (
@@ -1253,7 +1255,10 @@ export const Sidebar = memo(function Sidebar() {
</div>
{/* Workflows */}
<div className='workflows-section relative mt-[14px] flex flex-col'>
<div
className='workflows-section relative mt-[14px] flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
{!isCollapsed && (

View File

@@ -50,7 +50,7 @@ const Content = React.forwardRef<
collisionPadding={8}
avoidCollisions={true}
className={cn(
'z-[10000300] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
'z-[10000300] max-w-[260px] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
className
)}
{...props}
@@ -98,6 +98,8 @@ interface PreviewProps {
width?: number
/** Height of the preview in pixels */
height?: number
/** Whether video should loop */
loop?: boolean
/** Optional additional class names */
className?: string
}
@@ -115,20 +117,22 @@ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov'] as const
* </Tooltip.Content>
* ```
*/
const Preview = ({ src, alt = '', width = 240, height, className }: PreviewProps) => {
const Preview = ({ src, alt = '', width = 240, height, loop = true, className }: PreviewProps) => {
const pathname = src.toLowerCase().split('?')[0].split('#')[0]
const isVideo = VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext))
return (
<div className={cn('mt-[4px] overflow-hidden rounded-[3px]', className)}>
<div
className={cn('-mx-[8px] -mb-[3.5px] mt-[4px] overflow-hidden rounded-b-[4px]', className)}
>
{isVideo ? (
<video
src={src}
width={width}
height={height}
className='block max-w-full'
className='block w-full'
autoPlay
loop
loop={loop}
muted
playsInline
preload='none'
@@ -140,7 +144,7 @@ const Preview = ({ src, alt = '', width = 240, height, className }: PreviewProps
alt={alt}
width={width}
height={height}
className='block max-w-full'
className='block w-full'
loading='lazy'
/>
)}

View File

@@ -1,6 +1,7 @@
'use client'
import type * as React from 'react'
import { type RefObject, useRef } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { X } from 'lucide-react'
import { createPortal } from 'react-dom'
@@ -67,8 +68,13 @@ function TourCard({
{step} / {totalSteps}
</span>
<div className='flex items-center gap-[6px]'>
<div className={cn(isFirst && 'pointer-events-none opacity-0')}>
<Button variant='default' size='sm' onClick={onBack}>
<div className={cn(isFirst && 'invisible')}>
<Button
variant='default'
size='sm'
onClick={onBack}
tabIndex={isFirst ? -1 : undefined}
>
Back
</Button>
</div>
@@ -141,6 +147,9 @@ function TourTooltip({
onClose,
className,
}: TourTooltipProps) {
const virtualRef = useRef<HTMLElement | null>(null)
virtualRef.current = targetEl
if (typeof document === 'undefined') return null
if (!isVisible) return null
@@ -186,7 +195,7 @@ function TourTooltip({
return createPortal(
<PopoverPrimitive.Root open>
<PopoverPrimitive.Anchor virtualRef={{ current: targetEl }} />
<PopoverPrimitive.Anchor virtualRef={virtualRef as RefObject<HTMLElement>} />
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side={PLACEMENT_TO_SIDE[placement] || 'bottom'}

View File

@@ -717,7 +717,7 @@ export const auth = betterAuth({
captcha({
provider: 'cloudflare-turnstile',
secretKey: env.TURNSTILE_SECRET_KEY,
endpoints: ['/sign-up/email', '/sign-in/email'],
endpoints: ['/sign-up/email'],
}),
]
: []),

View File

@@ -6,13 +6,14 @@ import {
knowledgeConnectorSyncLog,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm'
import { and, eq, gt, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'
import { decryptApiKey } from '@/lib/api-key/crypto'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import type { DocumentData } from '@/lib/knowledge/documents/service'
import {
hardDeleteDocuments,
isTriggerAvailable,
processDocumentAsync,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import { StorageService } from '@/lib/uploads'
import { deleteFile } from '@/lib/uploads/core/storage-service'
@@ -39,6 +40,8 @@ class ConnectorDeletedException extends Error {
const SYNC_BATCH_SIZE = 5
const MAX_PAGES = 500
const MAX_SAFE_TITLE_LENGTH = 200
const STALE_PROCESSING_MINUTES = 45
const RETRY_WINDOW_DAYS = 7
/** Sanitizes a document title for use in S3 storage keys. */
function sanitizeStorageTitle(title: string): string {
@@ -147,11 +150,14 @@ export async function dispatchSync(
const requestId = options?.requestId ?? crypto.randomUUID()
if (isTriggerAvailable()) {
await knowledgeConnectorSync.trigger({
connectorId,
fullSync: options?.fullSync,
requestId,
})
await knowledgeConnectorSync.trigger(
{
connectorId,
fullSync: options?.fullSync,
requestId,
},
{ tags: [`connector:${connectorId}`] }
)
logger.info(`Dispatched connector sync to Trigger.dev`, { connectorId, requestId })
} else {
executeSync(connectorId, { fullSync: options?.fullSync }).catch((error) => {
@@ -395,6 +401,8 @@ export async function executeSync(
const seenExternalIds = new Set<string>()
const pendingProcessing: DocumentData[] = []
const pendingOps: DocOp[] = []
for (const extDoc of externalDocs) {
seenExternalIds.add(extDoc.externalId)
@@ -503,6 +511,7 @@ export async function executeSync(
for (let j = 0; j < settled.length; j++) {
const outcome = settled[j]
if (outcome.status === 'fulfilled') {
pendingProcessing.push(outcome.value)
if (batch[j].type === 'add') result.docsAdded++
else result.docsUpdated++
} else {
@@ -537,9 +546,14 @@ export async function executeSync(
throw new Error(`Knowledge base ${connector.knowledgeBaseId} was deleted during sync`)
}
// Retry stuck documents that failed or never completed processing.
// Retry stuck documents that failed, never started, or were abandoned mid-processing.
// Only retry docs uploaded BEFORE this sync — docs added in the current sync
// are still processing asynchronously and would cause a duplicate processing race.
// Documents stuck in 'processing' beyond STALE_PROCESSING_MINUTES are considered
// abandoned (e.g. the Trigger.dev task process exited before processing completed).
// Documents uploaded more than RETRY_WINDOW_DAYS ago are not retried.
const staleProcessingCutoff = new Date(Date.now() - STALE_PROCESSING_MINUTES * 60 * 1000)
const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000)
const stuckDocs = await db
.select({
id: document.id,
@@ -552,8 +566,18 @@ export async function executeSync(
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.processingStatus, ['pending', 'failed']),
or(
inArray(document.processingStatus, ['pending', 'failed']),
and(
eq(document.processingStatus, 'processing'),
or(
isNull(document.processingStartedAt),
lt(document.processingStartedAt, staleProcessingCutoff)
)
)
),
lt(document.uploadedAt, syncStartedAt),
gt(document.uploadedAt, retryCutoff),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
@@ -562,28 +586,60 @@ export async function executeSync(
if (stuckDocs.length > 0) {
logger.info(`Retrying ${stuckDocs.length} stuck documents`, { connectorId })
for (const doc of stuckDocs) {
processDocumentAsync(
connector.knowledgeBaseId,
doc.id,
{
try {
await processDocumentsWithQueue(
stuckDocs.map((doc) => ({
documentId: doc.id,
filename: doc.filename ?? 'document.txt',
fileUrl: doc.fileUrl ?? '',
fileSize: doc.fileSize ?? 0,
mimeType: doc.mimeType ?? 'text/plain',
},
{}
).catch((error) => {
logger.warn('Failed to retry stuck document', {
documentId: doc.id,
error: error instanceof Error ? error.message : String(error),
})
})),
connector.knowledgeBaseId,
{},
crypto.randomUUID()
)
} catch (error) {
logger.warn('Failed to enqueue stuck documents for reprocessing', {
connectorId,
count: stuckDocs.length,
error: error instanceof Error ? error.message : String(error),
})
}
}
// Enqueue all added/updated documents for processing in a single batch
if (pendingProcessing.length > 0) {
try {
await processDocumentsWithQueue(
pendingProcessing,
connector.knowledgeBaseId,
{},
crypto.randomUUID()
)
} catch (error) {
logger.warn('Failed to enqueue documents for processing — will retry on next sync', {
connectorId,
count: pendingProcessing.length,
error: error instanceof Error ? error.message : String(error),
})
}
}
await completeSyncLog(syncLogId, 'completed', result)
const [{ count: actualDocCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
const now = new Date()
await db
.update(knowledgeConnector)
@@ -591,7 +647,7 @@ export async function executeSync(
status: 'active',
lastSyncAt: now,
lastSyncError: null,
lastSyncDocCount: externalDocs.length,
lastSyncDocCount: actualDocCount,
nextSyncAt: calculateNextSyncTime(connector.syncIntervalMinutes),
consecutiveFailures: 0,
updatedAt: now,
@@ -711,7 +767,7 @@ async function addDocument(
connectorType: string,
extDoc: ExternalDocument,
sourceConfig?: Record<string, unknown>
): Promise<void> {
): Promise<DocumentData> {
if (await isKnowledgeBaseDeleted(knowledgeBaseId)) {
throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`)
}
@@ -773,23 +829,13 @@ async function addDocument(
throw error
}
processDocumentAsync(
knowledgeBaseId,
return {
documentId,
{
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
},
{}
).catch((error) => {
logger.error('Failed to process connector document', {
documentId,
connectorId,
error: error instanceof Error ? error.message : String(error),
})
})
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
}
}
/**
@@ -803,7 +849,7 @@ async function updateDocument(
connectorType: string,
extDoc: ExternalDocument,
sourceConfig?: Record<string, unknown>
): Promise<void> {
): Promise<DocumentData> {
if (await isKnowledgeBaseDeleted(knowledgeBaseId)) {
throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`)
}
@@ -894,21 +940,11 @@ async function updateDocument(
}
}
processDocumentAsync(
knowledgeBaseId,
existingDocId,
{
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
},
{}
).catch((error) => {
logger.error('Failed to re-process updated connector document', {
documentId: existingDocId,
connectorId,
error: error instanceof Error ? error.message : String(error),
})
})
return {
documentId: existingDocId,
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
}
}

View File

@@ -114,11 +114,11 @@ export interface DocumentData {
}
export interface ProcessingOptions {
chunkSize: number
minCharactersPerChunk: number
recipe: string
lang: string
chunkOverlap: number
chunkSize?: number
minCharactersPerChunk?: number
recipe?: string
lang?: string
chunkOverlap?: number
}
export interface DocumentJobData {
@@ -668,7 +668,7 @@ export function isTriggerAvailable(): boolean {
export async function processDocumentsWithTrigger(
documents: DocumentProcessingPayload[],
requestId: string
): Promise<{ success: boolean; message: string; jobIds?: string[] }> {
): Promise<{ success: boolean; message: string; batchIds?: string[] }> {
if (!isTriggerAvailable()) {
throw new Error('Trigger.dev is not configured - TRIGGER_SECRET_KEY missing')
}
@@ -676,19 +676,32 @@ export async function processDocumentsWithTrigger(
try {
logger.info(`[${requestId}] Triggering background processing for ${documents.length} documents`)
const jobPromises = documents.map(async (document) => {
const job = await tasks.trigger('knowledge-process-document', document)
return job.id
})
const MAX_BATCH_SIZE = 1000
const batchIds: string[] = []
const jobIds = await Promise.all(jobPromises)
for (let i = 0; i < documents.length; i += MAX_BATCH_SIZE) {
const chunk = documents.slice(i, i + MAX_BATCH_SIZE)
const batchResult = await tasks.batchTrigger(
'knowledge-process-document',
chunk.map((doc) => ({
payload: doc,
options: {
idempotencyKey: `doc-process-${doc.documentId}-${requestId}`,
tags: [`kb:${doc.knowledgeBaseId}`, `doc:${doc.documentId}`],
},
}))
)
batchIds.push(batchResult.batchId)
}
logger.info(`[${requestId}] Triggered ${jobIds.length} document processing jobs`)
logger.info(
`[${requestId}] Triggered ${documents.length} document processing jobs in ${batchIds.length} batch(es)`
)
return {
success: true,
message: `${documents.length} document processing jobs triggered`,
jobIds,
batchIds,
}
} catch (error) {
logger.error(`[${requestId}] Failed to trigger document processing jobs:`, error)
@@ -1590,10 +1603,19 @@ export async function retryDocumentProcessing(
chunkOverlap: kbConfig.overlap,
}
processDocumentAsync(knowledgeBaseId, documentId, docData, processingOptions).catch(
(error: unknown) => {
logger.error(`[${requestId}] Background retry processing error:`, error)
}
await processDocumentsWithQueue(
[
{
documentId,
filename: docData.filename,
fileUrl: docData.fileUrl,
fileSize: docData.fileSize,
mimeType: docData.mimeType,
},
],
knowledgeBaseId,
processingOptions,
requestId
)
logger.info(`[${requestId}] Document retry initiated: ${documentId}`)

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user_stats" ALTER COLUMN "total_tokens_used" SET DATA TYPE bigint;--> statement-breakpoint
ALTER TABLE "user_stats" ALTER COLUMN "total_copilot_tokens" SET DATA TYPE bigint;

File diff suppressed because it is too large Load Diff

View File

@@ -1261,6 +1261,13 @@
"when": 1774305252055,
"tag": "0180_amused_marvel_boy",
"breakpoints": true
},
{
"idx": 181,
"version": "7",
"when": 1774411211528,
"tag": "0181_dazzling_the_leader",
"breakpoints": true
}
]
}

View File

@@ -753,7 +753,7 @@ export const userStats = pgTable('user_stats', {
totalChatExecutions: integer('total_chat_executions').notNull().default(0),
totalMcpExecutions: integer('total_mcp_executions').notNull().default(0),
totalA2aExecutions: integer('total_a2a_executions').notNull().default(0),
totalTokensUsed: integer('total_tokens_used').notNull().default(0),
totalTokensUsed: bigint('total_tokens_used', { mode: 'number' }).notNull().default(0),
totalCost: decimal('total_cost').notNull().default('0'),
currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $5 (1,000 credits) for free plan, null for team/enterprise
usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(),
@@ -769,7 +769,7 @@ export const userStats = pgTable('user_stats', {
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'),
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),
totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0),
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
// MCP Copilot usage tracking
totalMcpCopilotCalls: integer('total_mcp_copilot_calls').notNull().default(0),