Skip to content

Commit 1a6d590

Browse files
committed
fix(mothership): repair background wakes and simplify watch activity
1 parent d021220 commit 1a6d590

14 files changed

Lines changed: 341 additions & 105 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ export function AgentGroupView({
315315
{activity}
316316
</ActivityStream>
317317
)}
318-
{error && <p className='pl-6 text-[var(--text-error)] text-caption'>{error}</p>}
318+
{error && <p className='pl-6 text-[var(--text-tertiary)] text-caption'>{error}</p>}
319319
{activeBrowserTakeover && (
320320
<div key={activeBrowserTakeover.id} className='animate-stream-fade-in'>
321321
{renderBrowserTakeover?.(activeBrowserTakeover.reason)}

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

Lines changed: 0 additions & 1 deletion
This file was deleted.

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

Lines changed: 0 additions & 64 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/task-pill/task-pill.test.tsx renamed to apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity.test.tsx

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { act, type ReactNode } from 'react'
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
7-
import { TaskPill } from '@/app/workspace/[workspaceId]/home/components/message-content/components/task-pill/task-pill'
7+
import { WatchActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity'
88
import { mothershipTaskKeys } from '@/hooks/queries/mothership-tasks'
99

1010
const { request } = vi.hoisted(() => ({ request: vi.fn() }))
@@ -13,12 +13,6 @@ vi.mock('@/lib/mothership/tools/client/resource-display', () => ({
1313
id === 'workflow' ? 'Alfred' : undefined,
1414
}))
1515
vi.mock('@/lib/api/client/request', () => ({ requestJson: request }))
16-
vi.mock('@sim/emcn', () => ({
17-
Check: () => null,
18-
Clock: () => null,
19-
X: () => null,
20-
cn: (...values: string[]) => values.join(' '),
21-
}))
2216
const task = {
2317
taskId: '22222222-2222-4222-8222-222222222222',
2418
kind: 'timer',
@@ -55,27 +49,34 @@ afterEach(() => {
5549
client.clear()
5650
})
5751

58-
it('updates a pill in an earlier message without needing an event in that turn', async () => {
52+
it('updates the same pending tool row in an earlier message without needing an event in that turn', async () => {
5953
render(
6054
<QueryClientProvider client={client}>
61-
<TaskPill task={task} />
55+
<WatchActivity task={task} />
6256
</QueryClientProvider>
6357
)
6458
await act(async () => vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)))
59+
const row = statusElement()
60+
expect(row.parentElement?.getAttribute('aria-busy')).toBe('true')
61+
expect(row.querySelector('[class*=shimmer]')).not.toBeNull()
62+
expect(row.textContent).toBe('Waiting for timer')
6563
request.mockResolvedValue({ taskId: task.taskId, status: 'completed', summary: 'Timer elapsed' })
6664
await act(() => client.invalidateQueries({ queryKey: mothershipTaskKeys.detail(task.taskId) }))
6765
await act(async () =>
6866
vi.waitFor(() => {
69-
expect(statusElement().textContent).toMatch(/Background watch.*Completed timer/)
67+
expect(statusElement().textContent).toBe('Timer finished')
7068
})
7169
)
72-
expect(statusElement().title).toContain('Timer elapsed')
70+
expect(statusElement()).toBe(row)
71+
expect(row.parentElement?.getAttribute('aria-busy')).toBe('false')
72+
expect(row.querySelector('[class*=shimmer]')).toBeNull()
73+
expect(row.parentElement?.title).toContain('Timer elapsed')
7374
})
7475

7576
it('never replaces a live terminal event with a cached pending status', async () => {
7677
const view = render(
7778
<QueryClientProvider client={client}>
78-
<TaskPill task={task} />
79+
<WatchActivity task={task} />
7980
</QueryClientProvider>
8081
)
8182
await act(async () =>
@@ -85,18 +86,18 @@ it('never replaces a live terminal event with a cached pending status', async ()
8586
)
8687
view.rerender(
8788
<QueryClientProvider client={client}>
88-
<TaskPill task={{ ...task, status: 'stopped' }} />
89+
<WatchActivity task={{ ...task, status: 'stopped' }} />
8990
</QueryClientProvider>
9091
)
91-
expect(statusElement().textContent).toMatch(/Stopped timer/)
92+
expect(statusElement().textContent).toBe('Timer stopped')
9293
})
9394

9495
it('shows completed workflow watches as status without duplicate run ids or action controls', () => {
9596
const executionId = '733e3428-f081-4504-bc5e-25be3b515304'
9697
const summary = `Workflow run ${executionId} of "Alfred" completed`
9798
render(
9899
<QueryClientProvider client={client}>
99-
<TaskPill
100+
<WatchActivity
100101
task={{
101102
...task,
102103
kind: 'workflow_run',
@@ -108,8 +109,8 @@ it('shows completed workflow watches as status without duplicate run ids or acti
108109
</QueryClientProvider>
109110
)
110111
const status = statusElement()
111-
expect(status.textContent).toBe('Background watch · Completed workflow run · Alfred')
112-
expect(status.title).toContain(summary)
112+
expect(status.textContent).toBe('Completed workflow run: Alfred')
113+
expect(status.parentElement?.title).toContain(summary)
113114
expect(status.textContent).not.toContain(executionId)
114115
expect(container.querySelector('button')).toBeNull()
115116
expect(request).not.toHaveBeenCalled()
@@ -120,10 +121,36 @@ it.each(['failed', 'expired', 'stopped'] as const)(
120121
(status) => {
121122
render(
122123
<QueryClientProvider client={client}>
123-
<TaskPill task={{ ...task, kind: 'workflow_run', status, summary: 'Run did not finish' }} />
124+
<WatchActivity
125+
task={{ ...task, kind: 'workflow_run', status, summary: 'Run did not finish' }}
126+
/>
124127
</QueryClientProvider>
125128
)
126129
expect(statusElement().textContent).not.toContain('Completed')
127-
expect(statusElement().title).toContain('Run did not finish')
130+
expect(statusElement().parentElement?.title).toContain('Run did not finish')
128131
}
129132
)
133+
134+
it('renders a timer as a normal pending tool row without separate status chrome', () => {
135+
const firesAt = '2026-09-18T17:32:00Z'
136+
const time = new Date(firesAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
137+
render(
138+
<QueryClientProvider client={client}>
139+
<WatchActivity task={{ ...task, target: { firesAt } }} />
140+
</QueryClientProvider>
141+
)
142+
expect(statusElement().textContent).toBe(`Waiting until ${time}`)
143+
expect(statusElement().querySelector('[class*=shimmer]')).not.toBeNull()
144+
expect(container.textContent).not.toContain('Background watch')
145+
expect(container.querySelector('svg')).toBeNull()
146+
})
147+
148+
it('keeps pending workflow watches active even after the originating turn ends', () => {
149+
render(
150+
<QueryClientProvider client={client}>
151+
<WatchActivity task={{ ...task, kind: 'workflow_run', target: { workflowId: 'workflow' } }} />
152+
</QueryClientProvider>
153+
)
154+
expect(statusElement().textContent).toBe('Waiting for workflow run: Alfred')
155+
expect(statusElement().querySelector('[class*=shimmer]')).not.toBeNull()
156+
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ActivityStatus } from '@/components/ui/activity-status'
2+
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
3+
import { resolveResourceDisplayName } from '@/lib/mothership/tools/client/resource-display'
4+
import { useMothershipTaskStatus } from '@/hooks/queries/mothership-tasks'
5+
6+
interface WatchActivityProps {
7+
task: TaskBlockInfo
8+
}
9+
10+
function watchLabel(task: TaskBlockInfo): string {
11+
const status = task.status ?? 'pending'
12+
if (task.kind === 'workflow_run') {
13+
const workspaceId =
14+
typeof task.target.workspaceId === 'string' ? task.target.workspaceId : undefined
15+
const name = resolveResourceDisplayName('workflow', task.target.workflowId, { workspaceId })
16+
const target = name ? `workflow run: ${name}` : 'workflow run'
17+
return {
18+
pending: `Waiting for ${target}`,
19+
completed: `Completed ${target}`,
20+
failed: `Failed ${target}`,
21+
stopped: `Stopped watching ${target}`,
22+
expired: `Watch expired for ${target}`,
23+
}[status]
24+
}
25+
const at = typeof task.target.firesAt === 'string' ? new Date(task.target.firesAt) : undefined
26+
const time =
27+
at && Number.isFinite(at.getTime())
28+
? at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
29+
: undefined
30+
if (status === 'pending') return time ? `Waiting until ${time}` : 'Waiting for timer'
31+
const label = {
32+
completed: 'Timer finished',
33+
failed: 'Timer failed',
34+
stopped: 'Timer stopped',
35+
expired: 'Timer expired',
36+
}[status]
37+
return time ? `${label} · ${time}` : label
38+
}
39+
40+
/** A watch uses the normal tool row, but remains pending independently of the turn. */
41+
export function WatchActivity({ task: recorded }: WatchActivityProps) {
42+
const { data } = useMothershipTaskStatus(recorded)
43+
const task =
44+
data && (!recorded.status || recorded.status === 'pending')
45+
? { ...recorded, status: data.status, summary: data.summary ?? undefined }
46+
: recorded
47+
const pending = task.status === undefined || task.status === 'pending'
48+
return (
49+
<div aria-busy={pending} title={[task.summary, task.note].filter(Boolean).join('\n')}>
50+
<ActivityStatus label={watchLabel(task)} isActive={pending} />
51+
</div>
52+
)
53+
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,22 @@ describe('MessageContent shared thinking indicator', () => {
219219
}
220220
)
221221

222+
it('keeps interrupted subagent details visible with neutral styling and no active shimmer', () => {
223+
const error = 'Subagent interrupted during run recovery.'
224+
render(
225+
[{ ...start('task'), subagentName: 'Build wakeups and delivery', endedAt: 2, error }],
226+
false
227+
)
228+
expect(container.textContent).toContain('Build wakeups and delivery')
229+
const detail = Array.from(container.querySelectorAll('p')).find(
230+
(node) => node.textContent === error
231+
)
232+
expect(detail).toBeDefined()
233+
expect(detail?.className).toContain('--text-tertiary')
234+
expect(container.innerHTML).not.toContain('--text-error')
235+
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
236+
})
237+
222238
it('keeps thinking hidden while prose streams and finishes revealing', () => {
223239
const blocks: ContentBlock[] = [
224240
start('browser'),

0 commit comments

Comments
 (0)