Compare commits

...

5 Commits

Author SHA1 Message Date
Waleed
7ecd377c41 fix(tasks-ui): update in progress to be amber, added status to dropdown menu, fixed scheduled tasks modal (#3597)
* fix(tasks-ui): update in progress to be amber, added status to dropdownmenu

* fix play button, scheduled tasks modal
2026-03-14 23:56:18 -07:00
Theodore Li
743742d058 Show rendered md by default (#3594)
* Show rendered md by default

* Lint fix

* chore(file-viewer): remove dead PREVIEW_ONLY_EXTENSIONS export

Co-authored-by: Theodore Li <TheodoreSpeaks@users.noreply.github.com>

* Fix new file logic to default to text

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Theodore Li <TheodoreSpeaks@users.noreply.github.com>
2026-03-14 22:17:40 -04:00
Theodore Li
b7b575c4d2 fix(ui) reduce redundant rerenders (#3593)
* Improve rerendering of resource view

* Lint fix

* Remove console log

* Fix panel logic

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-14 22:02:21 -04:00
Siddharth Ganesan
aad620c456 fix(mothership): run workflow tools (run from block, run until block) (#3595)
* Fix redis queuing and run

* Fix dynimp
2026-03-14 18:57:55 -07:00
Vikhyath Mondreti
f57294936b fix(embedded): viewport options breaking autolayout (#3596) 2026-03-14 18:57:36 -07:00
18 changed files with 278 additions and 175 deletions

View File

@@ -14,6 +14,7 @@ import {
} from '@/lib/copilot/chat-streaming'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -454,6 +455,30 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
status: string
} | null = null
if (chat.conversationId) {
try {
const [meta, events] = await Promise.all([
getStreamMeta(chat.conversationId),
readStreamEvents(chat.conversationId, 0),
])
streamSnapshot = {
events: events || [],
status: meta?.status || 'unknown',
}
} catch (err) {
logger.warn('Failed to read stream snapshot for chat', {
chatId,
conversationId: chat.conversationId,
error: err instanceof Error ? err.message : String(err),
})
}
}
const transformedChat = {
id: chat.id,
title: chat.title,
@@ -466,6 +491,7 @@ export async function GET(req: NextRequest) {
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
}
logger.info(`Retrieved chat ${chatId}`)

View File

@@ -98,7 +98,7 @@ export function FileViewer({
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
previewMode={previewMode ?? (showPreview ? 'split' : 'editor')}
previewMode={previewMode ?? (showPreview ? 'preview' : 'editor')}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}

View File

@@ -1,3 +1,3 @@
export type { PreviewMode } from './file-viewer'
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'
export { PREVIEW_ONLY_EXTENSIONS, RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'
export { RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'

View File

@@ -23,9 +23,6 @@ const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
svg: 'svg',
}
/** Extensions that should default to rendered preview (no raw editor). */
export const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm', 'svg'])
/** All extensions that have a rich preview renderer. */
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))

View File

@@ -476,10 +476,11 @@ export function Files() {
}, [closeListContextMenu])
useEffect(() => {
if (justCreatedFileIdRef.current && selectedFileId !== justCreatedFileIdRef.current) {
const isJustCreated = selectedFileId != null && justCreatedFileIdRef.current === selectedFileId
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
setShowPreview(true)
setShowPreview(!isJustCreated)
}, [selectedFileId])
useEffect(() => {
@@ -521,8 +522,8 @@ export function Files() {
...(canPreview
? [
{
label: showPreview ? 'Hide Preview' : 'Preview',
icon: Eye,
label: showPreview ? 'Edit' : 'Preview',
icon: showPreview ? Pencil : Eye,
onClick: handleTogglePreview,
},
]

View File

@@ -1,6 +1,6 @@
'use client'
import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'
import { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { Square } from 'lucide-react'
import { useRouter } from 'next/navigation'
@@ -51,7 +51,11 @@ interface ResourceContentProps {
* Handles table, file, and workflow resource types with appropriate
* embedded rendering for each.
*/
export function ResourceContent({ workspaceId, resource, previewMode }: ResourceContentProps) {
export const ResourceContent = memo(function ResourceContent({
workspaceId,
resource,
previewMode,
}: ResourceContentProps) {
switch (resource.type) {
case 'table':
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
@@ -84,7 +88,7 @@ export function ResourceContent({ workspaceId, resource, previewMode }: Resource
default:
return null
}
}
})
interface ResourceActionsProps {
workspaceId: string
@@ -303,10 +307,12 @@ interface EmbeddedWorkflowProps {
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId]))
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const hydrationWorkflowId = useWorkflowRegistry((state) => state.hydration.workflowId)
const isMetadataLoaded = hydrationPhase !== 'idle' && hydrationPhase !== 'metadata-loading'
const hasLoadError = hydrationPhase === 'error' && hydrationWorkflowId === workflowId
const isMetadataLoaded = useWorkflowRegistry(
(state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading'
)
const hasLoadError = useWorkflowRegistry(
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
)
if (!isMetadataLoaded) return LOADING_SKELETON

View File

@@ -8,7 +8,7 @@ import {
useState,
} from 'react'
import { Button, Tooltip } from '@/components/emcn'
import { Columns3, Eye, PanelLeft, Rows3 } from '@/components/emcn/icons'
import { Columns3, Eye, PanelLeft, Pencil } from '@/components/emcn/icons'
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'
@@ -36,9 +36,9 @@ const EDGE_ZONE = 40
const SCROLL_SPEED = 8
const PREVIEW_MODE_ICONS = {
editor: Rows3,
split: Columns3,
preview: Eye,
editor: Columns3,
split: Eye,
preview: Pencil,
} satisfies Record<PreviewMode, (props: ComponentProps<typeof Eye>) => ReactNode>
/**

View File

@@ -1,13 +1,10 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
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'
import {
PREVIEW_ONLY_EXTENSIONS,
RICH_PREVIEWABLE_EXTENSIONS,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { RICH_PREVIEWABLE_EXTENSIONS } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import type {
MothershipResource,
MothershipResourceType,
@@ -34,7 +31,7 @@ interface MothershipViewProps {
className?: string
}
export function MothershipView({
export const MothershipView = memo(function MothershipView({
workspaceId,
chatId,
resources,
@@ -49,12 +46,11 @@ export function MothershipView({
}: MothershipViewProps) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
const [previewMode, setPreviewMode] = useState<PreviewMode>('split')
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
useEffect(() => {
const ext = active?.type === 'file' ? getFileExtension(active.title) : ''
setPreviewMode(PREVIEW_ONLY_EXTENSIONS.has(ext) ? 'preview' : 'split')
setPreviewMode('preview')
}, [active?.id])
const isActivePreviewable =
@@ -99,4 +95,4 @@ export function MothershipView({
</div>
</div>
)
}
})

View File

@@ -167,7 +167,6 @@ export function Home({ chatId }: HomeProps = {}) {
const handleResourceEvent = useCallback(() => {
if (isResourceCollapsedRef.current) {
/** Auto-collapse sidebar to give resource panel maximum width for immersive experience */
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
if (!isCollapsed) toggleCollapsed()
setIsResourceCollapsed(false)
@@ -178,6 +177,7 @@ export function Home({ chatId }: HomeProps = {}) {
const {
messages,
isSending,
isReconnecting,
sendMessage,
stopGeneration,
resolvedChatId,
@@ -330,7 +330,7 @@ export function Home({ chatId }: HomeProps = {}) {
return () => ro.disconnect()
}, [hasMessages])
if (!hasMessages && chatId && isLoadingHistory) {
if (chatId && (isLoadingHistory || isReconnecting)) {
return (
<ChatSkeleton>
<UserInput

View File

@@ -45,6 +45,7 @@ import type {
export interface UseChatReturn {
messages: ChatMessage[]
isSending: boolean
isReconnecting: boolean
error: string | null
resolvedChatId: string | undefined
sendMessage: (
@@ -250,6 +251,7 @@ export function useChat(
const queryClient = useQueryClient()
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
const [isReconnecting, setIsReconnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
const [resources, setResources] = useState<MothershipResource[]>([])
@@ -268,6 +270,10 @@ export function useChat(
}, [messageQueue])
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
const processSSEStreamRef = useRef<
(reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => Promise<void>
>(async () => {})
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
const abortControllerRef = useRef<AbortController | null>(null)
const chatIdRef = useRef<string | undefined>(initialChatId)
@@ -329,6 +335,7 @@ export function useChat(
setMessages([])
setError(null)
setIsSending(false)
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setMessageQueue([])
@@ -346,6 +353,7 @@ export function useChat(
setMessages([])
setError(null)
setIsSending(false)
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setMessageQueue([])
@@ -365,6 +373,95 @@ export function useChat(
ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
}
}
// Kick off stream reconnection immediately if there's an active stream.
// The stream snapshot was fetched in parallel with the chat history (same
// API call), so there's no extra round-trip.
const activeStreamId = chatHistory.activeStreamId
const snapshot = chatHistory.streamSnapshot
if (activeStreamId && !sendingRef.current) {
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
streamIdRef.current = activeStreamId
sendingRef.current = true
setIsReconnecting(true)
const assistantId = crypto.randomUUID()
const reconnect = async () => {
try {
const encoder = new TextEncoder()
const batchEvents = snapshot?.events ?? []
const streamStatus = snapshot?.status ?? ''
if (!snapshot || (batchEvents.length === 0 && streamStatus === 'unknown')) {
// No snapshot available — stream buffer expired. Clean up.
const cid = chatIdRef.current
if (cid) {
fetch('/api/mothership/chat/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId: cid, streamId: activeStreamId, content: '' }),
}).catch(() => {})
}
return
}
setIsSending(true)
setIsReconnecting(false)
const lastEventId =
batchEvents.length > 0 ? batchEvents[batchEvents.length - 1].eventId : 0
const isStreamDone = streamStatus === 'complete' || streamStatus === 'error'
const combinedStream = new ReadableStream<Uint8Array>({
async start(controller) {
if (batchEvents.length > 0) {
const sseText = batchEvents
.map((e) => `data: ${JSON.stringify(e.event)}\n`)
.join('\n')
controller.enqueue(encoder.encode(`${sseText}\n`))
}
if (!isStreamDone) {
try {
const sseRes = await fetch(
`/api/copilot/chat/stream?streamId=${activeStreamId}&from=${lastEventId}`,
{ signal: abortController.signal }
)
if (sseRes.ok && sseRes.body) {
const reader = sseRes.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
controller.enqueue(value)
}
}
} catch (err) {
if (!(err instanceof Error && err.name === 'AbortError')) {
logger.warn('SSE tail failed during reconnect', err)
}
}
}
controller.close()
},
})
await processSSEStreamRef.current(combinedStream.getReader(), assistantId)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
} finally {
setIsReconnecting(false)
if (streamGenRef.current === gen) {
finalizeRef.current()
}
}
}
reconnect()
}
}, [chatHistory, workspaceId])
useEffect(() => {
@@ -405,11 +502,14 @@ export function useChat(
const flush = () => {
streamingBlocksRef.current = [...blocks]
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: runningText, contentBlocks: [...blocks] } : m
)
)
const snapshot = { content: runningText, contentBlocks: [...blocks] }
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
}
return [...prev, { id: assistantId, role: 'assistant' as const, ...snapshot }]
})
}
while (true) {
@@ -582,8 +682,7 @@ export function useChat(
readArgs?.path as string | undefined,
tc.result.output
)
if (resource) {
addResource(resource)
if (resource && addResource(resource)) {
onResourceEventRef.current?.()
}
}
@@ -594,12 +693,21 @@ export function useChat(
case 'resource_added': {
const resource = parsed.resource
if (resource?.type && resource?.id) {
addResource(resource)
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') {
if (ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)) {
const wasRegistered = ensureWorkflowInRegistry(
resource.id,
resource.title,
workspaceId
)
if (wasAdded && wasRegistered) {
useWorkflowRegistry.getState().setActiveWorkflow(resource.id)
} else {
useWorkflowRegistry.getState().loadWorkflowState(resource.id)
@@ -662,6 +770,9 @@ export function useChat(
},
[workspaceId, queryClient, addResource, removeResource]
)
useLayoutEffect(() => {
processSSEStreamRef.current = processSSEStream
})
const persistPartialResponse = useCallback(async () => {
const chatId = chatIdRef.current
@@ -750,50 +861,9 @@ export function useChat(
},
[invalidateChatQueries]
)
useEffect(() => {
const activeStreamId = chatHistory?.activeStreamId
if (!activeStreamId || !appliedChatIdRef.current || sendingRef.current) return
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
sendingRef.current = true
setIsSending(true)
const assistantId = crypto.randomUUID()
setMessages((prev) => [
...prev,
{
id: assistantId,
role: 'assistant' as const,
content: '',
contentBlocks: [],
},
])
const reconnect = async () => {
try {
const response = await fetch(`/api/copilot/chat/stream?streamId=${activeStreamId}&from=0`, {
signal: abortController.signal,
})
if (!response.ok || !response.body) return
await processSSEStream(response.body.getReader(), assistantId)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
} finally {
if (streamGenRef.current === gen) {
finalize()
}
}
}
reconnect()
return () => {
abortController.abort()
appliedChatIdRef.current = undefined
}
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
useLayoutEffect(() => {
finalizeRef.current = finalize
})
const sendMessage = useCallback(
async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
@@ -937,7 +1007,11 @@ export function useChat(
if (sendingRef.current) {
await persistPartialResponse()
}
const sid = streamIdRef.current
const sid =
streamIdRef.current ||
queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatIdRef.current))
?.activeStreamId ||
undefined
streamGenRef.current++
abortControllerRef.current?.abort()
abortControllerRef.current = null
@@ -1054,6 +1128,7 @@ export function useChat(
return {
messages,
isSending,
isReconnecting,
error,
resolvedChatId,
sendMessage,

View File

@@ -57,7 +57,7 @@ export const STATUS_CONFIG: Record<
> = {
error: { variant: 'red', label: 'Error', color: 'var(--text-error)' },
pending: { variant: 'amber', label: 'Pending', color: '#f59e0b' },
running: { variant: 'green', label: 'Running', color: '#22c55e' },
running: { variant: 'amber', label: 'Running', color: '#f59e0b' },
cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316' },
info: { variant: 'gray', label: 'Info', color: 'var(--terminal-status-info-color)' },
}

View File

@@ -249,7 +249,11 @@ export function ScheduledTasks() {
>
Cancel
</Button>
<Button variant='default' onClick={handleDelete} disabled={deleteSchedule.isPending}>
<Button
variant='destructive'
onClick={handleDelete}
disabled={deleteSchedule.isPending}
>
{deleteSchedule.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>

View File

@@ -521,62 +521,69 @@ export async function executeWorkflowWithFullLogging(
const data = line.substring(6).trim()
if (data === '[DONE]') continue
let event: any
try {
const event = JSON.parse(data)
event = JSON.parse(data)
} catch {
continue
}
switch (event.type) {
case 'execution:started': {
setCurrentExecutionId(wfId, event.executionId)
executionIdRef.current = event.executionId || executionId
break
}
case 'block:started':
blockHandlers.onBlockStarted(event.data)
break
case 'block:completed':
blockHandlers.onBlockCompleted(event.data)
break
case 'block:error':
blockHandlers.onBlockError(event.data)
break
case 'block:childWorkflowStarted':
blockHandlers.onBlockChildWorkflowStarted(event.data)
break
case 'execution:completed':
setCurrentExecutionId(wfId, null)
executionResult = {
success: event.data.success,
output: event.data.output,
logs: [],
metadata: {
duration: event.data.duration,
startTime: event.data.startTime,
endTime: event.data.endTime,
},
}
break
case 'execution:cancelled':
setCurrentExecutionId(wfId, null)
executionResult = {
success: false,
output: {},
error: 'Execution was cancelled',
logs: [],
}
break
case 'execution:error':
setCurrentExecutionId(wfId, null)
throw new Error(event.data.error || 'Execution failed')
switch (event.type) {
case 'execution:started': {
setCurrentExecutionId(wfId, event.executionId)
executionIdRef.current = event.executionId || executionId
break
}
} catch (parseError) {
// Skip malformed SSE events
case 'block:started':
blockHandlers.onBlockStarted(event.data)
break
case 'block:completed':
blockHandlers.onBlockCompleted(event.data)
break
case 'block:error':
blockHandlers.onBlockError(event.data)
break
case 'block:childWorkflowStarted':
blockHandlers.onBlockChildWorkflowStarted(event.data)
break
case 'execution:completed':
setCurrentExecutionId(wfId, null)
executionResult = {
success: event.data.success,
output: event.data.output,
logs: [],
metadata: {
duration: event.data.duration,
startTime: event.data.startTime,
endTime: event.data.endTime,
},
}
break
case 'execution:cancelled':
setCurrentExecutionId(wfId, null)
executionResult = {
success: false,
output: {},
error: 'Execution was cancelled',
logs: [],
}
break
case 'execution:error':
setCurrentExecutionId(wfId, null)
executionResult = {
success: false,
output: {},
error: event.data.error || 'Execution failed',
logs: [],
}
break
}
}
}

View File

@@ -207,6 +207,7 @@ const reactFlowStyles = [
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const embeddedFitViewOptions = { padding: 0.15, maxZoom: 0.85, minZoom: 0.35 } as const
const reactFlowProOptions = { hideAttribution: true } as const
/**
@@ -3851,11 +3852,11 @@ const WorkflowContent = React.memo(
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
onInit={(instance) => {
requestAnimationFrame(() => {
instance.fitView(reactFlowFitViewOptions)
instance.fitView(embedded ? embeddedFitViewOptions : reactFlowFitViewOptions)
setIsCanvasReady(true)
})
}}
fitViewOptions={reactFlowFitViewOptions}
fitViewOptions={embedded ? embeddedFitViewOptions : reactFlowFitViewOptions}
minZoom={0.1}
maxZoom={1.3}
panOnScroll

View File

@@ -138,9 +138,12 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
{task.id !== 'new' && (
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
{isActive && !isCurrentRoute && (
<span className='absolute h-[7px] w-[7px] animate-ping rounded-full bg-[#33C482] opacity-30 group-hover:hidden' />
<span className='absolute h-[7px] w-[7px] animate-ping rounded-full bg-amber-400 opacity-30 group-hover:hidden' />
)}
{(isActive || isUnread) && !isCurrentRoute && (
{isActive && !isCurrentRoute && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-amber-400 group-hover:hidden' />
)}
{!isActive && isUnread && !isCurrentRoute && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-[#33C482] group-hover:hidden' />
)}
<button
@@ -1096,7 +1099,15 @@ export const Sidebar = memo(function Sidebar() {
tasks.map((task) => (
<DropdownMenuItem key={task.id} asChild>
<Link href={task.href}>
<Blimp className='h-[16px] w-[16px]' />
<span className='relative flex-shrink-0'>
<Blimp className='h-[16px] w-[16px]' />
{task.isActive && (
<span className='-bottom-[1px] -right-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400' />
)}
{!task.isActive && task.isUnread && (
<span className='-bottom-[1px] -right-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]' />
)}
</span>
<span>{task.name}</span>
</Link>
</DropdownMenuItem>

View File

@@ -40,7 +40,7 @@ export function PlayOutline(props: SVGProps<SVGSVGElement>) {
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M6.25 3.9C6.25 3.408 6.799 3.114 7.209 3.399L15.709 9.299C16.063 9.545 16.063 10.069 15.709 10.315L7.209 16.215C6.799 16.5 6.25 16.206 6.25 15.714V3.9Z' />
<path d='M7.5 3.5C7.5 2.672 8.452 2.18 9.128 2.66L18.128 9.16C18.72 9.58 18.72 10.46 18.128 10.88L9.128 17.38C8.452 17.86 7.5 17.368 7.5 16.54V3.5Z' />
</svg>
)
}

View File

@@ -9,12 +9,18 @@ export interface TaskMetadata {
isUnread: boolean
}
export interface StreamSnapshot {
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
status: string
}
export interface TaskChatHistory {
id: string
title: string | null
messages: TaskStoredMessage[]
activeStreamId: string | null
resources: MothershipResource[]
streamSnapshot?: StreamSnapshot | null
}
export interface TaskStoredToolCall {
@@ -135,6 +141,7 @@ async function fetchChatHistory(chatId: string, signal?: AbortSignal): Promise<T
messages: Array.isArray(chat.messages) ? chat.messages : [],
activeStreamId: chat.conversationId || null,
resources: Array.isArray(chat.resources) ? chat.resources : [],
streamSnapshot: chat.streamSnapshot || null,
}
}

View File

@@ -21,12 +21,6 @@ import type {
import { executeToolAndReport, waitForToolCompletion, waitForToolDecision } from './tool-execution'
const logger = createLogger('CopilotSseHandlers')
const CLIENT_WORKFLOW_TOOLS = new Set([
'run_workflow',
'run_workflow_until_block',
'run_block',
'run_from_block',
])
/**
* Extract the `ui` object from a Go SSE event. The Go backend enriches
@@ -320,17 +314,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
}
if (options.interactive === false) {
if (clientExecutable && CLIENT_WORKFLOW_TOOLS.has(toolName)) {
toolCall.status = 'executing'
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
handleClientCompletion(toolCall, toolCallId, completion)
await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
return
}
if (options.autoExecuteTools !== false) {
fireToolExecution()
}
@@ -580,17 +563,6 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
}
if (options.interactive === false) {
if (clientExecutable && CLIENT_WORKFLOW_TOOLS.has(toolName)) {
toolCall.status = 'executing'
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
handleClientCompletion(toolCall, toolCallId, completion)
await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
return
}
if (options.autoExecuteTools !== false) {
fireToolExecution()
}