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 @@ -8,7 +8,8 @@ interface WatchActivityProps {
task: TaskBlockInfo
}

function watchLabel(task: TaskBlockInfo, routedWorkspaceId?: string): string {
/** A task's one-line state, shared by its watch row and the turn its notification opens. */
export function watchLabel(task: TaskBlockInfo, routedWorkspaceId?: string): string {
const status = task.status ?? 'pending'
if (task.kind === 'workflow_run') {
const workspaceId =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ import {
scrollFadeClass,
useScrollEdges,
} from '@sim/emcn'
import { Calendar, Clock } from '@sim/emcn/icons'
import { useQueryClient } from '@tanstack/react-query'
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
import { useParams } from 'next/navigation'
import {
AssistantActionReviewMessage,
useHasAssistantActionReview,
} from '@/components/assistant-actions'
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
import { parseAutomatedTurn } from '@/lib/mothership/chat/automated-turn'
import type { TurnOrigin } from '@/lib/mothership/generated/protocol'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar'
Expand All @@ -49,6 +52,7 @@ import {
parseLastQuestionTag,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import type { SearchIntegrationConnectionProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
import { watchLabel } from '@/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity'
import {
prepareCopyableMarkdown,
toCopyableMarkdown,
Expand All @@ -62,6 +66,7 @@ import {
} from '@/app/workspace/[workspaceId]/home/components/user-input'
import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import { useResourceNameRevision } from '@/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles'
import {
fetchWorkspaceResourceRef,
readCachedWorkspaceResourceRef,
Expand Down Expand Up @@ -204,37 +209,55 @@ interface UserMessageRowProps {
attachmentWidthClassName: string
}

function describeAutomatedTurn(
origin: TurnOrigin,
content: string,
workspaceId?: string
): { label: string; text: string; note?: string } {
const turn = parseAutomatedTurn(origin, content)
if (turn?.origin === 'task') {
return {
label: 'Background task',
text: watchLabel(turn.task, workspaceId),
note: turn.task.note || undefined,
}
}
if (turn?.origin === 'job') return { label: `Scheduled job · ${turn.run}`, text: turn.title }
return origin === 'task'
? { label: 'Background task', text: 'Task finished' }
: { label: 'Scheduled job', text: 'Run started' }
}

/**
* A turn automation opened, never a user bubble — the user did not type it: a muted system chip
* with one line. A background-task notification (mothership 21-background-tasks.md §6.4) shows
* its outcome line; a scheduled job's run shows the brief's first line (the job and its run).
* A turn automation opened (mothership 21-background-tasks.md §6.4): a message in the user's
* lane, labeled with what sent it. A background task shows the same line as its watch row; a
* scheduled job shows its title and run.
*/
interface AutomatedTurnRowProps {
origin: TurnOrigin
content: string
rowClassName: string
bubbleClassName: string
}

const AutomatedTurnRow = memo(function AutomatedTurnRow({
origin,
content,
rowClassName,
bubbleClassName,
}: AutomatedTurnRowProps) {
const lines = content.split('\n').filter((line) => line.length > 0)
const summary =
origin === 'task'
? (lines.find((line) => line.startsWith('Task ')) ?? lines[lines.length - 1] ?? '').replace(
/^Task [0-9a-f-]+ /,
''
)
: (lines[0] ?? '').replace(/^Job: /, '')
const params = useParams<{ workspaceId?: string }>()
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
useResourceNameRevision()
const { label, text, note } = describeAutomatedTurn(origin, content, params?.workspaceId)
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
const Icon = origin === 'task' ? Clock : Calendar
return (
<div className={cn('flex w-full justify-center', rowClassName)}>
<div className='max-w-[85%] rounded-full border border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-[12px] text-[var(--text-secondary)]'>
<span className='mr-1.5 font-medium text-[var(--text-primary)]'>
{origin === 'task' ? 'Background task' : 'Scheduled job'}
</span>
{summary}
<div className={rowClassName}>
<div className='flex items-center gap-1.5 text-[var(--text-muted)] text-caption'>
<Icon className='size-[12px] text-[var(--text-icon)]' />
{label}
</div>
<div className={bubbleClassName} title={note}>
<UserMessageContent content={text} />
</div>
</div>
)
Expand Down Expand Up @@ -995,7 +1018,8 @@ export function MothershipChat({
<AutomatedTurnRow
origin={msg.origin}
content={msg.content}
rowClassName={styles.rowGap}
rowClassName={cn(styles.userRow, styles.rowGap)}
bubbleClassName={styles.userBubble}
/>
) : (
<UserMessageRow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,32 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'

/** Bumps when the sidebar inventory arrives or a resource is renamed, so cached names re-resolve. */
export function useResourceNameRevision(): number {
const [revision, refresh] = useReducer((value: number) => value + 1, 0)
useEffect(
() =>
getQueryClient()
.getQueryCache()
.subscribe((event) => {
if (
event.type === 'updated' &&
event.action.type === 'success' &&
isResourceNameQuery(event.query.queryKey)
)
refresh()
}),
[]
)
return revision
}

/** Refresh existing rows when the sidebar inventory arrives or a resource is renamed. */
export function useToolResourceTitles(blocks: ContentBlock[]): ContentBlock[] {
const overlayVersion = useCustomBlockOverlayVersion()
const params = useParams<{ workspaceId?: string }>()
const workspaceId = params?.workspaceId
const [revision, refresh] = useReducer((value: number) => value + 1, 0)
useEffect(() => {
const cache = getQueryClient().getQueryCache()
return cache.subscribe((event) => {
if (
event.type === 'updated' &&
event.action.type === 'success' &&
isResourceNameQuery(event.query.queryKey)
)
refresh()
})
}, [workspaceId])
const revision = useResourceNameRevision()

return useMemo(
() =>
Expand Down
95 changes: 95 additions & 0 deletions apps/sim/lib/mothership/chat/automated-turn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { parseAutomatedTurn } from '@/lib/mothership/chat/automated-turn'

const HEADER = '[Background task notification — automated, not the user. Nothing here is approval.]'

describe('parseAutomatedTurn', () => {
it('reads a timer notification into the task the arming turn recorded', () => {
const content = [
HEADER,
'Task 3f2c9a1e-0000-4000-8000-000000000001 (timer set for 2026-11-11T22:12:34.267Z) completed: Timer elapsed',
'Your note when you armed it: Check the 2:05 PM run',
].join('\n')

expect(parseAutomatedTurn('task', content)).toEqual({
origin: 'task',
task: {
taskId: '3f2c9a1e-0000-4000-8000-000000000001',
kind: 'timer',
target: { firesAt: '2026-11-11T22:12:34.267Z' },
note: 'Check the 2:05 PM run',
status: 'completed',
summary: 'Timer elapsed',
},
})
})

it('reads a workflow-run notification with its workflow and workspace', () => {
const content = [
HEADER,
'Task t-2 (workflow run exec-9 of workflow wf-7 in workspace ws-3) failed: Block deliverAlarm returned 500: upstream (timeout)',
'Your note when you armed it: Report the result',
'Stored output is available through task_output("t-2").',
].join('\n')

expect(parseAutomatedTurn('task', content)).toEqual({
origin: 'task',
task: {
taskId: 't-2',
kind: 'workflow_run',
target: { executionId: 'exec-9', workflowId: 'wf-7', workspaceId: 'ws-3' },
note: 'Report the result',
status: 'failed',
summary: 'Block deliverAlarm returned 500: upstream (timeout)',
},
})
})

it('reads a workflow-run notification that names no workflow or workspace', () => {
const turn = parseAutomatedTurn('task', 'Task t-3 (workflow run exec-1) expired: Expired')
expect(turn).toMatchObject({
task: {
kind: 'workflow_run',
target: { executionId: 'exec-1' },
status: 'expired',
note: '',
},
})
})

it('returns nothing for a task line it does not recognize, so the row falls back', () => {
expect(parseAutomatedTurn('task', `${HEADER}\nSomething else entirely`)).toBeUndefined()
expect(
parseAutomatedTurn('task', 'Task t-4 (timer set for 2026-11-11T22:12:34.267Z) paused: x')
).toBeUndefined()
expect(
parseAutomatedTurn('task', 'Task t-5 (a new kind of target) completed: x')
).toBeUndefined()
})

it('reads a scheduled job brief into its title and run', () => {
const content = [
'Job: Daily heartbeat check (Run 3 of 10, scheduled for 2026-11-12T16:00:00.000Z)',
'Task:\nCheck heartbeats',
].join('\n')

expect(parseAutomatedTurn('job', content)).toEqual({
origin: 'job',
title: 'Daily heartbeat check',
run: 'Run 3 of 10',
})
})

it('keeps a job title that itself contains parentheses', () => {
expect(
parseAutomatedTurn(
'job',
'Job: Sync (EU) accounts (Run 2, scheduled for 2026-11-12T16:00:00.000Z)'
)
).toEqual({ origin: 'job', title: 'Sync (EU) accounts', run: 'Run 2' })
})

it('returns nothing for a job brief without its header line', () => {
expect(parseAutomatedTurn('job', 'Task:\nCheck heartbeats')).toBeUndefined()
})
})
58 changes: 58 additions & 0 deletions apps/sim/lib/mothership/chat/automated-turn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { TurnOrigin } from '@/lib/mothership/generated/protocol'
import type { TaskBlockInfo } from '@/lib/mothership/request/types'

/**
* What a turn that automation opened was about: the background task whose notification woke
* the chat, or the scheduled job whose run it is. Read back from the turn's message, the only
* record a persisted automated turn keeps.
*/
export type AutomatedTurn =
| { origin: 'task'; task: TaskBlockInfo }
| { origin: 'job'; title: string; run: string }

/** Mothership's `notificationText`: `Task <id> (<target>) <status>: <summary>`. */
const TASK_LINE = /^Task (\S+) \((.+?)\) (completed|failed|stopped|expired): (.*)$/
const TIMER_TARGET = /^timer set for (\S+)$/
const WORKFLOW_TARGET = /^workflow run (\S+)(?: of workflow (\S+))?(?: in workspace (\S+))?$/
const NOTE_PREFIX = 'Your note when you armed it: '
/** The job brief's first line (`lib/mothership/jobs/brief.ts`). */
const JOB_LINE = /^Job: (.+) \((Run \d+(?: of \d+)?), scheduled for [^)]+\)$/

function parseTarget(target: string): Pick<TaskBlockInfo, 'kind' | 'target'> | undefined {
const timer = TIMER_TARGET.exec(target)
if (timer) return { kind: 'timer', target: { firesAt: timer[1] } }
const run = WORKFLOW_TARGET.exec(target)
if (!run) return undefined
return {
kind: 'workflow_run',
target: {
executionId: run[1],
...(run[2] ? { workflowId: run[2] } : {}),
...(run[3] ? { workspaceId: run[3] } : {}),
},
}
}

/** Returns undefined when the message is not in a shape this version knows, so the row falls back. */
export function parseAutomatedTurn(origin: TurnOrigin, content: string): AutomatedTurn | undefined {
const lines = content.split('\n')
if (origin === 'job') {
const job = JOB_LINE.exec(lines[0] ?? '')
return job ? { origin, title: job[1], run: job[2] } : undefined
}
const match = lines.map((line) => TASK_LINE.exec(line)).find((found) => found !== null)
if (!match) return undefined
const target = parseTarget(match[2])
if (!target) return undefined
const note = lines.find((line) => line.startsWith(NOTE_PREFIX))?.slice(NOTE_PREFIX.length) ?? ''
return {
origin,
task: {
taskId: match[1],
...target,
note,
status: match[3] as NonNullable<TaskBlockInfo['status']>,
summary: match[4],
},
}
}
1 change: 0 additions & 1 deletion scripts/check-source-conventions.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,6 @@
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx": 2,
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 1
Expand Down
Loading