Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
# bun specific
bun-debug.log*

# cursor debug logs
.cursor/debug-*.log

# this repo uses bun.lock; package-lock.json files are accidental
package-lock.json

Expand Down Expand Up @@ -44,6 +47,11 @@ dump.rdb
.env.test
.env.production

# editor swap files
*.swp
*.swo
*.swn

# vercel
.vercel

Expand Down
18 changes: 18 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1958,6 +1958,24 @@ export function WhatsAppIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function SportmonksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 25 24'
fill='none'
fillRule='evenodd'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M11.857 8.546c1.893 0 3.517.678 4.872 2.033 1.355 1.336 2.032 2.96 2.032 4.872 0 1.91-.677 3.535-2.032 4.871-1.355 1.355-2.979 2.032-4.872 2.032H1V17.093h10.857c.446 0 .825-.157 1.142-.473.334-.334.5-.724.5-1.17 0-.445-.166-.835-.5-1.17a1.558 1.558 0 00-1.142-.472H7.905c-1.912 0-3.537-.677-4.873-2.032C1.678 10.421 1 8.796 1 6.903 1 4.993 1.678 3.368 3.033 2.032 4.368.678 5.992 0 7.905 0h10.188V5.263H7.904a1.65 1.65 0 00-1.17.473 1.586 1.586 0 00-.472 1.169c0 .445.157.835.473 1.17.334.315.724.472 1.17.472h3.952z'
fill='currentColor'
/>
<circle cx='21.27' cy='20.123' r='2.732' fill='#FF0F50' />
</svg>
)
}

export function SquareIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 501.42 501.42' xmlns='http://www.w3.org/2000/svg'>
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ import {
SixtyfourIcon,
SlackIcon,
SmtpIcon,
SportmonksIcon,
SQSIcon,
SquareIcon,
SshIcon,
Expand Down Expand Up @@ -449,6 +450,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
sixtyfour: SixtyfourIcon,
slack: SlackIcon,
smtp: SmtpIcon,
sportmonks: SportmonksIcon,
sqs: SQSIcon,
square: SquareIcon,
ssh: SshIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@
"sixtyfour",
"slack",
"smtp",
"sportmonks",
"sqs",
"square",
"ssh",
Expand Down
1,517 changes: 1,517 additions & 0 deletions apps/docs/content/docs/en/integrations/sportmonks.mdx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ToolCallData, ToolCallStatus } from '../../../../types'
import type { AgentGroupItem } from './agent-group'
import { isAgentGroupResolved } from './agent-group'

let toolSeq = 0

function tool(status: ToolCallStatus): AgentGroupItem {
toolSeq += 1
const data: ToolCallData = {
id: `tool-${toolSeq}`,
toolName: 'grep',
displayTitle: 'Searching',
status,
}
return { type: 'tool', data }
}

function text(content: string): AgentGroupItem {
return { type: 'text', content }
}

function group(items: AgentGroupItem[], isDelegating = false): AgentGroupItem {
return {
type: 'agent_group',
group: {
id: `group-${toolSeq}`,
agentName: 'deploy',
agentLabel: 'Deploy',
items,
isDelegating,
isOpen: true,
},
}
}

