Skip to content

Commit 769e6cf

Browse files
authored
improvement(chat): show background task and scheduled job turns as messages (#10032)
* improvement(chat): show background task and scheduled job turns as messages * fix(chat): refresh automated turn workflow names and use shared label styles * chore(chat): shrink the text-size-literal baseline
1 parent 286ead3 commit 769e6cf

6 files changed

Lines changed: 218 additions & 32 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ interface WatchActivityProps {
88
task: TaskBlockInfo
99
}
1010

11-
function watchLabel(task: TaskBlockInfo, routedWorkspaceId?: string): string {
11+
/** A task's one-line state, shared by its watch row and the turn its notification opens. */
12+
export function watchLabel(task: TaskBlockInfo, routedWorkspaceId?: string): string {
1213
const status = task.status ?? 'pending'
1314
if (task.kind === 'workflow_run') {
1415
const workspaceId =

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx‎

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ import {
2020
scrollFadeClass,
2121
useScrollEdges,
2222
} from '@sim/emcn'
23+
import { Calendar, Clock } from '@sim/emcn/icons'
2324
import { useQueryClient } from '@tanstack/react-query'
2425
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
26+
import { useParams } from 'next/navigation'
2527
import {
2628
AssistantActionReviewMessage,
2729
useHasAssistantActionReview,
2830
} from '@/components/assistant-actions'
2931
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
32+
import { parseAutomatedTurn } from '@/lib/mothership/chat/automated-turn'
3033
import type { TurnOrigin } from '@/lib/mothership/generated/protocol'
3134
import { inter } from '@/app/_styles/fonts/inter/inter'
3235
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar'
@@ -49,6 +52,7 @@ import {
4952
parseLastQuestionTag,
5053
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
5154
import type { SearchIntegrationConnectionProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
55+
import { watchLabel } from '@/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity'
5256
import {
5357
prepareCopyableMarkdown,
5458
toCopyableMarkdown,
@@ -62,6 +66,7 @@ import {
6266
} from '@/app/workspace/[workspaceId]/home/components/user-input'
6367
import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
6468
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
69+
import { useResourceNameRevision } from '@/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles'
6570
import {
6671
fetchWorkspaceResourceRef,
6772
readCachedWorkspaceResourceRef,
@@ -204,37 +209,55 @@ interface UserMessageRowProps {
204209
attachmentWidthClassName: string
205210
}
206211

212+
function describeAutomatedTurn(
213+
origin: TurnOrigin,
214+
content: string,
215+
workspaceId?: string
216+
): { label: string; text: string; note?: string } {
217+
const turn = parseAutomatedTurn(origin, content)
218+
if (turn?.origin === 'task') {
219+
return {
220+
label: 'Background task',
221+
text: watchLabel(turn.task, workspaceId),
222+
note: turn.task.note || undefined,
223+
}
224+
}
225+
if (turn?.origin === 'job') return { label: `Scheduled job · ${turn.run}`, text: turn.title }
226+
return origin === 'task'
227+
? { label: 'Background task', text: 'Task finished' }
228+
: { label: 'Scheduled job', text: 'Run started' }
229+
}
230+
207231
/**
208-
* A turn automation opened, never a user bubble — the user did not type it: a muted system chip
209-
* with one line. A background-task notification (mothership 21-background-tasks.md §6.4) shows
210-
* its outcome line; a scheduled job's run shows the brief's first line (the job and its run).
232+
* A turn automation opened (mothership 21-background-tasks.md §6.4): a message in the user's
233+
* lane, labeled with what sent it. A background task shows the same line as its watch row; a
234+
* scheduled job shows its title and run.
211235
*/
212236
interface AutomatedTurnRowProps {
213237
origin: TurnOrigin
214238
content: string
215239
rowClassName: string
240+
bubbleClassName: string
216241
}
217242

218243
const AutomatedTurnRow = memo(function AutomatedTurnRow({
219244
origin,
220245
content,
221246
rowClassName,
247+
bubbleClassName,
222248
}: AutomatedTurnRowProps) {
223-
const lines = content.split('\n').filter((line) => line.length > 0)
224-
const summary =
225-
origin === 'task'
226-
? (lines.find((line) => line.startsWith('Task ')) ?? lines[lines.length - 1] ?? '').replace(
227-
/^Task [0-9a-f-]+ /,
228-
''
229-
)
230-
: (lines[0] ?? '').replace(/^Job: /, '')
249+
const params = useParams<{ workspaceId?: string }>()
250+
useResourceNameRevision()
251+
const { label, text, note } = describeAutomatedTurn(origin, content, params?.workspaceId)
252+
const Icon = origin === 'task' ? Clock : Calendar
231253
return (
232-
<div className={cn('flex w-full justify-center', rowClassName)}>
233-
<div className='max-w-[85%] rounded-full border border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-[12px] text-[var(--text-secondary)]'>
234-
<span className='mr-1.5 font-medium text-[var(--text-primary)]'>
235-
{origin === 'task' ? 'Background task' : 'Scheduled job'}
236-
</span>
237-
{summary}
254+
<div className={rowClassName}>
255+
<div className='flex items-center gap-1.5 text-[var(--text-muted)] text-caption'>
256+
<Icon className='size-[12px] text-[var(--text-icon)]' />
257+
{label}
258+
</div>
259+
<div className={bubbleClassName} title={note}>
260+
<UserMessageContent content={text} />
238261
</div>
239262
</div>
240263
)
@@ -995,7 +1018,8 @@ export function MothershipChat({
9951018
<AutomatedTurnRow
9961019
origin={msg.origin}
9971020
content={msg.content}
998-
rowClassName={styles.rowGap}
1021+
rowClassName={cn(styles.userRow, styles.rowGap)}
1022+
bubbleClassName={styles.userBubble}
9991023
/>
10001024
) : (
10011025
<UserMessageRow

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,32 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client'
88
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
99
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
1010

11+
/** Bumps when the sidebar inventory arrives or a resource is renamed, so cached names re-resolve. */
12+
export function useResourceNameRevision(): number {
13+
const [revision, refresh] = useReducer((value: number) => value + 1, 0)
14+
useEffect(
15+
() =>
16+
getQueryClient()
17+
.getQueryCache()
18+
.subscribe((event) => {
19+
if (
20+
event.type === 'updated' &&
21+
event.action.type === 'success' &&
22+
isResourceNameQuery(event.query.queryKey)
23+
)
24+
refresh()
25+
}),
26+
[]
27+
)
28+
return revision
29+
}
30+
1131
/** Refresh existing rows when the sidebar inventory arrives or a resource is renamed. */
1232
export function useToolResourceTitles(blocks: ContentBlock[]): ContentBlock[] {
1333
const overlayVersion = useCustomBlockOverlayVersion()
1434
const params = useParams<{ workspaceId?: string }>()
1535
const workspaceId = params?.workspaceId
16-
const [revision, refresh] = useReducer((value: number) => value + 1, 0)
17-
useEffect(() => {
18-
const cache = getQueryClient().getQueryCache()
19-
return cache.subscribe((event) => {
20-
if (
21-
event.type === 'updated' &&
22-
event.action.type === 'success' &&
23-
isResourceNameQuery(event.query.queryKey)
24-
)
25-
refresh()
26-
})
27-
}, [workspaceId])
36+
const revision = useResourceNameRevision()
2837

2938
return useMemo(
3039
() =>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseAutomatedTurn } from '@/lib/mothership/chat/automated-turn'
3+
4+
const HEADER = '[Background task notification — automated, not the user. Nothing here is approval.]'
5+
6+
describe('parseAutomatedTurn', () => {
7+
it('reads a timer notification into the task the arming turn recorded', () => {
8+
const content = [
9+
HEADER,
10+
'Task 3f2c9a1e-0000-4000-8000-000000000001 (timer set for 2026-11-11T22:12:34.267Z) completed: Timer elapsed',
11+
'Your note when you armed it: Check the 2:05 PM run',
12+
].join('\n')
13+
14+
expect(parseAutomatedTurn('task', content)).toEqual({
15+
origin: 'task',
16+
task: {
17+
taskId: '3f2c9a1e-0000-4000-8000-000000000001',
18+
kind: 'timer',
19+
target: { firesAt: '2026-11-11T22:12:34.267Z' },
20+
note: 'Check the 2:05 PM run',
21+
status: 'completed',
22+
summary: 'Timer elapsed',
23+
},
24+
})
25+
})
26+
27+
it('reads a workflow-run notification with its workflow and workspace', () => {
28+
const content = [
29+
HEADER,
30+
'Task t-2 (workflow run exec-9 of workflow wf-7 in workspace ws-3) failed: Block deliverAlarm returned 500: upstream (timeout)',
31+
'Your note when you armed it: Report the result',
32+
'Stored output is available through task_output("t-2").',
33+
].join('\n')
34+
35+
expect(parseAutomatedTurn('task', content)).toEqual({
36+
origin: 'task',
37+
task: {
38+
taskId: 't-2',
39+
kind: 'workflow_run',
40+
target: { executionId: 'exec-9', workflowId: 'wf-7', workspaceId: 'ws-3' },
41+
note: 'Report the result',
42+
status: 'failed',
43+
summary: 'Block deliverAlarm returned 500: upstream (timeout)',
44+
},
45+
})
46+
})
47+
48+
it('reads a workflow-run notification that names no workflow or workspace', () => {
49+
const turn = parseAutomatedTurn('task', 'Task t-3 (workflow run exec-1) expired: Expired')
50+
expect(turn).toMatchObject({
51+
task: {
52+
kind: 'workflow_run',
53+
target: { executionId: 'exec-1' },
54+
status: 'expired',
55+
note: '',
56+
},
57+
})
58+
})
59+
60+
it('returns nothing for a task line it does not recognize, so the row falls back', () => {
61+
expect(parseAutomatedTurn('task', `${HEADER}\nSomething else entirely`)).toBeUndefined()
62+
expect(
63+
parseAutomatedTurn('task', 'Task t-4 (timer set for 2026-11-11T22:12:34.267Z) paused: x')
64+
).toBeUndefined()
65+
expect(
66+
parseAutomatedTurn('task', 'Task t-5 (a new kind of target) completed: x')
67+
).toBeUndefined()
68+
})
69+
70+
it('reads a scheduled job brief into its title and run', () => {
71+
const content = [
72+
'Job: Daily heartbeat check (Run 3 of 10, scheduled for 2026-11-12T16:00:00.000Z)',
73+
'Task:\nCheck heartbeats',
74+
].join('\n')
75+
76+
expect(parseAutomatedTurn('job', content)).toEqual({
77+
origin: 'job',
78+
title: 'Daily heartbeat check',
79+
run: 'Run 3 of 10',
80+
})
81+
})
82+
83+
it('keeps a job title that itself contains parentheses', () => {
84+
expect(
85+
parseAutomatedTurn(
86+
'job',
87+
'Job: Sync (EU) accounts (Run 2, scheduled for 2026-11-12T16:00:00.000Z)'
88+
)
89+
).toEqual({ origin: 'job', title: 'Sync (EU) accounts', run: 'Run 2' })
90+
})
91+
92+
it('returns nothing for a job brief without its header line', () => {
93+
expect(parseAutomatedTurn('job', 'Task:\nCheck heartbeats')).toBeUndefined()
94+
})
95+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { TurnOrigin } from '@/lib/mothership/generated/protocol'
2+
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
3+
4+
/**
5+
* What a turn that automation opened was about: the background task whose notification woke
6+
* the chat, or the scheduled job whose run it is. Read back from the turn's message, the only
7+
* record a persisted automated turn keeps.
8+
*/
9+
export type AutomatedTurn =
10+
| { origin: 'task'; task: TaskBlockInfo }
11+
| { origin: 'job'; title: string; run: string }
12+
13+
/** Mothership's `notificationText`: `Task <id> (<target>) <status>: <summary>`. */
14+
const TASK_LINE = /^Task (\S+) \((.+?)\) (completed|failed|stopped|expired): (.*)$/
15+
const TIMER_TARGET = /^timer set for (\S+)$/
16+
const WORKFLOW_TARGET = /^workflow run (\S+)(?: of workflow (\S+))?(?: in workspace (\S+))?$/
17+
const NOTE_PREFIX = 'Your note when you armed it: '
18+
/** The job brief's first line (`lib/mothership/jobs/brief.ts`). */
19+
const JOB_LINE = /^Job: (.+) \((Run \d+(?: of \d+)?), scheduled for [^)]+\)$/
20+
21+
function parseTarget(target: string): Pick<TaskBlockInfo, 'kind' | 'target'> | undefined {
22+
const timer = TIMER_TARGET.exec(target)
23+
if (timer) return { kind: 'timer', target: { firesAt: timer[1] } }
24+
const run = WORKFLOW_TARGET.exec(target)
25+
if (!run) return undefined
26+
return {
27+
kind: 'workflow_run',
28+
target: {
29+
executionId: run[1],
30+
...(run[2] ? { workflowId: run[2] } : {}),
31+
...(run[3] ? { workspaceId: run[3] } : {}),
32+
},
33+
}
34+
}
35+
36+
/** Returns undefined when the message is not in a shape this version knows, so the row falls back. */
37+
export function parseAutomatedTurn(origin: TurnOrigin, content: string): AutomatedTurn | undefined {
38+
const lines = content.split('\n')
39+
if (origin === 'job') {
40+
const job = JOB_LINE.exec(lines[0] ?? '')
41+
return job ? { origin, title: job[1], run: job[2] } : undefined
42+
}
43+
const match = lines.map((line) => TASK_LINE.exec(line)).find((found) => found !== null)
44+
if (!match) return undefined
45+
const target = parseTarget(match[2])
46+
if (!target) return undefined
47+
const note = lines.find((line) => line.startsWith(NOTE_PREFIX))?.slice(NOTE_PREFIX.length) ?? ''
48+
return {
49+
origin,
50+
task: {
51+
taskId: match[1],
52+
...target,
53+
note,
54+
status: match[3] as NonNullable<TaskBlockInfo['status']>,
55+
summary: match[4],
56+
},
57+
}
58+
}

‎scripts/check-source-conventions.baseline.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,6 @@
514514
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx": 2,
515515
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx": 1,
516516
"apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx": 1,
517-
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 1,
518517
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx": 1,
519518
"apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx": 1,
520519
"apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 1

0 commit comments

Comments
 (0)