diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx index 8f744159736..e39953a37af 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx @@ -3,10 +3,12 @@ import { SlackIcon } from '@/components/icons' import { ActivityStatus } from '@/components/ui/activity-status' import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' /** Demo fixtures have known brands, so the landing page never loads the block registry. */ export function HeroToolCallItem({ toolCallId, + renderStatus, toolName, displayTitle, status, @@ -16,12 +18,13 @@ export function HeroToolCallItem({ ? SlackIcon : toolCallId === 'hero-read-table' ? Table - : undefined - return ( + : getToolIcon(toolName) + const activity = ( } /> ) + return renderStatus ? renderStatus(activity) : activity } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index 4edb1a06e76..0c8395fd1cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { Chip, ChipLink } from '@sim/emcn' import { useQueryStates } from 'nuqs' -import { ShimmerText } from '@/components/ui/shimmer-text' +import { ActivityStatus } from '@/components/ui/activity-status' import type { WorkspaceKnowledgeSearchResult, WorkspaceSearchFilters, @@ -190,9 +190,9 @@ export function KnowledgeSearchResults({ } if (isPending || (isFetching && !results)) { return ( -

- Searching… -

+
+ +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx new file mode 100644 index 00000000000..c25f9b774e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx @@ -0,0 +1,59 @@ +'use client' + +import { type ReactNode, useId } from 'react' +import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' +import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport' + +interface ActivityDisclosureProps { + header: ReactNode + children: ReactNode + expanded: boolean + onToggle: () => void + isStreaming: boolean + unbounded?: boolean +} + +/** Shared disclosure chrome; callers own expansion and blocking-interaction decisions. */ +export function ActivityDisclosure({ + header, + children, + expanded, + onToggle, + isStreaming, + unbounded = false, +}: ActivityDisclosureProps) { + const contentId = useId() + const headerId = useId() + + return ( +
+ + + + + {children} + + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx new file mode 100644 index 00000000000..977a74557aa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx @@ -0,0 +1,96 @@ +'use client' + +import { type ReactNode, useEffect, useLayoutEffect, useRef } from 'react' +import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' + +interface ActivityViewportProps { + children: ReactNode + isStreaming: boolean + /** A nested blocking interaction must not be clipped by this ancestor's log viewport. */ + unbounded?: boolean +} + +const BOTTOM_STICK_THRESHOLD_PX = 8 + +export function ActivityViewport({ + children, + isStreaming, + unbounded = false, +}: ActivityViewportProps) { + const ref = useRef(null) + const rafRef = useRef(null) + const stickToBottomRef = useRef(true) + const prevScrollTopRef = useRef(0) + const edges = useScrollEdges(ref, { enabled: !unbounded }) + + useEffect(() => { + if (unbounded) { + stickToBottomRef.current = true + return + } + const el = ref.current + if (!el) return + /** Upward input detaches auto-stick; reaching the bottom while scrolling down resumes it. */ + const handleWheel = (e: WheelEvent) => { + if (e.deltaY < 0) stickToBottomRef.current = false + } + const handleScroll = () => { + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) { + stickToBottomRef.current = true + } + prevScrollTopRef.current = el.scrollTop + } + el.addEventListener('wheel', handleWheel, { passive: true }) + el.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + el.removeEventListener('wheel', handleWheel) + el.removeEventListener('scroll', handleScroll) + } + }, [unbounded]) + + useLayoutEffect(() => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + if (unbounded || !isStreaming) return + const tick = () => { + const node = ref.current + if (!node || !stickToBottomRef.current) { + rafRef.current = null + return + } + const target = node.scrollHeight - node.clientHeight + const gap = target - node.scrollTop + if (gap < 1) { + rafRef.current = null + return + } + node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18) + rafRef.current = window.requestAnimationFrame(tick) + } + rafRef.current = window.requestAnimationFrame(tick) + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + } + }) + + return ( +
+ {children} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index 0b8d1f3886e..584387a69f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -1,22 +1,16 @@ 'use client' -import { - type ComponentType, - type ReactNode, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react' -import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn' -import { ShimmerText } from '@/components/ui' +import { type ComponentType, type ReactNode, useMemo, useState } from 'react' +import { ActivityStatus } from '@/components/ui/activity-status' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' +import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure' import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown' -import { getVisibleMainAgentItems } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' +import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' +import { getToolActivitySummary } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' import { getAgentIcon, isToolDone, @@ -53,7 +47,7 @@ export interface AgentGroupProps { isLaneOpen?: boolean /** Opens a subagent group on first render. */ defaultExpanded?: boolean - /** Keeps the activity viewport anchored at the top while new rows stream in. */ + /** Follows incoming activity until the user scrolls up. */ autoScrollActivity?: boolean } @@ -78,12 +72,11 @@ function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] { return tools } -/** True when any row in this group (or a nested one) is waiting on a permission decision. */ -function hasAwaitingApproval(items: AgentGroupItem[]): boolean { +/** Reveal blocking interactions even when a parent group was manually collapsed. */ +function hasPendingInteraction(items: AgentGroupItem[]): boolean { return items.some((item) => { - if (item.type === 'tool') return item.data.status === ToolCallStatus.awaiting_approval - // Text rows carry no tool calls, so only nested groups need recursing into. - return item.type === 'agent_group' ? hasAwaitingApproval(item.group.items) : false + if (item.type === 'tool') return needsToolInput(item.data) + return item.type === 'agent_group' ? hasPendingInteraction(item.group.items) : false }) } @@ -165,14 +158,7 @@ export function AgentGroupView({ ) const isMainAgent = agentName === 'mothership' - // Collapsed status line: the latest tool call, always in its RUNNING - // phrasing — it never flips to the completed rewrite (that lives in the - // expanded log). Work delegated further down bubbles up, so a group whose - // own turn is idle still narrates what its nested agent is doing rather - // than freezing on its last own tool. With several tools running at any - // depth, the most recently started wins and the rest become "+ n"; between - // rounds the last tool's title stays frozen; a closed lane shows the bare - // name. + /** Open lanes surface their latest work, including work delegated to nested agents. */ const status = useMemo(() => { if (isMainAgent || !isLaneOpen) return undefined const tools = collectGroupTools(items) @@ -187,7 +173,12 @@ export function AgentGroupView({ const last = tools.at(-1) return last ? toolStatusTitle(last) : undefined }, [isLaneOpen, isMainAgent, items]) - const headerText = status ? `${agentLabel} — ${status}` : agentLabel + const completedTools = !isMainAgent && !isLaneOpen ? collectGroupTools(items) : [] + const headerText = status + ? `${agentLabel} — ${status}` + : completedTools.length > 0 + ? `${agentLabel} — ${getToolActivitySummary(completedTools)}` + : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() @@ -199,11 +190,10 @@ export function AgentGroupView({ const [manualExpanded, setManualExpanded] = useState(defaultExpanded) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) - // An outstanding permission prompt overrides a manual collapse: the turn - // cannot proceed until it is answered, so hiding it would deadlock the chat - // with nothing on screen to explain why. + const pendingInteraction = hasPendingInteraction(items) + /** Blocking interactions override manual collapse so the user can resume the turn. */ const expanded = - hasAwaitingApproval(items) || + pendingInteraction || nestedBrowserTakeover || (activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded) @@ -215,96 +205,84 @@ export function AgentGroupView({ setManualExpanded(!expanded) } - const visibleItems = isMainAgent ? getVisibleMainAgentItems(items) : items - const activity = ( -
- {visibleItems.map((item, idx) => { - if (item.type === 'tool') { - return ( - - ) - } - if (item.type === 'agent_group') { - return ( - - ) - } - return ( - - ) - })} -
+ const renderItem = (item: AgentGroupItem, idx: number) => { + if (item.type === 'tool') { + return ( + + ) + } + if (item.type === 'agent_group') { + return ( + + ) + } + return ( + + ) + } + + const activity = isMainAgent ? ( + + ) : ( +
{items.map(renderItem)}
+ ) + const header = ( + {agentIcon} + } + /> ) return ( -
- {isMainAgent ? null : hasItems ? ( - - ) : ( -
-
{agentIcon}
- {isWorking ? ( - {headerText} - ) : ( - - )} -
- )} +
{isMainAgent ? ( activity ) : hasItems ? ( - - - - {activity} - - - - ) : null} + + {activity} + + ) : ( + header + )} {activeBrowserTakeover && (
{renderBrowserTakeover?.(activeBrowserTakeover.reason)} @@ -329,112 +307,8 @@ function NarrationText({ content, isStreaming }: NarrationTextProps) { const revealed = useSmoothText(content, isStreaming) return ( - + {renderInlineMarkdown(revealed.trim())} ) } - -interface BoundedViewportProps { - children: React.ReactNode - isStreaming: boolean - /** A nested blocking interaction must not be clipped by this ancestor's log viewport. */ - unbounded?: boolean -} - -const BOTTOM_STICK_THRESHOLD_PX = 8 - -function BoundedViewport({ children, isStreaming, unbounded = false }: BoundedViewportProps) { - const ref = useRef(null) - const rafRef = useRef(null) - const stickToBottomRef = useRef(true) - const prevScrollTopRef = useRef(0) - const [hasOverflow, setHasOverflow] = useState(false) - - useEffect(() => { - if (unbounded) { - stickToBottomRef.current = true - return - } - const el = ref.current - if (!el) return - // Upward user input detaches auto-stick; a downward scroll reaching the - // bottom re-attaches it (a small upward flick can't re-stick itself). - const handleWheel = (e: WheelEvent) => { - if (e.deltaY < 0) stickToBottomRef.current = false - } - const handleScroll = () => { - const distance = el.scrollHeight - el.scrollTop - el.clientHeight - if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) { - stickToBottomRef.current = true - } - prevScrollTopRef.current = el.scrollTop - } - el.addEventListener('wheel', handleWheel, { passive: true }) - el.addEventListener('scroll', handleScroll, { passive: true }) - return () => { - el.removeEventListener('wheel', handleWheel) - el.removeEventListener('scroll', handleScroll) - } - }, [unbounded]) - - useLayoutEffect(() => { - const el = ref.current - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - if (unbounded) { - setHasOverflow(false) - return - } - if (el) { - const next = el.scrollHeight > el.clientHeight - setHasOverflow((prev) => (prev === next ? prev : next)) - } - if (!isStreaming) return - const tick = () => { - const node = ref.current - if (!node || !stickToBottomRef.current) { - rafRef.current = null - return - } - const target = node.scrollHeight - node.clientHeight - const gap = target - node.scrollTop - if (gap < 1) { - rafRef.current = null - return - } - node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18) - rafRef.current = window.requestAnimationFrame(tick) - } - rafRef.current = window.requestAnimationFrame(tick) - return () => { - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }) - - return ( -
-
- {children} -
- {!unbounded && hasOverflow && ( - <> -
-
- - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index ee1df0a2380..10e7f827292 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -111,12 +111,16 @@ describe('AgentGroup inline main activity', () => { beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') + document.body.appendChild(container) root = createRoot(container) }) - afterEach(() => act(() => root.unmount())) + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) - it('replaces the previous tool in place and settles without restoring the nested log', () => { + it('replaces the active status in place and expands the full completed history', () => { const first: AgentGroupItem = { type: 'tool', data: { id: 'first', toolName: 'grep', displayTitle: 'Searching files', status: 'executing' }, @@ -146,7 +150,9 @@ describe('AgentGroup inline main activity', () => { expect(container.firstElementChild).toBe(activity) expect(container.textContent).toBe('Reading notes') expect(container.querySelector('[class*="shimmer"]')).not.toBeNull() - expect(container.querySelector('button, svg, [data-state], .pl-6')).toBeNull() + expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('svg')).not.toBeNull() + expect(container.textContent).not.toContain('Sim') render( [ @@ -155,9 +161,201 @@ describe('AgentGroup inline main activity', () => { ], false ) - expect(container.textContent).toBe('Read notes') + expect(container.textContent).toBe('Searched files, read files') expect(container.querySelector('[class*="shimmer"]')).toBeNull() - expect(container.querySelector('button, svg, [data-state], .pl-6')).toBeNull() + const header = container.querySelector('button') + act(() => header?.click()) + expect(header?.getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('[data-state="open"]')?.textContent).toBe( + 'Searched filesRead notes' + ) + act(() => header?.click()) + expect(header?.getAttribute('aria-expanded')).toBe('false') + expect(container.textContent).toBe('Searched files, read files') + }) + + it('keeps history expanded as new tools arrive', () => { + const first: AgentGroupItem = { + type: 'tool', + data: { id: 'first', toolName: 'read', displayTitle: 'Reading notes', status: 'success' }, + } + const render = (items: AgentGroupItem[]) => + act(() => + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items, + isStreaming: true, + }) + ) + ) + render([first]) + act(() => container.querySelector('button')?.click()) + render([ + first, + { + type: 'tool', + data: { + id: 'second', + toolName: 'terminal_run', + displayTitle: 'Running checks', + status: 'executing', + }, + }, + ]) + expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('[data-state="open"]')?.textContent).toBe( + 'Read notesRunning checks' + ) + }) + + it('shares one countdown and preserves the viewport across active tool changes', () => { + vi.useFakeTimers() + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval') + try { + const wait: AgentGroupItem = { + type: 'tool', + data: { + id: 'wait-first', + toolName: 'wait', + displayTitle: 'Waiting', + status: 'executing', + params: { seconds: 3 }, + }, + } + const render = (items: AgentGroupItem[]) => + act(() => + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items, + isStreaming: true, + }) + ) + ) + render([wait]) + act(() => vi.advanceTimersByTime(2000)) + expect(container.textContent).toBe('Waiting 1s') + const header = container.querySelector('button') + act(() => header?.click()) + expect(header?.hasAttribute('aria-label')).toBe(false) + expect(header?.textContent).toBe('Waiting 1s') + expect(header).toHaveAccessibleName('Waiting 1s') + expect(container.querySelector('[data-state="open"]')?.textContent).toBe('Waiting 1s') + expect(setIntervalSpy).toHaveBeenCalledTimes(1) + act(() => header?.click()) + act(() => header?.click()) + expect(container.querySelector('[data-state="open"]')?.textContent).toBe('Waiting 1s') + const viewport = container.querySelector('.overflow-y-auto') + render([ + { ...wait, data: { ...wait.data, status: 'success' } }, + { ...wait, data: { ...wait.data, id: 'wait-second' } }, + ]) + expect(header?.textContent).toBe('Waiting 3s') + expect(header).toHaveAccessibleName('Waiting 3s') + expect(container.querySelector('.overflow-y-auto')).toBe(viewport) + expect(container.querySelector('[data-state="open"]')?.textContent).toBe('WaitedWaiting 3s') + expect(setIntervalSpy).toHaveBeenCalledTimes(2) + render([ + { ...wait, data: { ...wait.data, status: 'success' } }, + { ...wait, data: { ...wait.data, id: 'wait-second', status: 'success' } }, + ]) + expect(header?.textContent).toBe('Waited') + expect(container.querySelector('.overflow-y-auto')).toBe(viewport) + expect(clearIntervalSpy).toHaveBeenCalledTimes(2) + } finally { + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it.each(['browser', 'workflow', 'research', 'deploy', 'file', 'table'])( + 'summarizes and expands the full %s activity history', + (agentName) => { + const items: AgentGroupItem[] = [ + { + type: 'tool', + data: { id: 'read', toolName: 'read', displayTitle: 'Reading notes', status: 'success' }, + }, + { + type: 'tool', + data: { + id: 'run', + toolName: 'terminal', + displayTitle: 'Running checks', + status: 'success', + params: { operation: 'run' }, + }, + }, + ] + act(() => + root.render( + createElement(AgentGroupView, { + agentName, + agentLabel: 'Agent', + items, + ToolCallComponent: ({ toolCallId, displayTitle }: ToolCallItemProps) => + createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle), + }) + ) + ) + const header = container.querySelector('button') + expect(header?.textContent).toBe('Agent — Read files, ran commands') + expect(header).toHaveAccessibleName('Agent — Read files, ran commands') + expect(container.querySelectorAll('[data-tool-call-id]')).toHaveLength(0) + act(() => header?.click()) + expect( + Array.from(container.querySelectorAll('[data-tool-call-id]'), (row) => + row.getAttribute('data-tool-call-id') + ) + ).toEqual(['read', 'run']) + act(() => header?.click()) + expect(header?.getAttribute('aria-expanded')).toBe('false') + } + ) + + it('reveals a nested terminal handoff through collapsed ancestors', () => { + act(() => + root.render( + createElement(AgentGroupView, { + agentName: 'workflow', + agentLabel: 'Workflow', + isLaneOpen: true, + isStreaming: true, + items: [ + group([ + { + type: 'tool', + data: { + id: 'handoff', + toolName: 'terminal', + displayTitle: 'Finish signing in', + status: 'executing', + params: { operation: 'handoff' }, + }, + }, + ]), + ], + ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => { + const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle) + return renderStatus ? renderStatus(status) : status + }, + }) + ) + ) + const headers = Array.from(container.querySelectorAll('button')) + expect(headers).toHaveLength(2) + expect(headers.every((header) => header.getAttribute('aria-expanded') === 'true')).toBe(true) + act(() => headers[0].click()) + expect(headers[0].getAttribute('aria-expanded')).toBe('true') + expect( + container.querySelector('[data-tool-call-id="handoff"]')?.closest('[data-state="closed"]') + ).toBeNull() }) it('keeps a browser question and answer after the main agent resumes tool activity', () => { @@ -197,7 +395,9 @@ describe('AgentGroup inline main activity', () => { 'Choose a result.: Open the second result.' ) expect(container.textContent).toContain('Searched files') - expect(container.querySelector('button, svg, [data-state], .pl-6')).toBeNull() + expect( + container.querySelector('[data-takeover-answer="true"]')?.closest('[data-state]') + ).toBeNull() }) it('keeps pending permissions and terminal handoffs visible when newer tools arrive', () => { @@ -247,8 +447,10 @@ describe('AgentGroup inline main activity', () => { agentLabel: 'Sim', items, isStreaming: true, - ToolCallComponent: ({ toolCallId, displayTitle }: ToolCallItemProps) => - createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle), + ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => { + const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle) + return renderStatus ? renderStatus(status) : status + }, }) ) }) @@ -258,7 +460,12 @@ describe('AgentGroup inline main activity', () => { row.getAttribute('data-tool-call-id') ) ).toEqual(['permission', 'handoff', 'latest']) - expect(container.querySelector('[data-state], .pl-6')).toBeNull() + expect( + container.querySelector('[data-tool-call-id="permission"]')?.closest('[data-state]') + ).toBeNull() + expect( + container.querySelector('[data-tool-call-id="handoff"]')?.closest('[data-state]') + ).toBeNull() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.ts deleted file mode 100644 index f8514d9c8a5..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Terminal as TerminalTool } from '@/lib/copilot/generated/tool-catalog-v1' -import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' -import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' -import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' - -export function getLatestToolId(items: AgentGroupItem[]): string | undefined { - for (let index = items.length - 1; index >= 0; index--) { - const item = items[index] - if (item.type === 'tool') return item.data.id - } -} - -/** Keep interaction controls and answers when a newer tool replaces the activity text. */ -export function getVisibleMainAgentItems( - items: AgentGroupItem[], - latestToolId = getLatestToolId(items) -): AgentGroupItem[] { - return items.filter( - (item) => - item.type !== 'tool' || - item.data.id === latestToolId || - item.data.status === ToolCallStatus.awaiting_approval || - (item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && - item.data.status === ToolCallStatus.success) || - (item.data.status === ToolCallStatus.executing && - item.data.toolName === TerminalTool.id && - item.data.params?.operation === 'handoff') - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx new file mode 100644 index 00000000000..82e62b4e3a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx @@ -0,0 +1,69 @@ +import { type ComponentType, Fragment, type ReactNode } from 'react' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' +import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' +import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' + +interface MainAgentActivityProps { + items: AgentGroupItem[] + ToolCallComponent: ComponentType + renderItem: (item: AgentGroupItem, index: number) => ReactNode + autoScrollActivity: boolean +} + +/** Keep answers and interactions in the transcript, outside collapsible tool history. */ +function isStandaloneItem(item: AgentGroupItem): boolean { + return ( + item.type !== 'tool' || + needsToolInput(item.data) || + item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID + ) +} + +export function MainAgentActivity({ + items, + ToolCallComponent, + renderItem, + autoScrollActivity, +}: MainAgentActivityProps) { + const activity: ReactNode[] = [] + let tools: ToolCallData[] = [] + const flushTools = () => { + if (tools.length === 0) return + activity.push( + + ) + tools = [] + } + + for (const [index, item] of items.entries()) { + if (item.type === 'tool' && !isStandaloneItem(item)) { + tools.push(item.data) + continue + } + flushTools() + activity.push( + + {renderItem(item, index)} + + ) + } + flushTools() + + return
{activity}
+} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts new file mode 100644 index 00000000000..6a24458b245 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getToolActivitySummary } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' +import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +function tool(toolName: string, status: ToolCallStatus = 'success'): ToolCallData { + return { id: toolName, toolName, displayTitle: `Running ${toolName}`, status } +} + +describe('getToolActivitySummary', () => { + it('caps distinct actions in order and counts the remaining categories, not repeated calls', () => { + expect( + getToolActivitySummary([tool('read'), tool('terminal_run'), tool('read'), tool('grep')]) + ).toBe('Read files, ran commands +1 more') + }) + + it('summarizes browser navigation and interactions without repeating actions', () => { + expect( + getToolActivitySummary([ + tool('browser_navigate'), + tool('browser_read_text'), + tool('browser_type'), + tool('browser_navigate'), + ]) + ).toBe('Navigated pages, read pages +1 more') + }) + + it('does not describe unsuccessful work as completed actions', () => { + expect( + getToolActivitySummary([ + tool('read'), + tool('apply_file_edit', 'error'), + tool('terminal_run', 'cancelled'), + tool('browser_type', 'rejected'), + ]) + ).toBe('Read files · 1 failed · 1 stopped · 1 skipped') + }) + + it('does not invent actions when all calls failed or were stopped', () => { + expect( + getToolActivitySummary([ + tool('apply_file_edit', 'error'), + tool('terminal_run', 'interrupted'), + ]) + ).toBe('Tool activity · 1 failed · 1 stopped') + }) + + it('keeps an individual tool’s descriptive title', () => { + expect( + getToolActivitySummary([{ ...tool('read'), displayTitle: 'Reading project notes' }]) + ).toBe('Read project notes') + }) + + it.each([ + ['skipped', 'Skipped running checks'], + ['interrupted', 'Stopped running checks'], + ] as const)('labels a single %s tool as finished', (status, expected) => { + expect( + getToolActivitySummary([{ ...tool('terminal', status), displayTitle: 'Running checks' }]) + ).toBe(expected) + }) + + it('keeps unknown tools visible with a neutral summary', () => { + expect(getToolActivitySummary([tool('future_tool'), tool('browser_future_action')])).toBe( + 'Used tools, used the browser' + ) + }) + + it('describes current browser and workflow tools', () => { + expect( + getToolActivitySummary([ + tool('browser_open_url'), + tool('browser_fill_form'), + tool('browser_insert_text'), + tool('read_document'), + tool('run_workflow'), + tool('deploy_as_api'), + tool('table_rows'), + ]) + ).toBe('Navigated pages, filled forms +5 more') + }) + + it('keeps failure and interruption counts visible when action categories are capped', () => { + expect( + getToolActivitySummary([ + tool('read'), + tool('grep'), + tool('terminal'), + tool('browser_navigate'), + tool('apply_file_edit', 'error'), + tool('wait', 'interrupted'), + tool('browser_type', 'skipped'), + ]) + ).toBe('Read files, searched files +2 more · 1 failed · 1 stopped · 1 skipped') + }) + + it('describes terminal runs from their operation', () => { + expect( + getToolActivitySummary([{ ...tool('terminal'), params: { operation: 'run' } }, tool('read')]) + ).toBe('Ran commands, read files') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx new file mode 100644 index 00000000000..90922371b8c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx @@ -0,0 +1,158 @@ +'use client' + +import { type ComponentType, Fragment, useState } from 'react' +import { ActivityStatus } from '@/components/ui/activity-status' +import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure' +import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' +import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +const MAX_SUMMARY_ACTIONS = 2 + +const ACTIVITY_LABELS: Readonly> = { + read: 'read files', + read_document: 'read documents', + glob: 'found files', + grep: 'searched files', + web_search: 'searched the web', + web_fetch: 'read web pages', + web_scrape: 'read web pages', + search_library_docs: 'read documentation', + search_knowledge_base: 'searched sources', + call_integration_tool: 'used integrations', + prepare_file_edit: 'prepared file edits', + apply_file_edit: 'edited files', + create_workflow: 'created workflows', + edit_workflow: 'edited workflows', + run_workflow: 'ran workflows', + run_workflow_until_block: 'ran workflows', + deploy_as_api: 'deployed workflows', + table_rows: 'used tables', + terminal: 'used the terminal', + terminal_run: 'ran commands', + terminal_input: 'sent terminal input', + terminal_read: 'read terminal output', + run_function: 'ran code', + run_code: 'ran code', + browser_navigate: 'navigated pages', + browser_open_url: 'navigated pages', + browser_open_tab: 'opened tabs', + browser_switch_tab: 'switched tabs', + browser_close_tab: 'closed tabs', + browser_snapshot: 'read pages', + browser_read_text: 'read pages', + browser_extract: 'read pages', + browser_find: 'searched pages', + browser_click: 'clicked elements', + browser_click_at: 'clicked elements', + browser_drag: 'dragged elements', + browser_type: 'entered text', + browser_insert_text: 'entered text', + browser_fill_form: 'filled forms', + browser_screenshot: 'captured screenshots', + browser_scroll: 'scrolled pages', + browser_select_option: 'selected options', + browser_set_checked: 'updated selections', + open_resource: 'opened resources', + wait: 'waited', +} as const + +/** Summarize completed actions without describing failed or skipped work as successful. */ +export function getToolActivitySummary(tools: ToolCallData[]): string { + if (tools.length === 1) { + const tool = tools[0] + return getToolStatusDisplayTitle(tool.displayTitle, tool.status, tool.toolName) + } + const labels = new Set() + let failed = 0 + let stopped = 0 + let skipped = 0 + for (const tool of tools) { + if (tool.status === ToolCallStatus.success) { + const label = + tool.toolName === 'terminal' && tool.params?.operation === 'run' + ? 'ran commands' + : (ACTIVITY_LABELS[tool.toolName] ?? + (tool.toolName.startsWith('browser_') ? 'used the browser' : 'used tools')) + labels.add(label) + } else if (tool.status === ToolCallStatus.error) failed++ + else if (tool.status === ToolCallStatus.cancelled || tool.status === ToolCallStatus.interrupted) + stopped++ + else if (tool.status === ToolCallStatus.skipped || tool.status === ToolCallStatus.rejected) + skipped++ + } + const summary = Array.from(labels).slice(0, MAX_SUMMARY_ACTIONS).join(', ') + const summaryLabel = summary ? summary[0].toUpperCase() + summary.slice(1) : 'Tool activity' + const additionalActions = Math.max(0, labels.size - MAX_SUMMARY_ACTIONS) + const outcomes = [ + failed && `${failed} failed`, + stopped && `${stopped} stopped`, + skipped && `${skipped} skipped`, + ].filter(Boolean) + return [ + additionalActions > 0 ? `${summaryLabel} +${additionalActions} more` : summaryLabel, + ...outcomes, + ].join(' · ') +} + +interface ToolActivityGroupProps { + tools: ToolCallData[] + ToolCallComponent: ComponentType + autoScrollActivity?: boolean +} + +export function ToolActivityGroup({ + tools, + ToolCallComponent, + autoScrollActivity = true, +}: ToolActivityGroupProps) { + const [expanded, setExpanded] = useState(false) + let activeTool: ToolCallData | undefined + for (let index = tools.length - 1; index >= 0; index--) { + if (tools[index].status === ToolCallStatus.executing) { + activeTool = tools[index] + break + } + } + const statusTool = activeTool ?? tools[tools.length - 1] + const showToolHeader = Boolean(activeTool) || tools.length === 1 + const SummaryIcon = getToolIcon(tools[0].toolName) + + return ( + ( + } + /> + ) + } + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + isStreaming={Boolean(activeTool) && autoScrollActivity} + > +
+ {tools.map((tool) => ( + + {tool.id === statusTool.id ? ( + status + ) : ( + + )} + + ))} +
+
+ )} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx index 065448ac4e7..b52d7b1b0e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx @@ -20,7 +20,7 @@ describe('ToolCallItem', () => { }) it.each(['executing', 'success', 'error', 'cancelled'] as const)( - 'renders the %s tool row without an icon', + 'renders the %s tool row with its EMCN icon', (status) => { const markup = renderToStaticMarkup( { /> ) - expect(markup).not.toContain(' ReactNode } function stringParam(params: Record | undefined, key: string): string { @@ -85,22 +90,23 @@ const COUNTDOWN_TICK_MS = 250 * mid-countdown instead of restarting; falls back to activation time when the * caller has no start to give. */ -function useElapsedMs(active: boolean, startedAt: number | undefined): number { - const [elapsedMs, setElapsedMs] = useState(0) +function useElapsedMs( + active: boolean, + startedAt: number | undefined, + toolCallId: string | undefined +): number { + const [sample, setSample] = useState({ toolCallId, elapsedMs: 0 }) useEffect(() => { - if (!active) { - setElapsedMs(0) - return - } + if (!active) return const anchor = startedAt ?? Date.now() - const tick = () => setElapsedMs(Date.now() - anchor) + const tick = () => setSample({ toolCallId, elapsedMs: Date.now() - anchor }) tick() const interval = setInterval(tick, COUNTDOWN_TICK_MS) return () => clearInterval(interval) - }, [active, startedAt]) + }, [active, startedAt, toolCallId]) - return elapsedMs + return active && sample.toolCallId === toolCallId ? sample.elapsedMs : 0 } /** @@ -125,6 +131,7 @@ export function ToolCallItem({ streamingArgs, toolCallId, startedAt, + renderStatus, }: ToolCallItemProps) { useCustomBlockOverlayVersion() const readPath = params?.path @@ -176,7 +183,7 @@ export function ToolCallItem({ const isBrowserTakeover = toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID const isCountingDown = toolName === WaitTool.id && isExecuting - const elapsedMs = useElapsedMs(isCountingDown, startedAt) + const elapsedMs = useElapsedMs(isCountingDown, startedAt, toolCallId) const liveTitle = isCountingDown ? getWaitCountdownTitle(params, elapsedMs) @@ -195,6 +202,7 @@ export function ToolCallItem({ : null const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon + const ToolIcon = getToolIcon(toolName) if (displayState === 'awaiting_approval' && toolCallId) { return ( @@ -232,11 +240,18 @@ export function ToolCallItem({ ) } - return ( + const activity = ( } + icon={ + BlockIcon ? ( + + ) : ( + + ) + } /> ) + return renderStatus ? renderStatus(activity) : activity } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts new file mode 100644 index 00000000000..245c0f4a112 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts @@ -0,0 +1,12 @@ +import { Terminal as TerminalTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +/** A permission decision or terminal handoff must stay reachable while an agent is waiting. */ +export function needsToolInput(tool: ToolCallData): boolean { + return ( + tool.status === ToolCallStatus.awaiting_approval || + (tool.status === ToolCallStatus.executing && + tool.toolName === TerminalTool.id && + tool.params?.operation === 'handoff') + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 12c57a11c7e..c89261507ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -335,7 +335,7 @@ describe('parseBlocks span-identity tree', () => { expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true) }) - it('replaces earlier main activity across prose and subagents while preserving stream order', () => { + it('retains main activity around prose and subagents in stream order', () => { const blocks: ContentBlock[] = [ mainText('Let me search.'), mainToolCall('t1', 'grep'), @@ -349,18 +349,19 @@ describe('parseBlocks span-identity tree', () => { const segments = parseBlocks(blocks) const shape = segments.map((s) => (s.type === 'agent_group' ? s.agentName : s.type)) - expect(shape).toEqual(['text', 'research', 'text', 'mothership']) + expect(shape).toEqual(['text', 'mothership', 'research', 'text', 'mothership']) const mothershipGroups = segments.filter( (s) => s.type === 'agent_group' && s.agentName === 'mothership' ) - expect(mothershipGroups).toHaveLength(1) - const [latest] = mothershipGroups - if (latest.type !== 'agent_group') { - throw new Error('expected mothership group') - } - expect(latest.items).toHaveLength(1) - expect(latest.items[0].type === 'tool' && latest.items[0].data.id).toBe('t2') + expect(mothershipGroups).toHaveLength(2) + expect( + mothershipGroups.flatMap((group) => + group.type === 'agent_group' + ? group.items.flatMap((item) => (item.type === 'tool' ? [item.data.id] : [])) + : [] + ) + ).toEqual(['t1', 't2']) }) it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => { @@ -715,7 +716,7 @@ describe('narration text seams', () => { }) describe('parseBlocks legacy — thinking between top-level tools', () => { - it('shows only the latest main tool across intervening thinking', () => { + it('retains every main tool across intervening thinking', () => { const blocks: ContentBlock[] = [ { type: 'thinking', content: 'planning the search', timestamp: 1 }, mainToolCall('t1', 'grep'), @@ -728,11 +729,14 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { expect(groups).toHaveLength(1) if (groups[0].type !== 'agent_group') throw new Error('expected group') expect(groups[0].agentName).toBe('mothership') - expect(groups[0].items).toHaveLength(1) - expect(groups[0].items[0].type === 'tool' && groups[0].items[0].data.id).toBe('t3') + expect(groups[0].items.map((item) => item.type === 'tool' && item.data.id)).toEqual([ + 't1', + 't2', + 't3', + ]) }) - it('replaces earlier main tools across prose without leaving an empty activity segment', () => { + it('keeps separate activity groups around assistant prose', () => { const blocks: ContentBlock[] = [ mainToolCall('t1', 'grep'), mainText('Here is what I found so far.'), @@ -740,8 +744,8 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { ] const segments = parseBlocks(blocks) const groups = segments.filter((s) => s.type === 'agent_group') - expect(groups).toHaveLength(1) - expect(segments.map((segment) => segment.type)).toEqual(['text', 'agent_group']) + expect(groups).toHaveLength(2) + expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group']) }) it('does not let main thinking affect subagent lane grouping', () => { @@ -783,7 +787,7 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { }) describe('assistantMessageHasVisibleExecutingTool', () => { - it.each([undefined, 'main'])('ignores a replaced running tool with spanId=%s', (spanId) => { + it.each([undefined, 'main'])('retains an earlier running tool with spanId=%s', (spanId) => { const blocks: ContentBlock[] = [ { type: 'tool_call', @@ -795,8 +799,8 @@ describe('assistantMessageHasVisibleExecutingTool', () => { mainToolCall('latest', 'read'), ] const segments = parseBlocks(blocks) - expect(segments.map((segment) => segment.type)).toEqual(['text', 'agent_group']) - expect(assistantMessageHasVisibleExecutingTool(segments)).toBe(false) + expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group']) + expect(assistantMessageHasVisibleExecutingTool(segments)).toBe(true) }) it('does not treat an open subagent lane as an executing tool row', () => { @@ -897,6 +901,7 @@ describe('parseBlocks main activity controls', () => { 'permission', 'handoff', 'answered-takeover', + 'older', 'latest', ]) const completed = blocks.map((block) => @@ -908,6 +913,7 @@ describe('parseBlocks main activity controls', () => { 'permission', 'handoff', 'answered-takeover', + 'older', 'latest', ]) expect(visibleTools(completed).at(-1)?.status).toBe('success') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 76495ca4bb9..54931cf07dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -21,10 +21,6 @@ import { humanizeToolName, } from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' -import { - getLatestToolId, - getVisibleMainAgentItems, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations' @@ -494,7 +490,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { * Groups content blocks into agent-scoped segments. * Dispatch tool_calls (name matches a subagent key, no calledBy) are absorbed * into the agent header. Inner tool_calls are nested underneath their agent. - * Main-agent tool calls share one latest activity status across all segments. + * Main-agent segments retain their tool history for inline activity summaries. * * New backends stamp every subagent block with deterministic span identity; in * that case {@link parseBlocksWithSpanTree} builds a real nested tree. The @@ -502,22 +498,9 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { * span identity existed. */ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { - const segments = blocks.some((block) => Boolean(block.spanId)) + return blocks.some((block) => Boolean(block.spanId)) ? parseBlocksWithSpanTree(blocks) : parseBlocksLegacy(blocks) - let latestToolId: string | undefined - for (let index = segments.length - 1; index >= 0; index--) { - const segment = segments[index] - if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') continue - latestToolId = getLatestToolId(segment.items) - if (latestToolId !== undefined) break - } - - return segments.flatMap((segment) => { - if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') return [segment] - const items = getVisibleMainAgentItems(segment.items, latestToolId) - return items.length > 0 ? [{ ...segment, items }] : [] - }) } function joinRenderableText(parts: string[]): string { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 722770fbfa9..b2d3d27d499 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -30,6 +30,7 @@ const TOOL_ICONS: Record = { glob: FolderCode, grep: Search, read: File, + read_document: File, mv: FolderCode, cp: Layout, mkdir: FolderCode, @@ -48,6 +49,10 @@ const TOOL_ICONS: Record = { apply_file_edit: File, create_workflow: Layout, edit_workflow: Pencil, + run_workflow: PlayOutline, + run_workflow_until_block: PlayOutline, + deploy_as_api: Rocket, + table_rows: TableIcon, workflow: Hammer, debug: Bug, run: PlayOutline, @@ -75,6 +80,7 @@ const TOOL_ICONS: Record = { ffmpeg: Wrench, browser: Globe, browser_navigate: Cursor, + browser_open_url: Cursor, browser_go_back: Cursor, browser_go_forward: Cursor, browser_reload: Cursor, @@ -91,7 +97,11 @@ const TOOL_ICONS: Record = { browser_screenshot: Eye, browser_extract: Search, browser_click: Cursor, + browser_click_at: Cursor, + browser_drag: Cursor, browser_type: Pencil, + browser_insert_text: Pencil, + browser_fill_form: Pencil, browser_press_key: Cursor, browser_scroll: Cursor, browser_select_option: Cursor, @@ -112,6 +122,10 @@ export function getAgentIcon(name: string): IconComponent { return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp } +export function getToolIcon(name: string): IconComponent { + return TOOL_ICONS[name] ?? Wrench +} + export type MessagePhase = 'streaming' | 'revealing' | 'settled' interface DeriveMessagePhaseArgs { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 87a86f21752..4901e6b2dca 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' +import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn' import { Table } from '@sim/emcn/icons' import { stripVersionSuffix } from '@sim/utils/string' import { useParams } from 'next/navigation' @@ -361,9 +361,11 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { )} > - - {action.label} - + ) diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx index e6e66c12d0e..43b66444e5b 100644 --- a/apps/sim/components/ui/activity-status.tsx +++ b/apps/sim/components/ui/activity-status.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { OverflowText } from '@sim/emcn' import { ShimmerText } from '@/components/ui/shimmer-text' interface ActivityStatusProps { @@ -10,17 +11,17 @@ interface ActivityStatusProps { /** Inline tool status with the shared shimmer while active. */ export function ActivityStatus({ label, isActive, icon }: ActivityStatusProps) { return ( -
+ {icon} - {isActive ? ( - - {label} - - ) : ( - - {label} - - )} -
+ + {isActive ? ( + {label} + ) : undefined} + + ) } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 89e15b8c3fd..9eae8f49e93 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -795,3 +795,24 @@ describe('resource-naming titles', () => { ) }) }) + +/** Skipped live calls and interrupted history must not retain running titles. */ +describe('getToolStatusDisplayTitle for skipped and interrupted calls', () => { + it.each([ + ['skipped', 'Skipped running checks'], + ['interrupted', 'Stopped running checks'], + ] as const)('projects %s titles once', (status, expected) => { + expect(getToolStatusDisplayTitle('Running checks', status)).toBe(expected) + expect(getToolStatusDisplayTitle(expected, status)).toBe(expected) + }) + + it('preserves titles already describing a terminal outcome', () => { + expect(getToolStatusDisplayTitle('Skipped reading notes', 'interrupted')).toBe( + 'Skipped reading notes' + ) + expect(getToolStatusDisplayTitle('Attempted to run checks', 'skipped')).toBe( + 'Attempted to run checks' + ) + expect(getToolStatusDisplayTitle('Checks', 'skipped')).toBe('Skipped: Checks') + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 166840b8c22..5d925212190 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1439,6 +1439,16 @@ function statesTerminalOutcome(title: string): boolean { return TERMINAL_TITLE_PREFIXES.has(firstWordOf(title).replace(/:$/, '')) } +/** Apply one terminal outcome prefix while preserving already-resolved titles. */ +function getToolOutcomeTitle(title: string, outcome: 'Failed' | 'Stopped' | 'Skipped'): string { + if (statesTerminalOutcome(title)) return title + const firstWord = firstWordOf(title) + if (COMPLETED_VERB_REWRITES[firstWord]) { + return `${outcome} ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` + } + return `${outcome}: ${title}` +} + /** * Rewrite a resolved display title for a FAILED tool call. A gerund title * becomes "Failed …" ("Searching for X" → "Failed searching for X"); @@ -1446,29 +1456,19 @@ function statesTerminalOutcome(title: string): boolean { * its present-tense activity title verbatim and read as still running. */ export function getToolFailedTitle(title: string): string { - if (statesTerminalOutcome(title)) return title - const firstWord = firstWordOf(title) - if (COMPLETED_VERB_REWRITES[firstWord]) { - return `Failed ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` - } - return `Failed: ${title}` + return getToolOutcomeTitle(title, 'Failed') } /** Rewrite a resolved display title for a CANCELLED tool call ("Stopped …"). */ export function getToolStoppedTitle(title: string): string { - if (statesTerminalOutcome(title)) return title - const firstWord = firstWordOf(title) - if (COMPLETED_VERB_REWRITES[firstWord]) { - return `Stopped ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` - } - return `Stopped: ${title}` + return getToolOutcomeTitle(title, 'Stopped') } /** * Resolve the final title for a tool status at a rendering boundary. Persisted * and live snapshots intentionally keep the present-tense activity title so a * RUNNING row remains truthful; terminal states project a tense that says the - * work is over — completed (past tense), failed, or stopped. + * work is over — completed (past tense), failed, stopped, or skipped. */ export function getToolStatusDisplayTitle( title: string, @@ -1480,6 +1480,9 @@ export function getToolStatusDisplayTitle( } if (status === 'success') return getToolCompletedTitle(title) ?? title if (status === 'error' || status === 'rejected') return getToolFailedTitle(title) - if (status === 'cancelled' || status === 'aborted') return getToolStoppedTitle(title) + if (status === 'cancelled' || status === 'aborted' || status === 'interrupted') { + return getToolStoppedTitle(title) + } + if (status === 'skipped') return getToolOutcomeTitle(title, 'Skipped') return title }