Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
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