Skip to content

Commit 9db700f

Browse files
committed
Keep Mothership resource panels and activity groups in sync
1 parent 2adc1bc commit 9db700f

36 files changed

Lines changed: 1651 additions & 104 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { NextRequest } from 'next/server'
2+
import {
3+
type MothershipSandboxParams,
4+
mothershipSandboxParamsSchema,
5+
} from '@/lib/api/contracts/mothership-sandbox'
6+
import { validationErrorResponse } from '@/lib/api/server'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { proxySandboxResourceRequest } from '@/lib/mothership/tools/sandbox-resource-transport'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
/** Raw transport boundary preserves v2 JSON, multipart and binary bodies; v2 owns operation authorization. */
13+
const handler = withRouteHandler(
14+
async (request: NextRequest, context: { params: Promise<MothershipSandboxParams> }) => {
15+
const params = mothershipSandboxParamsSchema.safeParse(await context.params)
16+
if (!params.success) return validationErrorResponse(params.error)
17+
return proxySandboxResourceRequest(request, params.data.token)
18+
}
19+
)
20+
21+
export const GET = handler
22+
export const HEAD = handler
23+
export const POST = handler
24+
export const PUT = handler
25+
export const PATCH = handler
26+
export const DELETE = handler
27+
export const OPTIONS = handler

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,43 @@ describe('top-level activity groups', () => {
161161
expect(groups[1].items).toMatchObject([{ type: 'tool', data: { id: 'b1' } }])
162162
})
163163

