-
- {messages.map((msg, index) => {
- if (msg.role === 'user') {
- const hasAttachments = Boolean(msg.attachments?.length)
- return (
-
- {hasAttachments && (
-
- )}
-
-
+ {isLoading && !hasMessages ? (
+
+ ) : (
+
+ {messages.map((msg, index) => {
+ if (msg.role === 'user') {
+ const hasAttachments = Boolean(msg.attachments?.length)
+ return (
+
+ {hasAttachments && (
+
+ )}
+
+
+
+ )
+ }
+
+ const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
+ const hasRenderableAssistant = assistantMessageHasRenderableContent(
+ msg.contentBlocks ?? [],
+ msg.content ?? ''
+ )
+ const isLastAssistant = index === messages.length - 1
+ const isThisStreaming = isStreamActive && isLastAssistant
+
+ if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
+ return
+ }
+
+ if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
+ return null
+ }
+
+ const isLastMessage = index === messages.length - 1
+ const precedingUserMsg = [...messages]
+ .slice(0, index)
+ .reverse()
+ .find((m) => m.role === 'user')
+
+ return (
+
+
+ {!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
+
+
+
+ )}
)
- }
-
- const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
- const hasRenderableAssistant = assistantMessageHasRenderableContent(
- msg.contentBlocks ?? [],
- msg.content ?? ''
- )
- const isLastAssistant = index === messages.length - 1
- const isThisStreaming = isStreamActive && isLastAssistant
-
- if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
- return
- }
-
- if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
- return null
- }
-
- const isLastMessage = index === messages.length - 1
- const precedingUserMsg = [...messages]
- .slice(0, index)
- .reverse()
- .find((m) => m.role === 'user')
-
- return (
-
-
- {!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
-
-
-
- )}
-
- )
- })}
-
+ })}
+
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index ab2655ed48..4b25c0affc 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -1,22 +1,28 @@
'use client'
-import { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'
+import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Square } from 'lucide-react'
import { useRouter } from 'next/navigation'
-import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
+import { Button, Eye, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
import {
Download,
FileX,
Folder as FolderIcon,
+ Library,
SquareArrowUpRight,
WorkflowX,
} from '@/components/emcn/icons'
+import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
+import type { FilePreviewSession } from '@/lib/copilot/request/session'
import {
cancelRunToolExecution,
markRunToolManuallyStopped,
reportManualRunToolStop,
-} from '@/lib/copilot/client-sse/run-tool-execution'
+} from '@/lib/copilot/tools/client/run-tool-execution'
+import { cn } from '@/lib/core/utils/cn'
+import { formatDuration } from '@/lib/core/utils/formatting'
+import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
downloadWorkspaceFile,
getFileExtension,
@@ -37,6 +43,18 @@ import type {
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
+import {
+ ExecutionSnapshot,
+ FileCards,
+ TraceSpans,
+ WorkflowOutputSection,
+} from '@/app/workspace/[workspaceId]/logs/components'
+import {
+ formatDate,
+ getDisplayStatus,
+ StatusBadge,
+ TriggerBadge,
+} from '@/app/workspace/[workspaceId]/logs/utils'
import {
useUserPermissionsContext,
useWorkspacePermissionsContext,
@@ -45,9 +63,11 @@ import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useFolders } from '@/hooks/queries/folders'
+import { useLogDetail } from '@/hooks/queries/logs'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
+import { formatCost } from '@/providers/utils'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -65,8 +85,9 @@ interface ResourceContentProps {
workspaceId: string
resource: MothershipResource
previewMode?: PreviewMode
- streamingFile?: { fileName: string; content: string } | null
+ previewSession?: FilePreviewSession | null
genericResourceData?: GenericResourceData
+ previewContextKey?: string
}
/**
@@ -80,18 +101,19 @@ export const ResourceContent = memo(function ResourceContent({
workspaceId,
resource,
previewMode,
- streamingFile,
+ previewSession,
genericResourceData,
+ previewContextKey,
}: ResourceContentProps) {
- const streamFileName = streamingFile?.fileName || 'file.md'
- const streamingExtractedContent = useMemo(() => {
- if (!streamingFile) return undefined
- const extracted = extractFileContent(streamingFile.content)
- return extracted.length > 0 ? extracted : undefined
- }, [streamingFile])
+ const streamFileName = previewSession?.fileName || 'file.md'
const syntheticFile = useMemo(() => {
const ext = getFileExtension(streamFileName)
- const type = ext === 'pptx' ? 'text/x-pptxgenjs' : getMimeTypeFromExtension(ext)
+ const SOURCE_MIME_MAP: Record
= {
+ pptx: 'text/x-pptxgenjs',
+ docx: 'text/x-docxjs',
+ pdf: 'text/x-pdflibjs',
+ }
+ const type = SOURCE_MIME_MAP[ext] ?? getMimeTypeFromExtension(ext)
return {
id: 'streaming-file',
workspaceId,
@@ -105,16 +127,26 @@ export const ResourceContent = memo(function ResourceContent({
}
}, [workspaceId, streamFileName])
- if (streamingFile && resource.id === 'streaming-file') {
+ const streamingFileMode: 'append' | 'replace' = 'replace'
+ const disableStreamingAutoScroll = previewSession?.operation === 'patch'
+ const rawPreviewText = previewSession?.previewText
+ const streamingPreviewText =
+ typeof rawPreviewText === 'string' && rawPreviewText.length > 0 ? rawPreviewText : undefined
+
+ if (previewSession && resource.id === 'streaming-file') {
return (
- {streamingExtractedContent !== undefined ? (
+ {streamingPreviewText !== undefined ? (
) : (
@@ -136,7 +168,12 @@ export const ResourceContent = memo(function ResourceContent({
workspaceId={workspaceId}
fileId={resource.id}
previewMode={previewMode}
- streamingContent={streamingExtractedContent}
+ streamingContent={
+ previewSession?.fileId === resource.id ? streamingPreviewText : undefined
+ }
+ streamingMode={streamingFileMode}
+ disableStreamingAutoScroll={disableStreamingAutoScroll}
+ previewContextKey={previewContextKey}
/>
)
@@ -158,6 +195,9 @@ export const ResourceContent = memo(function ResourceContent({
case 'folder':
return
+ case 'log':
+ return
+
case 'generic':
return (
@@ -183,6 +223,8 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
return (
)
+ case 'log':
+ return
case 'folder':
case 'generic':
return null
@@ -425,9 +467,20 @@ interface EmbeddedFileProps {
fileId: string
previewMode?: PreviewMode
streamingContent?: string
+ streamingMode?: 'append' | 'replace'
+ disableStreamingAutoScroll?: boolean
+ previewContextKey?: string
}
-function EmbeddedFile({ workspaceId, fileId, previewMode, streamingContent }: EmbeddedFileProps) {
+function EmbeddedFile({
+ workspaceId,
+ fileId,
+ previewMode,
+ streamingContent,
+ streamingMode,
+ disableStreamingAutoScroll = false,
+ previewContextKey,
+}: EmbeddedFileProps) {
const { canEdit } = useUserPermissionsContext()
const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
@@ -455,8 +508,12 @@ function EmbeddedFile({ workspaceId, fileId, previewMode, streamingContent }: Em
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
+ streamingMode={streamingMode}
previewMode={previewMode}
streamingContent={streamingContent}
+ disableStreamingAutoScroll={disableStreamingAutoScroll}
+ previewContextKey={previewContextKey}
+ useCodeRendererForCodeFiles
/>
)
@@ -528,15 +585,316 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
)
}
-function extractFileContent(raw: string): string {
- const marker = '"content":'
- const idx = raw.indexOf(marker)
- if (idx === -1) return ''
- let rest = raw.slice(idx + marker.length).trimStart()
- if (rest.startsWith('"')) rest = rest.slice(1)
- return rest
- .replace(/\\n/g, '\n')
- .replace(/\\t/g, '\t')
- .replace(/\\"/g, '"')
- .replace(/\\\\/g, '\\')
+interface EmbeddedLogProps {
+ logId: string
+}
+
+function EmbeddedLog({ logId }: EmbeddedLogProps) {
+ const { data: log, isLoading } = useLogDetail(logId)
+ const [isSnapshotOpen, setIsSnapshotOpen] = useState(false)
+
+ const logStatus = getDisplayStatus(log?.status)
+
+ const workflowOutput = useMemo(() => {
+ const executionData = log?.executionData as
+ | { finalOutput?: Record
}
+ | undefined
+ if (!executionData?.finalOutput) return null
+ return filterHiddenOutputKeys(executionData.finalOutput) as Record
+ }, [log?.executionData])
+
+ const isWorkflowExecutionLog = useMemo(() => {
+ if (!log) return false
+ return (
+ (log.trigger === 'manual' && !!log.duration) ||
+ (log.executionData?.enhanced && log.executionData?.traceSpans)
+ )
+ }, [log])
+
+ const hasCostInfo = isWorkflowExecutionLog && log?.cost
+
+ const formattedTimestamp = useMemo(
+ () => (log ? formatDate(log.createdAt) : null),
+ [log?.createdAt]
+ )
+
+ if (isLoading) return LOADING_SKELETON
+
+ if (!log) {
+ return (
+
+
+
+
Log not found
+
+ This log may have been deleted or is no longer available
+
+
+
+ )
+ }
+
+ const workflowColor =
+ log.trigger === 'mothership'
+ ? '#ec4899'
+ : log.workflow?.color || (!log.workflowId ? 'var(--text-tertiary)' : undefined)
+
+ const totalToolCost = (() => {
+ const models = (log.cost as Record)?.models as
+ | Record
+ | undefined
+ return models ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) : 0
+ })()
+
+ return (
+
+
+ {/* Timestamp & Workflow Row */}
+
+
+
Timestamp
+
+
+ {formattedTimestamp?.compactDate || 'N/A'}
+
+
+ {formattedTimestamp?.compactTime || 'N/A'}
+
+
+
+
+
+ {log.trigger === 'mothership' ? 'Job' : 'Workflow'}
+
+
+
+
+ {log.trigger === 'mothership'
+ ? log.jobTitle || 'Untitled Job'
+ : log.workflow?.name || (!log.workflowId ? 'Deleted Workflow' : 'Unknown')}
+
+
+
+
+
+ {/* Execution ID */}
+ {log.executionId && (
+
+
+ Execution ID
+
+
+ {log.executionId}
+
+
+ )}
+
+ {/* Details Section */}
+
+
+ Level
+
+
+
+ Trigger
+ {log.trigger ? (
+
+ ) : (
+ —
+ )}
+
+
+ Duration
+
+ {formatDuration(log.duration, { precision: 2 }) || '—'}
+
+
+ {log.deploymentVersion && (
+
+
+ Version
+
+
+
+ {log.deploymentVersionName || `v${log.deploymentVersion}`}
+
+
+
+ )}
+
+
+ {/* Workflow State Snapshot */}
+ {isWorkflowExecutionLog && log.executionId && log.trigger !== 'mothership' && (
+
+
+ Workflow State
+
+
+
+ )}
+
+ {/* Workflow Output */}
+ {isWorkflowExecutionLog && workflowOutput && (
+
+
+ Workflow Output
+
+
+
+ )}
+
+ {/* Trace Spans */}
+ {isWorkflowExecutionLog && log.executionData?.traceSpans && (
+
+ Trace Span
+
+
+ )}
+
+ {/* Files */}
+ {log.files && log.files.length > 0 &&
}
+
+ {/* Cost Breakdown */}
+ {hasCostInfo && (
+
+
+ Cost Breakdown
+
+
+
+
+
+ Base Execution:
+
+
+ {formatCost(BASE_EXECUTION_CHARGE)}
+
+
+
+
+ Model Input:
+
+
+ {formatCost(log.cost?.input || 0)}
+
+
+
+
+ Model Output:
+
+
+ {formatCost(log.cost?.output || 0)}
+
+
+ {totalToolCost > 0 && (
+
+
+ Tool Usage:
+
+
+ {formatCost(totalToolCost)}
+
+
+ )}
+
+
+
+
+
+ Total:
+
+
+ {formatCost(log.cost?.total || 0)}
+
+
+
+
+ Tokens:
+
+
+ {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '}
+ {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out
+
+
+
+
+
+
+ Total cost includes a base execution charge of {formatCost(BASE_EXECUTION_CHARGE)}{' '}
+ plus any model and tool usage costs.
+
+
+
+ )}
+
+
+ {/* Frozen Canvas Modal */}
+ {log.executionId && (
+
setIsSnapshotOpen(false)}
+ />
+ )}
+
+ )
+}
+
+interface EmbeddedLogActionsProps {
+ workspaceId: string
+ logId: string
+}
+
+export function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsProps) {
+ const router = useRouter()
+ const { data: log } = useLogDetail(logId)
+
+ const handleOpenInLogs = useCallback(() => {
+ const param = log?.executionId ? `?executionId=${log.executionId}` : ''
+ router.push(`/workspace/${workspaceId}/logs${param}`)
+ }, [router, workspaceId, log?.executionId])
+
+ return (
+
+
+
+
+
+ Open in logs
+
+
+ )
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
index 59628a4123..b5ffc7d6f2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
@@ -8,6 +8,7 @@ import {
Database,
File as FileIcon,
Folder as FolderIcon,
+ Library,
Table as TableIcon,
TerminalWindow,
} from '@/components/emcn/icons'
@@ -20,6 +21,7 @@ import type {
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
+import { logKeys } from '@/hooks/queries/logs'
import { tableKeys } from '@/hooks/queries/tables'
import { taskKeys } from '@/hooks/queries/tasks'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
@@ -163,6 +165,15 @@ export const RESOURCE_REGISTRY: Record ,
},
+ log: {
+ type: 'log',
+ label: 'Logs',
+ icon: Library,
+ renderTabIcon: (_resource, className) => (
+
+ ),
+ renderDropdownItem: (props) => ,
+ },
} as const
export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY)
@@ -200,6 +211,10 @@ const RESOURCE_INVALIDATORS: Record<
task: (qc, wId) => {
qc.invalidateQueries({ queryKey: taskKeys.list(wId) })
},
+ log: (qc, _wId, id) => {
+ qc.invalidateQueries({ queryKey: logKeys.details() })
+ qc.invalidateQueries({ queryKey: logKeys.detail(id) })
+ },
}
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index d94f9c966c..68fb65c1ac 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -9,8 +9,8 @@ import {
} from 'react'
import { Button, Tooltip } from '@/components/emcn'
import { Columns3, Eye, PanelLeft, Pencil } from '@/components/emcn/icons'
-import { isEphemeralResource } from '@/lib/copilot/resource-extraction'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
+import { isEphemeralResource } from '@/lib/copilot/resources/types'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
@@ -38,7 +38,11 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const EDGE_ZONE = 40
const SCROLL_SPEED = 8
-const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const
+const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = [
+ 'folder',
+ 'task',
+ 'log',
+] as const
/**
* Returns the id of the nearest resource to `idx` that is in `filter`
@@ -70,14 +74,16 @@ function buildMultiDragImage(
): HTMLElement | null {
if (!scrollNode || selected.length === 0) return null
const container = document.createElement('div')
- container.style.position = 'fixed'
- container.style.top = '-10000px'
- container.style.left = '-10000px'
- container.style.display = 'flex'
- container.style.alignItems = 'center'
- container.style.gap = '6px'
- container.style.padding = '4px'
- container.style.pointerEvents = 'none'
+ Object.assign(container.style, {
+ position: 'fixed',
+ top: '-10000px',
+ left: '-10000px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px',
+ padding: '4px',
+ pointerEvents: 'none',
+ } satisfies Partial)
let appendedAny = false
for (const r of selected) {
const original = scrollNode.querySelector(
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
index d2d7225026..ccdebb09b6 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
@@ -1,6 +1,7 @@
'use client'
-import { forwardRef, memo, useCallback, useState } from 'react'
+import { forwardRef, memo, useCallback, useEffect, useState } from 'react'
+import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -19,37 +20,19 @@ const PREVIEW_CYCLE: Record = {
preview: 'editor',
} as const
-function streamFileBasename(name: string): string {
- const n = name.replace(/\\/g, '/').trim()
- const parts = n.split('/').filter(Boolean)
- return parts.length ? parts[parts.length - 1]! : n
-}
-
-function fileTitlesEquivalent(streamFileName: string, resourceTitle: string): boolean {
- return streamFileBasename(streamFileName) === streamFileBasename(resourceTitle)
-}
-
/**
- * Whether the active resource should show the in-progress file_write stream.
- * The synthetic `streaming-file` tab always shows it; a real file tab shows it when
- * the streamed `fileName` matches that resource (so users who stay on the open file see live text).
+ * Whether the active resource should show the in-progress file stream.
+ * The synthetic `streaming-file` tab always shows it; a real file tab only shows it
+ * when the streamed fileId matches that exact resource.
*/
-function streamReferencesFileId(raw: string, fileId: string): boolean {
- if (!fileId) return false
- const escaped = fileId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
- return new RegExp(`"fileId"\\s*:\\s*"${escaped}"`).test(raw)
-}
-
function shouldShowStreamingFilePanel(
- streamingFile: { fileName: string; content: string } | null | undefined,
+ previewSession: FilePreviewSession | null | undefined,
active: MothershipResource | null
): boolean {
- if (!streamingFile || !active) return false
+ if (!previewSession || previewSession.status === 'complete' || !active) return false
if (active.id === 'streaming-file') return true
if (active.type !== 'file') return false
- const fn = streamingFile.fileName.trim()
- if (fn && fileTitlesEquivalent(fn, active.title)) return true
- if (active.id && streamReferencesFileId(streamingFile.content, active.id)) return true
+ if (active.id && previewSession.fileId === active.id) return true
return false
}
@@ -65,7 +48,7 @@ interface MothershipViewProps {
onCollapse: () => void
isCollapsed: boolean
className?: string
- streamingFile?: { fileName: string; content: string } | null
+ previewSession?: FilePreviewSession | null
genericResourceData?: GenericResourceData
}
@@ -83,7 +66,7 @@ export const MothershipView = memo(
onCollapse,
isCollapsed,
className,
- streamingFile,
+ previewSession,
genericResourceData,
}: MothershipViewProps,
ref
@@ -91,20 +74,21 @@ export const MothershipView = memo(
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
const { canEdit } = useUserPermissionsContext()
- const streamingForActive =
- streamingFile && active && shouldShowStreamingFilePanel(streamingFile, active)
- ? streamingFile
+ const previewForActive =
+ previewSession && active && shouldShowStreamingFilePanel(previewSession, active)
+ ? previewSession
: undefined
const [previewMode, setPreviewMode] = useState('preview')
const [prevActiveId, setPrevActiveId] = useState(active?.id)
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
- // Reset preview mode to default when the active resource changes (guarded render-phase update)
- if (active?.id !== prevActiveId) {
- setPrevActiveId(active?.id)
- setPreviewMode('preview')
- }
+ useEffect(() => {
+ if (active?.id !== prevActiveId) {
+ setPrevActiveId(active?.id)
+ setPreviewMode('preview')
+ }
+ }, [active?.id, prevActiveId])
const isActivePreviewable =
canEdit &&
@@ -143,8 +127,9 @@ export const MothershipView = memo(
workspaceId={workspaceId}
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
- streamingFile={streamingForActive}
+ previewSession={previewForActive}
genericResourceData={active.type === 'generic' ? genericResourceData : undefined}
+ previewContextKey={chatId}
/>
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index b50580143e..9c54cb87f0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -104,7 +104,7 @@ interface UserInputProps {
userId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
- onEnterWhileEmpty?: () => boolean
+ onSendQueuedHead?: () => void
}
export function UserInput({
@@ -118,7 +118,7 @@ export function UserInput({
userId,
onContextAdd,
onContextRemove,
- onEnterWhileEmpty,
+ onSendQueuedHead,
}: UserInputProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { navigateToSettings } = useSettingsNavigation()
@@ -266,8 +266,8 @@ export function UserInput({
filesRef.current = files
const contextRef = useRef(contextManagement)
contextRef.current = contextManagement
- const onEnterWhileEmptyRef = useRef(onEnterWhileEmpty)
- onEnterWhileEmptyRef.current = onEnterWhileEmpty
+ const onSendQueuedHeadRef = useRef(onSendQueuedHead)
+ onSendQueuedHeadRef.current = onSendQueuedHead
const isSendingRef = useRef(isSending)
isSendingRef.current = isSending
@@ -449,6 +449,7 @@ export function UserInput({
sttPrefixRef.current = ''
resetTranscript()
currentFiles.clearAttachedFiles()
+ prevSelectedContextsRef.current = []
currentContext.clearContexts()
if (textareaRef.current) {
@@ -460,8 +461,13 @@ export function UserInput({
(e: React.KeyboardEvent
) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
- if (isSendingRef.current && !valueRef.current.trim()) {
- onEnterWhileEmptyRef.current?.()
+ const hasSubmitPayload =
+ valueRef.current.trim().length > 0 ||
+ filesRef.current.attachedFiles.some((file) => !file.uploading && file.key)
+ if (!hasSubmitPayload) {
+ if (isSendingRef.current) {
+ onSendQueuedHeadRef.current?.()
+ }
return
}
handleSubmit()
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
index 3778f8fd1c..d4a3ea7c7c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx
@@ -65,14 +65,16 @@ function MentionHighlight({ context }: { context: ChatMessageContext }) {
}
export function UserMessageContent({ content, contexts }: UserMessageContentProps) {
+ const trimmed = content.trim()
+
if (!contexts || contexts.length === 0) {
- return {content}
+ return {trimmed}
}
const ranges = computeMentionRanges(content, contexts)
if (ranges.length === 0) {
- return {content}
+ return {trimmed}
}
const elements: React.ReactNode[] = []
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index 132d87b2a9..229b161f72 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -17,7 +17,7 @@ import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
-import type { FileAttachmentForApi, MothershipResourceType } from './types'
+import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
const logger = createLogger('Home')
@@ -115,7 +115,7 @@ export function Home({ chatId }: HomeProps = {}) {
const wasSendingRef = useRef(false)
- useChatHistory(chatId)
+ const { isPending: isChatHistoryPending } = useChatHistory(chatId)
const { mutate: markRead } = useMarkTaskRead(workspaceId)
const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize()
@@ -157,7 +157,7 @@ export function Home({ chatId }: HomeProps = {}) {
removeFromQueue,
sendNow,
editQueuedMessage,
- streamingFile,
+ previewSession,
genericResourceData,
} = useChat(
workspaceId,
@@ -201,8 +201,13 @@ export function Home({ chatId }: HomeProps = {}) {
useEffect(() => {
wasSendingRef.current = false
- if (resolvedChatId) markRead(resolvedChatId)
- }, [resolvedChatId, markRead])
+ if (resolvedChatId) {
+ markRead(resolvedChatId)
+ } else {
+ clearWidth()
+ setIsResourceCollapsed(true)
+ }
+ }, [resolvedChatId, markRead, clearWidth])
useEffect(() => {
if (wasSendingRef.current && !isSending && resolvedChatId) {
@@ -228,7 +233,7 @@ export function Home({ chatId }: HomeProps = {}) {
workspace_id: workspaceId,
view: 'mothership',
})
- stopGeneration()
+ void stopGeneration().catch(() => {})
}, [stopGeneration, workspaceId])
const handleSubmit = useCallback(
@@ -291,7 +296,7 @@ export function Home({ chatId }: HomeProps = {}) {
[resolveResourceFromContext, addResource, handleResourceEvent]
)
- const handleContextRemove = useCallback(
+ const handleInitialContextRemove = useCallback(
(context: ChatContext) => {
const resolved = resolveResourceFromContext(context)
if (resolved) removeResource(resolved.type, resolved.id)
@@ -299,7 +304,19 @@ export function Home({ chatId }: HomeProps = {}) {
[resolveResourceFromContext, removeResource]
)
+ const handleWorkspaceResourceSelect = useCallback(
+ (resource: MothershipResource) => {
+ const wasAdded = addResource(resource)
+ if (!wasAdded) {
+ setActiveResourceId(resource.id)
+ }
+ handleResourceEvent()
+ },
+ [addResource, handleResourceEvent, setActiveResourceId]
+ )
+
const hasMessages = messages.length > 0
+ const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
useEffect(() => {
if (hasMessages) return
@@ -336,7 +353,7 @@ export function Home({ chatId }: HomeProps = {}) {
onStopGeneration={handleStopGeneration}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
- onContextRemove={handleContextRemove}
+ onContextRemove={handleInitialContextRemove}
/>
@@ -358,6 +375,7 @@ export function Home({ chatId }: HomeProps = {}) {
messages={messages}
isSending={isSending}
isReconnecting={isReconnecting}
+ isLoading={showChatSkeleton}
onSubmit={handleSubmit}
onStopGeneration={handleStopGeneration}
messageQueue={messageQueue}
@@ -367,7 +385,7 @@ export function Home({ chatId }: HomeProps = {}) {
userId={session?.user?.id}
chatId={resolvedChatId}
onContextAdd={handleContextAdd}
- onContextRemove={handleContextRemove}
+ onWorkspaceResourceSelect={handleWorkspaceResourceSelect}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
animateInput={isInputEntering}
@@ -401,8 +419,8 @@ export function Home({ chatId }: HomeProps = {}) {
onReorderResources={reorderResources}
onCollapse={collapseResource}
isCollapsed={isResourceCollapsed}
- streamingFile={streamingFile}
- genericResourceData={genericResourceData}
+ previewSession={previewSession}
+ genericResourceData={genericResourceData ?? undefined}
className={skipResourceTransition ? '!transition-none' : undefined}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index ac5bf1ab16..5720130685 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -1,67 +1,126 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
-import { usePathname } from 'next/navigation'
+import { usePathname, useRouter } from 'next/navigation'
+import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
+import type {
+ PersistedFileAttachment,
+ PersistedMessage,
+} from '@/lib/copilot/chat/persisted-message'
+import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
+import type {
+ MothershipStreamV1ErrorPayload,
+ MothershipStreamV1ToolUI,
+} from '@/lib/copilot/generated/mothership-stream-v1'
import {
+ MothershipStreamV1EventType,
+ MothershipStreamV1ResourceOp,
+ MothershipStreamV1RunKind,
+ MothershipStreamV1SessionKind,
+ MothershipStreamV1SpanLifecycleEvent,
+ MothershipStreamV1SpanPayloadKind,
+ MothershipStreamV1ToolOutcome,
+ MothershipStreamV1ToolPhase,
+} from '@/lib/copilot/generated/mothership-stream-v1'
+import {
+ CrawlWebsite,
+ CreateFolder,
+ DeleteFolder,
+ DeleteWorkflow,
+ DeployApi,
+ DeployChat,
+ DeployMcp,
+ GetPageContents,
+ GetWorkflowLogs,
+ Glob,
+ Grep,
+ ManageCredential,
+ ManageCredentialOperation,
+ ManageCustomTool,
+ ManageCustomToolOperation,
+ ManageJob,
+ ManageJobOperation,
+ ManageMcpTool,
+ ManageMcpToolOperation,
+ ManageSkill,
+ ManageSkillOperation,
+ MoveFolder,
+ MoveWorkflow,
+ Read as ReadTool,
+ Redeploy,
+ RenameWorkflow,
+ RunFromBlock,
+ RunWorkflow,
+ RunWorkflowUntilBlock,
+ ScrapePage,
+ SearchOnline,
+ ToolSearchToolRegex,
+ WorkspaceFile,
+ WorkspaceFileOperation,
+} from '@/lib/copilot/generated/tool-catalog-v1'
+import { parsePersistedStreamEventEnvelopeJson } from '@/lib/copilot/request/session/contract'
+import {
+ type FilePreviewSession,
+ isFilePreviewSession,
+} from '@/lib/copilot/request/session/file-preview-session-contract'
+import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/copilot/request/session/types'
+import {
+ extractResourcesFromToolResult,
+ isResourceToolName,
+} from '@/lib/copilot/resources/extraction'
+import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
+import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
+import {
+ bindRunToolToExecution,
cancelRunToolExecution,
executeRunToolOnClient,
markRunToolManuallyStopped,
reportManualRunToolStop,
-} from '@/lib/copilot/client-sse/run-tool-execution'
-import {
- COPILOT_CHAT_API_PATH,
- COPILOT_CHAT_STREAM_API_PATH,
- MOTHERSHIP_CHAT_API_PATH,
-} from '@/lib/copilot/constants'
-import {
- extractResourcesFromToolResult,
- isEphemeralResource,
- isResourceToolName,
-} from '@/lib/copilot/resource-extraction'
-import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
-import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
+} from '@/lib/copilot/tools/client/run-tool-execution'
+import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools'
import { generateId } from '@/lib/core/utils/uuid'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
-import type {
- ChatMessage,
- ChatMessageAttachment,
- ContentBlock,
- ContentBlockType,
- FileAttachmentForApi,
- GenericResourceData,
- GenericResourceEntry,
- MothershipResource,
- MothershipResourceType,
- QueuedMessage,
- SSEPayload,
- SSEPayloadData,
- ToolCallStatus,
-} from '@/app/workspace/[workspaceId]/home/types'
+import {
+ buildCompletedPreviewSessions,
+ type FilePreviewSessionsState,
+ INITIAL_FILE_PREVIEW_SESSIONS_STATE,
+ reduceFilePreviewSessions,
+ useFilePreviewSessions,
+} from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
fetchChatHistory,
- type StreamSnapshot,
type TaskChatHistory,
- type TaskStoredContentBlock,
- type TaskStoredFileAttachment,
- type TaskStoredMessage,
- type TaskStoredToolCall,
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
+import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { workflowKeys } from '@/hooks/queries/workflows'
+import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
import type { ChatContext } from '@/stores/panel'
-import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
+import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
+import { useWorkflowStore } from '@/stores/workflows/workflow/store'
+import type {
+ ChatMessage,
+ ContentBlock,
+ FileAttachmentForApi,
+ GenericResourceData,
+ MothershipResource,
+ MothershipResourceType,
+ QueuedMessage,
+} from '../types'
+
+const FILE_SUBAGENT_ID = 'file'
export interface UseChatReturn {
messages: ChatMessage[]
@@ -85,228 +144,467 @@ export interface UseChatReturn {
removeFromQueue: (id: string) => void
sendNow: (id: string) => Promise
editQueuedMessage: (id: string) => QueuedMessage | undefined
- streamingFile: { fileName: string; content: string } | null
- genericResourceData: GenericResourceData
+ previewSession: FilePreviewSession | null
+ genericResourceData: GenericResourceData | null
}
-const STATE_TO_STATUS: Record = {
- success: 'success',
- error: 'error',
- cancelled: 'cancelled',
- rejected: 'error',
- skipped: 'success',
-} as const
+const DEPLOY_TOOL_NAMES: Set = new Set([
+ DeployApi.id,
+ DeployChat.id,
+ DeployMcp.id,
+ Redeploy.id,
+])
-const DEPLOY_TOOL_NAMES = new Set(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy'])
+const FOLDER_TOOL_NAMES: Set = new Set([CreateFolder.id, DeleteFolder.id, MoveFolder.id])
+
+const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([
+ MoveWorkflow.id,
+ RenameWorkflow.id,
+ DeleteWorkflow.id,
+])
const RECONNECT_TAIL_ERROR =
'Live reconnect failed before the stream finished. The latest response may be incomplete.'
-const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled'])
const MAX_RECONNECT_ATTEMPTS = 10
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
-interface StreamEventEnvelope {
- eventId: number
- streamId: string
- event: Record
+const logger = createLogger('useChat')
+
+type StreamPayload = Record
+
+type QueueDispatchAction = { type: 'send_head'; epoch: number }
+
+type QueueDispatchActionInput = { type: 'send_head' }
+
+type ActiveTurn = {
+ userMessageId: string
+ assistantMessageId: string
+ optimisticUserMessage: ChatMessage
+ optimisticAssistantMessage: ChatMessage
}
-interface StreamBatchResponse {
+function stringParam(value: unknown): string | undefined {
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
+}
+
+function stringArrayParam(value: unknown): string[] {
+ if (!Array.isArray(value)) return []
+ return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
+}
+
+function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined {
+ const id = stringParam(workflowId)
+ if (!id) return undefined
+ const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
+ if (!workspaceId) return undefined
+ return getWorkflowById(workspaceId, id)?.name
+}
+
+function resolveBlockNameForDisplay(blockId: unknown): string | undefined {
+ const id = stringParam(blockId)
+ if (!id) return undefined
+ return useWorkflowStore.getState().blocks[id]?.name
+}
+
+function resolveWorkspaceFileDisplayTitle(
+ operation: unknown,
+ title: unknown,
+ targetFileName?: unknown
+): string | undefined {
+ const chunkTitle = stringParam(title)
+ const fileName = stringParam(targetFileName)
+ let verb = 'Writing'
+
+ switch (operation) {
+ case WorkspaceFileOperation.append:
+ verb = 'Adding'
+ break
+ case WorkspaceFileOperation.patch:
+ verb = 'Editing'
+ break
+ case WorkspaceFileOperation.update:
+ verb = 'Writing'
+ break
+ }
+
+ if (chunkTitle) return `${verb} ${chunkTitle}`
+ if (fileName) return `${verb} ${fileName}`
+ return undefined
+}
+
+function resolveOperationDisplayTitle(
+ operation: unknown,
+ labels: Partial>,
+ fallback: string
+): string {
+ const label = typeof operation === 'string' ? labels[operation] : undefined
+ return label ?? fallback
+}
+
+function resolveToolDisplayTitle(name: string, args?: Record): string | undefined {
+ if (!args) return undefined
+
+ if (name === WorkspaceFile.id) {
+ const target = asPayloadRecord(args.target)
+ return resolveWorkspaceFileDisplayTitle(args.operation, args.title, target?.fileName)
+ }
+
+ if (name === SearchOnline.id) {
+ const toolTitle = stringParam(args.toolTitle)
+ return toolTitle ? `Searching online for ${toolTitle}` : 'Searching online'
+ }
+
+ if (name === Grep.id) {
+ const toolTitle = stringParam(args.toolTitle)
+ return toolTitle ? `Searching for ${toolTitle}` : 'Searching'
+ }
+
+ if (name === Glob.id) {
+ const toolTitle = stringParam(args.toolTitle)
+ return toolTitle ? `Finding ${toolTitle}` : 'Finding files'
+ }
+
+ if (name === ScrapePage.id) {
+ const url = stringParam(args.url)
+ return url ? `Scraping ${url}` : 'Scraping page'
+ }
+
+ if (name === CrawlWebsite.id) {
+ const url = stringParam(args.url)
+ return url ? `Crawling ${url}` : 'Crawling website'
+ }
+
+ if (name === GetPageContents.id) {
+ const urls = stringArrayParam(args.urls)
+ if (urls.length === 1) return `Getting ${urls[0]}`
+ if (urls.length > 1) return `Getting ${urls.length} pages`
+ return 'Getting page contents'
+ }
+
+ if (name === ManageCustomTool.id) {
+ return resolveOperationDisplayTitle(
+ args.operation,
+ {
+ [ManageCustomToolOperation.add]: 'Creating custom tool',
+ [ManageCustomToolOperation.edit]: 'Updating custom tool',
+ [ManageCustomToolOperation.delete]: 'Deleting custom tool',
+ [ManageCustomToolOperation.list]: 'Listing custom tools',
+ },
+ 'Custom tool action'
+ )
+ }
+
+ if (name === ManageMcpTool.id) {
+ return resolveOperationDisplayTitle(
+ args.operation,
+ {
+ [ManageMcpToolOperation.add]: 'Creating MCP server',
+ [ManageMcpToolOperation.edit]: 'Updating MCP server',
+ [ManageMcpToolOperation.delete]: 'Deleting MCP server',
+ [ManageMcpToolOperation.list]: 'Listing MCP servers',
+ },
+ 'MCP server action'
+ )
+ }
+
+ if (name === ManageSkill.id) {
+ return resolveOperationDisplayTitle(
+ args.operation,
+ {
+ [ManageSkillOperation.add]: 'Creating skill',
+ [ManageSkillOperation.edit]: 'Updating skill',
+ [ManageSkillOperation.delete]: 'Deleting skill',
+ [ManageSkillOperation.list]: 'Listing skills',
+ },
+ 'Skill action'
+ )
+ }
+
+ if (name === ManageJob.id) {
+ return resolveOperationDisplayTitle(
+ args.operation,
+ {
+ [ManageJobOperation.create]: 'Creating job',
+ [ManageJobOperation.get]: 'Getting job',
+ [ManageJobOperation.update]: 'Updating job',
+ [ManageJobOperation.delete]: 'Deleting job',
+ [ManageJobOperation.list]: 'Listing jobs',
+ },
+ 'Job action'
+ )
+ }
+
+ if (name === ManageCredential.id) {
+ return resolveOperationDisplayTitle(
+ args.operation,
+ {
+ [ManageCredentialOperation.rename]: 'Renaming credential',
+ [ManageCredentialOperation.delete]: 'Deleting credential',
+ },
+ 'Credential action'
+ )
+ }
+
+ if (name === RunWorkflow.id) {
+ const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
+ return workflowName ? `Running ${workflowName}` : 'Running workflow'
+ }
+
+ if (name === RunFromBlock.id) {
+ const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
+ const blockName = resolveBlockNameForDisplay(args.startBlockId)
+ if (workflowName && blockName) return `Running ${workflowName} from ${blockName}`
+ if (workflowName) return `Running ${workflowName}`
+ if (blockName) return `Running from ${blockName}`
+ return 'Running workflow'
+ }
+
+ if (name === RunWorkflowUntilBlock.id) {
+ const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
+ const blockName = resolveBlockNameForDisplay(args.stopAfterBlockId)
+ if (workflowName && blockName) return `Running ${workflowName} until ${blockName}`
+ if (workflowName) return `Running ${workflowName}`
+ if (blockName) return `Running until ${blockName}`
+ return 'Running workflow'
+ }
+
+ if (name === GetWorkflowLogs.id) {
+ const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
+ return workflowName ? `Getting logs for ${workflowName}` : 'Getting logs'
+ }
+
+ return undefined
+}
+
+function decodeStreamingString(value: string): string {
+ return value
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) =>
+ String.fromCharCode(Number.parseInt(hex, 16))
+ )
+ .replace(/\\"/g, '"')
+ .replace(/\\\\/g, '\\')
+}
+
+function matchStreamingStringArg(streamingArgs: string, key: string): string | undefined {
+ const match = streamingArgs.match(new RegExp(`"${key}"\\s*:\\s*"([^"]*)"`, 'm'))
+ return match?.[1] ? decodeStreamingString(match[1]) : undefined
+}
+
+function resolveStreamingToolDisplayTitle(name: string, streamingArgs: string): string | undefined {
+ if (name === WorkspaceFile.id) {
+ return resolveWorkspaceFileDisplayTitle(
+ matchStreamingStringArg(streamingArgs, 'operation'),
+ matchStreamingStringArg(streamingArgs, 'title'),
+ matchStreamingStringArg(streamingArgs, 'fileName')
+ )
+ }
+
+ if (name === SearchOnline.id) {
+ const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
+ return toolTitle ? `Searching online for ${toolTitle}` : undefined
+ }
+
+ if (name === Grep.id) {
+ const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
+ return toolTitle ? `Searching for ${toolTitle}` : undefined
+ }
+
+ if (name === Glob.id) {
+ const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
+ return toolTitle ? `Finding ${toolTitle}` : undefined
+ }
+
+ if (name === ScrapePage.id) {
+ const url = matchStreamingStringArg(streamingArgs, 'url')
+ return url ? `Scraping ${url}` : undefined
+ }
+
+ if (name === CrawlWebsite.id) {
+ const url = matchStreamingStringArg(streamingArgs, 'url')
+ return url ? `Crawling ${url}` : undefined
+ }
+
+ if (name === ManageCustomTool.id) {
+ return resolveOperationDisplayTitle(
+ matchStreamingStringArg(streamingArgs, 'operation'),
+ {
+ [ManageCustomToolOperation.add]: 'Creating custom tool',
+ [ManageCustomToolOperation.edit]: 'Updating custom tool',
+ [ManageCustomToolOperation.delete]: 'Deleting custom tool',
+ [ManageCustomToolOperation.list]: 'Listing custom tools',
+ },
+ 'Custom tool action'
+ )
+ }
+
+ if (name === ManageMcpTool.id) {
+ return resolveOperationDisplayTitle(
+ matchStreamingStringArg(streamingArgs, 'operation'),
+ {
+ [ManageMcpToolOperation.add]: 'Creating MCP server',
+ [ManageMcpToolOperation.edit]: 'Updating MCP server',
+ [ManageMcpToolOperation.delete]: 'Deleting MCP server',
+ [ManageMcpToolOperation.list]: 'Listing MCP servers',
+ },
+ 'MCP server action'
+ )
+ }
+
+ if (name === ManageSkill.id) {
+ return resolveOperationDisplayTitle(
+ matchStreamingStringArg(streamingArgs, 'operation'),
+ {
+ [ManageSkillOperation.add]: 'Creating skill',
+ [ManageSkillOperation.edit]: 'Updating skill',
+ [ManageSkillOperation.delete]: 'Deleting skill',
+ [ManageSkillOperation.list]: 'Listing skills',
+ },
+ 'Skill action'
+ )
+ }
+
+ if (name === ManageJob.id) {
+ return resolveOperationDisplayTitle(
+ matchStreamingStringArg(streamingArgs, 'operation'),
+ {
+ [ManageJobOperation.create]: 'Creating job',
+ [ManageJobOperation.get]: 'Getting job',
+ [ManageJobOperation.update]: 'Updating job',
+ [ManageJobOperation.delete]: 'Deleting job',
+ [ManageJobOperation.list]: 'Listing jobs',
+ },
+ 'Job action'
+ )
+ }
+
+ if (name === ManageCredential.id) {
+ return resolveOperationDisplayTitle(
+ matchStreamingStringArg(streamingArgs, 'operation'),
+ {
+ [ManageCredentialOperation.rename]: 'Renaming credential',
+ [ManageCredentialOperation.delete]: 'Deleting credential',
+ },
+ 'Credential action'
+ )
+ }
+
+ return undefined
+}
+
+type StreamToolUI = {
+ hidden?: boolean
+ title?: string
+ phaseLabel?: string
+ clientExecutable?: boolean
+}
+
+type StreamBatchResponse = {
success: boolean
- events: StreamEventEnvelope[]
+ events: StreamBatchEvent[]
+ previewSessions?: FilePreviewSession[]
status: string
}
-interface StreamTerminationResult {
- sawStreamError: boolean
- sawDoneEvent: boolean
- lastEventId: number
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
-interface StreamProcessingOptions {
- expectedGen?: number
- initialLastEventId?: number
- preserveExistingState?: boolean
-}
+function parseStreamBatchResponse(value: unknown): StreamBatchResponse {
+ if (!isRecord(value)) {
+ throw new Error('Invalid stream batch response')
+ }
-interface AttachToStreamOptions {
- streamId: string
- assistantId: string
- expectedGen: number
- snapshot?: StreamSnapshot | null
- initialLastEventId?: number
-}
-
-interface AttachToStreamResult {
- aborted: boolean
- error: boolean
-}
-
-interface PendingStreamRecovery {
- streamId: string
- snapshot?: StreamSnapshot | null
-}
+ const rawEvents = Array.isArray(value.events) ? value.events : []
+ const events: StreamBatchEvent[] = []
+ for (const entry of rawEvents) {
+ if (!isStreamBatchEvent(entry)) {
+ throw new Error('Invalid stream batch event')
+ }
+ events.push(entry)
+ }
-function isTerminalStreamStatus(status?: string | null): boolean {
- return Boolean(status && TERMINAL_STREAM_STATUSES.has(status))
-}
+ const rawPreviewSessions = Array.isArray(value.previewSessions)
+ ? value.previewSessions
+ : undefined
+ const previewSessions =
+ rawPreviewSessions?.map((session) => {
+ if (!isFilePreviewSession(session)) {
+ throw new Error('Invalid stream preview session')
+ }
+ return session
+ }) ?? undefined
-function isActiveStreamConflictError(input: unknown): boolean {
- if (typeof input !== 'string') return false
- return input.includes('A response is already in progress for this chat')
-}
-
-/**
- * Extracts tool call IDs from snapshot events so that replayed client-executable
- * tool calls are not re-executed after a page refresh.
- */
-function extractToolCallIdsFromSnapshot(snapshot?: StreamSnapshot | null): Set {
- const ids = new Set()
- if (!snapshot?.events) return ids
- for (const entry of snapshot.events) {
- const event = entry.event
- if (event.type === 'tool_call' && typeof event.toolCallId === 'string') {
- ids.add(event.toolCallId)
- }
+ return {
+ success: value.success === true,
+ events,
+ ...(previewSessions ? { previewSessions } : {}),
+ status: typeof value.status === 'string' ? value.status : 'unknown',
}
- return ids
}
-function buildReplayStream(events: StreamEventEnvelope[]): ReadableStream {
- const encoder = new TextEncoder()
- return new ReadableStream({
- start(controller) {
- if (events.length > 0) {
- const payload = events
+function buildChatHistoryHydrationKey(chatHistory: TaskChatHistory): string {
+ const resourceKey = chatHistory.resources
+ .map((resource) => `${resource.type}:${resource.id}:${resource.title}`)
+ .join('|')
+ const messageKey = chatHistory.messages.map((message) => message.id).join('|')
+ const streamSnapshot = chatHistory.streamSnapshot
+ const snapshotKey = streamSnapshot
+ ? [
+ streamSnapshot.status,
+ streamSnapshot.events.length,
+ streamSnapshot.events[streamSnapshot.events.length - 1]?.eventId ?? '',
+ streamSnapshot.previewSessions
.map(
- (entry) =>
- `data: ${JSON.stringify({ ...entry.event, eventId: entry.eventId, streamId: entry.streamId })}\n\n`
+ (session) =>
+ `${session.id}:${session.previewVersion}:${session.status}:${session.updatedAt}`
)
- .join('')
- controller.enqueue(encoder.encode(payload))
- }
+ .join('|'),
+ ].join('~')
+ : 'none'
+
+ return [
+ chatHistory.id,
+ chatHistory.activeStreamId ?? '',
+ messageKey,
+ resourceKey,
+ snapshotKey,
+ ].join('::')
+}
+
+const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled'])
+
+function isTerminalStreamStatus(status: string | null | undefined): boolean {
+ return TERMINAL_STREAM_STATUSES.has(status ?? '')
+}
+
+const sseEncoder = new TextEncoder()
+function buildReplayStream(events: StreamBatchEvent[]): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ const payload = events.map((entry) => `data: ${JSON.stringify(entry.event)}\n\n`).join('')
+ controller.enqueue(sseEncoder.encode(payload))
controller.close()
},
})
}
-function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
- if (block.type === 'thinking') {
- return {
- type: 'text',
- content: block.content ? `${block.content}` : '',
- }
- }
-
- const mapped: ContentBlock = {
- type: block.type as ContentBlockType,
- content: block.content,
- }
-
- if (block.type === 'tool_call' && block.toolCall) {
- const resolvedStatus = STATE_TO_STATUS[block.toolCall.state ?? ''] ?? 'error'
- mapped.toolCall = {
- id: block.toolCall.id ?? '',
- name: block.toolCall.name ?? 'unknown',
- status: resolvedStatus,
- displayTitle:
- resolvedStatus === 'cancelled' ? 'Stopped by user' : block.toolCall.display?.text,
- params: block.toolCall.params,
- calledBy: block.toolCall.calledBy,
- result: block.toolCall.result,
- }
- }
-
- return mapped
+function asPayloadRecord(value: unknown): StreamPayload | undefined {
+ return isRecord(value) ? value : undefined
}
-function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock {
- const resolvedStatus = (STATE_TO_STATUS[tc.status] ?? 'error') as ToolCallStatus
+function getToolUI(ui?: MothershipStreamV1ToolUI): StreamToolUI | undefined {
+ if (!ui) {
+ return undefined
+ }
+
return {
- type: 'tool_call',
- toolCall: {
- id: tc.id,
- name: tc.name,
- status: resolvedStatus,
- displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined,
- params: tc.params,
- result:
- tc.result != null
- ? {
- success: tc.status === 'success',
- output: tc.result,
- error: tc.error,
- }
- : undefined,
- },
+ ...(typeof ui.hidden === 'boolean' ? { hidden: ui.hidden } : {}),
+ ...(typeof ui.title === 'string' ? { title: ui.title } : {}),
+ ...(typeof ui.phaseLabel === 'string' ? { phaseLabel: ui.phaseLabel } : {}),
+ ...(typeof ui.clientExecutable === 'boolean' ? { clientExecutable: ui.clientExecutable } : {}),
}
}
-function toDisplayAttachment(f: TaskStoredFileAttachment): ChatMessageAttachment {
- return {
- id: f.id,
- filename: f.filename,
- media_type: f.media_type,
- size: f.size,
- previewUrl: f.media_type.startsWith('image/')
- ? `/api/files/serve/${encodeURIComponent(f.key)}?context=mothership`
- : undefined,
- }
-}
-
-function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
- const mapped: ChatMessage = {
- id: msg.id,
- role: msg.role,
- content: msg.content,
- ...(msg.requestId ? { requestId: msg.requestId } : {}),
- }
-
- const hasContentBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0
- const hasToolCalls = Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0
- const contentBlocksHaveTools =
- hasContentBlocks && msg.contentBlocks!.some((b) => b.type === 'tool_call')
-
- if (hasContentBlocks && (!hasToolCalls || contentBlocksHaveTools)) {
- const blocks = msg.contentBlocks!.map(mapStoredBlock)
- const hasText = blocks.some((b) => b.type === 'text' && b.content?.trim())
- if (!hasText && msg.content?.trim()) {
- blocks.push({ type: 'text', content: msg.content })
- }
- mapped.contentBlocks = blocks
- } else if (hasToolCalls) {
- const blocks: ContentBlock[] = msg.toolCalls!.map(mapStoredToolCall)
- if (msg.content?.trim()) {
- blocks.push({ type: 'text', content: msg.content })
- }
- mapped.contentBlocks = blocks
- }
-
- if (Array.isArray(msg.fileAttachments) && msg.fileAttachments.length > 0) {
- mapped.attachments = msg.fileAttachments.map(toDisplayAttachment)
- }
-
- if (Array.isArray(msg.contexts) && msg.contexts.length > 0) {
- mapped.contexts = msg.contexts.map((c) => ({
- kind: c.kind,
- label: c.label,
- ...(c.workflowId && { workflowId: c.workflowId }),
- ...(c.knowledgeId && { knowledgeId: c.knowledgeId }),
- ...(c.tableId && { tableId: c.tableId }),
- ...(c.fileId && { fileId: c.fileId }),
- ...(c.folderId && { folderId: c.folderId }),
- }))
- }
-
- return mapped
-}
-
-const logger = createLogger('useChat')
-
-function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
- return typeof payload.data === 'object' ? payload.data : undefined
-}
-
/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */
function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean {
const workflows = getWorkflows(workspaceId)
@@ -399,7 +697,7 @@ export function getWorkflowCopilotUseChatOptions(
> = {}
): UseChatOptions {
return {
- apiPath: COPILOT_CHAT_API_PATH,
+ apiPath: MOTHERSHIP_CHAT_API_PATH,
stopPath: '/api/mothership/chat/stop',
...options,
}
@@ -411,6 +709,7 @@ export function useChat(
options?: UseChatOptions
): UseChatReturn {
const pathname = usePathname()
+ const router = useRouter()
const queryClient = useQueryClient()
const [messages, setMessages] = useState([])
const [isSending, setIsSending] = useState(false)
@@ -418,14 +717,17 @@ export function useChat(
const [error, setError] = useState(null)
const [resolvedChatId, setResolvedChatId] = useState(initialChatId)
const [resources, setResources] = useState([])
- const [activeResourceId, setActiveResourceId] = useState(null)
- const initialActiveResourceIdRef = useRef(options?.initialActiveResourceId)
+ const [activeResourceId, setActiveResourceId] = useState(
+ options?.initialActiveResourceId ?? null
+ )
+ const [genericResourceData, setGenericResourceData] = useState(null)
const onResourceEventRef = useRef(options?.onResourceEvent)
onResourceEventRef.current = options?.onResourceEvent
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
apiPathRef.current = options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH
const stopPathRef = useRef(options?.stopPath ?? '/api/mothership/chat/stop')
stopPathRef.current = options?.stopPath ?? '/api/mothership/chat/stop'
+ const pendingStopPromiseRef = useRef | null>(null)
const workflowIdRef = useRef(options?.workflowId)
workflowIdRef.current = options?.workflowId
const onToolResultRef = useRef(options?.onToolResult)
@@ -434,6 +736,13 @@ export function useChat(
onTitleUpdateRef.current = options?.onTitleUpdate
const onStreamEndRef = useRef(options?.onStreamEnd)
onStreamEndRef.current = options?.onStreamEnd
+
+ const clearQueueDispatchState = useCallback(() => {
+ queueDispatchEpochRef.current++
+ queueDispatchActionsRef.current = []
+ queuedMessageDispatchIdsRef.current.clear()
+ queueDispatchTaskRef.current = null
+ }, [])
const resourcesRef = useRef(resources)
resourcesRef.current = resources
@@ -449,46 +758,190 @@ export function useChat(
const activeResourceIdRef = useRef(effectiveActiveResourceId)
activeResourceIdRef.current = effectiveActiveResourceId
- const [streamingFile, setStreamingFile] = useState<{
- fileName: string
- content: string
- } | null>(null)
- const streamingFileRef = useRef(streamingFile)
- streamingFileRef.current = streamingFile
-
- const [genericResourceData, setGenericResourceData] = useState({
- entries: [],
+ const {
+ previewSession,
+ previewSessionsById,
+ activePreviewSessionId,
+ hydratePreviewSessions,
+ upsertPreviewSession,
+ completePreviewSession,
+ removePreviewSession,
+ resetPreviewSessions,
+ } = useFilePreviewSessions()
+ const previewSessionRef = useRef(previewSession)
+ previewSessionRef.current = previewSession
+ const previewSessionsRef = useRef(previewSessionsById)
+ previewSessionsRef.current = previewSessionsById
+ const activePreviewSessionIdRef = useRef(activePreviewSessionId)
+ activePreviewSessionIdRef.current = activePreviewSessionId
+ const previewSessionsStateRef = useRef({
+ activeSessionId: activePreviewSessionId,
+ sessions: previewSessionsById,
})
- const genericResourceDataRef = useRef({ entries: [] })
+ previewSessionsStateRef.current = {
+ activeSessionId: activePreviewSessionId,
+ sessions: previewSessionsById,
+ }
+
+ const syncPreviewSessionRefs = useCallback((nextState: FilePreviewSessionsState) => {
+ previewSessionsStateRef.current = nextState
+ previewSessionsRef.current = nextState.sessions
+ activePreviewSessionIdRef.current = nextState.activeSessionId
+ previewSessionRef.current =
+ nextState.activeSessionId !== null
+ ? (nextState.sessions[nextState.activeSessionId] ?? null)
+ : null
+ }, [])
+
+ const applyPreviewSessionUpdate = useCallback(
+ (session: FilePreviewSession, options?: { activate?: boolean }) => {
+ const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
+ type: 'upsert',
+ session,
+ ...(options?.activate === false ? { activate: false } : {}),
+ })
+ syncPreviewSessionRefs(nextState)
+ upsertPreviewSession(session, options)
+ return nextState
+ },
+ [syncPreviewSessionRefs, upsertPreviewSession]
+ )
+
+ const applyCompletedPreviewSession = useCallback(
+ (session: FilePreviewSession) => {
+ const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
+ type: 'complete',
+ session,
+ })
+ syncPreviewSessionRefs(nextState)
+ completePreviewSession(session)
+ return nextState
+ },
+ [completePreviewSession, syncPreviewSessionRefs]
+ )
+
+ const reconcileTerminalPreviewSessions = useCallback(() => {
+ const completedAt = new Date().toISOString()
+ const completedSessions = buildCompletedPreviewSessions(
+ previewSessionsStateRef.current.sessions,
+ completedAt
+ )
+
+ for (const session of completedSessions) {
+ applyCompletedPreviewSession(session)
+ }
+ }, [applyCompletedPreviewSession])
+
+ const removePreviewSessionImmediate = useCallback(
+ (sessionId: string) => {
+ const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
+ type: 'remove',
+ sessionId,
+ })
+ syncPreviewSessionRefs(nextState)
+ removePreviewSession(sessionId)
+ return nextState
+ },
+ [removePreviewSession, syncPreviewSessionRefs]
+ )
const [messageQueue, setMessageQueue] = useState([])
const messageQueueRef = useRef([])
messageQueueRef.current = messageQueue
- const [pendingRecoveryMessage, setPendingRecoveryMessage] = useState(null)
- const pendingRecoveryMessageRef = useRef(null)
- pendingRecoveryMessageRef.current = pendingRecoveryMessage
+ const queuedMessageDispatchIdsRef = useRef>(new Set())
+ const queueDispatchActionsRef = useRef([])
+ const queueDispatchTaskRef = useRef | null>(null)
+ const queueDispatchEpochRef = useRef(0)
+ const queueDispatchLoopRef = useRef<() => Promise>(async () => {})
+ const enqueueQueueDispatchRef = useRef<(action: QueueDispatchActionInput) => Promise>(
+ async () => {}
+ )
- const sendMessageRef = useRef(async () => {})
const processSSEStreamRef = useRef<
(
reader: ReadableStreamDefaultReader,
assistantId: string,
- options?: StreamProcessingOptions
- ) => Promise
- >(async () => ({
- sawStreamError: false,
- sawDoneEvent: false,
- lastEventId: 0,
- }))
- const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
- const retryReconnectRef = useRef<
+ expectedGen?: number,
+ options?: { preserveExistingState?: boolean }
+ ) => Promise<{ sawStreamError: boolean; sawComplete: boolean }>
+ >(async () => ({ sawStreamError: false, sawComplete: false }))
+ const attachToExistingStreamRef = useRef<
(opts: {
streamId: string
assistantId: string
- gen: number
- initialSnapshot?: StreamSnapshot | null
- }) => Promise
+ expectedGen: number
+ initialBatch?: StreamBatchResponse | null
+ afterCursor?: string
+ }) => Promise<{ error: boolean; aborted: boolean }>
+ >(async () => ({ error: false, aborted: true }))
+ const retryReconnectRef = useRef<
+ (opts: { streamId: string; assistantId: string; gen: number }) => Promise
>(async () => false)
+ const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
+
+ const resetEphemeralPreviewState = useCallback(
+ (options?: { removeStreamingResource?: boolean }) => {
+ syncPreviewSessionRefs(INITIAL_FILE_PREVIEW_SESSIONS_STATE)
+ resetPreviewSessions()
+ if (options?.removeStreamingResource) {
+ setResources((current) => current.filter((resource) => resource.id !== 'streaming-file'))
+ }
+ },
+ [resetPreviewSessions, syncPreviewSessionRefs]
+ )
+
+ const syncPreviewResourceChrome = useCallback((session: FilePreviewSession) => {
+ if (session.targetKind === 'new_file') {
+ setResources((current) => {
+ const existing = current.find((resource) => resource.id === 'streaming-file')
+ if (existing) {
+ return current.map((resource) =>
+ resource.id === 'streaming-file'
+ ? { ...resource, title: session.fileName || 'Writing file...' }
+ : resource
+ )
+ }
+ return [
+ ...current,
+ {
+ type: 'file',
+ id: 'streaming-file',
+ title: session.fileName || 'Writing file...',
+ },
+ ]
+ })
+ setActiveResourceId('streaming-file')
+ return
+ }
+
+ if (session.fileId) {
+ setResources((current) => current.filter((resource) => resource.id !== 'streaming-file'))
+ setActiveResourceId(session.fileId)
+ }
+ }, [])
+
+ const seedPreviewSessions = useCallback(
+ (sessions: FilePreviewSession[]) => {
+ if (sessions.length === 0) {
+ return
+ }
+
+ const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
+ type: 'hydrate',
+ sessions,
+ })
+ syncPreviewSessionRefs(nextState)
+ hydratePreviewSessions(sessions)
+ const active =
+ nextState.activeSessionId !== null
+ ? (nextState.sessions[nextState.activeSessionId] ?? null)
+ : null
+ if (active) {
+ syncPreviewResourceChrome(active)
+ }
+ },
+ [hydratePreviewSessions, syncPreviewResourceChrome, syncPreviewSessionRefs]
+ )
const abortControllerRef = useRef(null)
const streamReaderRef = useRef | null>(null)
@@ -496,17 +949,102 @@ export function useChat(
/** Panel/task selection — drives createNewChat + request chatId; may differ from chatIdRef while a stream is still finishing. */
const selectedChatIdRef = useRef(initialChatId)
selectedChatIdRef.current = initialChatId
- const appliedChatIdRef = useRef(undefined)
- const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null)
+ const appliedChatHistoryKeyRef = useRef(undefined)
+ const activeTurnRef = useRef(null)
+ const pendingUserMsgRef = useRef(null)
const streamIdRef = useRef(undefined)
- const lastEventIdRef = useRef(0)
+ const locallyTerminalStreamIdRef = useRef(undefined)
+ const lastCursorRef = useRef('0')
const sendingRef = useRef(false)
const streamGenRef = useRef(0)
const streamingContentRef = useRef('')
const streamingBlocksRef = useRef([])
- const clientExecutionStartedRef = useRef>(new Set())
+ const handledClientWorkflowToolIdsRef = useRef>(new Set())
+ const recoveringClientWorkflowToolIdsRef = useRef>(new Set())
const executionStream = useExecutionStream()
const isHomePage = pathname.endsWith('/home')
+ const wasHomePageRef = useRef(isHomePage)
+ const pendingHomeResetRef = useRef(false)
+
+ const setTransportIdle = useCallback(() => {
+ sendingRef.current = false
+ setIsSending(false)
+ setIsReconnecting(false)
+ }, [])
+
+ const setTransportStreaming = useCallback(() => {
+ sendingRef.current = true
+ setIsSending(true)
+ setIsReconnecting(false)
+ }, [])
+
+ const setTransportReconnecting = useCallback(() => {
+ sendingRef.current = true
+ setIsSending(true)
+ setIsReconnecting(true)
+ }, [])
+
+ const resetStreamingBuffers = useCallback(() => {
+ streamingContentRef.current = ''
+ streamingBlocksRef.current = []
+ }, [])
+
+ const clearActiveTurn = useCallback(() => {
+ activeTurnRef.current = null
+ pendingUserMsgRef.current = null
+ streamIdRef.current = undefined
+ lastCursorRef.current = '0'
+ resetStreamingBuffers()
+ }, [resetStreamingBuffers])
+
+ const resetHomeChatState = useCallback(() => {
+ streamGenRef.current++
+ chatIdRef.current = undefined
+ lastCursorRef.current = '0'
+ locallyTerminalStreamIdRef.current = undefined
+ clearActiveTurn()
+ setResolvedChatId(undefined)
+ appliedChatHistoryKeyRef.current = undefined
+ abortControllerRef.current = null
+ setMessages([])
+ setError(null)
+ setTransportIdle()
+ setResources([])
+ setActiveResourceId(null)
+ resetEphemeralPreviewState()
+ setMessageQueue([])
+ clearQueueDispatchState()
+ }, [clearActiveTurn, clearQueueDispatchState, resetEphemeralPreviewState, setTransportIdle])
+
+ const mergeServerMessagesWithActiveTurn = useCallback(
+ (serverMessages: ChatMessage[], previousMessages: ChatMessage[]) => {
+ const activeTurn = activeTurnRef.current
+ if (!activeTurn || !sendingRef.current) {
+ return serverMessages
+ }
+
+ const nextMessages = [...serverMessages]
+ const localStreamingUser =
+ previousMessages.find(
+ (message) => message.id === activeTurn.userMessageId && message.role === 'user'
+ ) ?? activeTurn.optimisticUserMessage
+ const localStreamingAssistant =
+ previousMessages.find(
+ (message) => message.id === activeTurn.assistantMessageId && message.role === 'assistant'
+ ) ?? activeTurn.optimisticAssistantMessage
+
+ if (!nextMessages.some((message) => message.id === localStreamingUser.id)) {
+ nextMessages.push(localStreamingUser)
+ }
+
+ if (!nextMessages.some((message) => message.id === localStreamingAssistant.id)) {
+ nextMessages.push(localStreamingAssistant)
+ }
+
+ return nextMessages
+ },
+ []
+ )
const { data: chatHistory } = useChatHistory(initialChatId)
@@ -522,13 +1060,13 @@ export function useChat(
})
setActiveResourceId(resource.id)
- if (isEphemeralResource(resource)) {
+ if (resource.id === 'streaming-file') {
return true
}
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
if (persistChatId) {
- fetch('/api/copilot/chat/resources', {
+ fetch('/api/mothership/chat/resources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId: persistChatId, resource }),
@@ -541,12 +1079,99 @@ export function useChat(
const removeResource = useCallback((resourceType: MothershipResourceType, resourceId: string) => {
setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId)))
+ setActiveResourceId((prev) => (prev === resourceId ? null : prev))
}, [])
const reorderResources = useCallback((newOrder: MothershipResource[]) => {
setResources(newOrder)
}, [])
+ const ensureWorkflowToolResource = useCallback(
+ (toolArgs: Record): string | undefined => {
+ const targetWorkflowId =
+ typeof toolArgs.workflowId === 'string'
+ ? toolArgs.workflowId
+ : useWorkflowRegistry.getState().activeWorkflowId
+
+ if (!targetWorkflowId) {
+ return undefined
+ }
+
+ const meta = getWorkflowById(workspaceId, targetWorkflowId)
+ const wasAdded = addResource({
+ type: 'workflow',
+ id: targetWorkflowId,
+ title: meta?.name ?? 'Workflow',
+ })
+ if (!wasAdded && activeResourceIdRef.current !== targetWorkflowId) {
+ setActiveResourceId(targetWorkflowId)
+ }
+ onResourceEventRef.current?.()
+
+ return targetWorkflowId
+ },
+ [addResource, workspaceId]
+ )
+
+ const startClientWorkflowTool = useCallback(
+ (toolCallId: string, toolName: string, toolArgs: Record) => {
+ if (!isWorkflowToolName(toolName)) {
+ return
+ }
+ if (handledClientWorkflowToolIdsRef.current.has(toolCallId)) {
+ return
+ }
+ handledClientWorkflowToolIdsRef.current.add(toolCallId)
+
+ ensureWorkflowToolResource(toolArgs)
+ executeRunToolOnClient(toolCallId, toolName, toolArgs)
+ },
+ [ensureWorkflowToolResource]
+ )
+
+ const recoverPendingClientWorkflowTools = useCallback(
+ async (nextMessages: ChatMessage[]) => {
+ for (const message of nextMessages) {
+ for (const block of message.contentBlocks ?? []) {
+ const toolCall = block.toolCall
+ if (!toolCall || !isWorkflowToolName(toolCall.name)) {
+ continue
+ }
+ if (toolCall.status !== 'executing') {
+ continue
+ }
+
+ if (
+ handledClientWorkflowToolIdsRef.current.has(toolCall.id) ||
+ recoveringClientWorkflowToolIdsRef.current.has(toolCall.id)
+ ) {
+ continue
+ }
+
+ recoveringClientWorkflowToolIdsRef.current.add(toolCall.id)
+
+ try {
+ const toolArgs = toolCall.params ?? {}
+ const targetWorkflowId = ensureWorkflowToolResource(toolArgs)
+
+ if (targetWorkflowId) {
+ const rebound = await bindRunToolToExecution(toolCall.id, targetWorkflowId)
+ if (rebound) {
+ handledClientWorkflowToolIdsRef.current.add(toolCall.id)
+ continue
+ }
+ }
+
+ startClientWorkflowTool(toolCall.id, toolCall.name, toolArgs)
+ } finally {
+ recoveringClientWorkflowToolIdsRef.current.delete(toolCall.id)
+ }
+ }
+ }
+ },
+ [ensureWorkflowToolResource, startClientWorkflowTool]
+ )
+
useEffect(() => {
if (sendingRef.current) {
const streamOwnerId = chatIdRef.current
@@ -560,372 +1185,190 @@ export function useChat(
// Reopening that chat later will reconnect through the existing chatHistory flow.
streamGenRef.current++
abortControllerRef.current = null
- sendingRef.current = false
- setIsSending(false)
- setIsReconnecting(false)
- lastEventIdRef.current = 0
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
+ clearActiveTurn()
+ setTransportIdle()
if (abandonedChatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(abandonedChatId) })
}
} else {
setResolvedChatId(initialChatId)
- setMessageQueue([])
return
}
}
chatIdRef.current = initialChatId
+ lastCursorRef.current = '0'
+ locallyTerminalStreamIdRef.current = undefined
+ clearActiveTurn()
setResolvedChatId(initialChatId)
- appliedChatIdRef.current = undefined
+ appliedChatHistoryKeyRef.current = undefined
setMessages([])
setError(null)
- setIsSending(false)
- setIsReconnecting(false)
+ setTransportIdle()
setResources([])
setActiveResourceId(null)
- setStreamingFile(null)
- streamingFileRef.current = null
- genericResourceDataRef.current = { entries: [] }
- setGenericResourceData({ entries: [] })
+ resetEphemeralPreviewState()
setMessageQueue([])
- lastEventIdRef.current = 0
- clientExecutionStartedRef.current.clear()
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
- }, [initialChatId, queryClient])
+ clearQueueDispatchState()
+ }, [
+ initialChatId,
+ queryClient,
+ resetEphemeralPreviewState,
+ clearQueueDispatchState,
+ clearActiveTurn,
+ setTransportIdle,
+ ])
useEffect(() => {
- if (workflowIdRef.current) return
- if (!isHomePage || !chatIdRef.current) return
- streamGenRef.current++
- chatIdRef.current = undefined
- setResolvedChatId(undefined)
- appliedChatIdRef.current = undefined
- abortControllerRef.current = null
- sendingRef.current = false
- setMessages([])
- setError(null)
- setIsSending(false)
- setIsReconnecting(false)
- setResources([])
- setActiveResourceId(null)
- setStreamingFile(null)
- streamingFileRef.current = null
- genericResourceDataRef.current = { entries: [] }
- setGenericResourceData({ entries: [] })
- setMessageQueue([])
- lastEventIdRef.current = 0
- clientExecutionStartedRef.current.clear()
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
- }, [isHomePage])
+ const wasHomePage = wasHomePageRef.current
+ wasHomePageRef.current = isHomePage
- const fetchStreamBatch = useCallback(
- async (
- streamId: string,
- fromEventId: number,
- signal?: AbortSignal
- ): Promise => {
- const response = await fetch(
- `${COPILOT_CHAT_STREAM_API_PATH}?streamId=${encodeURIComponent(streamId)}&from=${fromEventId}&batch=true`,
- { signal }
- )
+ if (!isHomePage) {
+ pendingHomeResetRef.current = false
+ return
+ }
+ if (workflowIdRef.current || !chatIdRef.current) return
- if (!response.ok) {
- throw new Error(`Stream resume batch failed: ${response.status}`)
- }
+ const shouldHandleHomeReset = pendingHomeResetRef.current || !wasHomePage
+ if (!shouldHandleHomeReset) return
- return response.json()
- },
- []
- )
+ const hasActiveTransport =
+ isSending ||
+ sendingRef.current ||
+ isReconnecting ||
+ abortControllerRef.current !== null ||
+ streamReaderRef.current !== null
- const attachToExistingStream = useCallback(
- async ({
- streamId,
- assistantId,
- expectedGen,
- snapshot,
- initialLastEventId = 0,
- }: AttachToStreamOptions): Promise => {
- let latestEventId = initialLastEventId
- let seedEvents = snapshot?.events ?? []
- let streamStatus = snapshot?.status ?? 'unknown'
- let attachAttempt = 0
+ if (hasActiveTransport) {
+ pendingHomeResetRef.current = true
+ return
+ }
- setIsSending(true)
- setIsReconnecting(true)
- setError(null)
-
- logger.info('Attaching to existing stream', {
- streamId,
- expectedGen,
- initialLastEventId,
- seedEventCount: seedEvents.length,
- streamStatus,
- })
-
- try {
- while (streamGenRef.current === expectedGen) {
- if (seedEvents.length > 0) {
- const replayResult = await processSSEStreamRef.current(
- buildReplayStream(seedEvents).getReader(),
- assistantId,
- {
- expectedGen,
- initialLastEventId: latestEventId,
- preserveExistingState: true,
- }
- )
- latestEventId = Math.max(
- replayResult.lastEventId,
- seedEvents[seedEvents.length - 1]?.eventId ?? latestEventId
- )
- lastEventIdRef.current = latestEventId
- seedEvents = []
-
- if (replayResult.sawStreamError) {
- logger.warn('Replay stream ended with error event', { streamId, latestEventId })
- return { aborted: false, error: true }
- }
- }
-
- if (isTerminalStreamStatus(streamStatus)) {
- logger.info('Existing stream already reached terminal status', {
- streamId,
- latestEventId,
- streamStatus,
- })
- if (streamStatus === 'error') {
- setError(RECONNECT_TAIL_ERROR)
- }
- return { aborted: false, error: streamStatus === 'error' }
- }
-
- const activeAbortController = abortControllerRef.current
- if (!activeAbortController) {
- return { aborted: true, error: false }
- }
-
- logger.info('Opening live stream tail', {
- streamId,
- fromEventId: latestEventId,
- attempt: attachAttempt,
- })
-
- const sseRes = await fetch(
- `${COPILOT_CHAT_STREAM_API_PATH}?streamId=${encodeURIComponent(streamId)}&from=${latestEventId}`,
- { signal: activeAbortController.signal }
- )
- if (!sseRes.ok || !sseRes.body) {
- throw new Error(RECONNECT_TAIL_ERROR)
- }
-
- setIsReconnecting(false)
-
- const liveResult = await processSSEStreamRef.current(
- sseRes.body.getReader(),
- assistantId,
- {
- expectedGen,
- initialLastEventId: latestEventId,
- preserveExistingState: true,
- }
- )
- latestEventId = Math.max(latestEventId, liveResult.lastEventId)
- lastEventIdRef.current = latestEventId
-
- if (liveResult.sawStreamError) {
- logger.warn('Live stream tail ended with error event', { streamId, latestEventId })
- return { aborted: false, error: true }
- }
-
- attachAttempt += 1
- setIsReconnecting(true)
-
- logger.warn('Live stream ended without terminal event, fetching replay batch', {
- streamId,
- latestEventId,
- attempt: attachAttempt,
- })
-
- const batch = await fetchStreamBatch(
- streamId,
- latestEventId,
- activeAbortController.signal
- )
- seedEvents = batch.events
- streamStatus = batch.status
-
- if (batch.events.length > 0) {
- latestEventId = batch.events[batch.events.length - 1].eventId
- lastEventIdRef.current = latestEventId
- }
-
- logger.info('Fetched replay batch after non-terminal stream close', {
- streamId,
- latestEventId,
- streamStatus,
- eventCount: batch.events.length,
- attempt: attachAttempt,
- })
-
- if (batch.events.length === 0 && !isTerminalStreamStatus(batch.status)) {
- logger.info('No new replay events yet; reopening active stream tail', {
- streamId,
- latestEventId,
- streamStatus,
- attempt: attachAttempt,
- })
- if (activeAbortController.signal.aborted || streamGenRef.current !== expectedGen) {
- return { aborted: true, error: false }
- }
- }
- }
-
- return { aborted: true, error: false }
- } catch (err) {
- if (err instanceof Error && err.name === 'AbortError') {
- return { aborted: true, error: false }
- }
-
- logger.error('Failed to attach to existing stream, will throw for outer retry', {
- streamId,
- latestEventId,
- error: err instanceof Error ? err.message : String(err),
- })
- throw err
- } finally {
- setIsReconnecting(false)
- }
- },
- [fetchStreamBatch]
- )
-
- const applyChatHistorySnapshot = useCallback(
- (history: TaskChatHistory, options?: { preserveActiveStreamingMessage?: boolean }) => {
- const preserveActiveStreamingMessage = options?.preserveActiveStreamingMessage ?? false
- const activeStreamId = history.activeStreamId
- appliedChatIdRef.current = history.id
-
- const mappedMessages = history.messages.map(mapStoredMessage)
- const shouldPreserveActiveStreamingMessage =
- preserveActiveStreamingMessage &&
- sendingRef.current &&
- Boolean(activeStreamId) &&
- activeStreamId === streamIdRef.current
-
- if (shouldPreserveActiveStreamingMessage) {
- setMessages((prev) => {
- const localStreamingAssistant = prev[prev.length - 1]
- if (localStreamingAssistant?.role !== 'assistant') {
- return mappedMessages
- }
-
- const nextMessages =
- mappedMessages[mappedMessages.length - 1]?.role === 'assistant'
- ? mappedMessages.slice(0, -1)
- : mappedMessages
-
- return [...nextMessages, localStreamingAssistant]
- })
- } else {
- setMessages(mappedMessages)
- }
-
- if (history.resources.some((r) => r.id === 'streaming-file')) {
- fetch('/api/copilot/chat/resources', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- chatId: history.id,
- resourceType: 'file',
- resourceId: 'streaming-file',
- }),
- }).catch(() => {})
- }
-
- const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file')
- if (persistedResources.length > 0) {
- setResources(persistedResources)
- const initialId = initialActiveResourceIdRef.current
- const restoredId =
- initialId && persistedResources.some((r) => r.id === initialId)
- ? initialId
- : persistedResources[persistedResources.length - 1].id
- setActiveResourceId(restoredId)
-
- for (const resource of persistedResources) {
- if (resource.type !== 'workflow') continue
- ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
- }
- } else if (history.resources.some((r) => r.id === 'streaming-file')) {
- setResources([])
- setActiveResourceId(null)
- }
- },
- [workspaceId]
- )
-
- const preparePendingStreamRecovery = useCallback(
- async (chatId: string): Promise => {
- const latestHistory = await fetchChatHistory(chatId)
- queryClient.setQueryData(taskKeys.detail(chatId), latestHistory)
- applyChatHistorySnapshot(latestHistory)
-
- if (!latestHistory.activeStreamId) {
- return null
- }
-
- return {
- streamId: latestHistory.activeStreamId,
- snapshot: latestHistory.streamSnapshot,
- }
- },
- [applyChatHistorySnapshot, queryClient]
- )
+ pendingHomeResetRef.current = false
+ resetHomeChatState()
+ }, [isHomePage, isReconnecting, isSending, resetHomeChatState])
useEffect(() => {
if (!chatHistory) return
- const activeStreamId = chatHistory.activeStreamId
- const snapshot = chatHistory.streamSnapshot
- const isNewChat = appliedChatIdRef.current !== chatHistory.id
+ const hydrationKey = buildChatHistoryHydrationKey(chatHistory)
+ if (appliedChatHistoryKeyRef.current === hydrationKey) return
- if (isNewChat) {
- applyChatHistorySnapshot(chatHistory, { preserveActiveStreamingMessage: true })
- } else if (!activeStreamId || sendingRef.current) {
- return
+ const activeStreamId = chatHistory.activeStreamId
+ appliedChatHistoryKeyRef.current = hydrationKey
+ const mappedMessages = chatHistory.messages.map(toDisplayMessage)
+ const snapshotEvents = Array.isArray(chatHistory.streamSnapshot?.events)
+ ? chatHistory.streamSnapshot.events
+ : []
+ const snapshotHasCompleteEvent = snapshotEvents.some(
+ (entry) => entry?.event?.type === MothershipStreamV1EventType.complete
+ )
+ const shouldReconnectActiveStream =
+ Boolean(activeStreamId) &&
+ !sendingRef.current &&
+ activeStreamId !== locallyTerminalStreamIdRef.current &&
+ !isTerminalStreamStatus(chatHistory.streamSnapshot?.status) &&
+ !snapshotHasCompleteEvent
+
+ if (!activeStreamId && locallyTerminalStreamIdRef.current) {
+ locallyTerminalStreamIdRef.current = undefined
+ }
+ const shouldPreserveLocalActiveTurn = sendingRef.current && activeTurnRef.current !== null
+
+ if (shouldPreserveLocalActiveTurn) {
+ setMessages((prev) => mergeServerMessagesWithActiveTurn(mappedMessages, prev))
+ } else {
+ setMessages(mappedMessages)
}
- if (activeStreamId && !sendingRef.current) {
+ void recoverPendingClientWorkflowTools(mappedMessages)
+
+ if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
+ fetch('/api/mothership/chat/resources', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ chatId: chatHistory.id,
+ resourceType: 'file',
+ resourceId: 'streaming-file',
+ }),
+ }).catch(() => {})
+ }
+
+ const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file')
+ if (persistedResources.length > 0) {
+ setResources(persistedResources)
+ setActiveResourceId(persistedResources[persistedResources.length - 1].id)
+
+ for (const resource of persistedResources) {
+ if (resource.type !== 'workflow') continue
+ ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
+ }
+ } else if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
+ setResources([])
+ setActiveResourceId(null)
+ }
+
+ const snapshotPreviewSessions = Array.isArray(chatHistory.streamSnapshot?.previewSessions)
+ ? (chatHistory.streamSnapshot.previewSessions as FilePreviewSession[])
+ : []
+ if (snapshotPreviewSessions.length > 0) {
+ seedPreviewSessions(snapshotPreviewSessions)
+ }
+
+ if (shouldReconnectActiveStream && activeStreamId) {
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
streamIdRef.current = activeStreamId
- lastEventIdRef.current = snapshot?.events?.[snapshot.events.length - 1]?.eventId ?? 0
- sendingRef.current = true
- streamingContentRef.current = ''
- streamingBlocksRef.current = []
- clientExecutionStartedRef.current = extractToolCallIdsFromSnapshot(snapshot)
+ lastCursorRef.current = '0'
+ setTransportReconnecting()
const assistantId = generateId()
const reconnect = async () => {
- const succeeded = await retryReconnectRef.current({
- streamId: activeStreamId,
- assistantId,
- gen,
- initialSnapshot: snapshot,
- })
+ const initialSnapshot = chatHistory.streamSnapshot
+ const snapshotEvents = Array.isArray(initialSnapshot?.events)
+ ? (initialSnapshot.events as StreamBatchEvent[])
+ : []
+
+ const reconnectResult =
+ snapshotEvents.length > 0
+ ? await attachToExistingStreamRef.current({
+ streamId: activeStreamId,
+ assistantId,
+ expectedGen: gen,
+ initialBatch: {
+ success: true,
+ events: snapshotEvents,
+ previewSessions: snapshotPreviewSessions,
+ status: initialSnapshot?.status ?? 'unknown',
+ },
+ afterCursor: String(snapshotEvents[snapshotEvents.length - 1]?.eventId ?? '0'),
+ })
+ : null
+
+ const succeeded =
+ reconnectResult !== null
+ ? !reconnectResult.error || reconnectResult.aborted
+ : await retryReconnectRef.current({
+ streamId: activeStreamId,
+ assistantId,
+ gen,
+ })
+ if (succeeded && streamGenRef.current === gen && sendingRef.current) {
+ finalizeRef.current()
+ return
+ }
+ if (succeeded && streamGenRef.current === gen) {
+ setTransportIdle()
+ abortControllerRef.current = null
+ return
+ }
if (!succeeded && streamGenRef.current === gen) {
try {
finalizeRef.current({ error: true })
} catch {
- sendingRef.current = false
- setIsSending(false)
- setIsReconnecting(false)
+ setTransportIdle()
abortControllerRef.current = null
setError('Failed to reconnect to the active stream')
}
@@ -933,51 +1376,68 @@ export function useChat(
}
reconnect()
}
- }, [applyChatHistorySnapshot, chatHistory, queryClient])
+ }, [
+ chatHistory,
+ workspaceId,
+ queryClient,
+ recoverPendingClientWorkflowTools,
+ seedPreviewSessions,
+ mergeServerMessagesWithActiveTurn,
+ setTransportIdle,
+ setTransportReconnecting,
+ ])
const processSSEStream = useCallback(
async (
reader: ReadableStreamDefaultReader,
assistantId: string,
- options?: StreamProcessingOptions
+ expectedGen?: number,
+ options?: { preserveExistingState?: boolean }
) => {
- const { expectedGen, initialLastEventId = 0, preserveExistingState = false } = options ?? {}
const decoder = new TextDecoder()
streamReaderRef.current = reader
let buffer = ''
- const blocks: ContentBlock[] = preserveExistingState ? [...streamingBlocksRef.current] : []
+
+ const preserveState = options?.preserveExistingState === true
+ const blocks: ContentBlock[] = preserveState ? [...streamingBlocksRef.current] : []
const toolMap = new Map()
const toolArgsMap = new Map>()
- // Maps toolCallId → index in genericResourceDataRef.current.entries for fast lookup
- const genericEntryMap = new Map()
- if (preserveExistingState) {
- for (const [idx, entry] of genericResourceDataRef.current.entries.entries()) {
- genericEntryMap.set(entry.toolCallId, idx)
- }
- }
- const clientExecutionStarted = clientExecutionStartedRef.current
- let activeSubagent: string | undefined
- let activeCompactionId: string | undefined
- let runningText = preserveExistingState ? streamingContentRef.current : ''
- let lastContentSource: 'main' | 'subagent' | null = null
- let streamRequestId: string | undefined
- let lastEventId = initialLastEventId
- let sawDoneEvent = false
- if (!preserveExistingState) {
- streamingContentRef.current = ''
- streamingBlocksRef.current = []
- }
-
- for (const [index, block] of blocks.entries()) {
- if (block.type === 'tool_call' && block.toolCall?.id) {
- toolMap.set(block.toolCall.id, index)
- if (block.toolCall.params) {
- toolArgsMap.set(block.toolCall.id, block.toolCall.params)
+ if (preserveState) {
+ for (let i = 0; i < blocks.length; i++) {
+ const tc = blocks[i].toolCall
+ if (tc) {
+ toolMap.set(tc.id, i)
+ if (tc.params) toolArgsMap.set(tc.id, tc.params)
}
}
}
+ let activeSubagent: string | undefined
+ let activeSubagentParentToolCallId: string | undefined
+ let activeCompactionId: string | undefined
+
+ if (preserveState) {
+ for (let i = blocks.length - 1; i >= 0; i--) {
+ if (blocks[i].type === 'subagent' && blocks[i].content) {
+ activeSubagent = blocks[i].content
+ break
+ }
+ if (blocks[i].type === 'subagent_end') {
+ break
+ }
+ }
+ }
+
+ let runningText = preserveState ? streamingContentRef.current || '' : ''
+ let lastContentSource: 'main' | 'subagent' | null = null
+ let streamRequestId: string | undefined
+
+ if (!preserveState) {
+ streamingContentRef.current = ''
+ streamingBlocksRef.current = []
+ }
+
const ensureTextBlock = (): ContentBlock => {
const last = blocks[blocks.length - 1]
if (last?.type === 'text' && last.subagent === activeSubagent) return last
@@ -997,14 +1457,14 @@ export function useChat(
flush()
}
- const buildInlineErrorTag = (payload: SSEPayload) => {
- const data = getPayloadData(payload) as Record | undefined
+ const buildInlineErrorTag = (payload: MothershipStreamV1ErrorPayload) => {
const message =
- (data?.displayMessage as string | undefined) ||
- payload.error ||
+ (typeof payload.displayMessage === 'string' ? payload.displayMessage : undefined) ||
+ (typeof payload.message === 'string' ? payload.message : undefined) ||
+ (typeof payload.error === 'string' ? payload.error : undefined) ||
'An unexpected error occurred'
- const provider = (data?.provider as string | undefined) || undefined
- const code = (data?.code as string | undefined) || undefined
+ const provider = typeof payload.provider === 'string' ? payload.provider : undefined
+ const code = typeof payload.code === 'string' ? payload.code : undefined
return `${JSON.stringify({
message,
...(code ? { code } : {}),
@@ -1014,6 +1474,8 @@ export function useChat(
const isStale = () => expectedGen !== undefined && streamGenRef.current !== expectedGen
let sawStreamError = false
+ let sawCompleteEvent = false
+ let scheduledTextFlushFrame: number | null = null
const flush = () => {
if (isStale()) return
@@ -1036,504 +1498,550 @@ export function useChat(
})
}
- const appendGenericEntry = (entry: GenericResourceEntry): number => {
- const entries = [...genericResourceDataRef.current.entries, entry]
- genericResourceDataRef.current.entries = entries
- setGenericResourceData({ entries })
- return entries.length - 1
- }
-
- const updateGenericEntry = (
- entryIdx: number,
- changes: Partial
- ): void => {
- const entries = genericResourceDataRef.current.entries.slice()
- entries[entryIdx] = { ...entries[entryIdx], ...changes }
- genericResourceDataRef.current.entries = entries
- setGenericResourceData({ entries })
+ const flushText = () => {
+ if (isStale()) return
+ if (scheduledTextFlushFrame !== null) return
+ if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
+ flush()
+ return
+ }
+ scheduledTextFlushFrame = window.requestAnimationFrame(() => {
+ scheduledTextFlushFrame = null
+ flush()
+ })
}
try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- if (isStale()) continue
+ const pendingLines: string[] = []
- buffer += decoder.decode(value, { stream: true })
- const lines = buffer.split('\n')
- buffer = lines.pop() || ''
+ readLoop: while (true) {
+ if (pendingLines.length === 0) {
+ const { done, value } = await reader.read()
+ if (done) break
+ if (isStale()) continue
- for (const line of lines) {
- if (isStale()) break
- if (!line.startsWith('data: ')) continue
- const raw = line.slice(6)
-
- let parsed: SSEPayload
- try {
- parsed = JSON.parse(raw)
- } catch {
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split('\n')
+ buffer = lines.pop() || ''
+ pendingLines.push(...lines)
+ if (pendingLines.length === 0) {
continue
}
+ }
- if (typeof (parsed as SSEPayload & { eventId?: unknown }).eventId === 'number') {
- lastEventId = Math.max(
- lastEventId,
- (parsed as SSEPayload & { eventId: number }).eventId
- )
- lastEventIdRef.current = lastEventId
- }
+ const line = pendingLines.shift()
+ if (line === undefined) {
+ continue
+ }
+ if (isStale()) {
+ pendingLines.length = 0
+ continue
+ }
+ if (!line.startsWith('data: ')) continue
+ const raw = line.slice(6)
- logger.debug('SSE event received', parsed)
- switch (parsed.type) {
- case 'chat_id': {
- if (parsed.chatId) {
- const isNewChat = !chatIdRef.current
- chatIdRef.current = parsed.chatId
- const selected = selectedChatIdRef.current
- if (selected == null) {
- if (isNewChat) {
- setResolvedChatId(parsed.chatId)
- }
- } else if (parsed.chatId === selected) {
- setResolvedChatId(parsed.chatId)
- }
- queryClient.invalidateQueries({
- queryKey: taskKeys.list(workspaceId),
- })
+ const parsedResult = parsePersistedStreamEventEnvelopeJson(raw)
+ if (!parsedResult.ok) {
+ logger.warn('Failed to parse chat SSE event', {
+ reason: parsedResult.reason,
+ message: parsedResult.message,
+ errors: parsedResult.errors,
+ })
+ continue
+ }
+ const parsed = parsedResult.event
+
+ if (parsed.trace?.requestId && parsed.trace.requestId !== streamRequestId) {
+ streamRequestId = parsed.trace.requestId
+ flush()
+ }
+ if (parsed.stream?.streamId) {
+ streamIdRef.current = parsed.stream.streamId
+ }
+ if (parsed.stream?.cursor) {
+ lastCursorRef.current = parsed.stream.cursor
+ } else if (typeof parsed.seq === 'number') {
+ lastCursorRef.current = String(parsed.seq)
+ }
+
+ logger.debug('SSE event received', parsed)
+ switch (parsed.type) {
+ case MothershipStreamV1EventType.session: {
+ const payload = parsed.payload
+ const payloadChatId =
+ payload.kind === MothershipStreamV1SessionKind.chat
+ ? payload.chatId
+ : typeof parsed.stream?.chatId === 'string'
+ ? parsed.stream.chatId
+ : undefined
+ if (payload.kind === MothershipStreamV1SessionKind.chat && payloadChatId) {
+ const isNewChat = !chatIdRef.current
+ chatIdRef.current = payloadChatId
+ const selected = selectedChatIdRef.current
+ if (selected == null) {
if (isNewChat) {
- const userMsg = pendingUserMsgRef.current
- const activeStreamId = streamIdRef.current
- if (userMsg && activeStreamId) {
- queryClient.setQueryData(taskKeys.detail(parsed.chatId), {
- id: parsed.chatId,
- title: null,
- messages: [
- {
- id: userMsg.id,
- role: 'user',
- content: userMsg.content,
- },
- ],
- activeStreamId,
- resources: [],
- })
- }
- if (!workflowIdRef.current) {
- window.history.replaceState(
- null,
- '',
- `/workspace/${workspaceId}/task/${parsed.chatId}`
- )
- }
+ setResolvedChatId(payloadChatId)
+ }
+ } else if (payloadChatId === selected) {
+ setResolvedChatId(payloadChatId)
+ }
+ queryClient.invalidateQueries({
+ queryKey: taskKeys.list(workspaceId),
+ })
+ if (isNewChat) {
+ const userMsg = pendingUserMsgRef.current
+ const activeStreamId = streamIdRef.current
+ if (userMsg && activeStreamId) {
+ queryClient.setQueryData(taskKeys.detail(payloadChatId), {
+ id: payloadChatId,
+ title: null,
+ messages: [userMsg],
+ activeStreamId,
+ resources: resourcesRef.current,
+ })
+ }
+ if (!workflowIdRef.current) {
+ router.replace(`/workspace/${workspaceId}/task/${payloadChatId}`, {
+ scroll: false,
+ })
}
}
- break
}
- case 'request_id': {
- const rid = typeof parsed.data === 'string' ? parsed.data : undefined
- if (rid) {
- streamRequestId = rid
- flush()
- }
- break
+ if (payload.kind === MothershipStreamV1SessionKind.title) {
+ queryClient.invalidateQueries({
+ queryKey: taskKeys.list(workspaceId),
+ })
+ onTitleUpdateRef.current?.()
}
- 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()
- const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
- tb.content = (tb.content ?? '') + normalizedChunk
- if (activeSubagent) tb.subagent = activeSubagent
- runningText += normalizedChunk
- lastContentSource = contentSource
- streamingContentRef.current = runningText
- flush()
- }
- break
+ break
+ }
+ case MothershipStreamV1EventType.text: {
+ const chunk = parsed.payload.text
+ if (chunk) {
+ const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main'
+ const needsBoundaryNewline =
+ lastContentSource !== null &&
+ lastContentSource !== contentSource &&
+ runningText.length > 0 &&
+ !runningText.endsWith('\n')
+ const tb = ensureTextBlock()
+ const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
+ tb.content = (tb.content ?? '') + normalizedChunk
+ if (activeSubagent) tb.subagent = activeSubagent
+ runningText += normalizedChunk
+ lastContentSource = contentSource
+ streamingContentRef.current = runningText
+ flushText()
}
- case 'reasoning': {
- const d = (
- parsed.data && typeof parsed.data === 'object' ? parsed.data : {}
- ) as Record
- const phase = d.phase as string | undefined
- if (phase === 'start') {
- const tb = ensureTextBlock()
- tb.content = `${tb.content ?? ''}`
- runningText += ''
- streamingContentRef.current = runningText
- flush()
- } else if (phase === 'end') {
- const tb = ensureTextBlock()
- tb.content = `${tb.content ?? ''}`
- runningText += ''
- streamingContentRef.current = runningText
- flush()
- } else {
- const chunk =
- typeof d.data === 'string' ? d.data : (parsed.content as string | undefined)
- if (chunk) {
- const tb = ensureTextBlock()
- tb.content = (tb.content ?? '') + chunk
- runningText += chunk
- streamingContentRef.current = runningText
- flush()
- }
- }
- break
- }
- case 'tool_generating':
- case 'tool_call': {
- const id = parsed.toolCallId
- const data = getPayloadData(parsed)
- const name = parsed.toolName || data?.name || 'unknown'
- const isPartial = data?.partial === true
- if (!id) break
+ break
+ }
+ case MothershipStreamV1EventType.tool: {
+ const payload = parsed.payload
+ const id = payload.toolCallId
- if (name === 'tool_search_tool_regex') {
+ if ('previewPhase' in payload) {
+ const prevSession = previewSessionsRef.current[id]
+ const target =
+ payload.previewPhase === 'file_preview_target' ? payload.target : undefined
+ const targetKind =
+ 'targetKind' in payload &&
+ (payload.targetKind === 'new_file' || payload.targetKind === 'file_id')
+ ? payload.targetKind
+ : target?.kind === 'new_file' || target?.kind === 'file_id'
+ ? target.kind
+ : prevSession?.targetKind
+ const fileId =
+ 'fileId' in payload && typeof payload.fileId === 'string'
+ ? payload.fileId
+ : typeof target?.fileId === 'string'
+ ? target.fileId
+ : prevSession?.fileId
+ const fileName =
+ 'fileName' in payload && typeof payload.fileName === 'string'
+ ? payload.fileName
+ : typeof target?.fileName === 'string'
+ ? target.fileName
+ : (prevSession?.fileName ?? '')
+ const operation =
+ 'operation' in payload && typeof payload.operation === 'string'
+ ? payload.operation
+ : prevSession?.operation
+ const edit =
+ ('edit' in payload ? asPayloadRecord(payload.edit) : undefined) ??
+ prevSession?.edit
+ const streamId = parsed.stream?.streamId ?? prevSession?.streamId ?? ''
+ const nextPreviewVersion =
+ 'previewVersion' in payload &&
+ typeof payload.previewVersion === 'number' &&
+ Number.isFinite(payload.previewVersion)
+ ? payload.previewVersion
+ : (prevSession?.previewVersion ?? 0) + 1
+ const baseSession: FilePreviewSession = {
+ schemaVersion: 1,
+ id,
+ streamId,
+ toolCallId: id,
+ status: prevSession?.status ?? 'pending',
+ fileName,
+ ...(fileId ? { fileId } : {}),
+ ...(targetKind ? { targetKind } : {}),
+ ...(operation ? { operation } : {}),
+ ...(edit ? { edit } : {}),
+ previewText: prevSession?.previewText ?? '',
+ previewVersion: prevSession?.previewVersion ?? 0,
+ updatedAt: prevSession?.updatedAt ?? new Date().toISOString(),
+ ...(prevSession?.completedAt ? { completedAt: prevSession.completedAt } : {}),
+ }
+
+ if (payload.previewPhase === 'file_preview_start') {
+ const nextSession: FilePreviewSession = {
+ ...baseSession,
+ status: 'pending',
+ updatedAt: new Date().toISOString(),
+ }
+ if (nextSession.fileId) {
+ setActiveResourceId(nextSession.fileId)
+ }
+ applyPreviewSessionUpdate(nextSession)
break
}
- const ui = parsed.ui || data?.ui
- if (ui?.hidden) break
- const displayTitle = ui?.title || ui?.phaseLabel
- const phaseLabel = ui?.phaseLabel
- const args = (data?.arguments ?? data?.input) as Record | undefined
- if (!toolMap.has(id)) {
- toolMap.set(id, blocks.length)
- blocks.push({
- type: 'tool_call',
- toolCall: {
- id,
- name,
- status: 'executing',
- displayTitle,
- phaseLabel,
- params: args,
- calledBy: activeSubagent,
- },
- })
- if (name === 'read' || isResourceToolName(name)) {
- if (args) toolArgsMap.set(id, args)
+
+ if (payload.previewPhase === 'file_preview_target') {
+ const nextSession: FilePreviewSession = {
+ ...baseSession,
+ updatedAt: new Date().toISOString(),
}
- } else {
- const idx = toolMap.get(id)!
- const tc = blocks[idx].toolCall
- if (tc) {
- tc.name = name
- if (displayTitle) tc.displayTitle = displayTitle
- if (phaseLabel) tc.phaseLabel = phaseLabel
- if (args) tc.params = args
+ const nextState = applyPreviewSessionUpdate(nextSession)
+ const activePreview =
+ nextState.activeSessionId !== null
+ ? (nextState.sessions[nextState.activeSessionId] ?? null)
+ : null
+ if (activePreview?.id === nextSession.id) {
+ syncPreviewResourceChrome(activePreview)
}
+ break
}
- flush()
- // TODO: Uncomment when rich UI for Results tab is ready
- // if (shouldOpenGenericResource(name)) {
- // if (!genericEntryMap.has(id)) {
- // const entryIdx = appendGenericEntry({
- // toolCallId: id,
- // toolName: name,
- // displayTitle: displayTitle ?? name,
- // status: 'executing',
- // params: args,
- // })
- // genericEntryMap.set(id, entryIdx)
- // const opened = addResource({ type: 'generic', id: 'results', title: 'Results' })
- // if (opened) onResourceEventRef.current?.()
- // else setActiveResourceId('results')
- // } else {
- // const entryIdx = genericEntryMap.get(id)
- // if (entryIdx !== undefined) {
- // updateGenericEntry(entryIdx, {
- // toolName: name,
- // ...(displayTitle && { displayTitle }),
- // ...(args && { params: args }),
- // })
- // }
- // }
- // }
+ if (payload.previewPhase === 'file_preview_edit_meta') {
+ const nextSession: FilePreviewSession = {
+ ...baseSession,
+ status: prevSession?.status ?? 'pending',
+ updatedAt: new Date().toISOString(),
+ }
+ applyPreviewSessionUpdate(nextSession)
+ break
+ }
- if (
- parsed.type === 'tool_call' &&
- ui?.clientExecutable &&
- isWorkflowToolName(name) &&
- !isPartial &&
- !clientExecutionStarted.has(id)
- ) {
- clientExecutionStarted.add(id)
- const args = data?.arguments ?? data?.input ?? {}
- const targetWorkflowId =
- typeof (args as Record).workflowId === 'string'
- ? ((args as Record).workflowId as string)
- : useWorkflowRegistry.getState().activeWorkflowId
- if (targetWorkflowId) {
- const meta = getWorkflowById(workspaceId, targetWorkflowId)
- const wasAdded = addResource({
- type: 'workflow',
- id: targetWorkflowId,
- title: meta?.name ?? 'Workflow',
+ if (payload.previewPhase === 'file_preview_content') {
+ const content = payload.content
+ const contentMode = payload.contentMode
+ const nextPreviewText =
+ contentMode === 'delta' ? (prevSession?.previewText ?? '') + content : content
+ const nextSession: FilePreviewSession = {
+ ...baseSession,
+ status: 'streaming',
+ previewText: nextPreviewText,
+ previewVersion: nextPreviewVersion,
+ updatedAt: new Date().toISOString(),
+ }
+ applyPreviewSessionUpdate(nextSession)
+ const previewToolIdx = toolMap.get(id)
+ if (previewToolIdx !== undefined && blocks[previewToolIdx].toolCall) {
+ blocks[previewToolIdx].toolCall!.status = 'executing'
+ }
+ break
+ }
+
+ if (payload.previewPhase === 'file_preview_complete') {
+ const resultData = asPayloadRecord(payload.output)
+ const completedAt = new Date().toISOString()
+ const nextSession: FilePreviewSession = {
+ ...baseSession,
+ status: 'complete',
+ previewVersion: payload.previewVersion ?? prevSession?.previewVersion ?? 0,
+ updatedAt: completedAt,
+ completedAt,
+ }
+ const nextState = applyCompletedPreviewSession(nextSession)
+
+ if (fileId && resultData?.id) {
+ const fileName = (resultData.name as string) ?? nextSession.fileName ?? 'File'
+ const fileResource = { type: 'file' as const, id: fileId, title: fileName }
+ setResources((rs) => {
+ const without = rs.filter((r) => r.id !== 'streaming-file')
+ if (without.some((r) => r.type === 'file' && r.id === fileResource.id)) {
+ return without
+ }
+ return [...without, fileResource]
})
- if (!wasAdded && activeResourceIdRef.current !== targetWorkflowId) {
- setActiveResourceId(targetWorkflowId)
- }
- onResourceEventRef.current?.()
- }
- executeRunToolOnClient(id, name, args as Record)
- }
- break
- }
- case 'tool_call_delta': {
- const id = parsed.toolCallId
- const delta = typeof parsed.data === 'string' ? parsed.data : ''
- if (!id || !delta) break
-
- const toolName = typeof parsed.toolName === 'string' ? parsed.toolName : ''
- const streamWorkspaceFile =
- activeSubagent === 'file_write' || toolName === 'workspace_file'
-
- if (streamWorkspaceFile) {
- let prev = streamingFileRef.current
- if (!prev) {
- prev = { fileName: '', content: '' }
- streamingFileRef.current = prev
- setStreamingFile(prev)
- }
- const raw = prev.content + delta
- let fileName = prev.fileName
- if (!fileName) {
- const m = raw.match(/"fileName"\s*:\s*"([^"]+)"/)
- if (m) {
- fileName = m[1]
- }
- }
- const fileIdMatch = raw.match(/"fileId"\s*:\s*"([^"]+)"/)
- const matchedResourceId = fileIdMatch?.[1]
- if (
- matchedResourceId &&
- resourcesRef.current.some(
- (resource) => resource.type === 'file' && resource.id === matchedResourceId
- )
- ) {
- setActiveResourceId(matchedResourceId)
- setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
- } else if (fileName || fileIdMatch) {
- const hasStreamingResource = resourcesRef.current.some(
- (resource) => resource.id === 'streaming-file'
- )
- if (!hasStreamingResource) {
- addResource({
- type: 'file',
- id: 'streaming-file',
- title: fileName || 'Writing file...',
- })
- } else if (fileName) {
- setResources((rs) =>
- rs.map((resource) =>
- resource.id === 'streaming-file'
- ? { ...resource, title: fileName }
- : resource
- )
+ setActiveResourceId(fileId)
+ if (nextSession.previewText) {
+ queryClient.setQueryData(
+ workspaceFilesKeys.content(workspaceId, fileId, 'text'),
+ nextSession.previewText
)
}
+ invalidateResourceQueries(queryClient, workspaceId, 'file', fileId)
+ } else {
+ const activePreview =
+ nextState.activeSessionId !== null
+ ? (nextState.sessions[nextState.activeSessionId] ?? null)
+ : null
+ if (activePreview) {
+ syncPreviewResourceChrome(activePreview)
+ }
}
- const next = { fileName, content: raw }
- streamingFileRef.current = next
- setStreamingFile(next)
+ break
}
+ }
+
+ if (payload.phase === MothershipStreamV1ToolPhase.args_delta) {
+ const delta = payload.argumentsDelta
+ if (!delta) break
const idx = toolMap.get(id)
if (idx !== undefined && blocks[idx].toolCall) {
const tc = blocks[idx].toolCall!
tc.streamingArgs = (tc.streamingArgs ?? '') + delta
+ const displayTitle = resolveStreamingToolDisplayTitle(tc.name, tc.streamingArgs)
+ if (displayTitle) tc.displayTitle = displayTitle
+
flush()
}
-
- // TODO: Uncomment when rich UI for Results tab is ready
- // if (toolName && shouldOpenGenericResource(toolName)) {
- // const entryIdx = genericEntryMap.get(id)
- // if (entryIdx !== undefined) {
- // const entry = genericResourceDataRef.current.entries[entryIdx]
- // if (entry) {
- // updateGenericEntry(entryIdx, {
- // streamingArgs: (entry.streamingArgs ?? '') + delta,
- // })
- // }
- // }
- // }
-
break
}
- case 'tool_result': {
- const id = parsed.toolCallId || getPayloadData(parsed)?.id
- if (!id) break
+
+ if (payload.phase === MothershipStreamV1ToolPhase.result) {
const idx = toolMap.get(id)
- if (idx !== undefined && blocks[idx].toolCall) {
- const tc = blocks[idx].toolCall!
-
- const payloadData = getPayloadData(parsed)
- const resultObj =
- parsed.result && typeof parsed.result === 'object'
- ? (parsed.result as Record)
- : undefined
- const isCancelled =
- resultObj?.reason === 'user_cancelled' ||
- resultObj?.cancelledByUser === true ||
- (payloadData as Record | undefined)?.reason ===
- 'user_cancelled' ||
- (payloadData as Record | undefined)?.cancelledByUser === true
-
- if (isCancelled) {
- tc.status = 'cancelled'
- tc.displayTitle = 'Stopped by user'
- } else {
- tc.status = parsed.success ? 'success' : 'error'
- }
- tc.streamingArgs = undefined
- tc.result = {
- success: !!parsed.success,
- output: parsed.result ?? getPayloadData(parsed)?.result,
- error: (parsed.error ?? getPayloadData(parsed)?.error) as string | undefined,
- }
- flush()
-
- if (tc.name === 'read' && tc.status === 'success') {
- const readArgs = toolArgsMap.get(id)
- const resource = extractResourceFromReadResult(
- readArgs?.path as string | undefined,
- tc.result.output
- )
- if (resource && addResource(resource)) {
- onResourceEventRef.current?.()
- }
- }
-
- if (DEPLOY_TOOL_NAMES.has(tc.name) && tc.status === 'success') {
- const output = tc.result?.output as Record | undefined
- const deployedWorkflowId = (output?.workflowId as string) ?? undefined
- if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') {
- queryClient.invalidateQueries({
- queryKey: deploymentKeys.info(deployedWorkflowId),
- })
- queryClient.invalidateQueries({
- queryKey: deploymentKeys.versions(deployedWorkflowId),
- })
- queryClient.invalidateQueries({
- queryKey: workflowKeys.list(workspaceId),
- })
- }
- }
-
- const extractedResources =
- tc.status === 'success' && isResourceToolName(tc.name)
- ? extractResourcesFromToolResult(
- tc.name,
- toolArgsMap.get(id) as Record | undefined,
- tc.result?.output
- )
- : []
-
- for (const resource of extractedResources) {
- invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
- }
-
- onToolResultRef.current?.(tc.name, tc.status === 'success', tc.result?.output)
-
- if (tc.name === 'workspace_file') {
- setStreamingFile(null)
- streamingFileRef.current = null
-
- const fileResource = extractedResources.find((r) => r.type === 'file')
- if (fileResource) {
- setResources((rs) => {
- const without = rs.filter((r) => r.id !== 'streaming-file')
- if (without.some((r) => r.type === 'file' && r.id === fileResource.id)) {
- return without
- }
- return [...without, fileResource]
- })
- setActiveResourceId(fileResource.id)
- } else {
- setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
- }
- }
-
- // TODO: Uncomment when rich UI for Results tab is ready
- // if (
- // shouldOpenGenericResource(tc.name) ||
- // (isDeferredResourceTool(tc.name) && extractedResources.length === 0)
- // ) {
- // const entryIdx = genericEntryMap.get(id)
- // if (entryIdx !== undefined) {
- // updateGenericEntry(entryIdx, {
- // status: tc.status,
- // result: tc.result ?? undefined,
- // streamingArgs: undefined,
- // })
- // } else {
- // const newIdx = appendGenericEntry({
- // toolCallId: id,
- // toolName: tc.name,
- // displayTitle: tc.displayTitle ?? tc.name,
- // status: tc.status,
- // params: toolArgsMap.get(id) as Record | undefined,
- // result: tc.result ?? undefined,
- // })
- // genericEntryMap.set(id, newIdx)
- // if (addResource({ type: 'generic', id: 'results', title: 'Results' })) {
- // onResourceEventRef.current?.()
- // }
- // }
- // }
+ if (idx === undefined || !blocks[idx].toolCall) {
+ break
}
+ const tc = blocks[idx].toolCall!
+ const outputObj = asPayloadRecord(payload.output)
+ const success =
+ payload.success ?? payload.status === MothershipStreamV1ToolOutcome.success
+ const isCancelled =
+ outputObj?.reason === 'user_cancelled' ||
+ outputObj?.cancelledByUser === true ||
+ payload.status === MothershipStreamV1ToolOutcome.cancelled
- break
- }
- case 'resource_added': {
- const resource = parsed.resource
- if (resource?.type && resource?.id) {
- const wasAdded = addResource(resource)
- invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
-
- if (!wasAdded && activeResourceIdRef.current !== resource.id) {
- setActiveResourceId(resource.id)
- }
- onResourceEventRef.current?.()
-
- if (resource.type === 'workflow') {
- const wasRegistered = ensureWorkflowInRegistry(
- resource.id,
- resource.title,
- workspaceId
- )
- if (wasAdded && wasRegistered) {
- useWorkflowRegistry.getState().setActiveWorkflow(resource.id)
- } else {
- useWorkflowRegistry.getState().loadWorkflowState(resource.id)
- }
- }
+ if (isCancelled) {
+ tc.status = 'cancelled'
+ tc.displayTitle = 'Stopped by user'
+ } else {
+ tc.status = success ? 'success' : 'error'
}
- break
- }
- case 'resource_deleted': {
- const resource = parsed.resource
- if (resource?.type && resource?.id) {
- removeResource(resource.type as MothershipResourceType, resource.id)
- invalidateResourceQueries(
- queryClient,
- workspaceId,
- resource.type as MothershipResourceType,
- resource.id
+ tc.streamingArgs = undefined
+ tc.result = {
+ success: !!success,
+ output: payload.output,
+ error: typeof payload.error === 'string' ? payload.error : undefined,
+ }
+ flush()
+
+ if (tc.name === ReadTool.id && tc.status === 'success') {
+ const readArgs = toolArgsMap.get(id)
+ const resource = extractResourceFromReadResult(
+ typeof readArgs?.path === 'string' ? readArgs.path : undefined,
+ tc.result.output
)
- onResourceEventRef.current?.()
+ if (resource && addResource(resource)) {
+ onResourceEventRef.current?.()
+ }
+ }
+
+ if (DEPLOY_TOOL_NAMES.has(tc.name) && tc.status === 'success') {
+ const output = tc.result?.output as Record | undefined
+ const deployedWorkflowId = (output?.workflowId as string) ?? undefined
+ if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') {
+ queryClient.invalidateQueries({
+ queryKey: deploymentKeys.info(deployedWorkflowId),
+ })
+ queryClient.invalidateQueries({
+ queryKey: deploymentKeys.versions(deployedWorkflowId),
+ })
+ queryClient.invalidateQueries({
+ queryKey: workflowKeys.list(workspaceId),
+ })
+ }
+ }
+
+ if (FOLDER_TOOL_NAMES.has(tc.name) && tc.status === 'success') {
+ queryClient.invalidateQueries({
+ queryKey: folderKeys.list(workspaceId),
+ })
+ }
+ if (WORKFLOW_MUTATION_TOOL_NAMES.has(tc.name) && tc.status === 'success') {
+ queryClient.invalidateQueries({
+ queryKey: workflowKeys.list(workspaceId),
+ })
+ }
+
+ const extractedResources =
+ tc.status === 'success' && isResourceToolName(tc.name)
+ ? extractResourcesFromToolResult(
+ tc.name,
+ toolArgsMap.get(id) as Record | undefined,
+ tc.result?.output
+ )
+ : []
+
+ for (const resource of extractedResources) {
+ invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
+ }
+
+ onToolResultRef.current?.(tc.name, tc.status === 'success', tc.result?.output)
+
+ const workspaceFileOperation =
+ tc.name === WorkspaceFile.id && typeof tc.params?.operation === 'string'
+ ? tc.params.operation
+ : undefined
+ const shouldKeepWorkspacePreviewOpen =
+ tc.name === WorkspaceFile.id &&
+ (workspaceFileOperation === 'append' ||
+ workspaceFileOperation === 'update' ||
+ workspaceFileOperation === 'patch')
+
+ if (
+ (tc.name === WorkspaceFile.id || tc.name === 'edit_content') &&
+ !shouldKeepWorkspacePreviewOpen
+ ) {
+ if (tc.name === WorkspaceFile.id) {
+ removePreviewSessionImmediate(id)
+ }
+ const fileResource = extractedResources.find((r) => r.type === 'file')
+ if (fileResource) {
+ setResources((rs) => {
+ const without = rs.filter((r) => r.id !== 'streaming-file')
+ if (without.some((r) => r.type === 'file' && r.id === fileResource.id)) {
+ return without
+ }
+ return [...without, fileResource]
+ })
+ setActiveResourceId(fileResource.id)
+ invalidateResourceQueries(queryClient, workspaceId, 'file', fileResource.id)
+ } else if (!activeSubagent || activeSubagent !== FILE_SUBAGENT_ID) {
+ setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
+ }
}
break
}
- case 'context_compaction_start': {
+
+ const name = payload.toolName
+ const isPartial = payload.partial === true
+ if (name === ToolSearchToolRegex.id || isToolHiddenInUi(name)) {
+ break
+ }
+ const ui = getToolUI(payload.ui)
+ if (ui?.hidden) break
+ let displayTitle = ui?.title || ui?.phaseLabel
+ const phaseLabel = ui?.phaseLabel
+ const args = payload.arguments as Record | undefined
+
+ displayTitle = resolveToolDisplayTitle(name, args) ?? displayTitle
+
+ if (name === 'edit_content') {
+ const parentToolCallId =
+ activePreviewSessionIdRef.current ?? previewSessionRef.current?.toolCallId
+ const parentIdx =
+ parentToolCallId !== null && parentToolCallId !== undefined
+ ? toolMap.get(parentToolCallId)
+ : undefined
+ if (parentIdx !== undefined && blocks[parentIdx].toolCall) {
+ toolMap.set(id, parentIdx)
+ const tc = blocks[parentIdx].toolCall!
+ tc.status = 'executing'
+ tc.result = undefined
+ flush()
+ break
+ }
+ }
+
+ if (!toolMap.has(id)) {
+ toolMap.set(id, blocks.length)
+ blocks.push({
+ type: 'tool_call',
+ toolCall: {
+ id,
+ name,
+ status: 'executing',
+ displayTitle,
+ phaseLabel,
+ params: args,
+ calledBy: activeSubagent,
+ },
+ })
+ if (name === ReadTool.id || isResourceToolName(name)) {
+ if (args) toolArgsMap.set(id, args)
+ }
+ } else {
+ const idx = toolMap.get(id)!
+ const tc = blocks[idx].toolCall
+ if (tc) {
+ tc.name = name
+ if (displayTitle) tc.displayTitle = displayTitle
+ if (phaseLabel) tc.phaseLabel = phaseLabel
+ if (args) tc.params = args
+ }
+ }
+ flush()
+
+ if (isWorkflowToolName(name) && !isPartial) {
+ startClientWorkflowTool(id, name, args ?? {})
+ }
+ break
+ }
+ case MothershipStreamV1EventType.resource: {
+ const payload = parsed.payload
+ const resource = payload.resource
+
+ if (payload.op === MothershipStreamV1ResourceOp.remove) {
+ removeResource(resource.type as MothershipResourceType, resource.id)
+ invalidateResourceQueries(
+ queryClient,
+ workspaceId,
+ resource.type as MothershipResourceType,
+ resource.id
+ )
+ onResourceEventRef.current?.()
+ break
+ }
+
+ const nextResource = {
+ type: resource.type as MothershipResourceType,
+ id: resource.id,
+ title: typeof resource.title === 'string' ? resource.title : resource.id,
+ }
+ const wasAdded = addResource(nextResource)
+ invalidateResourceQueries(
+ queryClient,
+ workspaceId,
+ nextResource.type,
+ nextResource.id
+ )
+
+ if (!wasAdded && activeResourceIdRef.current !== nextResource.id) {
+ setActiveResourceId(nextResource.id)
+ }
+ onResourceEventRef.current?.()
+
+ if (nextResource.type === 'workflow') {
+ const wasRegistered = ensureWorkflowInRegistry(
+ nextResource.id,
+ nextResource.title,
+ workspaceId
+ )
+ if (wasAdded && wasRegistered) {
+ useWorkflowRegistry.getState().setActiveWorkflow(nextResource.id)
+ } else {
+ useWorkflowRegistry.getState().loadWorkflowState(nextResource.id)
+ }
+ }
+ break
+ }
+ case MothershipStreamV1EventType.run: {
+ const payload = parsed.payload
+ if (payload.kind === MothershipStreamV1RunKind.compaction_start) {
const compactionId = `compaction_${Date.now()}`
activeCompactionId = compactionId
toolMap.set(compactionId, blocks.length)
@@ -1547,9 +2055,7 @@ export function useChat(
},
})
flush()
- break
- }
- case 'context_compaction': {
+ } else if (payload.kind === MothershipStreamV1RunKind.compaction_done) {
const compactionId = activeCompactionId || `compaction_${Date.now()}`
activeCompactionId = undefined
const idx = toolMap.get(compactionId)
@@ -1569,142 +2075,514 @@ export function useChat(
})
}
flush()
+ }
+ break
+ }
+ case MothershipStreamV1EventType.span: {
+ const payload = parsed.payload
+ if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) {
break
}
- case 'tool_error': {
- const id = parsed.toolCallId || getPayloadData(parsed)?.id
- if (!id) break
- const idx = toolMap.get(id)
- if (idx !== undefined && blocks[idx].toolCall) {
- const toolCallName = blocks[idx].toolCall!.name
- blocks[idx].toolCall!.status = 'error'
- if (toolCallName === 'workspace_file') {
- setStreamingFile(null)
- streamingFileRef.current = null
- setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
- }
- flush()
-
- // TODO: Uncomment when rich UI for Results tab is ready
- // if (toolCallName && shouldOpenGenericResource(toolCallName)) {
- // const entryIdx = genericEntryMap.get(id)
- // if (entryIdx !== undefined) {
- // updateGenericEntry(entryIdx, { status: 'error', streamingArgs: undefined })
- // }
- // }
- }
- break
- }
- case 'subagent_start': {
- const name = parsed.subagent || getPayloadData(parsed)?.agent
- if (name) {
- activeSubagent = name
+ const spanData = asPayloadRecord(payload.data)
+ const parentToolCallId =
+ typeof parsed.scope?.parentToolCallId === 'string'
+ ? parsed.scope.parentToolCallId
+ : typeof spanData?.tool_call_id === 'string'
+ ? spanData.tool_call_id
+ : undefined
+ const isPendingPause = spanData?.pending === true
+ const name =
+ typeof payload.agent === 'string'
+ ? payload.agent
+ : typeof parsed.scope?.agentId === 'string'
+ ? parsed.scope.agentId
+ : undefined
+ if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) {
+ const isSameActiveSubagent =
+ activeSubagent === name &&
+ activeSubagentParentToolCallId &&
+ parentToolCallId === activeSubagentParentToolCallId
+ activeSubagent = name
+ activeSubagentParentToolCallId = parentToolCallId
+ if (!isSameActiveSubagent) {
blocks.push({ type: 'subagent', content: name })
- if (name === 'file_write') {
- const emptyFile = { fileName: '', content: '' }
- // Ref must be updated synchronously: tool_call_delta can arrive before React
- // re-renders after setStreamingFile, and the handler only appends when prev exists.
- streamingFileRef.current = emptyFile
- setStreamingFile(emptyFile)
- }
- flush()
}
- break
- }
- case 'subagent_end': {
+ if (name === FILE_SUBAGENT_ID && !isSameActiveSubagent) {
+ applyPreviewSessionUpdate({
+ schemaVersion: 1,
+ id: parentToolCallId || 'file-preview',
+ streamId: streamIdRef.current ?? '',
+ toolCallId: parentToolCallId || 'file-preview',
+ status: 'pending',
+ fileName: '',
+ previewText: '',
+ previewVersion: 0,
+ updatedAt: new Date().toISOString(),
+ })
+ }
+ flush()
+ } else if (payload.event === MothershipStreamV1SpanLifecycleEvent.end) {
+ if (isPendingPause) {
+ break
+ }
+ if (previewSessionRef.current && !activePreviewSessionIdRef.current) {
+ const lastFileResource = resourcesRef.current.find(
+ (r) => r.type === 'file' && r.id !== 'streaming-file'
+ )
+ setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
+ if (lastFileResource) {
+ setActiveResourceId(lastFileResource.id)
+ }
+ }
activeSubagent = undefined
+ activeSubagentParentToolCallId = undefined
blocks.push({ type: 'subagent_end' })
flush()
- break
- }
- case 'title_updated': {
- queryClient.invalidateQueries({
- queryKey: taskKeys.list(workspaceId),
- })
- onTitleUpdateRef.current?.()
- break
- }
- case 'error': {
- sawStreamError = true
- setError(parsed.error || 'An error occurred')
- appendInlineErrorTag(buildInlineErrorTag(parsed))
- break
- }
- case 'done': {
- sawDoneEvent = true
- break
}
+ break
+ }
+ case MothershipStreamV1EventType.error: {
+ sawStreamError = true
+ setError(parsed.payload.message || parsed.payload.error || 'An error occurred')
+ appendInlineErrorTag(buildInlineErrorTag(parsed.payload))
+ break
+ }
+ case MothershipStreamV1EventType.complete: {
+ sawCompleteEvent = true
+ // `complete` is terminal for this stream, even if the transport takes a moment
+ // longer to close.
+ break readLoop
}
}
}
} finally {
+ if (scheduledTextFlushFrame !== null) {
+ cancelAnimationFrame(scheduledTextFlushFrame)
+ scheduledTextFlushFrame = null
+ flush()
+ }
if (streamReaderRef.current === reader) {
streamReaderRef.current = null
}
}
- return {
- sawStreamError,
- sawDoneEvent,
- lastEventId,
- }
+ return { sawStreamError, sawComplete: sawCompleteEvent }
},
- [workspaceId, queryClient, addResource, removeResource]
+ [
+ workspaceId,
+ router,
+ queryClient,
+ addResource,
+ removeResource,
+ applyPreviewSessionUpdate,
+ applyCompletedPreviewSession,
+ removePreviewSessionImmediate,
+ syncPreviewResourceChrome,
+ ]
)
processSSEStreamRef.current = processSSEStream
- const persistPartialResponse = useCallback(async () => {
- const chatId = chatIdRef.current
- const streamId = streamIdRef.current
- if (!chatId || !streamId) return
+ const getActiveStreamIdForChat = useCallback(
+ async (chatId: string): Promise => {
+ const cached = queryClient.getQueryData(taskKeys.detail(chatId))
+ if (cached?.activeStreamId) {
+ return cached.activeStreamId
+ }
- const content = streamingContentRef.current
+ try {
+ const history = await fetchChatHistory(chatId)
+ queryClient.setQueryData(taskKeys.detail(chatId), history)
+ return history.activeStreamId ?? null
+ } catch (error) {
+ logger.warn('Failed to load chat history while recovering stream', {
+ chatId,
+ error: error instanceof Error ? error.message : String(error),
+ })
+ return null
+ }
+ },
+ [queryClient]
+ )
- const storedBlocks: TaskStoredContentBlock[] = streamingBlocksRef.current.map((block) => {
- if (block.type === 'tool_call' && block.toolCall) {
- const isCancelled =
- block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled'
- return {
- type: block.type,
- content: block.content,
- toolCall: {
- id: block.toolCall.id,
- name: block.toolCall.name,
- state: isCancelled ? 'cancelled' : block.toolCall.status,
- params: block.toolCall.params,
- result: block.toolCall.result,
- display: {
- text: isCancelled ? 'Stopped by user' : block.toolCall.displayTitle,
- },
- calledBy: block.toolCall.calledBy,
- },
+ const fetchStreamBatch = useCallback(
+ async (
+ streamId: string,
+ afterCursor: string,
+ signal?: AbortSignal
+ ): Promise => {
+ const response = await fetch(
+ `/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(afterCursor)}&batch=true`,
+ { signal }
+ )
+ if (!response.ok) {
+ throw new Error(`Stream resume batch failed: ${response.status}`)
+ }
+ const batch = parseStreamBatchResponse(await response.json())
+ if (Array.isArray(batch.previewSessions) && batch.previewSessions.length > 0) {
+ seedPreviewSessions(batch.previewSessions)
+ }
+ return batch
+ },
+ [seedPreviewSessions]
+ )
+
+ const attachToExistingStream = useCallback(
+ async (opts: {
+ streamId: string
+ assistantId: string
+ expectedGen: number
+ initialBatch?: StreamBatchResponse | null
+ afterCursor?: string
+ }): Promise<{ error: boolean; aborted: boolean }> => {
+ const { streamId, assistantId, expectedGen, afterCursor = '0' } = opts
+ let latestCursor = afterCursor
+ let seedEvents = opts.initialBatch?.events ?? []
+ let streamStatus = opts.initialBatch?.status ?? 'unknown'
+
+ const isStaleReconnect = () =>
+ streamGenRef.current !== expectedGen || abortControllerRef.current?.signal.aborted === true
+
+ if (isStaleReconnect()) {
+ return { error: false, aborted: true }
+ }
+
+ setTransportReconnecting()
+ setError(null)
+
+ try {
+ while (streamGenRef.current === expectedGen) {
+ if (seedEvents.length > 0) {
+ const replayResult = await processSSEStreamRef.current(
+ buildReplayStream(seedEvents).getReader(),
+ assistantId,
+ expectedGen,
+ { preserveExistingState: true }
+ )
+ latestCursor = String(seedEvents[seedEvents.length - 1]?.eventId ?? latestCursor)
+ lastCursorRef.current = latestCursor
+ seedEvents = []
+
+ if (replayResult.sawStreamError) {
+ return { error: true, aborted: false }
+ }
+ }
+
+ if (isTerminalStreamStatus(streamStatus)) {
+ if (streamStatus === 'error') {
+ setError(RECONNECT_TAIL_ERROR)
+ }
+ return { error: streamStatus === 'error', aborted: false }
+ }
+
+ const activeAbort = abortControllerRef.current
+ if (!activeAbort || activeAbort.signal.aborted) {
+ return { error: false, aborted: true }
+ }
+
+ logger.info('Opening live stream tail', { streamId, afterCursor: latestCursor })
+
+ const sseRes = await fetch(
+ `/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(latestCursor)}`,
+ { signal: activeAbort.signal }
+ )
+ if (!sseRes.ok || !sseRes.body) {
+ throw new Error(RECONNECT_TAIL_ERROR)
+ }
+
+ if (isStaleReconnect()) {
+ return { error: false, aborted: true }
+ }
+
+ setTransportStreaming()
+
+ const liveResult = await processSSEStreamRef.current(
+ sseRes.body.getReader(),
+ assistantId,
+ expectedGen,
+ { preserveExistingState: true }
+ )
+
+ if (liveResult.sawStreamError) {
+ return { error: true, aborted: false }
+ }
+
+ if (liveResult.sawComplete) {
+ return { error: false, aborted: false }
+ }
+
+ if (isStaleReconnect()) {
+ return { error: false, aborted: true }
+ }
+
+ setTransportReconnecting()
+
+ latestCursor = lastCursorRef.current || latestCursor
+
+ logger.warn('Live stream ended without terminal event, fetching batch', {
+ streamId,
+ latestCursor,
+ })
+
+ const batch = await fetchStreamBatch(streamId, latestCursor, activeAbort.signal)
+ seedEvents = batch.events
+ streamStatus = batch.status
+
+ if (batch.events.length > 0) {
+ latestCursor = String(batch.events[batch.events.length - 1].eventId)
+ lastCursorRef.current = latestCursor
+ }
+
+ if (batch.events.length === 0 && !isTerminalStreamStatus(batch.status)) {
+ if (activeAbort.signal.aborted || streamGenRef.current !== expectedGen) {
+ return { error: false, aborted: true }
+ }
+ }
+ }
+
+ return { error: false, aborted: true }
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') {
+ return { error: false, aborted: true }
+ }
+ throw err
+ } finally {
+ if (streamGenRef.current === expectedGen) {
+ if (sendingRef.current) {
+ setIsReconnecting(false)
+ } else {
+ setTransportIdle()
+ }
}
}
- return { type: block.type, content: block.content }
- })
+ },
+ [fetchStreamBatch, setTransportIdle, setTransportReconnecting, setTransportStreaming]
+ )
+ attachToExistingStreamRef.current = attachToExistingStream
- if (storedBlocks.length > 0) {
- storedBlocks.push({ type: 'stopped' })
- }
+ const resumeOrFinalize = useCallback(
+ async (opts: {
+ streamId: string
+ assistantId: string
+ gen: number
+ afterCursor: string
+ signal?: AbortSignal
+ }): Promise => {
+ const { streamId, assistantId, gen, afterCursor, signal } = opts
- try {
- const res = await fetch(stopPathRef.current, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- chatId,
- streamId,
- content,
- ...(storedBlocks.length > 0 && { contentBlocks: storedBlocks }),
- }),
- })
- if (res.ok) {
- streamingContentRef.current = ''
- streamingBlocksRef.current = []
+ const batch = await fetchStreamBatch(streamId, afterCursor, signal)
+ if (streamGenRef.current !== gen) return
+
+ if (isTerminalStreamStatus(batch.status)) {
+ if (batch.events.length > 0) {
+ await processSSEStreamRef.current(
+ buildReplayStream(batch.events).getReader(),
+ assistantId,
+ gen,
+ { preserveExistingState: true }
+ )
+ }
+ finalizeRef.current(batch.status === 'error' ? { error: true } : undefined)
+ return
}
- } catch (err) {
- logger.warn('Failed to persist partial response', err)
- }
- }, [])
+
+ const reconnectResult = await attachToExistingStream({
+ streamId,
+ assistantId,
+ expectedGen: gen,
+ initialBatch: batch,
+ afterCursor:
+ batch.events.length > 0
+ ? String(batch.events[batch.events.length - 1].eventId)
+ : afterCursor,
+ })
+
+ if (streamGenRef.current === gen && !reconnectResult.aborted) {
+ finalizeRef.current(reconnectResult.error ? { error: true } : undefined)
+ } else if (streamGenRef.current === gen && reconnectResult.aborted && !sendingRef.current) {
+ setTransportIdle()
+ }
+ },
+ [fetchStreamBatch, attachToExistingStream, setTransportIdle]
+ )
+
+ const retryReconnect = useCallback(
+ async (opts: { streamId: string; assistantId: string; gen: number }): Promise => {
+ const { streamId, assistantId, gen } = opts
+
+ const isStaleReconnect = () =>
+ streamGenRef.current !== gen || abortControllerRef.current?.signal.aborted === true
+
+ for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
+ if (isStaleReconnect()) return true
+
+ if (attempt > 0) {
+ const delayMs = Math.min(
+ RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1),
+ RECONNECT_MAX_DELAY_MS
+ )
+ logger.warn('Reconnect attempt', {
+ streamId,
+ attempt,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ delayMs,
+ })
+
+ if (isStaleReconnect()) return true
+
+ setTransportReconnecting()
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+ if (streamGenRef.current !== gen) {
+ if (!sendingRef.current) {
+ setTransportIdle()
+ } else {
+ setIsReconnecting(false)
+ }
+ return true
+ }
+ if (abortControllerRef.current?.signal.aborted) {
+ if (!sendingRef.current) {
+ setTransportIdle()
+ } else {
+ setIsReconnecting(false)
+ }
+ return true
+ }
+ }
+
+ try {
+ await resumeOrFinalize({
+ streamId,
+ assistantId,
+ gen,
+ afterCursor: lastCursorRef.current || '0',
+ signal: abortControllerRef.current?.signal,
+ })
+ if (streamGenRef.current !== gen) {
+ if (!sendingRef.current) {
+ setTransportIdle()
+ } else {
+ setIsReconnecting(false)
+ }
+ return true
+ }
+ if (abortControllerRef.current?.signal.aborted) {
+ if (!sendingRef.current) {
+ setTransportIdle()
+ } else {
+ setIsReconnecting(false)
+ }
+ return true
+ }
+ if (!sendingRef.current) {
+ setTransportIdle()
+ return true
+ }
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') {
+ if (!sendingRef.current) {
+ setTransportIdle()
+ } else {
+ setIsReconnecting(false)
+ }
+ return true
+ }
+ logger.warn('Reconnect attempt failed', {
+ streamId,
+ attempt: attempt + 1,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+
+ logger.error('All reconnect attempts exhausted', {
+ streamId,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ })
+ if (streamGenRef.current === gen) {
+ setIsReconnecting(false)
+ }
+ return false
+ },
+ [resumeOrFinalize, setTransportIdle, setTransportReconnecting]
+ )
+ retryReconnectRef.current = retryReconnect
+
+ const persistPartialResponse = useCallback(
+ async (overrides?: {
+ chatId?: string
+ streamId?: string
+ content?: string
+ blocks?: ContentBlock[]
+ }) => {
+ const chatId = overrides?.chatId ?? chatIdRef.current
+ const streamId = overrides?.streamId ?? streamIdRef.current
+ if (!chatId || !streamId) return
+
+ const content = overrides?.content ?? streamingContentRef.current
+
+ const sourceBlocks = overrides?.blocks ?? streamingBlocksRef.current
+ const storedBlocks = sourceBlocks.map((block) => {
+ if (block.type === 'tool_call' && block.toolCall) {
+ const isCancelled =
+ block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled'
+ const displayTitle = isCancelled ? 'Stopped by user' : block.toolCall.displayTitle
+ const display =
+ displayTitle || block.toolCall.phaseLabel
+ ? {
+ ...(displayTitle ? { title: displayTitle } : {}),
+ ...(block.toolCall.phaseLabel ? { phaseLabel: block.toolCall.phaseLabel } : {}),
+ }
+ : undefined
+ return {
+ type: block.type,
+ content: block.content,
+ toolCall: {
+ id: block.toolCall.id,
+ name: block.toolCall.name,
+ state: isCancelled ? MothershipStreamV1ToolOutcome.cancelled : block.toolCall.status,
+ params: block.toolCall.params,
+ result: block.toolCall.result,
+ ...(display ? { display } : {}),
+ calledBy: block.toolCall.calledBy,
+ },
+ }
+ }
+ return { type: block.type, content: block.content }
+ })
+
+ if (storedBlocks.length > 0) {
+ storedBlocks.push({ type: 'stopped', content: undefined })
+ }
+
+ try {
+ const res = await fetch(stopPathRef.current, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ chatId,
+ streamId,
+ content,
+ ...(storedBlocks.length > 0 && { contentBlocks: storedBlocks }),
+ }),
+ })
+ if (!res.ok) {
+ const payload = await res.json().catch(() => null)
+ throw new Error(
+ typeof payload?.error === 'string'
+ ? payload.error
+ : 'Failed to persist partial response'
+ )
+ }
+ if (!overrides || streamIdRef.current === streamId) {
+ streamingContentRef.current = ''
+ streamingBlocksRef.current = []
+ }
+ } catch (err) {
+ logger.warn('Failed to persist partial response', err)
+ throw err instanceof Error ? err : new Error('Failed to persist partial response')
+ }
+ },
+ []
+ )
const invalidateChatQueries = useCallback(() => {
const activeChatId = chatIdRef.current
@@ -1718,22 +2596,14 @@ export function useChat(
const messagesRef = useRef(messages)
messagesRef.current = messages
- const visibleMessageQueue = useMemo(
- () =>
- pendingRecoveryMessage
- ? [
- pendingRecoveryMessage,
- ...messageQueue.filter((msg) => msg.id !== pendingRecoveryMessage.id),
- ]
- : messageQueue,
- [messageQueue, pendingRecoveryMessage]
- )
const finalize = useCallback(
(options?: { error?: boolean }) => {
- sendingRef.current = false
- setIsSending(false)
- setIsReconnecting(false)
+ reconcileTerminalPreviewSessions()
+ locallyTerminalStreamIdRef.current =
+ streamIdRef.current ?? activeTurnRef.current?.userMessageId ?? undefined
+ clearActiveTurn()
+ setTransportIdle()
abortControllerRef.current = null
invalidateChatQueries()
@@ -1745,173 +2615,42 @@ export function useChat(
}
if (options?.error) {
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
- setMessageQueue([])
return
}
- const recoveryMessage = pendingRecoveryMessageRef.current
- if (recoveryMessage) {
- setPendingRecoveryMessage(null)
- const gen = streamGenRef.current
- queueMicrotask(() => {
- if (streamGenRef.current !== gen) return
- sendMessageRef.current(
- recoveryMessage.content,
- recoveryMessage.fileAttachments,
- recoveryMessage.contexts
- )
- })
- 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)
- })
+ if (messageQueueRef.current.length > 0) {
+ void enqueueQueueDispatchRef.current({ type: 'send_head' })
}
},
- [invalidateChatQueries]
+ [clearActiveTurn, invalidateChatQueries, reconcileTerminalPreviewSessions, setTransportIdle]
)
finalizeRef.current = finalize
- const resumeOrFinalize = useCallback(
- async (opts: {
- streamId: string
- assistantId: string
- gen: number
- fromEventId: number
- snapshot?: StreamSnapshot | null
- signal?: AbortSignal
- }): Promise => {
- const { streamId, assistantId, gen, fromEventId, snapshot, signal } = opts
-
- const batch =
- snapshot ??
- (await (async () => {
- const b = await fetchStreamBatch(streamId, fromEventId, signal)
- if (streamGenRef.current !== gen) return null
- return { events: b.events, status: b.status } as StreamSnapshot
- })())
-
- if (!batch || streamGenRef.current !== gen) return
-
- if (isTerminalStreamStatus(batch.status)) {
- finalize(batch.status === 'error' ? { error: true } : undefined)
- return
- }
-
- const reconnectResult = await attachToExistingStream({
- streamId,
- assistantId,
- expectedGen: gen,
- snapshot: batch,
- initialLastEventId: batch.events[batch.events.length - 1]?.eventId ?? fromEventId,
- })
-
- if (streamGenRef.current === gen && !reconnectResult.aborted) {
- finalize(reconnectResult.error ? { error: true } : undefined)
- }
- },
- [fetchStreamBatch, attachToExistingStream, finalize]
- )
-
- const retryReconnect = useCallback(
- async (opts: {
- streamId: string
- assistantId: string
- gen: number
- initialSnapshot?: StreamSnapshot | null
- }): Promise => {
- const { streamId, assistantId, gen, initialSnapshot } = opts
-
- for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
- if (streamGenRef.current !== gen) return true
- if (abortControllerRef.current?.signal.aborted) return true
-
- if (attempt > 0) {
- const delayMs = Math.min(
- RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1),
- RECONNECT_MAX_DELAY_MS
- )
- logger.warn('Reconnect attempt', {
- streamId,
- attempt,
- maxAttempts: MAX_RECONNECT_ATTEMPTS,
- delayMs,
- })
- setIsReconnecting(true)
- await new Promise((resolve) => setTimeout(resolve, delayMs))
- if (streamGenRef.current !== gen) return true
- if (abortControllerRef.current?.signal.aborted) return true
- }
-
- try {
- await resumeOrFinalize({
- streamId,
- assistantId,
- gen,
- fromEventId: lastEventIdRef.current,
- snapshot: attempt === 0 ? initialSnapshot : undefined,
- signal: abortControllerRef.current?.signal,
- })
- return true
- } catch (err) {
- if (err instanceof Error && err.name === 'AbortError') return true
- logger.warn('Reconnect attempt failed', {
- streamId,
- attempt: attempt + 1,
- error: err instanceof Error ? err.message : String(err),
- })
- }
- }
-
- logger.error('All reconnect attempts exhausted', {
- streamId,
- maxAttempts: MAX_RECONNECT_ATTEMPTS,
- })
- setIsReconnecting(false)
- return false
- },
- [resumeOrFinalize]
- )
- retryReconnectRef.current = retryReconnect
-
- const sendMessage = useCallback(
- async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
- if (!message.trim() || !workspaceId) return
-
- if (sendingRef.current) {
- const queued: QueuedMessage = {
- id: generateId(),
- content: message,
- fileAttachments,
- contexts,
- }
- setMessageQueue((prev) => [...prev, queued])
- return
- }
+ const startSendMessage = useCallback(
+ async (
+ message: string,
+ fileAttachments?: FileAttachmentForApi[],
+ contexts?: ChatContext[],
+ pendingStopOverride?: Promise | null
+ ) => {
+ if (!message.trim() || !workspaceId) return false
+ const pendingStop = pendingStopOverride ?? pendingStopPromiseRef.current
const gen = ++streamGenRef.current
+ let consumedByTranscript = false
setError(null)
- setIsSending(true)
- sendingRef.current = true
+ setTransportStreaming()
+ locallyTerminalStreamIdRef.current = undefined
const userMessageId = generateId()
const assistantId = generateId()
- pendingUserMsgRef.current = { id: userMessageId, content: message }
streamIdRef.current = userMessageId
- lastEventIdRef.current = 0
- clientExecutionStartedRef.current.clear()
+ lastCursorRef.current = '0'
+ resetStreamingBuffers()
- const storedAttachments: TaskStoredFileAttachment[] | undefined =
+ const storedAttachments: PersistedFileAttachment[] | undefined =
fileAttachments && fileAttachments.length > 0
? fileAttachments.map((f) => ({
id: f.id,
@@ -1923,30 +2662,6 @@ export function useChat(
: undefined
const requestChatId = selectedChatIdRef.current ?? chatIdRef.current
- const previousChatHistory = requestChatId
- ? queryClient.getQueryData(taskKeys.detail(requestChatId))
- : undefined
- if (requestChatId) {
- const cachedUserMsg: TaskStoredMessage = {
- id: userMessageId,
- role: 'user' as const,
- content: message,
- ...(storedAttachments && { fileAttachments: storedAttachments }),
- }
- queryClient.setQueryData(taskKeys.detail(requestChatId), (old) => {
- return old
- ? {
- ...old,
- messages: [...old.messages, cachedUserMsg],
- activeStreamId: userMessageId,
- }
- : undefined
- })
- }
-
- const userAttachments = storedAttachments?.map(toDisplayAttachment)
- const previousMessages = messagesRef.current
-
const messageContexts = contexts?.map((c) => ({
kind: c.kind,
label: c.label,
@@ -1956,23 +2671,104 @@ export function useChat(
...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}),
...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}),
}))
+ const cachedUserMsg: PersistedMessage = {
+ id: userMessageId,
+ role: 'user' as const,
+ content: message,
+ timestamp: new Date().toISOString(),
+ ...(storedAttachments && { fileAttachments: storedAttachments }),
+ ...(messageContexts && messageContexts.length > 0 ? { contexts: messageContexts } : {}),
+ }
+ pendingUserMsgRef.current = cachedUserMsg
- setMessages((prev) => [
- ...prev,
- {
- id: userMessageId,
- role: 'user',
- content: message,
- attachments: userAttachments,
- ...(messageContexts && messageContexts.length > 0 ? { contexts: messageContexts } : {}),
- },
- { id: assistantId, role: 'assistant', content: '', contentBlocks: [] },
- ])
+ const userAttachments = storedAttachments?.map((f) => ({
+ id: f.id,
+ filename: f.filename,
+ media_type: f.media_type,
+ size: f.size,
+ previewUrl: f.media_type.startsWith('image/')
+ ? `/api/files/serve/${encodeURIComponent(f.key)}?context=mothership`
+ : undefined,
+ }))
+
+ const optimisticUserMessage: ChatMessage = {
+ id: userMessageId,
+ role: 'user',
+ content: message,
+ attachments: userAttachments,
+ ...(messageContexts && messageContexts.length > 0 ? { contexts: messageContexts } : {}),
+ }
+ const optimisticAssistantMessage: ChatMessage = {
+ id: assistantId,
+ role: 'assistant',
+ content: '',
+ contentBlocks: [],
+ }
+ activeTurnRef.current = {
+ userMessageId,
+ assistantMessageId: assistantId,
+ optimisticUserMessage,
+ optimisticAssistantMessage,
+ }
+
+ const applyOptimisticSend = () => {
+ if (requestChatId) {
+ queryClient.setQueryData(taskKeys.detail(requestChatId), (old) => {
+ if (!old) return undefined
+ const nextMessages = old.messages.filter((m) => m.id !== userMessageId)
+ return {
+ ...old,
+ resources: old.resources.filter((r) => r.id !== 'streaming-file'),
+ messages: [...nextMessages, cachedUserMsg],
+ activeStreamId: userMessageId,
+ }
+ })
+ }
+
+ setMessages((prev) => {
+ const nextMessages = prev.filter((m) => m.id !== userMessageId && m.id !== assistantId)
+ return [...nextMessages, optimisticUserMessage, optimisticAssistantMessage]
+ })
+ }
+
+ const rollbackOptimisticSend = () => {
+ if (requestChatId) {
+ queryClient.setQueryData(taskKeys.detail(requestChatId), (old) => {
+ if (!old) return undefined
+ return {
+ ...old,
+ messages: old.messages.filter((m) => m.id !== userMessageId),
+ activeStreamId: old.activeStreamId === userMessageId ? null : old.activeStreamId,
+ }
+ })
+ }
+
+ setMessages((prev) => prev.filter((m) => m.id !== userMessageId && m.id !== assistantId))
+ }
+
+ applyOptimisticSend()
+ consumedByTranscript = true
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
+ if (pendingStop) {
+ try {
+ await pendingStop
+ // Query invalidation from the stop barrier can briefly stomp the optimistic tail.
+ // Re-apply it before the real POST so the mothership UI stays immediate.
+ applyOptimisticSend()
+ } catch (err) {
+ rollbackOptimisticSend()
+ abortControllerRef.current = null
+ clearActiveTurn()
+ setTransportIdle()
+ setError(err instanceof Error ? err.message : 'Failed to stop the previous response')
+ return false
+ }
+ }
+
const currentActiveId = activeResourceIdRef.current
const currentResources = resourcesRef.current
const resourceAttachments =
@@ -2005,86 +2801,55 @@ export function useChat(
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
+ if (response.status === 409) {
+ const conflictStreamId =
+ typeof errorData.activeStreamId === 'string'
+ ? errorData.activeStreamId
+ : userMessageId
+ streamIdRef.current = conflictStreamId
+ const succeeded = await retryReconnect({
+ streamId: conflictStreamId,
+ assistantId,
+ gen,
+ })
+ if (succeeded) return consumedByTranscript
+ if (streamGenRef.current === gen) {
+ finalize({ error: true })
+ }
+ return consumedByTranscript
+ }
throw new Error(errorData.error || `Request failed: ${response.status}`)
}
if (!response.body) throw new Error('No response body')
- const termination = await processSSEStream(response.body.getReader(), assistantId, {
- expectedGen: gen,
- })
+ const streamResult = await processSSEStream(response.body.getReader(), assistantId, gen)
if (streamGenRef.current === gen) {
- if (termination.sawStreamError) {
+ if (streamResult.sawStreamError) {
finalize({ error: true })
- return
+ return consumedByTranscript
+ }
+
+ // A live SSE `complete` event is already terminal. Finalize immediately so follow-up
+ // sends do not get spuriously queued behind an already-finished response.
+ if (streamResult.sawComplete) {
+ finalize()
+ return consumedByTranscript
}
await resumeOrFinalize({
- streamId: userMessageId,
+ streamId: streamIdRef.current || userMessageId,
assistantId,
gen,
- fromEventId: termination.lastEventId,
+ afterCursor: lastCursorRef.current || '0',
signal: abortController.signal,
})
+ if (streamGenRef.current === gen && sendingRef.current) {
+ finalize()
+ }
}
} catch (err) {
- if (err instanceof Error && err.name === 'AbortError') return
- const errorMessage = err instanceof Error ? err.message : 'Failed to send message'
- if (requestChatId && isActiveStreamConflictError(errorMessage)) {
- logger.info('Active stream conflict detected while sending message; reattaching', {
- chatId: requestChatId,
- attemptedStreamId: userMessageId,
- })
-
- if (previousChatHistory) {
- queryClient.setQueryData(taskKeys.detail(requestChatId), previousChatHistory)
- }
- setMessages(previousMessages)
- const queuedMessage: QueuedMessage = {
- id: generateId(),
- content: message,
- fileAttachments,
- contexts,
- }
- pendingRecoveryMessageRef.current = queuedMessage
- setPendingRecoveryMessage(queuedMessage)
-
- try {
- const pendingRecovery = await preparePendingStreamRecovery(requestChatId)
- if (!pendingRecovery) {
- setError(errorMessage)
- if (streamGenRef.current === gen) {
- finalize({ error: true })
- }
- return
- }
-
- streamIdRef.current = pendingRecovery.streamId
- lastEventIdRef.current =
- pendingRecovery.snapshot?.events?.[pendingRecovery.snapshot.events.length - 1]
- ?.eventId ?? 0
-
- const rehydratedMessages = messagesRef.current
- const lastAssistantMsg = [...rehydratedMessages]
- .reverse()
- .find((m) => m.role === 'assistant')
- const recoveryAssistantId = lastAssistantMsg?.id ?? assistantId
-
- await resumeOrFinalize({
- streamId: pendingRecovery.streamId,
- assistantId: recoveryAssistantId,
- gen,
- fromEventId: lastEventIdRef.current,
- snapshot: pendingRecovery.snapshot,
- })
- return
- } catch (recoveryError) {
- logger.warn('Failed to recover active stream after conflict', {
- chatId: requestChatId,
- error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
- })
- }
- }
+ if (err instanceof Error && err.name === 'AbortError') return consumedByTranscript
const activeStreamId = streamIdRef.current
if (activeStreamId && streamGenRef.current === gen) {
@@ -2093,15 +2858,16 @@ export function useChat(
assistantId,
gen,
})
- if (succeeded) return
+ if (succeeded) return consumedByTranscript
}
- setError(errorMessage)
+ setError(err instanceof Error ? err.message : 'Failed to send message')
if (streamGenRef.current === gen) {
finalize({ error: true })
}
- return
+ return consumedByTranscript
}
+ return consumedByTranscript
},
[
workspaceId,
@@ -2110,75 +2876,35 @@ export function useChat(
finalize,
resumeOrFinalize,
retryReconnect,
- preparePendingStreamRecovery,
+ clearActiveTurn,
+ resetStreamingBuffers,
+ setTransportIdle,
+ setTransportStreaming,
]
)
- sendMessageRef.current = sendMessage
+ const sendMessage = useCallback(
+ async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
+ if (!message.trim() || !workspaceId) return
- const stopGeneration = useCallback(async () => {
- const wasSending = sendingRef.current
- const sid =
- streamIdRef.current ||
- queryClient.getQueryData(taskKeys.detail(chatIdRef.current))
- ?.activeStreamId ||
- undefined
-
- streamGenRef.current++
- streamReaderRef.current?.cancel().catch(() => {})
- streamReaderRef.current = null
- abortControllerRef.current?.abort()
- abortControllerRef.current = null
- sendingRef.current = false
- setIsSending(false)
- setIsReconnecting(false)
- lastEventIdRef.current = 0
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
-
- 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', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ streamId: sid }),
- }).catch(() => {})
- }
-
- if (wasSending && !chatIdRef.current) {
- const start = Date.now()
- while (!chatIdRef.current && Date.now() - start < 3000) {
- await new Promise((r) => setTimeout(r, 50))
+ if (sendingRef.current) {
+ const queued: QueuedMessage = {
+ id: generateId(),
+ content: message,
+ fileAttachments,
+ contexts,
+ }
+ setMessageQueue((prev) => [...prev, queued])
+ return
}
- }
-
- if (wasSending && chatIdRef.current) {
- await persistPartialResponse()
- }
- invalidateChatQueries()
- setStreamingFile(null)
- streamingFileRef.current = null
- setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
+ await startSendMessage(message, fileAttachments, contexts)
+ },
+ [workspaceId, startSendMessage]
+ )
+ const cancelActiveWorkflowExecutions = useCallback(() => {
const execState = useExecutionStore.getState()
const consoleStore = useTerminalConsoleStore.getState()
+
for (const [workflowId, wfExec] of execState.workflowExecutions) {
if (!wfExec.isExecuting) continue
@@ -2212,75 +2938,292 @@ export function useChat(
})
executionStream.cancel(workflowId)
- consolePersistence.executionEnded()
execState.setIsExecuting(workflowId, false)
execState.setIsDebugging(workflowId, false)
execState.setActiveBlocks(workflowId, new Set())
reportManualRunToolStop(workflowId, toolCallId).catch(() => {})
}
- }, [invalidateChatQueries, persistPartialResponse, executionStream])
+ }, [executionStream])
+
+ const stopGeneration = useCallback(async () => {
+ if (pendingStopPromiseRef.current) {
+ return pendingStopPromiseRef.current
+ }
+
+ const wasSending = sendingRef.current
+ const sid =
+ streamIdRef.current ||
+ activeTurnRef.current?.userMessageId ||
+ queryClient.getQueryData(taskKeys.detail(chatIdRef.current))
+ ?.activeStreamId ||
+ undefined
+ const stopContentSnapshot = streamingContentRef.current
+ const stopBlocksSnapshot = streamingBlocksRef.current.map((block) => ({
+ ...block,
+ ...(block.options ? { options: [...block.options] } : {}),
+ ...(block.toolCall ? { toolCall: { ...block.toolCall } } : {}),
+ }))
+
+ locallyTerminalStreamIdRef.current = sid
+ streamGenRef.current++
+ clearActiveTurn()
+ streamReaderRef.current?.cancel().catch(() => {})
+ streamReaderRef.current = null
+ abortControllerRef.current?.abort('user_stop:client_stopGeneration')
+ abortControllerRef.current = null
+ setTransportIdle()
+
+ 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 }
+ })
+ )
+
+ // Cancel active run-tool executions before waiting for the server-side stream
+ // shutdown barrier; otherwise the abort settle can sit behind tool execution teardown.
+ cancelActiveWorkflowExecutions()
+
+ const stopBarrier = (async () => {
+ try {
+ if (wasSending && !chatIdRef.current) {
+ const start = Date.now()
+ while (!chatIdRef.current && Date.now() - start < 3000) {
+ await new Promise((r) => setTimeout(r, 50))
+ }
+ }
+
+ const resolvedChatId = chatIdRef.current
+ const abortPromise = sid
+ ? (async () => {
+ const res = await fetch('/api/mothership/chat/abort', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ streamId: sid,
+ ...(resolvedChatId ? { chatId: resolvedChatId } : {}),
+ }),
+ })
+ if (!res.ok) {
+ const payload = await res.json().catch(() => null)
+ throw new Error(
+ typeof payload?.error === 'string'
+ ? payload.error
+ : 'Failed to abort previous response'
+ )
+ }
+ })()
+ : Promise.resolve()
+
+ if (wasSending && resolvedChatId) {
+ await persistPartialResponse({
+ chatId: resolvedChatId,
+ streamId: sid,
+ content: stopContentSnapshot,
+ blocks: stopBlocksSnapshot,
+ })
+ }
+
+ await abortPromise
+ } finally {
+ invalidateChatQueries()
+ resetEphemeralPreviewState({ removeStreamingResource: true })
+ }
+ })()
+
+ pendingStopPromiseRef.current = stopBarrier
+ try {
+ await stopBarrier
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to stop the previous response')
+ throw err
+ } finally {
+ if (pendingStopPromiseRef.current === stopBarrier) {
+ pendingStopPromiseRef.current = null
+ }
+ }
+ }, [
+ cancelActiveWorkflowExecutions,
+ invalidateChatQueries,
+ persistPartialResponse,
+ queryClient,
+ resetEphemeralPreviewState,
+ clearActiveTurn,
+ setTransportIdle,
+ ])
+
+ const runQueueDispatchLoop = useCallback(async () => {
+ if (queueDispatchTaskRef.current) {
+ return queueDispatchTaskRef.current
+ }
+
+ const task = (async () => {
+ while (true) {
+ const action = queueDispatchActionsRef.current.shift()
+ if (!action) return
+
+ if (action.epoch !== queueDispatchEpochRef.current) {
+ continue
+ }
+
+ const msg = messageQueueRef.current[0]
+ if (!msg) continue
+
+ let originalIndex = 0
+ let removedFromQueue = false
+
+ try {
+ const currentIndex = messageQueueRef.current.findIndex((queued) => queued.id === msg.id)
+ if (currentIndex !== -1) {
+ originalIndex = currentIndex
+ removedFromQueue = true
+ setMessageQueue((prev) => prev.filter((queued) => queued.id !== msg.id))
+ }
+
+ const consumed = await startSendMessage(msg.content, msg.fileAttachments, msg.contexts)
+ if (!consumed && removedFromQueue && action.epoch === queueDispatchEpochRef.current) {
+ setMessageQueue((prev) => {
+ if (prev.some((queued) => queued.id === msg.id)) return prev
+ const next = [...prev]
+ next.splice(Math.min(originalIndex, next.length), 0, msg)
+ return next
+ })
+ }
+ } catch {
+ if (removedFromQueue && action.epoch === queueDispatchEpochRef.current) {
+ setMessageQueue((prev) => {
+ if (prev.some((queued) => queued.id === msg.id)) return prev
+ const next = [...prev]
+ next.splice(Math.min(originalIndex, next.length), 0, msg)
+ return next
+ })
+ }
+ }
+ }
+ })()
+
+ queueDispatchTaskRef.current = task
+
+ return task.finally(() => {
+ if (queueDispatchTaskRef.current === task) {
+ queueDispatchTaskRef.current = null
+ }
+ if (queueDispatchActionsRef.current.length > 0) {
+ void queueDispatchLoopRef.current()
+ }
+ })
+ }, [startSendMessage])
+ queueDispatchLoopRef.current = runQueueDispatchLoop
+
+ const enqueueQueueDispatch = useCallback((action: QueueDispatchActionInput) => {
+ const epoch = queueDispatchEpochRef.current
+ queueDispatchActionsRef.current.push({ ...action, epoch } as QueueDispatchAction)
+ return queueDispatchLoopRef.current()
+ }, [])
+ enqueueQueueDispatchRef.current = enqueueQueueDispatch
const removeFromQueue = useCallback((id: string) => {
- if (pendingRecoveryMessageRef.current?.id === id) {
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
- return
- }
- messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
}, [])
+ const sendQueuedMessageImmediately = useCallback(
+ async (id: string) => {
+ const epoch = queueDispatchEpochRef.current
+ const initialIndex = messageQueueRef.current.findIndex((m) => m.id === id)
+ if (initialIndex === -1) return
+ const msg = messageQueueRef.current[initialIndex]
+
+ if (queuedMessageDispatchIdsRef.current.has(msg.id)) {
+ return
+ }
+ queuedMessageDispatchIdsRef.current.add(msg.id)
+
+ // Explicit queue sends should supersede any older auto-drain work scheduled by finalize().
+ queueDispatchActionsRef.current = queueDispatchActionsRef.current.filter(
+ (queuedAction) => queuedAction.type !== 'send_head'
+ )
+
+ let originalIndex = initialIndex
+ let removedFromQueue = false
+ const restoreQueuedMessage = () => {
+ if (!removedFromQueue || epoch !== queueDispatchEpochRef.current) {
+ return
+ }
+ setMessageQueue((prev) => {
+ if (prev.some((queued) => queued.id === msg.id)) return prev
+ const next = [...prev]
+ next.splice(Math.min(originalIndex, next.length), 0, msg)
+ return next
+ })
+ }
+
+ try {
+ const currentIndex = messageQueueRef.current.findIndex((queued) => queued.id === msg.id)
+ if (currentIndex === -1) {
+ return
+ }
+
+ originalIndex = currentIndex
+ removedFromQueue = true
+ setMessageQueue((prev) => prev.filter((queued) => queued.id !== msg.id))
+
+ const pendingStop = sendingRef.current ? stopGeneration() : pendingStopPromiseRef.current
+ const consumed = await startSendMessage(
+ msg.content,
+ msg.fileAttachments,
+ msg.contexts,
+ pendingStop
+ )
+
+ if (!consumed) {
+ restoreQueuedMessage()
+ }
+ } catch {
+ restoreQueuedMessage()
+ } finally {
+ queuedMessageDispatchIdsRef.current.delete(msg.id)
+ }
+ },
+ [startSendMessage, stopGeneration]
+ )
+
const sendNow = useCallback(
async (id: string) => {
- const recoveryMessage = pendingRecoveryMessageRef.current
- const msg =
- recoveryMessage?.id === id
- ? recoveryMessage
- : messageQueueRef.current.find((m) => m.id === id)
- if (!msg) return
- // Eagerly update ref so a rapid second click finds the message already gone
- if (recoveryMessage?.id === id) {
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
- } else {
- messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
- }
- await stopGeneration()
- if (recoveryMessage?.id !== id) {
- setMessageQueue((prev) => prev.filter((m) => m.id !== id))
- }
- await sendMessage(msg.content, msg.fileAttachments, msg.contexts)
+ await sendQueuedMessageImmediately(id)
},
- [stopGeneration, sendMessage]
+ [sendQueuedMessageImmediately]
)
const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => {
- const recoveryMessage = pendingRecoveryMessageRef.current
- if (recoveryMessage?.id === id) {
- pendingRecoveryMessageRef.current = null
- setPendingRecoveryMessage(null)
- return recoveryMessage
- }
-
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 () => {
+ clearQueueDispatchState()
streamReaderRef.current = null
abortControllerRef.current = null
streamGenRef.current++
+ clearActiveTurn()
sendingRef.current = false
- lastEventIdRef.current = 0
- clientExecutionStartedRef.current.clear()
- pendingRecoveryMessageRef.current = null
}
- }, [])
+ }, [clearQueueDispatchState, clearActiveTurn])
return {
messages,
@@ -2296,11 +3239,11 @@ export function useChat(
addResource,
removeResource,
reorderResources,
- messageQueue: visibleMessageQueue,
+ messageQueue,
removeFromQueue,
sendNow,
editQueuedMessage,
- streamingFile,
+ previewSession,
genericResourceData,
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx
new file mode 100644
index 0000000000..863df51596
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx
@@ -0,0 +1,182 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import type { FilePreviewSession } from '@/lib/copilot/request/session'
+import {
+ buildCompletedPreviewSessions,
+ INITIAL_FILE_PREVIEW_SESSIONS_STATE,
+ reduceFilePreviewSessions,
+} from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions'
+
+function createSession(
+ overrides: Partial & Pick
+): FilePreviewSession {
+ return {
+ schemaVersion: 1,
+ id: overrides.id,
+ streamId: overrides.streamId ?? 'stream-1',
+ toolCallId: overrides.toolCallId,
+ status: overrides.status ?? 'streaming',
+ fileName: overrides.fileName ?? `${overrides.id}.md`,
+ previewText: overrides.previewText ?? '',
+ previewVersion: overrides.previewVersion ?? 1,
+ updatedAt: overrides.updatedAt ?? '2026-04-10T00:00:00.000Z',
+ ...(overrides.fileId ? { fileId: overrides.fileId } : {}),
+ ...(overrides.targetKind ? { targetKind: overrides.targetKind } : {}),
+ ...(overrides.operation ? { operation: overrides.operation } : {}),
+ ...(overrides.edit ? { edit: overrides.edit } : {}),
+ ...(overrides.completedAt ? { completedAt: overrides.completedAt } : {}),
+ }
+}
+
+describe('reduceFilePreviewSessions', () => {
+ it('builds complete sessions for terminal stream reconciliation', () => {
+ const completedAt = '2026-04-10T00:00:10.000Z'
+ const nextSessions = buildCompletedPreviewSessions(
+ {
+ 'preview-1': createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'pending',
+ previewText: 'draft',
+ }),
+ 'preview-2': createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ status: 'streaming',
+ previewText: 'partial',
+ }),
+ 'preview-3': createSession({
+ id: 'preview-3',
+ toolCallId: 'preview-3',
+ status: 'complete',
+ previewText: 'done',
+ completedAt: '2026-04-10T00:00:03.000Z',
+ }),
+ },
+ completedAt
+ )
+
+ expect(nextSessions).toHaveLength(2)
+ expect(nextSessions.map((session) => session.id)).toEqual(['preview-1', 'preview-2'])
+ expect(nextSessions.every((session) => session.status === 'complete')).toBe(true)
+ expect(nextSessions.every((session) => session.updatedAt === completedAt)).toBe(true)
+ expect(nextSessions.every((session) => session.completedAt === completedAt)).toBe(true)
+ })
+
+ it('hydrates the latest active preview session', () => {
+ const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'hydrate',
+ sessions: [
+ createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 1,
+ updatedAt: '2026-04-10T00:00:00.000Z',
+ }),
+ createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ previewVersion: 2,
+ updatedAt: '2026-04-10T00:00:01.000Z',
+ previewText: 'latest',
+ }),
+ ],
+ })
+
+ expect(state.activeSessionId).toBe('preview-2')
+ expect(state.sessions['preview-2']?.previewText).toBe('latest')
+ })
+
+ it('drops the active session when it completes and promotes the next active session', () => {
+ const hydratedState = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'hydrate',
+ sessions: [
+ createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 1,
+ updatedAt: '2026-04-10T00:00:00.000Z',
+ }),
+ createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ previewVersion: 2,
+ updatedAt: '2026-04-10T00:00:01.000Z',
+ }),
+ ],
+ })
+ const completedState = reduceFilePreviewSessions(hydratedState, {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-2',
+ toolCallId: 'preview-2',
+ status: 'complete',
+ previewVersion: 3,
+ updatedAt: '2026-04-10T00:00:02.000Z',
+ completedAt: '2026-04-10T00:00:02.000Z',
+ }),
+ })
+
+ expect(completedState.activeSessionId).toBe('preview-1')
+ expect(completedState.sessions['preview-1']?.id).toBe('preview-1')
+ })
+
+ it('clears active session when the only session completes', () => {
+ const onlyStreaming = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 2,
+ updatedAt: '2026-04-10T00:00:01.000Z',
+ previewText: 'final',
+ }),
+ })
+
+ const completed = reduceFilePreviewSessions(onlyStreaming, {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 3,
+ updatedAt: '2026-04-10T00:00:02.000Z',
+ completedAt: '2026-04-10T00:00:02.000Z',
+ previewText: 'final',
+ }),
+ })
+
+ expect(completed.activeSessionId).toBeNull()
+ expect(completed.sessions['preview-1']?.status).toBe('complete')
+ })
+
+ it('ignores stale complete events for a newer active session', () => {
+ const activeState = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
+ type: 'upsert',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ previewVersion: 3,
+ updatedAt: '2026-04-10T00:00:03.000Z',
+ }),
+ })
+
+ const staleCompleteState = reduceFilePreviewSessions(activeState, {
+ type: 'complete',
+ session: createSession({
+ id: 'preview-1',
+ toolCallId: 'preview-1',
+ status: 'complete',
+ previewVersion: 2,
+ updatedAt: '2026-04-10T00:00:02.000Z',
+ completedAt: '2026-04-10T00:00:02.000Z',
+ }),
+ })
+
+ expect(staleCompleteState.activeSessionId).toBe('preview-1')
+ expect(staleCompleteState.sessions['preview-1']?.status).toBe('streaming')
+ expect(staleCompleteState.sessions['preview-1']?.previewVersion).toBe(3)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts
new file mode 100644
index 0000000000..6782585bbb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts
@@ -0,0 +1,201 @@
+import { useCallback, useMemo, useReducer } from 'react'
+import type { FilePreviewSession } from '@/lib/copilot/request/session'
+
+export interface FilePreviewSessionsState {
+ activeSessionId: string | null
+ sessions: Record
+}
+
+export type FilePreviewSessionsAction =
+ | { type: 'hydrate'; sessions: FilePreviewSession[] }
+ | { type: 'upsert'; session: FilePreviewSession; activate?: boolean }
+ | { type: 'complete'; session: FilePreviewSession }
+ | { type: 'remove'; sessionId: string }
+ | { type: 'reset' }
+
+export const INITIAL_FILE_PREVIEW_SESSIONS_STATE: FilePreviewSessionsState = {
+ activeSessionId: null,
+ sessions: {},
+}
+
+export function shouldReplaceSession(
+ current: FilePreviewSession | undefined,
+ next: FilePreviewSession
+): boolean {
+ if (!current) return true
+ if (next.previewVersion !== current.previewVersion) {
+ return next.previewVersion > current.previewVersion
+ }
+ return next.updatedAt >= current.updatedAt
+}
+
+export function pickActiveSessionId(
+ sessions: Record,
+ preferredId?: string | null
+): string | null {
+ if (preferredId && sessions[preferredId]?.status !== 'complete') {
+ return preferredId
+ }
+
+ let latestActive: FilePreviewSession | null = null
+ for (const session of Object.values(sessions)) {
+ if (session.status === 'complete') continue
+ if (!latestActive || shouldReplaceSession(latestActive, session)) {
+ latestActive = session
+ }
+ }
+
+ return latestActive?.id ?? null
+}
+
+export function buildCompletedPreviewSessions(
+ sessions: Record,
+ completedAt: string
+): FilePreviewSession[] {
+ return Object.values(sessions)
+ .filter((session) => session.status !== 'complete')
+ .map((session) => ({
+ ...session,
+ status: 'complete' as const,
+ updatedAt: completedAt,
+ completedAt,
+ }))
+}
+
+export function reduceFilePreviewSessions(
+ state: FilePreviewSessionsState,
+ action: FilePreviewSessionsAction
+): FilePreviewSessionsState {
+ switch (action.type) {
+ case 'hydrate': {
+ if (action.sessions.length === 0) {
+ return state
+ }
+
+ const nextSessions = { ...state.sessions }
+ for (const session of action.sessions) {
+ if (shouldReplaceSession(nextSessions[session.id], session)) {
+ nextSessions[session.id] = session
+ }
+ }
+
+ return {
+ sessions: nextSessions,
+ activeSessionId: pickActiveSessionId(nextSessions, state.activeSessionId),
+ }
+ }
+
+ case 'upsert': {
+ if (!shouldReplaceSession(state.sessions[action.session.id], action.session)) {
+ return state
+ }
+
+ const nextSessions = {
+ ...state.sessions,
+ [action.session.id]: action.session,
+ }
+
+ return {
+ sessions: nextSessions,
+ activeSessionId:
+ action.activate === false
+ ? pickActiveSessionId(nextSessions, state.activeSessionId)
+ : action.session.status === 'complete'
+ ? pickActiveSessionId(nextSessions, state.activeSessionId)
+ : action.session.id,
+ }
+ }
+
+ case 'complete': {
+ if (!shouldReplaceSession(state.sessions[action.session.id], action.session)) {
+ return state
+ }
+
+ const nextSessions = {
+ ...state.sessions,
+ [action.session.id]: action.session,
+ }
+
+ return {
+ sessions: nextSessions,
+ activeSessionId:
+ state.activeSessionId === action.session.id
+ ? pickActiveSessionId(nextSessions, null)
+ : state.activeSessionId,
+ }
+ }
+
+ case 'remove': {
+ if (!state.sessions[action.sessionId]) {
+ return state
+ }
+
+ const nextSessions = { ...state.sessions }
+ delete nextSessions[action.sessionId]
+
+ return {
+ sessions: nextSessions,
+ activeSessionId:
+ state.activeSessionId === action.sessionId
+ ? pickActiveSessionId(nextSessions, null)
+ : state.activeSessionId,
+ }
+ }
+
+ case 'reset':
+ return INITIAL_FILE_PREVIEW_SESSIONS_STATE
+
+ default:
+ return state
+ }
+}
+
+export function useFilePreviewSessions() {
+ const [state, dispatch] = useReducer(
+ reduceFilePreviewSessions,
+ INITIAL_FILE_PREVIEW_SESSIONS_STATE
+ )
+
+ const previewSession = useMemo(
+ () => (state.activeSessionId ? (state.sessions[state.activeSessionId] ?? null) : null),
+ [state.activeSessionId, state.sessions]
+ )
+
+ const hydratePreviewSessions = useCallback((sessions: FilePreviewSession[]) => {
+ dispatch({ type: 'hydrate', sessions })
+ }, [])
+
+ const upsertPreviewSession = useCallback(
+ (session: FilePreviewSession, options?: { activate?: boolean }) => {
+ dispatch({
+ type: 'upsert',
+ session,
+ ...(options?.activate === false ? { activate: false } : {}),
+ })
+ },
+ []
+ )
+
+ const completePreviewSession = useCallback((session: FilePreviewSession) => {
+ dispatch({ type: 'complete', session })
+ }, [])
+
+ const removePreviewSession = useCallback((sessionId: string) => {
+ dispatch({ type: 'remove', sessionId })
+ }, [])
+
+ const resetPreviewSessions = useCallback(() => {
+ dispatch({ type: 'reset' })
+ }, [])
+
+ return {
+ previewSession,
+ previewSessionsById: state.sessions,
+ activePreviewSessionId: state.activeSessionId,
+ hydratePreviewSessions,
+ upsertPreviewSession,
+ completePreviewSession,
+ removePreviewSession,
+ resetPreviewSessions,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts
index e6ae3c9f0a..5977f5e472 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts
@@ -1,10 +1,40 @@
-import type { MothershipResourceType } from '@/lib/copilot/resource-types'
+import {
+ Agent,
+ Auth,
+ CreateWorkflow,
+ Deploy,
+ EditWorkflow,
+ FunctionExecute,
+ GetPageContents,
+ Glob,
+ Grep,
+ Job,
+ Knowledge,
+ KnowledgeBase,
+ ManageMcpTool,
+ ManageSkill,
+ OpenResource,
+ Read as ReadTool,
+ Research,
+ ScrapePage,
+ SearchLibraryDocs,
+ SearchOnline,
+ Superagent,
+ Table,
+ UserMemory,
+ UserTable,
+ Workflow,
+ WorkspaceFile,
+} from '@/lib/copilot/generated/tool-catalog-v1'
import type { ChatContext } from '@/stores/panel'
+const EDIT_CONTENT_TOOL_ID = 'edit_content'
+const RUN_SUBAGENT_ID = 'run'
+
export type {
MothershipResource,
MothershipResourceType,
-} from '@/lib/copilot/resource-types'
+} from '@/lib/copilot/resources/types'
/** Union of all valid context kind strings, derived from {@link ChatContext}. */
export type ChatContextKind = ChatContext['kind']
@@ -24,169 +54,34 @@ export interface QueuedMessage {
contexts?: ChatContext[]
}
-/**
- * SSE event types emitted by the Go orchestrator backend.
- *
- * @example
- * ```json
- * { "type": "content", "data": "Hello world" }
- * { "type": "tool_call", "state": "executing", "toolCallId": "toolu_...", "toolName": "glob", "ui": { "title": "..." } }
- * { "type": "subagent_start", "subagent": "build" }
- * ```
- */
-export type SSEEventType =
- | 'chat_id'
- | 'request_id'
- | 'title_updated'
- | 'content'
- | 'reasoning' // openai reasoning - render as thinking text
- | 'tool_call' // tool call name
- | 'tool_call_delta' // chunk of tool call
- | 'tool_generating' // start a tool call
- | 'tool_result' // tool call result
- | 'tool_error' // tool call error
- | 'resource_added' // add a resource to the chat
- | 'resource_deleted' // delete a resource from the chat
- | 'subagent_start' // start a subagent
- | 'subagent_end' // end a subagent
- | 'structured_result' // structured result from a tool call
- | 'subagent_result' // result from a subagent
- | 'done' // end of the chat
- | 'context_compaction_start' // context compaction started
- | 'context_compaction' // conversation context was compacted
- | 'error' // error in the chat
- | 'start' // start of the chat
-
/**
* All tool names observed in the mothership SSE stream, grouped by phase.
*
* @example
* ```json
- * { "type": "tool_generating", "toolName": "glob" }
- * { "type": "tool_call", "toolName": "function_execute", "ui": { "title": "Running code", "icon": "code" } }
+ * { "type": "tool", "phase": "call", "toolName": "glob" }
+ * { "type": "tool", "phase": "call", "toolName": "function_execute", "ui": { "title": "Running code", "icon": "code" } }
* ```
+ * Stream `type` is `MothershipStreamV1EventType.tool` (`mothership-stream-v1`) with `phase: 'call'`.
*/
-export type MothershipToolName =
- | 'glob'
- | 'grep'
- | 'read'
- | 'search_online'
- | 'scrape_page'
- | 'get_page_contents'
- | 'search_library_docs'
- | 'manage_mcp_tool'
- | 'manage_skill'
- | 'manage_credential'
- | 'manage_custom_tool'
- | 'manage_job'
- | 'user_memory'
- | 'function_execute'
- | 'superagent'
- | 'user_table'
- | 'workspace_file'
- | 'create_workflow'
- | 'delete_workflow'
- | 'edit_workflow'
- | 'rename_workflow'
- | 'move_workflow'
- | 'run_workflow'
- | 'run_block'
- | 'run_from_block'
- | 'run_workflow_until_block'
- | 'create_folder'
- | 'delete_folder'
- | 'move_folder'
- | 'list_folders'
- | 'list_user_workspaces'
- | 'create_job'
- | 'complete_job'
- | 'update_job_history'
- | 'job_respond'
- | 'download_to_workspace_file'
- | 'materialize_file'
- | 'context_write'
- | 'generate_image'
- | 'generate_visualization'
- | 'crawl_website'
- | 'get_execution_summary'
- | 'get_job_logs'
- | 'get_deployment_version'
- | 'revert_to_version'
- | 'check_deployment_status'
- | 'get_deployed_workflow_state'
- | 'get_workflow_data'
- | 'get_workflow_logs'
- | 'get_block_outputs'
- | 'get_block_upstream_references'
- | 'set_global_workflow_variables'
- | 'set_environment_variables'
- | 'get_platform_actions'
- | 'search_documentation'
- | 'search_patterns'
- | 'update_workspace_mcp_server'
- | 'delete_workspace_mcp_server'
- | 'create_workspace_mcp_server'
- | 'list_workspace_mcp_servers'
- | 'deploy_api'
- | 'deploy_chat'
- | 'deploy_mcp'
- | 'redeploy'
- | 'generate_api_key'
- | 'oauth_get_auth_link'
- | 'oauth_request_access'
- | 'build'
- | 'run'
- | 'deploy'
- | 'auth'
- | 'knowledge'
- | 'knowledge_base'
- | 'table'
- | 'job'
- | 'agent'
- | 'custom_tool'
- | 'research'
- | 'plan'
- | 'debug'
- | 'edit'
- | 'fast_edit'
- | 'open_resource'
- | 'context_compaction'
-/**
- * Subagent identifiers dispatched via `subagent_start` SSE events.
- *
- * @example
- * ```json
- * { "type": "subagent_start", "subagent": "build" }
- * ```
- */
-export type SubagentName =
- | 'build'
- | 'deploy'
- | 'auth'
- | 'research'
- | 'knowledge'
- | 'table'
- | 'custom_tool'
- | 'superagent'
- | 'plan'
- | 'debug'
- | 'edit'
- | 'fast_edit'
- | 'run'
- | 'agent'
- | 'job'
- | 'file_write'
+export const ToolPhase = {
+ workspace: 'workspace',
+ search: 'search',
+ management: 'management',
+ execution: 'execution',
+ resource: 'resource',
+ subagent: 'subagent',
+} as const
+export type ToolPhase = (typeof ToolPhase)[keyof typeof ToolPhase]
-export type ToolPhase =
- | 'workspace'
- | 'search'
- | 'management'
- | 'execution'
- | 'resource'
- | 'subagent'
-
-export type ToolCallStatus = 'executing' | 'success' | 'error' | 'cancelled'
+export const ToolCallStatus = {
+ executing: 'executing',
+ success: 'success',
+ error: 'error',
+ cancelled: 'cancelled',
+} as const
+export type ToolCallStatus = (typeof ToolCallStatus)[keyof typeof ToolCallStatus]
export interface ToolCallResult {
success: boolean
@@ -194,7 +89,6 @@ export interface ToolCallResult {
error?: string
}
-/** A single tool call result entry in the generic Results resource tab. */
export interface GenericResourceEntry {
toolCallId: string
toolName: string
@@ -205,7 +99,6 @@ export interface GenericResourceEntry {
result?: ToolCallResult
}
-/** Accumulated feed of tool call results shown in the generic Results tab. */
export interface GenericResourceData {
entries: GenericResourceEntry[]
}
@@ -228,7 +121,7 @@ export interface ToolCallInfo {
phaseLabel?: string
params?: Record
calledBy?: string
- result?: { success: boolean; output?: unknown; error?: string }
+ result?: ToolCallResult
streamingArgs?: string
}
@@ -237,14 +130,17 @@ export interface OptionItem {
label: string
}
-export type ContentBlockType =
- | 'text'
- | 'tool_call'
- | 'subagent'
- | 'subagent_end'
- | 'subagent_text'
- | 'options'
- | 'stopped'
+export const ContentBlockType = {
+ text: 'text',
+ tool_call: 'tool_call',
+ subagent: 'subagent',
+ subagent_end: 'subagent_end',
+ subagent_text: 'subagent_text',
+ subagent_thinking: 'subagent_thinking',
+ options: 'options',
+ stopped: 'stopped',
+} as const
+export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType]
export interface ContentBlock {
type: ContentBlockType
@@ -283,23 +179,19 @@ export interface ChatMessage {
requestId?: string
}
-export const SUBAGENT_LABELS: Record = {
- build: 'Build agent',
- deploy: 'Deploy agent',
- auth: 'Integration agent',
- research: 'Research agent',
- knowledge: 'Knowledge agent',
- table: 'Table agent',
- custom_tool: 'Custom Tool agent',
+export const SUBAGENT_LABELS: Record = {
+ workflow: 'Workflow Agent',
+ deploy: 'Deploy Agent',
+ auth: 'Auth Agent',
+ research: 'Research Agent',
+ knowledge: 'Knowledge Agent',
+ table: 'Table Agent',
+ custom_tool: 'Custom Tool Agent',
superagent: 'Superagent',
- plan: 'Plan agent',
- debug: 'Debug agent',
- edit: 'Edit agent',
- fast_edit: 'Build agent',
- run: 'Run agent',
- agent: 'Agent manager',
- job: 'Job agent',
- file_write: 'File Write',
+ run: 'Run Agent',
+ agent: 'Tools Agent',
+ job: 'Job Agent',
+ file: 'File Agent',
} as const
export interface ToolUIMetadata {
@@ -309,206 +201,127 @@ export interface ToolUIMetadata {
}
/**
- * Primary UI metadata for tools observed in the SSE stream.
- * Maps tool IDs to human-readable display names shown in the chat.
- * This is the single source of truth — server-sent `ui.title` values are not used.
+ * Default UI metadata for tools observed in the SSE stream.
+ * The backend may send `ui` on some `MothershipStreamV1EventType.tool` payloads (`phase: 'call'`);
+ * this map provides fallback metadata when `ui` is absent.
*/
-export const TOOL_UI_METADATA: Record = {
- // Workspace
- glob: { title: 'Searching workspace', phaseLabel: 'Workspace', phase: 'workspace' },
- grep: { title: 'Searching workspace', phaseLabel: 'Workspace', phase: 'workspace' },
- read: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' },
- // Search
- search_online: { title: 'Searching online', phaseLabel: 'Search', phase: 'search' },
- scrape_page: { title: 'Reading webpage', phaseLabel: 'Search', phase: 'search' },
- get_page_contents: { title: 'Reading page', phaseLabel: 'Search', phase: 'search' },
- search_library_docs: { title: 'Searching docs', phaseLabel: 'Search', phase: 'search' },
- crawl_website: { title: 'Browsing website', phaseLabel: 'Search', phase: 'search' },
- // Execution
- function_execute: { title: 'Running code', phaseLabel: 'Code', phase: 'execution' },
- superagent: { title: 'Taking action', phaseLabel: 'Action', phase: 'execution' },
- run_workflow: { title: 'Running workflow', phaseLabel: 'Execution', phase: 'execution' },
- run_block: { title: 'Running block', phaseLabel: 'Execution', phase: 'execution' },
- run_from_block: { title: 'Running from block', phaseLabel: 'Execution', phase: 'execution' },
- run_workflow_until_block: {
- title: 'Running partial workflow',
- phaseLabel: 'Execution',
+export const TOOL_UI_METADATA: Record = {
+ [Glob.id]: {
+ title: 'Finding files',
+ phaseLabel: 'Workspace',
+ phase: 'workspace',
+ },
+ [Grep.id]: {
+ title: 'Searching',
+ phaseLabel: 'Workspace',
+ phase: 'workspace',
+ },
+ [ReadTool.id]: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' },
+ [SearchOnline.id]: {
+ title: 'Searching online',
+ phaseLabel: 'Search',
+ phase: 'search',
+ },
+ [ScrapePage.id]: {
+ title: 'Scraping page',
+ phaseLabel: 'Search',
+ phase: 'search',
+ },
+ [GetPageContents.id]: {
+ title: 'Getting page contents',
+ phaseLabel: 'Search',
+ phase: 'search',
+ },
+ [SearchLibraryDocs.id]: {
+ title: 'Searching library docs',
+ phaseLabel: 'Search',
+ phase: 'search',
+ },
+ [ManageMcpTool.id]: {
+ title: 'MCP server action',
+ phaseLabel: 'Management',
+ phase: 'management',
+ },
+ [ManageSkill.id]: {
+ title: 'Skill action',
+ phaseLabel: 'Management',
+ phase: 'management',
+ },
+ [UserMemory.id]: {
+ title: 'Accessing memory',
+ phaseLabel: 'Management',
+ phase: 'management',
+ },
+ [FunctionExecute.id]: {
+ title: 'Running code',
+ phaseLabel: 'Code',
phase: 'execution',
},
- complete_job: { title: 'Completing job', phaseLabel: 'Execution', phase: 'execution' },
- get_execution_summary: { title: 'Checking results', phaseLabel: 'Execution', phase: 'execution' },
- get_job_logs: { title: 'Checking logs', phaseLabel: 'Execution', phase: 'execution' },
- get_workflow_logs: { title: 'Checking logs', phaseLabel: 'Execution', phase: 'execution' },
- get_workflow_data: { title: 'Loading workflow', phaseLabel: 'Execution', phase: 'execution' },
- get_block_outputs: {
- title: 'Checking block outputs',
- phaseLabel: 'Execution',
+ [Superagent.id]: {
+ title: 'Executing action',
+ phaseLabel: 'Action',
phase: 'execution',
},
- get_block_upstream_references: {
- title: 'Checking references',
- phaseLabel: 'Execution',
- phase: 'execution',
- },
- get_deployed_workflow_state: {
- title: 'Checking deployment',
- phaseLabel: 'Execution',
- phase: 'execution',
- },
- check_deployment_status: {
- title: 'Checking deployment',
- phaseLabel: 'Execution',
- phase: 'execution',
- },
- // Workflows & folders
- create_workflow: { title: 'Creating workflow', phaseLabel: 'Resource', phase: 'resource' },
- delete_workflow: { title: 'Deleting workflow', phaseLabel: 'Resource', phase: 'resource' },
- edit_workflow: { title: 'Editing workflow', phaseLabel: 'Resource', phase: 'resource' },
- rename_workflow: { title: 'Renaming workflow', phaseLabel: 'Resource', phase: 'resource' },
- move_workflow: { title: 'Moving workflow', phaseLabel: 'Resource', phase: 'resource' },
- create_folder: { title: 'Creating folder', phaseLabel: 'Resource', phase: 'resource' },
- delete_folder: { title: 'Deleting folder', phaseLabel: 'Resource', phase: 'resource' },
- move_folder: { title: 'Moving folder', phaseLabel: 'Resource', phase: 'resource' },
- list_folders: { title: 'Browsing folders', phaseLabel: 'Resource', phase: 'resource' },
- list_user_workspaces: { title: 'Browsing workspaces', phaseLabel: 'Resource', phase: 'resource' },
- revert_to_version: { title: 'Restoring version', phaseLabel: 'Resource', phase: 'resource' },
- get_deployment_version: {
- title: 'Checking deployment',
+ [UserTable.id]: {
+ title: 'Managing table',
phaseLabel: 'Resource',
phase: 'resource',
},
- open_resource: { title: 'Opening resource', phaseLabel: 'Resource', phase: 'resource' },
- // Files
- workspace_file: { title: 'Working with files', phaseLabel: 'Resource', phase: 'resource' },
- download_to_workspace_file: {
- title: 'Downloading file',
+ [WorkspaceFile.id]: {
+ title: 'Editing file',
phaseLabel: 'Resource',
phase: 'resource',
},
- materialize_file: { title: 'Saving file', phaseLabel: 'Resource', phase: 'resource' },
- generate_image: { title: 'Generating image', phaseLabel: 'Resource', phase: 'resource' },
- generate_visualization: {
- title: 'Generating visualization',
+ [EDIT_CONTENT_TOOL_ID]: {
+ title: 'Applying file content',
phaseLabel: 'Resource',
phase: 'resource',
},
- // Tables & knowledge
- user_table: { title: 'Editing table', phaseLabel: 'Resource', phase: 'resource' },
- knowledge_base: { title: 'Updating knowledge base', phaseLabel: 'Resource', phase: 'resource' },
- // Jobs
- create_job: { title: 'Creating job', phaseLabel: 'Resource', phase: 'resource' },
- manage_job: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
- update_job_history: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
- job_respond: { title: 'Explaining job scheduled', phaseLabel: 'Execution', phase: 'execution' },
- // Management
- manage_mcp_tool: { title: 'Updating integration', phaseLabel: 'Management', phase: 'management' },
- manage_skill: { title: 'Updating skill', phaseLabel: 'Management', phase: 'management' },
- manage_credential: { title: 'Connecting account', phaseLabel: 'Management', phase: 'management' },
- manage_custom_tool: { title: 'Updating tool', phaseLabel: 'Management', phase: 'management' },
- update_workspace_mcp_server: {
- title: 'Updating MCP server',
- phaseLabel: 'Management',
- phase: 'management',
+ [CreateWorkflow.id]: {
+ title: 'Creating workflow',
+ phaseLabel: 'Resource',
+ phase: 'resource',
},
- delete_workspace_mcp_server: {
- title: 'Removing MCP server',
- phaseLabel: 'Management',
- phase: 'management',
+ [EditWorkflow.id]: {
+ title: 'Editing workflow',
+ phaseLabel: 'Resource',
+ phase: 'resource',
},
- create_workspace_mcp_server: {
- title: 'Creating MCP server',
- phaseLabel: 'Management',
- phase: 'management',
+ [Workflow.id]: { title: 'Workflow Agent', phaseLabel: 'Workflow', phase: 'subagent' },
+ [RUN_SUBAGENT_ID]: { title: 'Run Agent', phaseLabel: 'Run', phase: 'subagent' },
+ [Deploy.id]: { title: 'Deploy Agent', phaseLabel: 'Deploy', phase: 'subagent' },
+ [Auth.id]: {
+ title: 'Auth Agent',
+ phaseLabel: 'Auth',
+ phase: 'subagent',
},
- list_workspace_mcp_servers: {
- title: 'Browsing MCP servers',
- phaseLabel: 'Management',
- phase: 'management',
+ [Knowledge.id]: {
+ title: 'Knowledge Agent',
+ phaseLabel: 'Knowledge',
+ phase: 'subagent',
},
- oauth_get_auth_link: {
- title: 'Connecting account',
- phaseLabel: 'Management',
- phase: 'management',
+ [KnowledgeBase.id]: {
+ title: 'Managing knowledge base',
+ phaseLabel: 'Resource',
+ phase: 'resource',
},
- oauth_request_access: {
- title: 'Connecting account',
- phaseLabel: 'Management',
- phase: 'management',
+ [Table.id]: { title: 'Table Agent', phaseLabel: 'Table', phase: 'subagent' },
+ [Job.id]: { title: 'Job Agent', phaseLabel: 'Job', phase: 'subagent' },
+ [Agent.id]: { title: 'Tools Agent', phaseLabel: 'Agent', phase: 'subagent' },
+ custom_tool: {
+ title: 'Creating tool',
+ phaseLabel: 'Tool',
+ phase: 'subagent',
},
- set_environment_variables: {
- title: 'Updating environment',
- phaseLabel: 'Management',
- phase: 'management',
+ [Research.id]: { title: 'Research Agent', phaseLabel: 'Research', phase: 'subagent' },
+ [OpenResource.id]: {
+ title: 'Opening resource',
+ phaseLabel: 'Resource',
+ phase: 'resource',
},
- set_global_workflow_variables: {
- title: 'Updating variables',
- phaseLabel: 'Management',
- phase: 'management',
- },
- get_platform_actions: { title: 'Loading actions', phaseLabel: 'Management', phase: 'management' },
- search_documentation: { title: 'Searching docs', phaseLabel: 'Search', phase: 'search' },
- search_patterns: { title: 'Searching patterns', phaseLabel: 'Search', phase: 'search' },
- deploy_api: { title: 'Deploying API', phaseLabel: 'Deploy', phase: 'management' },
- deploy_chat: { title: 'Deploying chat', phaseLabel: 'Deploy', phase: 'management' },
- deploy_mcp: { title: 'Deploying MCP', phaseLabel: 'Deploy', phase: 'management' },
- redeploy: { title: 'Redeploying', phaseLabel: 'Deploy', phase: 'management' },
- generate_api_key: { title: 'Generating API key', phaseLabel: 'Deploy', phase: 'management' },
- user_memory: { title: 'Updating memory', phaseLabel: 'Management', phase: 'management' },
- context_write: { title: 'Writing notes', phaseLabel: 'Management', phase: 'management' },
context_compaction: {
- title: 'Optimizing context',
- phaseLabel: 'Management',
+ title: 'Compacted context',
+ phaseLabel: 'Context',
phase: 'management',
},
- // Subagents
- build: { title: 'Building', phaseLabel: 'Build', phase: 'subagent' },
- run: { title: 'Running', phaseLabel: 'Run', phase: 'subagent' },
- deploy: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' },
- auth: { title: 'Connecting integration', phaseLabel: 'Auth', phase: 'subagent' },
- knowledge: { title: 'Working with knowledge', phaseLabel: 'Knowledge', phase: 'subagent' },
- table: { title: 'Working with tables', phaseLabel: 'Table', phase: 'subagent' },
- job: { title: 'Working with jobs', phaseLabel: 'Job', phase: 'subagent' },
- agent: { title: 'Taking action', phaseLabel: 'Agent', 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' },
-}
-
-export interface SSEPayloadUI {
- hidden?: boolean
- title?: string
- phaseLabel?: string
- icon?: string
- internal?: boolean
- clientExecutable?: boolean
-}
-
-export interface SSEPayloadData {
- name?: string
- ui?: SSEPayloadUI
- id?: string
- agent?: string
- partial?: boolean
- arguments?: Record
- input?: Record
- result?: unknown
- error?: string
-}
-
-export interface SSEPayload {
- type: SSEEventType | (string & {})
- chatId?: string
- data?: string | SSEPayloadData
- content?: string
- toolCallId?: string
- toolName?: string
- ui?: SSEPayloadUI
- success?: boolean
- result?: unknown
- error?: string
- subagent?: string
- resource?: { type: MothershipResourceType; id: string; title: string }
}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts
index 1a907cfd89..3c0c5922ad 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts
@@ -1,5 +1,5 @@
export { Dashboard } from './dashboard'
-export { LogDetails } from './log-details'
+export { LogDetails, WorkflowOutputSection } from './log-details'
export { ExecutionSnapshot } from './log-details/components/execution-snapshot'
export { FileCards } from './log-details/components/file-download'
export { TraceSpans } from './log-details/components/trace-spans'
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts
index 5d9685fdbd..47078f505c 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts
@@ -1 +1 @@
-export { LogDetails } from './log-details'
+export { LogDetails, WorkflowOutputSection } from './log-details'
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
index 963aba1714..3c8f4e499d 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
@@ -45,7 +45,7 @@ import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/log
/**
* Workflow Output section with code viewer, copy, search, and context menu functionality
*/
-const WorkflowOutputSection = memo(
+export const WorkflowOutputSection = memo(
function WorkflowOutputSection({ output }: { output: Record }) {
const contentRef = useRef(null)
const [copied, setCopied] = useState(false)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index 9610babe87..de5096caef 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -144,6 +144,13 @@ const Admin = dynamic(
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin),
{ loading: () => }
)
+const Mothership = dynamic(
+ () =>
+ import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then(
+ (m) => m.Mothership
+ ),
+ { loading: () => }
+)
const RecentlyDeleted = dynamic(
() =>
import(
@@ -188,7 +195,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
? 'general'
: section === 'admin' && !sessionLoading && !isAdminRole
? 'general'
- : section
+ : section === 'mothership' && !sessionLoading && !isAdminRole
+ ? 'general'
+ : section
const label =
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
@@ -222,6 +231,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'inbox' && }
{effectiveSection === 'recently-deleted' && }
{effectiveSection === 'admin' && }
+ {effectiveSection === 'mothership' && }
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
new file mode 100644
index 0000000000..261f5c9b53
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
@@ -0,0 +1,908 @@
+'use client'
+
+import { useCallback, useMemo, useState } from 'react'
+import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+import {
+ type MothershipEnv,
+ useGenerateLicense,
+ useMothershipEnterpriseStats,
+ useMothershipLicenses,
+ useMothershipRequests,
+ useMothershipTrace,
+ useMothershipUserBreakdown,
+} from '@/hooks/queries/mothership-admin'
+
+type Tab = 'overview' | 'licenses' | 'enterprise' | 'traces'
+
+const TABS: { id: Tab; label: string }[] = [
+ { id: 'overview', label: 'Overview' },
+ { id: 'licenses', label: 'Licenses' },
+ { id: 'enterprise', label: 'Enterprise' },
+ { id: 'traces', label: 'Traces' },
+]
+
+const ENV_OPTIONS: { id: MothershipEnv; label: string }[] = [
+ { id: 'dev', label: 'Dev' },
+ { id: 'staging', label: 'Staging' },
+ { id: 'prod', label: 'Prod' },
+]
+
+function defaultTimeRange() {
+ const end = new Date()
+ const start = new Date()
+ start.setDate(start.getDate() - 7)
+ return {
+ start: start.toISOString().slice(0, 16),
+ end: end.toISOString().slice(0, 16),
+ }
+}
+
+function toRFC3339(local: string) {
+ if (!local) return ''
+ return new Date(local).toISOString()
+}
+
+function formatCost(cost: number) {
+ return `$${cost.toFixed(4)}`
+}
+
+function formatDate(d: string | null | undefined) {
+ if (!d) return '—'
+ return new Date(d).toLocaleString()
+}
+
+function Divider() {
+ return