describe('isAgentGroupResolved', () => {
it('is unresolved when there is no work yet', () => {
expect(isAgentGroupResolved([])).toBe(false)
expect(isAgentGroupResolved([text('thinking...')])).toBe(false)
})

it('resolves once every own tool is terminal', () => {
expect(isAgentGroupResolved([tool('success')])).toBe(true)
expect(isAgentGroupResolved([tool('success'), tool('error')])).toBe(true)
})

it('stays unresolved while any own tool is still executing', () => {
expect(isAgentGroupResolved([tool('success'), tool('executing')])).toBe(false)
})

it('resolves a parent whose only work is a finished child group', () => {
expect(isAgentGroupResolved([group([tool('success')])])).toBe(true)
})

it('stays unresolved while a nested child is still delegating', () => {
expect(isAgentGroupResolved([group([], true)])).toBe(false)
})

it('stays unresolved while a nested child has an executing tool', () => {
expect(isAgentGroupResolved([group([tool('executing')])])).toBe(false)
})

it('resolves deep nesting only when every descendant is terminal', () => {
expect(isAgentGroupResolved([group([group([tool('success')])])])).toBe(true)
expect(isAgentGroupResolved([group([group([tool('executing')])])])).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon } from '../../utils'
import { getAgentIcon, isToolDone } from '../../utils'
import { ToolCallItem } from './tool-call-item'

/**
Expand Down Expand Up @@ -35,15 +35,18 @@ interface AgentGroupProps {
defaultExpanded?: boolean
}

function isToolDone(status: ToolCallData['status']): boolean {
return (
status === 'success' ||
status === 'error' ||
status === 'cancelled' ||
status === 'skipped' ||
status === 'rejected' ||
status === 'interrupted'
)
export function isAgentGroupResolved(items: AgentGroupItem[]): boolean {
let hasWork = false
for (const item of items) {
if (item.type === 'tool') {
hasWork = true
if (!isToolDone(item.data.status)) return false
} else if (item.type === 'agent_group') {
hasWork = true
if (item.group.isDelegating || !isAgentGroupResolved(item.group.items)) return false
}
}
return hasWork
}

export function AgentGroup({
Expand All @@ -56,20 +59,18 @@ export function AgentGroup({
}: AgentGroupProps) {
const AgentIcon = getAgentIcon(agentName)
const hasItems = items.length > 0
const toolItems = items.filter(
(item): item is Extract<AgentGroupItem, { type: 'tool' }> => item.type === 'tool'
)
const allDone = toolItems.length > 0 && toolItems.every((t) => isToolDone(t.data.status))
// Only a live turn can be delegating. Once the turn is terminal (complete,
// errored, or stopped) no subagent should spin — even one aborted before its
// first tool call, where `allDone` is false because there are no tools yet.
const showDelegatingSpinner = isStreaming && isDelegating && !allDone
const resolved = isAgentGroupResolved(items)
// Pure projection of the run's own state: a subagent header spins while it is
// delegating with no resolved work yet. A terminal turn closes the lane (its
// subagent block is stamped ended), which clears `isDelegating`, so no
// transport gating is needed to stop an aborted-before-first-tool spinner.
const showDelegatingSpinner = isDelegating && !resolved
Comment thread
Sg312 marked this conversation as resolved.

// Expand only while the turn is live and the group is still open or working.
// Once the turn ends (isStreaming false) — or a subagent closes mid-turn — the
// group auto-collapses, so finished subagent blocks never stay expanded. A
// manual toggle pins the choice for the rest of the message.
const autoExpanded = isStreaming && (defaultExpanded || !allDone)
const autoExpanded = isStreaming && (defaultExpanded || !resolved)
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
const expanded = manualExpanded ?? autoExpanded

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
export { AgentGroup } from './agent-group'
export { AgentGroup, isAgentGroupResolved } from './agent-group'
export { CircleStop } from './tool-call-item'
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMemo } from 'react'
import { PillsRing } from '@/components/emcn'
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import type { ToolCallStatus } from '../../../../types'
import { getToolIcon } from '../../utils'
import { getToolIcon, resolveToolDisplayState } from '../../utils'

function CircleCheck({ className }: { className?: string }) {
return (
Expand Down Expand Up @@ -58,13 +58,14 @@ function Hyphen({ className }: { className?: string }) {
}

function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
if (status === 'executing') {
const display = resolveToolDisplayState(status)
if (display === 'spinner') {
return <PillsRing className='size-[15px] text-[var(--text-tertiary)]' animate />
}
if (status === 'cancelled') {
if (display === 'cancelled') {
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
}
if (status === 'interrupted') {
if (display === 'interrupted') {
return <Hyphen className='size-[15px] text-[var(--text-tertiary)]' />
}
const Icon = getToolIcon(toolName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,21 +279,30 @@ interface ChatContentProps {
isStreaming?: boolean
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onRevealStateChange?: (isRevealing: boolean) => void
}

function ChatContentInner({
content,
isStreaming = false,
onOptionSelect,
onWorkspaceResourceSelect,
onRevealStateChange,
}: ChatContentProps) {
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect

const onRevealStateChangeRef = useRef(onRevealStateChange)
onRevealStateChangeRef.current = onRevealStateChange

const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
const streamedContent = useSmoothText(displayContent, isStreaming)
const isRevealing = isStreaming || streamedContent.length < displayContent.length

useEffect(() => {
onRevealStateChangeRef.current?.(isRevealing)
}, [isRevealing])

/**
* One-way latch: once a message has streamed in this mount, keep rendering it
* through Streamdown's streaming/animation pipeline for the rest of its life.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
export { AgentGroup, CircleStop } from './agent-group'
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
export { ChatContent } from './chat-content'
export { Options } from './options'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ describe('parseBlocks span-identity tree', () => {
expect(nested.group.items.some((item) => item.type === 'tool')).toBe(true)
})

it('clears the parent delegating flag once it has spawned a child, leaving only the child active', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
subagentStart('deploy', 'S2', 'S1'),
]

const segments = parseBlocks(blocks)
expect(segments).toHaveLength(1)
const workflow = segments[0]
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
expect(workflow.isDelegating).toBe(false)

const nested = workflow.items.find((item) => item.type === 'agent_group')
if (!nested || nested.type !== 'agent_group') throw new Error('expected nested deploy group')
expect(nested.group.isDelegating).toBe(true)
})

it('keeps two top-level subagents as siblings', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
Expand Down Expand Up @@ -94,6 +111,56 @@ describe('parseBlocks span-identity tree', () => {
expect(withContent[0].isDelegating).toBe(false)
})

it('keeps two concurrently-open subagent lanes separate with interleaved text', () => {
const blocks: ContentBlock[] = [
subagentStart('research', 'A', 'main'),
subagentStart('research', 'B', 'main'),
{ type: 'subagent_text', content: 'A1 ', spanId: 'A', subagent: 'research', timestamp: 2 },
{ type: 'subagent_text', content: 'B1 ', spanId: 'B', subagent: 'research', timestamp: 2 },
{ type: 'subagent_text', content: 'A2', spanId: 'A', subagent: 'research', timestamp: 3 },
]

const segments = parseBlocks(blocks)
const groups = segments.filter((s) => s.type === 'agent_group')
expect(groups).toHaveLength(2)

const textOf = (g: (typeof groups)[number]): string => {
if (g.type !== 'agent_group') return ''
return g.items
.filter((i) => i.type === 'text')
.map((i) => (i.type === 'text' ? i.content : ''))
.join('')
}
// Group A (spanId A) created first, group B second. Interleaved chunks stay
// in their own lane and in order — no cross-contamination.
expect(textOf(groups[0])).toBe('A1 A2')
expect(textOf(groups[1])).toBe('B1 ')
})

it('renders a persisted subagent lane as closed when only endedAt is set (no subagent_end)', () => {
// The Sim backend stamps endedAt on the subagent block but does not emit a
// separate subagent_end block; a reloaded transcript must still show the
// lane closed (no stuck delegating spinner).
const blocks: ContentBlock[] = [
{
type: 'subagent',
content: 'research',
spanId: 'S1',
parentSpanId: 'main',
timestamp: 1,
endedAt: 5,
},
{ type: 'subagent_text', content: 'done', spanId: 'S1', subagent: 'research', timestamp: 2 },
]

const segments = parseBlocks(blocks)
const group = segments.find((s) => s.type === 'agent_group')
expect(group).toBeDefined()
if (!group || group.type !== 'agent_group') throw new Error('expected research group')
expect(group.isOpen).toBe(false)
expect(group.isDelegating).toBe(false)
})

it('prunes an empty nested subagent that started and ended without output', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
Expand Down
Loading
Loading