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
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe('flat expanded activity layout', () => {
expect(reservedSlot(column)?.classList).toContain(ICON_SLOT)
})

it('stacks main-lane blocks one gap-3 apart and search queries one gap-1.5 apart', () => {
it('keeps search and ordinary tool rows in the same history with shared spacing', () => {
render('mothership', [
tool('a'),
{
Expand Down Expand Up @@ -138,9 +138,13 @@ describe('flat expanded activity layout', () => {
expect(blocks.contains(statuses()[0])).toBe(true)
expect(blocks.classList).toContain('gap-3')
expect(blocks.classList).not.toContain('gap-1.5')
const queries = statuses().filter((status) => status.textContent === 'first')
const searchList = queries[0].closest('.flex-col.gap-1\\.5')!
expect(searchList).not.toBeNull()
expect(searchList.parentElement?.closest('.flex-col.gap-3')).toBe(blocks)
expect(statuses()).toHaveLength(1)
expand()
const rows = statuses().slice(1)
expect(rows).toHaveLength(3)
for (const row of rows) {
expect(row.closest('.flex-col')!.classList).toContain('gap-1.5')
expect(iconSlot(row).classList).toContain(ICON_SLOT)
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/
interface ActivityViewportProps {
children: ReactNode
isStreaming: boolean
/** A nested blocking interaction must not be clipped by this ancestor's log viewport. */
/** Keeps nested interactions or independently scrolling detail lists from being clipped. */
unbounded?: boolean
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function call(id: string, toolName: string, extra: Partial<ToolCallData> = {}):
const layout = (items: AgentGroupItem[]) =>
splitMainLane(items).map((entry) =>
entry.type === 'run'
? `${entry.run.isSearch ? 'search' : 'tools'}:${entry.run.tools.map((tool) => tool.id).join(',')}`
? `tools:${entry.run.tools.map((tool) => tool.id).join(',')}`
: entry.item.type
)

Expand All @@ -24,7 +24,7 @@ const liveId = (items: AgentGroupItem[], isOpen = true) => {
}

describe('splitMainLane', () => {
it('splits runs at search boundaries and interactions, in transcript order', () => {
it('groups search with other tools and splits only at interactions, in transcript order', () => {
expect(
layout([
call('a', 'read'),
Expand All @@ -35,7 +35,7 @@ describe('splitMainLane', () => {
call('approval', 'edit_workflow', { status: 'awaiting_approval' }),
call('c', 'read'),
])
).toEqual(['tools:a', 'search:s1,s2', 'tools:setup,b', 'tool', 'tools:c'])
).toEqual(['tools:a,s1,s2,setup,b', 'tool', 'tools:c'])
})
})

Expand All @@ -53,9 +53,9 @@ describe('getLaneLiveIndicator', () => {
expect(indicator?.type === 'call' && indicator.tool.id).toBe('a')
})

it('gives the gap to a succeeded trailing call, never a finished search or a failure', () => {
it('gives the gap to a succeeded trailing call, including search, never a failure', () => {
expect(liveId([call('s', 'search_workspace'), call('a', 'read')])).toBe('a')
expect(liveId([call('a', 'read'), call('s', 'search_workspace')])).toBeUndefined()
expect(liveId([call('a', 'read'), call('s', 'search_workspace')])).toBe('s')
expect(liveId([call('a', 'read'), call('b', 'read', { status: 'error' })])).toBeUndefined()
expect(liveId([call('a', 'read')], false)).toBeUndefined()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
AgentGroupItem,
NestedAgentGroup,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import { isSearchActivityTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity'
import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'

Expand All @@ -27,29 +26,27 @@ function canHoldIndicator(tool: ToolCallData): boolean {
return !needsToolInput(tool) && tool.toolName !== RETIRED_BROWSER_REQUEST_TAKEOVER_ID
}

/** A run of consecutive calls the main lane renders together, as one group or one search list. */
/** A run of consecutive calls the main lane renders together, as one activity group. */
export interface ActivityRun {
tools: ToolCallData[]
isSearch: boolean
}

export type MainLaneEntry =
| { type: 'run'; run: ActivityRun }
| { type: 'item'; item: AgentGroupItem; index: number }

/**
* How the main lane lays out its items: consecutive calls form runs, a search
* call never shares a run with another kind of call, and an interaction stands
* How the main lane lays out its items: consecutive calls form runs,
* including search and document reads, and an interaction stands
* on its own and closes the run before it.
*/
export function splitMainLane(items: AgentGroupItem[]): MainLaneEntry[] {
const entries: MainLaneEntry[] = []
let run: ActivityRun | undefined
for (const [index, item] of items.entries()) {
if (item.type === 'tool' && !isStandaloneItem(item)) {
const isSearch = isSearchActivityTool(item.data)
if (!run || run.isSearch !== isSearch) {
run = { tools: [], isSearch }
if (!run) {
run = { tools: [] }
entries.push({ type: 'run', run })
}
run.tools.push(item.data)
Expand Down Expand Up @@ -142,13 +139,10 @@ export interface LaneActivityInput {
isOpen: boolean
}

/**
* The latest call of the main lane's last run, which owns the trailing gap. A
* finished search shows static results, so its gap is never a call's.
*/
/** The latest call of the main lane's last run, which owns the trailing gap. */
function getMainTrailingCall(items: AgentGroupItem[]): ToolCallData | undefined {
const last = splitMainLane(items).at(-1)
return last?.type === 'run' && !last.run.isSearch ? last.run.tools.at(-1) : undefined
return last?.type === 'run' ? last.run.tools.at(-1) : undefined
}

/**
Expand Down Expand Up @@ -204,12 +198,11 @@ export interface TurnLiveIndicators {
* call anywhere in the lane, across all of its runs and the lanes nested in
* it. With none running and the lane open, the latest call of its trailing
* run owns the gap, and is live only if it succeeded; an error, rejection,
* stop, skip, or interruption hands the wait to the thinking row. A finished
* main-lane search shows static results, so its gap is never live. A subagent
* stop, skip, or interruption hands the wait to the thinking row. A subagent
* lane's trailing call is its latest call, and an open subagent lane with
* narration but no calls shows its own "Thinking" header.
* - Only the run holding the live call shimmers: its tool group header, or its
* one search row. A parent lane whose live call sits in a nested lane defers
* - Only the run holding the live call shimmers through its tool group header.
* A parent lane whose live call sits in a nested lane defers
* to that lane while the nested lane is still working and visible; a nested
* lane that has ended hands its last call back to the parent.
* - A header reads in the present tense exactly while it is live or its call
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { type ComponentType, Fragment, type ReactNode } from 'react'
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import { splitMainLane } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity'
import { SearchActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity'
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'

Expand Down Expand Up @@ -37,10 +36,8 @@ export function MainAgentActivity({
</Fragment>
)
}
const { tools, isSearch } = entry.run
return isSearch ? (
<SearchActivity key={tools[0].id} tools={tools} liveToolId={liveToolId} />
) : (
const { tools } = entry.run
return (
<ToolActivityGroup
key={tools[0].id}
tools={tools}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
collectRetrievalCitationEvidence,
parseCitationRecord,
} from '@/lib/mothership/chat/citation-evidence'
import { SearchActivityResults } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url'
import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'

/** Safe sources, an explicit empty result, or no displayable search details. */
export function getSearchActivitySources(tool: ToolCallData): SourceTagData[] | undefined {
if (tool.toolName !== 'search_workspace') return undefined
const evidence = collectRetrievalCitationEvidence([
{ toolCall: { name: tool.toolName, status: tool.status, result: tool.result } },
])
const sources = [...indexSourcesByUrl(evidence.values()).values()]
const output = parseCitationRecord(tool.result?.output)
const data = parseCitationRecord(output?.data) ?? output
const noResults = Boolean(
tool.status === ToolCallStatus.success &&
tool.result?.success &&
output?.success !== false &&
parseCitationRecord(data?.retrieval)?.status !== 'partial' &&
Array.isArray(data?.results) &&
Comment thread
waleedlatif1 marked this conversation as resolved.
data.results.length === 0
)

return sources.length > 0 || noResults ? sources : undefined
}

interface SearchActivityDetailsProps {
sources: SourceTagData[]
label: string
}

/** Per-call evidence stays in the shared activity history, never in the live header. */
export function SearchActivityDetails({ sources, label }: SearchActivityDetailsProps) {
return sources.length > 0 ? (
<SearchActivityResults sources={sources} label={label} />
) : (
<p className='text-[var(--text-muted)] text-caption'>No results</p>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/component

interface SearchActivityResultsProps {
sources: SourceTagData[]
query: string
label: string
}

/**
* Bounded results keep every match available without growing the activity
* transcript. The list shows four and a half 32px rows, so a clipped row signals
* that it scrolls.
*/
export function SearchActivityResults({ sources, query }: SearchActivityResultsProps) {
export function SearchActivityResults({ sources, label }: SearchActivityResultsProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const edges = useScrollEdges(scrollRef)

Expand All @@ -39,7 +39,7 @@ export function SearchActivityResults({ sources, query }: SearchActivityResultsP
<div
ref={scrollRef}
role='region'
aria-label={`Results for ${query}`}
aria-label={label}
className={cn('max-h-[152px] overflow-y-auto overscroll-contain p-1', scrollFadeClass)}
{...scrollFadeAttributes(edges)}
>
Expand Down
Loading
Loading