164+
it('keeps parallel activities open through thinking gaps until their batch closes', () => {
165+
const completed = [
166+
activityCall('a1', 'Checking invoice inputs'),
167+
activityCall('b1', 'Checking customer inputs'),
168+
activityReference('a2', 'Checking invoice inputs'),
169+
].map(
170+
(block): ContentBlock => ({
171+
...block,
172+
toolCall: { ...block.toolCall!, status: 'success' },
173+
})
174+
)
175+
const open = parseBlocks(completed, true)
176+
expect(open).toHaveLength(2)
177+
expect(open).toMatchObject([
178+
{ isOpen: true, items: [{ data: { id: 'a1' } }, { data: { id: 'a2' } }] },
179+
{ isOpen: true, items: [{ data: { id: 'b1' } }] },
180+
])
181+
const settled = parseBlocks(completed, false)
182+
expect(settled).toHaveLength(1)
183+
expect(settled[0]).toMatchObject({ isOpen: false, completedGroupCount: 2 })
184+
const proseClosed = parseBlocks([...completed, mainText('The inputs are ready.')], true)
185+
expect(proseClosed[0]).toMatchObject({ isOpen: false, completedGroupCount: 2 })
186+
})
187+
188+
it('closes at prose and reopens a reused activity below it during the same turn', () => {
189+
const first = activityCall('a1', 'Checking invoice inputs')
190+
first.toolCall!.status = 'success'
191+
const reused = activityReference('a2', 'Checking invoice inputs')
192+
reused.toolCall!.status = 'success'
193+
const segments = parseBlocks([first, mainText('Checking the updated inputs.'), reused], true)
194+
expect(segments).toMatchObject([
195+
{ type: 'agent_group', isOpen: false, activity: { id: 'Checking invoice inputs' } },
196+
{ type: 'text' },
197+
{ type: 'agent_group', isOpen: true, activity: { id: 'Checking invoice inputs' } },
198+
])
199+
})
200+
164201
it('does not merge activities across prose or absorb a subagent into the main activity', () => {
165202
const segments = parseBlocks([
166203
activityCall('a1', 'Checking invoice inputs'),

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
558558
}
559559

560560
/** Each explicit activity owns a top-level group, including interleaved parallel calls. */
561-
function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
561+
function groupByActivity(segments: MessageSegment[], isStreaming: boolean): MessageSegment[] {
562562
const labels = new Map<string, ToolActivity>()
563563
for (const segment of segments) {
564564
if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') continue
@@ -569,8 +569,10 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
569569
labels.set(activity.id, activity)
570570
}
571571
}
572-
return segments.flatMap((segment): MessageSegment[] => {
572+
return segments.flatMap((segment, index): MessageSegment[] => {
573573
if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') return [segment]
574+
/** A quiet model round can append calls to any activity in this visible batch. */
575+
const isOpen = isStreaming && index === segments.length - 1
574576
const groups: AgentGroupSegment[] = []
575577
const byActivity = new Map<string, AgentGroupSegment>()
576578
let current: AgentGroupSegment | undefined
@@ -584,6 +586,7 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
584586
else if (!current || activity || currentActivityId) {
585587
current = {
586588
...segment,
589+
isOpen,
587590
activity: activity ? labels.get(activity.id) : undefined,
588591
id:
589592
groups.length === 0
@@ -601,6 +604,7 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
601604
const summaryActivity = [...groups].reverse().find((group) => group.activity)?.activity
602605
if (
603606
groups.length > 1 &&
607+
!isOpen &&
604608
summaryActivity &&
605609
segment.items.every((item) => item.type === 'tool') &&
606610
isAgentGroupResolved(segment.items)
@@ -611,11 +615,12 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
611615
})
612616
}
613617

614-
export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
618+
export function parseBlocks(blocks: ContentBlock[], isStreaming = false): MessageSegment[] {
615619
return groupByActivity(
616620
blocks.some((block) => Boolean(block.spanId))
617621
? parseBlocksWithSpanTree(blocks)
618-
: parseBlocksLegacy(blocks)
622+
: parseBlocksLegacy(blocks),
623+
isStreaming
619624
)
620625
}
621626

@@ -1005,8 +1010,8 @@ function MessageContentInner({
10051010
)
10061011
const titledBlocks = useToolResourceTitles(cited.blocks)
10071012
const parsed = useMemo(
1008-
() => (titledBlocks.length > 0 ? parseBlocks(titledBlocks) : []),
1009-
[titledBlocks, blockOverlayVersion]
1013+
() => (titledBlocks.length > 0 ? parseBlocks(titledBlocks, isStreaming) : []),
1014+
[titledBlocks, blockOverlayVersion, isStreaming]
10101015
)
10111016

10121017
const [trailingRevealing, setTrailingRevealing] = useState(false)

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.test.tsx‎

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4-
import { act, type ReactNode, useImperativeHandle } from 'react'
4+
import { act, type ReactNode, StrictMode, useEffect, useImperativeHandle } from 'react'
5+
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'
56
import { createRoot, type Root } from 'react-dom/client'
67
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7-
import type { FilePreviewSession } from '@/lib/copilot/request/session'
8+
import type { FilePreviewSession } from '@/lib/mothership/request/session'
89
import type { FileDownloadSource } from '@/lib/uploads/client/download'
910
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1011

@@ -43,6 +44,33 @@ vi.mock('@/app/workspace/[workspaceId]/files/components/file-viewer', () => ({
4344
},
4445
}))
4546

47+
const logQueries = vi.hoisted(() => ({
48+
detail: vi.fn(() => ({ data: undefined, isLoading: true, error: null })),
49+
execution: vi.fn(() => ({ data: undefined, isLoading: true, error: null })),
50+
}))
51+
vi.mock('@/hooks/queries/logs', () => ({
52+
useLogDetail: logQueries.detail,
53+
useLogByExecutionId: logQueries.execution,
54+
}))
55+
const canvasHydration = vi.hoisted(() => ({ enabled: false, query: vi.fn(), failed: vi.fn() }))
56+
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/workflow', () => ({
57+
default: ({ workflowId }: { workflowId: string }) => {
58+
const client = useQueryClient()
59+
useEffect(() => {
60+
if (canvasHydration.enabled) {
61+
void client
62+
.fetchQuery({
63+
queryKey: workflowKeys.state(workflowId),
64+
queryFn: canvasHydration.query,
65+
staleTime: 0,
66+
})
67+
.catch(canvasHydration.failed)
68+
}
69+
}, [client, workflowId])
70+
return <div>Verified workflow canvas</div>
71+
},
72+
}))
73+
4674
vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/table', () => ({
4775
Table: () => null,
4876
}))
@@ -60,35 +88,42 @@ import {
6088
ResourceContent,
6189
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content'
6290
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
91+
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
6392
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
6493

6594
describe('ResourceContent handoff', () => {
6695
let container: HTMLDivElement
6796
let root: Root
97+
let client: QueryClient
6898

6999
beforeEach(() => {
100+
vi.clearAllMocks()
101+
canvasHydration.enabled = false
70102
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
71103
useTableViewPinStore.getState().reset()
72-
vi.clearAllMocks()
73104
files.length = 0
74105
container = document.createElement('div')
75106
root = createRoot(container)
107+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
76108
})
77109

78110
afterEach(() => {
79111
act(() => root.unmount())
80112
useTableViewPinStore.getState().reset()
113+
client.clear()
81114
})
82115

83116
function render(resource: MothershipResource) {
84117
act(() => {
85118
root.render(
86119
(
87-
<ResourceContent
88-
workspaceId='workspace-1'
89-
desktopScopeId='chat:chat-1'
90-
resource={resource}
91-
/>
120+
<QueryClientProvider client={client}>
121+
<ResourceContent
122+
workspaceId='workspace-1'
123+
desktopScopeId='chat:chat-1'
124+
resource={resource}
125+
/>
126+
</QueryClientProvider>
92127
) as ReactNode
93128
)
94129
})
@@ -103,6 +138,89 @@ describe('ResourceContent handoff', () => {
103138
expect(useTableViewPinStore.getState().pins['table-1']?.viewId).toBe('view-restored')
104139
})
105140

141+
it('opens a CLI log by its execution ID instead of querying it as a storage row', () => {
142+
render({ type: 'log', id: 'run-1', executionId: 'run-1', title: 'Workflow run' })
143+
expect(logQueries.detail).toHaveBeenCalledWith('run-1', 'workspace-1', { enabled: false })
144+
expect(logQueries.execution).toHaveBeenCalledWith('workspace-1', 'run-1')
145+
})
146+
147+
it('keeps existing log row IDs on their ordinary detail path', () => {
148+
render({ type: 'log', id: 'row-1', title: 'Workflow run' })
149+
expect(logQueries.detail).toHaveBeenCalledWith('row-1', 'workspace-1', { enabled: true })
150+
expect(logQueries.execution).toHaveBeenCalledWith('workspace-1', undefined)
151+
})
152+
153+
it('opens a foreign-workspace read without adding it to this workspace sidebar', () => {
154+
client.setQueryData(workflowKeys.list('workspace-1'), [])
155+
client.setQueryData(workflowKeys.state('foreign'), {
156+
id: 'foreign',
157+
workspaceId: 'workspace-2',
158+
name: 'Other workspace workflow',
159+
})
160+
render({ type: 'workflow', id: 'foreign', title: 'Other workspace workflow' })
161+
expect(container.textContent).toContain('Other workspace workflow')
162+
expect(container.textContent).toContain('Open in its workspace')
163+
expect(client.getQueryData(workflowKeys.list('workspace-1'))).toEqual([])
164+
})
165+
166+
it('uses authorized same-workspace metadata, including folder and order, for a missing inventory entry', async () => {
167+
client.setQueryData(workflowKeys.list('workspace-1'), [])
168+
client.setQueryData(workflowKeys.state('verified'), {
169+
id: 'verified',
170+
workspaceId: 'workspace-1',
171+
name: 'Verified name',
172+
folderId: 'actual-folder',
173+
sortOrder: 42,
174+
createdAt: new Date('2026-09-01'),
175+
updatedAt: new Date('2026-09-02'),
176+
archivedAt: null,
177+
})
178+
render({ type: 'workflow', id: 'verified', title: 'Untrusted placeholder' })
179+
await act(async () => {})
180+
expect(client.getQueryData(workflowKeys.list('workspace-1'))).toEqual([
181+
expect.objectContaining({
182+
id: 'verified',
183+
name: 'Verified name',
184+
folderId: 'actual-folder',
185+
sortOrder: 42,
186+
workspaceId: 'workspace-1',
187+
}),
188+
])
189+
})
190+
191+
it('does not cancel canvas hydration when StrictMode replays panel effects', async () => {
192+
client.setQueryData(workflowKeys.list('workspace-1'), [{ id: 'verified' }])
193+
canvasHydration.enabled = true
194+
let resolveHydration!: (value: object) => void
195+
let hydrationSignal: AbortSignal | undefined
196+
canvasHydration.query.mockImplementation(({ signal }: { signal: AbortSignal }) => {
197+
hydrationSignal = signal
198+
return new Promise((resolve) => {
199+
resolveHydration = resolve
200+
})
201+
})
202+
await act(async () => {
203+
root.render(
204+
<StrictMode>
205+
<QueryClientProvider client={client}>
206+
<ResourceContent
207+
workspaceId='workspace-1'
208+
desktopScopeId='chat:chat-1'
209+
resource={{ type: 'workflow', id: 'verified', title: 'Verified workflow' }}
210+
/>
211+
</QueryClientProvider>
212+
</StrictMode>
213+
)
214+
})
215+
expect(canvasHydration.failed).not.toHaveBeenCalled()
216+
expect(hydrationSignal?.aborted).toBe(false)
217+
await act(async () => {
218+
resolveHydration({ id: 'verified' })
219+
})
220+
expect(canvasHydration.failed).not.toHaveBeenCalled()
221+
expect(container.textContent).toContain('Verified workflow canvas')
222+
})
223+
106224
it('does not pin a table opened without a saved view', () => {
107225
render({ type: 'table', id: 'table-1', title: 'Invoices' })
108226

0 commit comments

Comments
 (0)